Compare commits

...

150 Commits

Author SHA1 Message Date
Daniel Lok 401f91c14e test(e2e): guard pinned-session delete clears the Pinned section
Adds a browser e2e that pins a session (while sitting on `/`, so it isn't
the active chat) and deletes it, asserting the "Pinned" section unmounts
in place — no reload.

Two harness details are load-bearing, and getting them wrong yields a
test that passes even against the buggy build:

- Delete a NON-active pinned session (page on `/`). Deleting the open
  session navigates away and refetches; an active session also gets a
  WS `removed`-frame reconcile. Either clears the row regardless of the
  cache bug.
- Assert the "Pinned" SECTION disappears, not the row's href. While the
  delete is in flight the row swaps to a hrefless "Deleting…" status row,
  so an href-count assertion flickers to 0 during that transient and
  passes spuriously; the section stays mounted until the pinned cache is
  actually empty.

Verified it fails (~3s) against a build with the pinned-cache delete
patch removed, and passes with it.

Co-authored-by: Isaac
2026-07-29 17:17:16 +08:00
Daniel Lok 21eebbef49 fix(web): keep the sidebar row size stable when editing the title
The inline rename row rendered a `text-sm` (14px) input inside a wrapper
whose `py-1` + `size-7` buttons summed to ~36px, while the interactive
row is `h-7` (28px) with the 13px `sidebar-compact-text` font. So
double-clicking to rename made the row grow taller and bump the font
size, an input visibly larger than the row it replaced.

Match the edit row's box metrics to the interactive row (h-7,
sidebar-compact-text, otto-sm radius) and drop the buttons to icon-xs
(24px) so they sit inside the 28px row, leaving only the muted edit
background to signal the mode.

Co-authored-by: Isaac
2026-07-29 16:42:38 +08:00
Daniel Lok f464e47492 fix(web): keep the sidebar row height stable during delete
The in-flight "Deleting…" status row that replaces an interactive
conversation row used `text-sm py-2` with no height constraint, while
the interactive row uses `sidebar-compact-text h-7 py-0.5`. So starting
a delete didn't just recolor the row — it grew taller and changed font
size, shifting the surrounding list.

Match the deleting row's box metrics to the interactive row (h-7,
sidebar-compact-text font size, otto-sm radius) so the swap only changes
color/opacity.

Co-authored-by: Isaac
2026-07-29 16:18:06 +08:00
Daniel Lok 2202ed81d5 fix(web): clear deleted pinned sessions from the sidebar's Pinned section
The Pinned section reads a sibling ["pinned-conversations"] cache that the
delete mutations' prefix-matched ["conversations"] sweep deliberately skips
(nesting it under that prefix breaks the pin-toggle's cache patch). That
isolation is by design, but it means the delete handlers must drop the row
from the pinned cache explicitly — which they didn't. So deleting a pinned
session removed it from the flat list but left it lingering in the Pinned
section until a full reload.

Mirror the unpin removal in all three delete paths (single-delete onSuccess,
bulk-delete onSuccess, and bulk-delete onError's partial-success branch),
patching the pinned cache in place rather than invalidating for the same
search-reindex-lag reason the list is patched in place.

Co-authored-by: Isaac
2026-07-29 15:41:02 +08:00
Dhruv Gupta 210adf0dad fix(ci): exclude dev/pre tags from the backcompat version matrix (#3473)
The scheduled server-compat matrix builds its default version set from
all tags, filtering only rcN. Dev/pre tags are snapshots of main, so
main-vs-them cells add no compat signal, and under the 256-job matrix
cap they evict the oldest final releases — the coverage the workflow
exists for. A stray v0.4.0.dev0 tag is already in the live matrix
today, and a nightly prerelease lane would add ~25 such tags a month.
Explicit VERSIONS dispatch overrides still accept prerelease tags.

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-29 01:19:21 +00:00
Andrew Peltekci 11c0fef152 fix(repl): don't report an Omnigent credential for ACP-backed sessions (#3431)
* fix(repl): don't report an Omnigent credential for ACP-backed sessions

acp / acp:<slug> / goose / qwen aren't in _HARNESS_FAMILY, so
default_provider_for_harness treats them as unmapped and falls through to
the configured anthropic/openai default. describe_active_credential then
hands back that provider's default_model and credential source, and both
the /model readout and the startup header render it as the active model.

But an ACP agent carries its own auth and picks its own model — the
executor only forwards a model at session/new when send_model_in_session_new
is set. So `omnigent run --harness acp:<agent>` confidently names a model
and an API key the session never touches.

Declines these harnesses at the resolver rather than the readout, so the
startup header stops fabricating too. The predicate reads the declared
capability record (ACP_SUBPROCESS + OWN_AUTH) instead of a hardcoded list,
so community ACP plugins are covered without further edits.

Signed-off-by: apeltekci <andrew@peltekci.com>

* fix(repl): scope the own-auth credential decline to acp/goose and keep overrides visible

The own-auth predicate wrongly included qwen: a harness mapped in
_HARNESS_FAMILY is provider-routed at spawn (_build_qwen_spawn_env injects
the configured openai-family default via
configure_agent_harness_with_provider, and QwenExecutor exports
OPENAI_BASE_URL / OPENAI_API_KEY / OPENAI_MODEL into the qwen subprocess —
see test_qwen_uses_openai_global_default), so its readout naming that
provider was truthful, and declining it fabricated "own auth" in the other
direction. The decline now applies only to unmapped ACP_SUBPROCESS +
OWN_AUTH harnesses (acp/acp:<slug>, goose, unmapped community ACP plugins).
The predicate is public now, so the REPL stops importing a private name,
and the manual acp:<slug> split is gone (canonicalize_harness already folds
it).

The own-auth readout also no longer claims an Omnigent-side /model override
does not reach the agent — model_env_keys() covers acp and goose, the
process manager respawns on a model change, and goose applies the override
as GOOSE_MODEL — and a live override is shown instead of hidden.

Tests: the resolver-level case now uses a key-kind openai default, the kind
the unmapped fallback actually fabricated (a subscription default was
already declined before the fix, so the previous case pinned nothing), and
new cases pin override visibility and qwen's provider-routed readout.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

---------

Signed-off-by: apeltekci <andrew@peltekci.com>
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-29 00:56:07 +00:00
Nikhil Chakre 2e6cde9303 fix(accounts): enforce the last-admin invariant atomically on delete (#3304)
DELETE /auth/users/{user_id} checked whether another admin existed and
deleted the target in two separate, unlocked transactions. Two
concurrent deletes of two different admins could each observe the
other as the remaining admin, both pass, and both apply, leaving
the deploy with zero admins and no in-app recovery path.

Lock the current admin set before counting it (BEGIN IMMEDIATE on
SQLite, SELECT ... FOR UPDATE on other dialects) so the check and
the delete happen in one transaction. A concurrent delete of a
different admin now blocks until the first commits and re-observes
the up-to-date count instead of a stale one.

Signed-off-by: Nick Chakre <nickchakre18@gmail.com>
2026-07-29 00:07:08 +00:00
Corey Zumar badd76a75a fix(web): align sidebar primary nav icons on one column (#3468)
* fix(web): align sidebar primary nav icons on one column

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

* fix(web): correct stale gap-1 reference in nav comment

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

* test(e2e-ui): regenerate visual baselines

---------

Signed-off-by: dbczumar <corey.zumar@databricks.com>
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-28 16:41:37 -07:00
Zeyi (Rice) Fan e1f409245b feat(android): target Android 16 (API level 36) (#3470)
## Related issue

N/A

## Summary

Bump the Android module's `compileSdk` and `targetSdk` from 35 to 36 to meet
Google Play's requirement that apps target API level 36 by August 30, 2026.
This required updating the full Android toolchain:

- AGP 8.6.1 → 9.1.1 (AGP 9 has built-in Kotlin support)
- Gradle wrapper 8.9 → 9.3.1
- Gradle Play Publisher 3.12.1 → 4.0.0
- AndroidX dependencies to versions compatible with compileSdk 36 (e.g.,
  `androidx.core` 1.18.0, `androidx.activity` 1.12.4, `androidx.webkit` 1.15.0)
- Robolectric 4.14.1 → 4.16.1

The `org.jetbrains.kotlin.android` plugin is no longer applied because AGP 9
bundles Kotlin compilation support. Build-script helper tasks that previously
used the Gradle `exec { }` DSL were switched to `ProcessBuilder` to stay
compatible with the new Kotlin/Gradle DSL scope, and `android.sdkDirectory`
was replaced with `androidComponents.sdkComponents.sdkDirectory`.

## Test Plan

Ran the full local Android build pipeline:

```bash
cd web/android
./gradlew :app:assembleDebug :app:lintDebug
./gradlew :app:bundleRelease
./gradlew :app:assembleDebugAndroidTest
```

All completed successfully and produced a debug APK, release AAB, and androidTest
APK with zero lint errors.

## 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
- [x] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Verified by running `:app:assembleDebug`, `:app:lintDebug`, `:app:bundleRelease`,
and `:app:assembleDebugAndroidTest` locally. The existing CI `android-bundle.yml`
workflow uses the Gradle wrapper and JDK 17, both compatible with the updated
toolchain.

## Changelog

Android app now targets Android 16 (API 36) to stay compliant with Google Play's
latest target API level policy.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-28 23:32:04 +00:00
Zeyi (Rice) Fan 2d17ee7b9b fix(build): migrate setup.py web UI build from npm to pnpm (#3467)
The repo migrated to a pnpm workspace (pnpm-workspace.yaml and
pnpm-lock.yaml at the root, packageManager: pnpm@11.15.1) but
setup.py's _build_web_ui still shelled out to 'npm install' / 'npm
run build' from inside web/. That path looked for a package-lock.json
that doesn't exist there (the lockfile is pnpm-lock.yaml at the
workspace root), so npm re-resolved from package.json alone and
hard-failed on the @lobehub/fluent-emoji@4.1.0 peer range
(react@^19 vs the pinned react@18.2.0) with ERESOLVE.

Migrate _build_web_ui to pnpm, matching deploy/databricks/build.sh
and the CI workflows (.github/workflows/e2e-ui.yml):

- Resolve pnpm via shutil.which('pnpm'), falling back to
  'corepack pnpm' (corepack ships with Node 22+ and auto-pins the
  version from package.json's packageManager field).
- Run from the workspace root (cwd=root), not web/, so pnpm uses
  the committed pnpm-lock.yaml.
- 'pnpm install --frozen-lockfile --filter web' then
  'pnpm --filter web run build' — exactly the CI commands.
  --frozen-lockfile guarantees the build is reproducible and
  resolves @lobehub/fluent-emoji against react@18.3.1 under the
  workspace's strictPeerDependencies: false, avoiding the peer
  conflict that broke npm.

Also enforce the Node.js 22 LTS floor up front via a new
_require_node_22 helper that fails fast with a dedicated, actionable
message if 'node' is missing or reports < 22 — instead of failing
deep inside the toolchain with an opaque error.

All existing skip/force env vars are preserved:
OMNIGENT_SKIP_WEB_UI=true (opt out), OMNIGENT_BUILD_WEB_UI=1
(force rebuild), skip-when-bundle-exists, skip-when-web-absent.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-28 15:52:36 -07:00
Zeyi (Rice) Fan 815cdbef43 refactor(sandboxes): split host-launch contract from exec transport (#3337)
## Related issue

N/A

## Summary

- Split `SandboxLauncher` into a layered hierarchy: `SandboxLifecycle`
  (lifecycle + capabilities), `SandboxExecTransport` (run/put/stream/exec),
  `SandboxHostLauncher` (abstract start_host), and `ExecModelHostLauncher`
  (default start_host + run_background + materialize_workspace).
- `SandboxLauncher` is now a backward-compat alias for `ExecModelHostLauncher`.
- Migrated Kubernetes to inherit `SandboxHostLauncher` directly — it no
  longer needs a fake `run()` that raises; the entrypoint-as-host model
  (Pod boots running the host) has no exec transport at all.
- All 8 providers now declare an explicit `capabilities` property instead
  of relying on class-var derivation.
- Updated the registry's `isinstance` guard to check `SandboxLifecycle`
  (the common base) so both exec-model and entrypoint-as-host providers pass.
- Updated the Kubernetes test that asserted `run()` raises to assert the
  method does not exist instead.

## Test Plan

```bash
uv run pytest tests/onboarding/sandboxes tests/sandbox tests/cli/test_cli.py tests/server -k managed -q
pre-commit run --files <all changed files>
```

All 780 selected tests pass and pre-commit is clean.

## Demo

N/A

## Type of change

- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] 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

Existing provider and CLI tests pass unchanged, confirming backward
compatibility. The Kubernetes test was updated to reflect that `run()` no
longer exists on the launcher. The registry test was updated for the
`SandboxLifecycle` guard message.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-28 14:51:55 -07:00
Dhruv Gupta e70f46578c fix(cli): resolve conversation ids pasted with stray punctuation in omni resume (#3465)
A conversation id pasted with surrounding punctuation (e.g. a trailing
period) crashed `omni resume` with a raw StatementError traceback from
the local store's Uuid16 bind. Strip the punctuation a paste drags
along — none of it can be part of a valid id — and resume the id the
argument contains, canonicalized to bare hex so downstream consumers
never see a legacy spelling. Error only when no valid id remains.

Co-authored-by: Isaac

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-28 21:40:44 +00:00
Zeyi (Rice) Fan 43f58d74cc feat(android): instrumented screenshot capture with Gradle-managed servers (#3389)
Add a `./gradlew recordScreenshots` task that captures four real-WebView
screenshots of the Android shell on a device/emulator, with zero manual
setup — Gradle starts and stops both the Vite dev server and an isolated
omnigent backend automatically.

Screens captured (app/build/screenshots/):
  - server_select.png — native ConnectActivity (server-entry screen)
  - home.png           — SPA landing page (sidebar closed)
  - session_list.png   — SPA home with sidebar drawer open (?sidebar=open)
  - session.png        — session/chat page with a real seeded user message

How it works:
  - startBackendServer: launches `omnigent server` in a throwaway mktemp
    data dir (OMNIGENT_DATA_DIR/CONFIG_HOME/DATABASE_URI isolated from
    ~/.omnigent, no-auth on loopback), pre-registers examples/kimi_hello.yaml.
  - seedDemoSession: POST /v1/sessions with an initial user message so the
    session screenshot has real content.
  - startWebDevServer: launches `node vite --host 127.0.0.1 --port 5173`
    directly (avoids spawning npm/pnpm whose grandchild is hard to kill),
    reuses an existing server if present. Vite proxies /v1 to the backend.
  - Per screen: pm clear + pre-grant POST_NOTIFICATIONS, then drive the real
    ConnectActivity → MainActivity flow via UI Automator (am instrument, not
    AGP's connectedDebugAndroidTest which auto-uninstalls and deletes the
    screenshot before we can pull), then adb pull the PNG.
  - stopWebDevServer / stopBackendServer: tear down both + clean temp dir.

The test (ScreenshotTest.kt) is pure UI Automator (out-of-process, black-box):
it launches the app from the launcher, types the server URL (base + route
path) into ConnectActivity, taps Connect, waits for the floating switch pill
as the "shell is up" signal, then captures via UiDevice.takeScreenshot. The
session-list screen uses the ?sidebar=open query param (AppShell reads it on
mount to open the conversation drawer) since uiautomator can't see inside the
WebView to tap the toggle button.

Dependencies added (pinned to the AGP 8.6 / compileSdk 35 toolchain):
  androidx.test:runner 1.6.2, :rules 1.6.1, ext:junit 1.2.1
  androidx.test.espresso:espresso-core 3.6.1
  androidx.test.uiautomator:uiautomator 2.4.0
Also sets testInstrumentationRunner = AndroidJUnitRunner.

Usage:
  ANDROID_SERIAL=emulator-5554 ./gradlew recordScreenshots
  open app/build/screenshots/*.png

Requires an emulator or unlocked device. The backend/Vite are fully managed
— no separate terminals needed.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-28 14:12:01 -07:00
Dhruv Gupta ef8423b3ab fix(release): harden finalize, cut, and homebrew against the 0.7.0-cycle failure modes (#3381)
* fix(release): harden finalize, cut, and homebrew against the 0.7.0-cycle failure modes

- finalize: docs sweep is advisory (never blocks publish), untagged drafts
  are rebound automatically, tag input is normalized
- release: bump-main gates in shell so CLI-dispatched boolean inputs cannot
  silently skip the post-release main bump
- update-homebrew: defer inside PyPI's 24h --uploaded-prior-to window and
  add a nightly catch-up that no-ops when the formula is current
- uv.lock: gitpython 3.1.50 -> 3.1.55 (clears 8 OSV advisories that tripped
  the Security Scan on every lock-touching PR)

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

* fix(images): serialize image builds and raise the build timeout to 120m

At the v0.7.0 cut the rc1 (21:51) and final (21:57) tag builds ran
concurrently under SHA-keyed concurrency, raced each other's layer cache
cold, and the final build died on the 60m job timeout — no v0.7.0 or
latest images until a manual re-run a day later. A single serialized
group lets the later build reuse the earlier one's layers; 120m gives a
genuinely cold build headroom.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

---------

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-28 14:04:50 -07:00
Zeyi (Rice) Fan 239e9cd36b chore(pnpm): migrate editors/vscode and deploy/cloudflare to the root workspace (#3390)
N/A

This is the final npm -> pnpm migration step for the OSS repo.

- Adds `editors/vscode` and `deploy/cloudflare` to `pnpm-workspace.yaml` so
  they use the root `packageManager: pnpm@11.15.1` and the shared
  `pnpm-lock.yaml`.
- Removes the per-package `package-lock.json` files and deletes the now-obsolete
  `scripts/normalize_package_lock_registry.py` hook/script.
- Merges the three remaining categories of build-script approvals into
  `pnpm-workspace.yaml` (`@vscode/vsce-sign`, `esbuild`, `keytar`, `sharp`,
  `workerd`) so `pnpm install` works at the workspace root.
- Migrates VS Code and release workflows to `setup-pnpm`:
  - `.github/workflows/vscode-extension-release.yml`
  - `.github/workflows/vscode-release-pr.yml`
  - `.github/workflows/release-omnigent.yml`
- Updates the lockfile regen workflows to refresh `pnpm-lock.yaml` instead of
  the old web-only `package-lock.json`:
  - `.github/workflows/oss-regenerate-and-smoke.yml`
  - `.github/workflows/oss-regen-on-comment.yml`
- Updates `editors/vscode/README.md`, `editors/vscode/PUBLISHING.md`, and
  `deploy/cloudflare/README.md` to reference pnpm commands.
- Removes the deprecated `.github/actions/setup-node` composite action.

- `pnpm install --frozen-lockfile --filter omnigent-vscode` passes locally.
- `pnpm install --frozen-lockfile --filter omnigent-cloudflare` passes locally.
- `uv run pre-commit run --all-files` passes (after dropping the package-lock
  registry hook).
- Inspected remaining `npm install` occurrences in workflows; the only survivors
  are transient agent CLI installs (`@anthropic-ai/claude-code`,
  `@openai/codex`) that are intentionally not tracked in the lockfile.

N/A

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

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

Verified the new workspace packages install from the frozen pnpm lockfile and
that the pnpm-only lockfile regen scripts produce a valid lock. The VS Code
workflow commands were checked against the package names/filters from
`pnpm-workspace.yaml`.

N/A

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-28 13:44:52 -07:00
Thomas Garnier eeb750c85e feat(sandbox): bind /proc in bwrap on Lakebox hosts (#3258)
The linux_bwrap sandbox mounts a fresh procfs under --unshare-pid, but a
Lakebox microVM masks /proc so that mount returns EPERM and the sandbox
fails to start. That blocked linux_bwrap — and the L7 egress management
built on top of it — on the Lakebox backend.

Bind the existing /proc instead of mounting a fresh one, but only on
outer sandbox backends known to be safe for it (allow-list: lakebox).
The backend is read from OMNIGENT_HOST_SANDBOX_BACKEND when set, else
autodetected via the /run/lakebox marker. Everywhere else the fresh-proc
mount and its fail-closed behavior stay unchanged.

Binding /proc exposes the outer process list and world-readable per-proc
files (cmdline/comm/stat/status). The retained user namespace still
blocks ptrace-gated files (environ/mem/maps/fd) and --unshare-pid still
contains signalling, so the leak is acceptable on a single-tenant
Lakebox microVM.

Signed-off-by: Thomas Garnier <6202935+mxatone@users.noreply.github.com>
2026-07-28 12:11:53 -07:00
Andrew Peltekci fa11e1ccf7 fix(kimi): report terminal status so a parent orchestrator is woken (#3166)
The kimi forwarder mirrored wire content but never posted an
external_session_status edge — the only native forwarder that didn't
(claude/codex/opencode/cursor all do). A kimi sub-agent therefore finished,
delivered its answer to the transcript, and left the parent waiting on it
forever: _mark_subagent_terminal_and_wake was never reached, so no result
ever landed in the parent's inbox.

kimi's wire has no turn.end row; its agent loop steps while step.end carries
finishReason 'tool_use' and stops on 'end_turn' (1:1 with turn.prompt across
every recorded session). Map that edge to external_session_status: idle,
carrying the turn's final assistant text — the runner delivers an empty
result when an idle edge forwards none.

Signed-off-by: apeltekci <andrew@peltekci.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:03:08 +00:00
Pat Sukprasert c41d40454e feat(harness): add NativeHarnessProvider seam foundation (PR 1.1) (#3239)
* feat(harness): add NativeHarnessProvider seam foundation (PR 1.1)

First, additive step of Phase 1 of the modular native-harness registry
(designs/harness-modular-registry-proposal.md). Introduces the behavior
side-channel that later PRs will dispatch through; no hub is rewired yet, so
this changes no runtime behavior.

- Add `NativeHarnessProvider` (frozen dataclass of dotted import-path strings
  for a native harness's lifecycle hooks) and the `native_providers` field on
  `HarnessContribution`, plus `native_providers()` / `native_provider_for_key()`
  accessors.
- Populate 11 built-in provider rows uniformly from the `omnigent.<key>_native`
  module layout (`run_<key>_native`, `_materialize_<key>_agent_spec`, and the
  `_auto_create_<key>_terminal` builder re-exported from `omnigent.runner.native`).
  Hooks that are still runner closures / inline dispatch (interrupt, stop,
  spawn-env, bridge-dir) stay None until those hubs migrate onto the seam.
- Add `omnigent/native_dispatch.py`: a lazy, per-path-cached resolver over the
  existing `load_object`, with `resolve` / `resolve_hook` / `resolve_hook_for_key`
  so hubs resolve a hook instead of branching on `key == "<x>"`. Import hygiene
  preserved — provider rows hold strings; only the resolver imports the target
  modules, and only at dispatch time.
- Tests: provider rows cover every native agent 1:1, required hooks are set, and
  every populated built-in path actually resolves to a callable (guards against
  a typo'd path or renamed symbol); resolver colon/dot forms, caching, and
  unset-hook / unknown-key None paths.

The validator still rejects community native metadata (Phase 2 flips it).

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

* docs(harness): add implementation-progress ledger (PR 1.1)

Add an append-only "Implementation progress" ledger to the modular-registry
proposal so each PR in the stack records its own status without editing the
plan tables (which would conflict across the 1.1→1.2→1.3 stack on every
rebase). Seed it with 1.1 (#3239, in review).

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-28 14:31:06 +00:00
Pat Sukprasert c7d7cedb91 [runner] Preserve sub-agent wake attribution (#3409)
* fix: preserve sub-agent wake attribution

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

* fix: harden runner event attribution

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

* fix: retry child dispatch without stale attribution

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

* fix: validate subagent send before actor lookup

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

* test: expect forwarded created_by field

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

* test: avoid escape closing codex config modal

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-28 14:28:17 +00:00
Pat Sukprasert 341652d6f8 [lint] Block hardcoded model pins (#3425)
* 🔧 chore(lint): Block hardcoded model pins

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

* 🔧 chore(lint): Tighten model baseline guard

- Reject duplicate path/model rows so baseline allowances cannot silently accumulate.
- Document heuristic false-negative and multiline-config gaps, plus the bounded full-scan tradeoff.
- Add focused coverage for duplicate baseline validation.

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

* 🔧 chore(lint): Guard model scan configuration

- Cross-check the pre-commit trigger against the scanner's tracked roots, extensions, exclusions, and allowlist path to prevent silent drift.
- Share the source-extension set across path discovery and scanning.
- Report malformed allowlist counts with consistent path and line context; cover both review cases with focused tests.

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

---------

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-28 14:22:40 +00:00
Pat Sukprasert e093f56d82 fix(codex): persist permission mode across host resume (#3411)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-28 13:25:11 +00:00
Tomu Hirata 627c8ee59e fix(ui): sub-agent sessions never show reconnect modal when runner dies (#3414)
* fix(ui): sub-agent sessions never show reconnect modal when runner dies

A sub-agent session with a dead runner classified as local_stranded,
which disabled the composer and showed the CLI reconnect modal — a
flow designed for top-level host-bound sessions. Sub-agents have no
host binding and can't be relaunched from a CLI command; they recover
via their parent's live runner (server-side heal, #3151).

- Add kind field ("default" | "sub_agent") to Session type and
  map it from the wire in sessionFromWire
- Thread kind through LivenessRow and livenessRowFromSession
- Add row 7a in useSessionLiveness: sub_agent with dead runner →
  runner_asleep (composer open) instead of local_stranded

Fixes #3413

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

# Conflicts:
#	web/src/hooks/useSessionLiveness.ts

* fixup: add kind and backgroundTaskCount to sessionsApi test fixture

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

* test(e2e_ui): sub-agent dead runner keeps composer open, no reconnect modal

Regression test for #3413: a sub-agent session with a dead runner was
classified as local_stranded, showing the CLI reconnect modal and
disabling the composer. After the fix (kind=="sub_agent" → runner_asleep)
the composer stays enabled and the "Agent disconnected" banner is absent.

Creates a real child session (parent_session_id set → kind="sub_agent"),
patches the browser's health poll to report runner offline, and asserts
the composer is usable and no reconnect banner appears.

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

* fixup: splice kind from session snapshot into livenessRow when sidebar conv present

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

* fixup: expose kind in SessionResponse so the UI can detect sub_agent sessions

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

* fixup: don't re-initialize session on heal — parent runner already hosts the child

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

* fixup: re-init session for native sub-agents, skip for SDK sub-agents

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

* fixup: regenerate openapi.json for kind field in SessionResponse

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

* fixup: update heal docstring + add SDK sub-agent no-init test

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-28 21:53:08 +09:00
Tomu Hirata d16ad3c7c0 fix(server): heal sub-agent stale runner_id on direct message-send (#3151)
* 🐛 fix(server): heal sub-agent stale runner_id on message-send

A sub-agent copies its parent's runner_id at creation and is never
repointed when the parent's runner is relaunched. The message-send path
returned a permanent 503 for any sub-agent whose runner had
idle-timed-out, even while the parent's replacement runner was healthy
(host_id is None short-circuits all existing relaunch paths).

- Extract _heal_subagent_runner_binding_via_parent from
  _recover_subagent_status_forward_via_parent: walks the ancestor chain
  (immediate parent → root), waits for the live runner tunnel, calls
  replace_runner_id on the child, returns the live client
- Wire the heal into the message-send path after the managed-launch
  rendezvous, guarded to kind=="sub_agent"; sets
  _runner_needs_session_init=True so the child's harness is initialized
  on the healed runner before dispatch
- Refactor _recover_subagent_status_forward_via_parent to delegate
  binding repair to the shared helper (no behavior change for the
  status-forward path)
- Add regression tests: heal succeeds, no-live-ancestor preserves 503,
  top-level sessions not treated as recoverable children

Fixes #3067

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

# Conflicts:
#	omnigent/server/routes/sessions.py

* fixup: rebase onto main, apply heal to routes_events.py, fix lint

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

* fixup: fix test payload format and monkeypatch targets for routes_events

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-28 18:20:34 +09:00
Pat Sukprasert b7ab0ba548 test: stabilize codex model metadata e2e (#3410)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-28 16:07:25 +07:00
Pat Sukprasert fe55ad2cf2 Import OpenClaw acpx agents during setup (#3354)
* Import OpenClaw acpx agents during setup

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

*  feat(cli): Add one-shot OpenClaw launch

- Resolve one registered agent into a temporary ACP launcher
- Keep user config unchanged and fail clearly on unknown agents

Refs #3351

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

* 🐛 fix(openclaw): Harden config bridge imports

- Parse wrapped configs with a real JSON5 implementation
- Deduplicate mirrored registries and preserve slug collisions
- Reject malformed ephemeral ACP payloads with clear errors

Refs #3351

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

* 🐛 fix(openclaw): Handle invalid config sources

- Treat filesystem and parser recursion failures as soft discovery errors
- Preserve valid sibling agents when one entry has malformed args
- Quote executable paths so ACP argv parsing handles spaces

Refs #3351

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

*  feat(openclaw): Let users choose import source

Always show the OpenClaw import action during setup, offer detected registries or a user-selected file, and reject unrelated files without changing Omnigent config.

Refs #3351

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

* 🐛 fix(openclaw): Unify registry parsing

Parse both acpx and wrapped OpenClaw registries as JSON5 regardless of discovery path, and document why the setup status-width floor must follow available terminal space.

Refs #3351

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-28 08:48:19 +00:00
Pat Sukprasert 624411a334 fix(ci): install CLIs under RUNNER_TEMP so a repo-root package.json can't hoist them (#3412)
The AI-agent workflows install the Claude Code / Codex CLIs with a bare
`npm install` after `cd`-ing into a workspace subdir (`.cc-cli` / `.codex-cli`)
that has no package.json of its own. npm then walks up to the nearest ancestor
package.json to resolve the project root.

Once a repo-root package.json was added, that ancestor became the repo root, so
the install landed in `${GITHUB_WORKSPACE}/node_modules` instead of the subdir.
The follow-up `node node_modules/@anthropic-ai/claude-code/install.cjs` (run from
the empty subdir) then failed with MODULE_NOT_FOUND, breaking Polly review,
issue/security triage, doc-sync, and the run-omnigent-agent action. The
`added 2 packages` line (claude-code has zero deps) was the tell that npm had
reconciled the root tree rather than an isolated install.

Install into `${RUNNER_TEMP}/omnigent-{cc,codex}-cli` instead — outside the
checked-out tree, so no ancestor package.json can ever capture the install. This
matches the pattern e2e-ui.yml and flake-stress-ui.yml already use.

Co-authored-by: Isaac

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-28 15:19:49 +07:00
Tomu Hirata d0450f8d7e ci: bump @anthropic-ai/claude-code 2.1.170→2.1.212 (#3404)
* ci: bump @anthropic-ai/claude-code 2.1.170→2.1.212

v2.1.170 has a corrupted npm cache entry on GitHub Actions runners
causing install.cjs to be missing after `npm install`. Bumping to the
current stable (2.1.212) forces a fresh fetch and clears the bad entry.

Also bumps the ci-deps/package.json pin (was 2.1.163) and the
run-omnigent-agent action default to keep everything consistent.

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

* ci: update pnpm-lock.yaml for claude-code 2.1.212

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-28 16:47:38 +09:00
Tomu Hirata 2cff2cea11 fix(codex-native): share plugins/cache into per-session homes (#3401)
Codex materializes its versioned plugin store (openai-curated templates,
browser, presentations, ...) into $CODEX_HOME/plugins/cache on session
start. Because codex-native points CODEX_HOME at a private per-session
home, codex re-materializes ~44 MB of identical plugin data into every
session — the dominant on-disk cost once the upstream logs_2.sqlite TRACE
bloat (openai/codex#28224) is fixed in codex >= 0.142.0.

Symlink plugins/cache from the shared source home into each private home,
mirroring the existing skills-symlink pattern. The cache is content-
addressed read-only reference data (verified byte-identical to the shared
copy), so unlike config.toml it needs no per-session isolation. Skipped in
minimal (title-sidecar) mode, which runs no plugins. Best-effort: a symlink
failure logs and lets codex repopulate its own copy.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-28 16:04:26 +09:00
Pat Sukprasert 23465441d5 feat(setup): Add Antigravity sign-in (#3391)
- Launch bare agy for Google OAuth and verify with agy models\n- Keep Gemini API-key setup available alongside native sign-in

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-28 13:44:36 +07:00
Zeyi (Rice) Fan 31183fbe92 chore(pnpm): approve build scripts for ci-deps dependencies (#3386)
Running a full workspace install without filters complained about ignored
build scripts for @anthropic-ai/claude-code, @google/genai, and protobufjs.
These come from the .github/ci-deps package and are legitimate; approving
them lets Scope: all 4 workspace projects
Already up to date
Done in 194ms using pnpm v11.15.1 / undefined
[ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL] Command "dev" not found at the workspace root run scripts
instead of erroring.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 17:37:55 -07:00
Sabhya Chhabria 3e139dab57 fix(claude-native): never launch a bare family alias a gateway rejects (#3378)
* fix(claude-native): never launch a bare family alias a gateway rejects

A family alias (opus/sonnet/haiku/fable) selected on a provider config
whose tier has no ANTHROPIC_DEFAULT_*_MODEL pin is canonicalized by
Claude Code to an Anthropic id (e.g. claude-opus-4-8) that gateways
404, failing session start with "There's an issue with the selected
model". Resolve unpinned aliases to the provider's default model in
resolve_claude_native_model_selection, which launch, sticky handoff,
and /model injection all route through.

Also stop offering the static subscription alias rows to provider
configs with no pins: the picker now lists the one model the config is
known to route.

Co-authored-by: Isaac
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>

* refactor: trim the unpinned-alias fix to its minimal form

Shorten the resolver docstring and the pin-less catalog fallback, drop
e2e assertions already implied by the single-row count, and fold the
three alias-passthrough regression tests into one.

Co-authored-by: Isaac
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>

* fix(claude-native): scope alias remap to endpoints that reject canonical ids

Review feedback on the unpinned-alias guard:

- Only rewrite an unpinned family alias when the config routes through a
  gateway/Bedrock endpoint; the Anthropic API (api.anthropic.com or no
  endpoint override) resolves aliases natively, so API-key providers keep
  their alias routing and the static picker catalog.
- Respect managed-settings tier pins: Claude Code applies them to the
  spawned process, so a managed pin means the alias still routes.
- The runner's /model handler now resolves the session launch config
  instead of reading the in-memory cache, so alias resolution survives a
  runner restart (cold cache previously skipped the remap).

Co-authored-by: Isaac
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>

---------

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-27 17:29:07 -07:00
Zeyi (Rice) Fan 355556dff4 chore(ci): migrate .github/ci-deps to pnpm and update docs for pnpm dev workflow (#3379)
- Add .github/ci-deps to the root pnpm workspace so it uses the shared
  pnpm lockfile and install machinery.
- Regenerate pnpm-lock.yaml entries for the e2e-ci-deps package.
- Replace npm install --ignore-scripts in ci.yml and flake-stress-e2e.yml with
  pnpm install --frozen-lockfile --ignore-scripts --filter e2e-ci-deps.
- Update electron-build.yml to use setup-pnpm and filter installs for web and
  web/electron.
- Update omnidev source so the local dev supervisor installs and runs Vite
  with pnpm.
- Update developer docs (README.md, CONTRIBUTING.md, web/README.md,
  web/electron/README.md, dev/omnidev/README.md, tests/e2e_ui visual/README.md
  and COVERAGE_GAPS.md) to reference pnpm commands.
- Add a minimal root package.json with packageManager: pnpm@11.15.1 and remove
  the explicit version from .github/actions/setup-pnpm so CI uses the same
  source of truth.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 16:58:33 -07:00
Elliot Sun 38cc498b75 fix(onboarding): detect agy settings.json as login fallback on macOS (#3289)
* fix(onboarding): detect agy settings.json as login fallback on macOS

On macOS, agy 1.1.7+ stores OAuth credentials in Keychain and writes
only ~/.gemini/antigravity-cli/settings.json (no oauth_creds.json).
The existing gemini_auth_has_credential() missed this and falsely
reported 'harness antigravity-native is not configured'.

Accept the existence of settings.json as a fallback signal when no
token files are found. This is safe because the caller
(resolve_native_antigravity_launch) uses it only for an informational
warning — agy always re-drives OAuth on first run regardless.

- Update gemini_auth_has_credential() with settings.json fallback
- Update docstrings to document the third detection path
- Update warning message in antigravity_native_launch.py
- Add unit test for settings.json-only detection
- Fix _GEMINI_DIR isolation in existing test

Signed-off-by: ElliotSun <elros1109@gmail.com>

* fix(onboarding): prove agy login via CLI, not settings.json existence

The macOS lockout this fixes is real: agy 1.1.7+ keeps OAuth in the
Keychain and writes no token file, so the file-only check reported
antigravity-native as unconfigured and connect.py refused to spawn a
runner for a user who was in fact signed in.

Accepting the bare existence of ~/.gemini/antigravity-cli/settings.json
as the fallback signal does not work, because omnigent creates that file
itself: the CLI launch path calls ensure_agy_feedback_survey_disabled
under the real home before agy starts, and build_agy_launch emits no HOME
override. One `omni antigravity` run therefore satisfied the credential
gate forever, on every platform — turning a hard launch gate into a
no-op and letting a runner spawn that dies on its first turn. That is
worst on headless hosts, where agy's OAuth prompt has no TTY.

Ask the CLI instead. `agy models` exits 0 only when signed in and reads
the credential wherever agy stored it, Keychain included, so nothing
omnigent writes can satisfy it. This mirrors ambient._claude_login_detected,
which already solves the identical Keychain split for Claude Code, and
reuses the probe harness_install already wires as the gemini family's
status command.

The fallback is gated on macOS: Linux writes a real token file, so its
absence is a true negative there and the fallback would only add a
subprocess while weakening a signal that works. Failures — missing
binary, non-zero exit, timeout, unreadable home — all read as False,
because readiness must never raise.

Content inspection of settings.json was the alternative considered. It
was rejected as unverifiable from here: no key in that file is known to
mark a completed sign-in on 1.1.7, so keying on one risks reintroducing
the very lockout being fixed.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>

* docs(skills): note agy's macOS Keychain credential in the e2e pre-flight

The pre-flight tells the reader agy's token lives under ~/.gemini, which
leaves a Mac developer on agy 1.1.7+ hunting for a file that is never
written. Name the Keychain case and the `agy models` fallback that
gemini_login_detected() now uses there.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>

---------

Signed-off-by: ElliotSun <elros1109@gmail.com>
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
Co-authored-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-27 16:49:59 -07:00
samarmstrong 08056475b0 fix(cursor-native): auto-accept lingering tool gates under --yolo (#2338)
* fix(cursor-native): auto-accept lingering tool gates under --yolo

cursor-agent's Run Everything mode still sometimes leaves pendingToolCall
markers long enough for Omnigent to mirror ApprovalCards and stall a
piloted parent. When the session launched with --yolo/--force/-f, accept
those tool gates in-pane instead of parking a web card; AskQuestion still
surfaces as deliberate human input.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Sam Armstrong <armstrongflg@gmail.com>

* fix(cursor-native): satisfy ruff format and PIE810 on yolo args

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Sam Armstrong <armstrongflg@gmail.com>

* fix(cursor-native): make yolo auto-accept bounded and fail-closed

Auto-answering a tool-approval gate is a safety boundary, so the accept path
now refuses to act on anything it cannot confirm, and always has a way out.

The accept was previously a blind keystroke loop: it never checked that a
prompt was on screen, recorded a send to a dead pane as a success, and had no
attempt cap or fallback. A gate that `y` does not clear therefore degraded from
a visible stall into a literal `y` typed into cursor's composer every two
seconds for the life of the session, with no card ever surfaced.

The accept key now goes out only while `capture_cursor_pane` shows cursor's
parenthesised accept hint, at most three times, and at most once per poll pass
(cursor renders one prompt at a time). A dead pane, a send tmux rejects, or a
gate still pending after the budget all fall back to the same ApprovalCard the
non-yolo path shows, so the worst case is the visible stall we have today.
Because a call accepted this way is never seen by a human, the INFO line now
carries an argument preview: it is the only record Omnigent approved the call.

`cursor_launch_args_enable_yolo` was failing open in the same spirit —
`--yolo=false` and `--force=false` both read as enabled, because only the
presence of the `=` form was checked. Explicit off-values are now honoured, and
a bare `--` ends the flag scan so a `-f` in the prompt text that follows is
text rather than a request to bypass approvals.

Tests cover the bounded retry, the fallback to a card, an idle pane, a dead
pane, an undelivered keystroke, an explicit non-yolo session, and the
off-value / end-of-flags argv cases. The design doc gains a section on the
fail-closed contract and drops its claim that Omnigent never sends a verdict of
its own initiative; its stale `Code:` pointer at the runner wiring is refreshed
to where that wiring now lives.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>

* fix(cursor-native): re-apply yolo wiring where auto-create now lives

`_auto_create_cursor_terminal` moved out of `omnigent/runner/app.py` into
`omnigent/runner/native/orchestration.py`, which left `app.py` a re-export
shell and this branch's wiring hunk applying to code that no longer runs.
Derive `auto_accept_approvals` from `launch_config.terminal_launch_args` at the
live call site instead.

This kwarg is the only thing that turns the in-pane auto-accept on, and it is
one line inside a large function, so a future move can drop it and leave the
feature inert with the whole suite green. Pin it: the auto-create harness now
captures the elicitation supervisor's kwargs, and a parametrized test asserts
the derived stance for `--yolo`, `--force`, `-f`, `--yolo=false`,
`--auto-review`, and no args. Deleting the kwarg fails all six.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>

---------

Signed-off-by: Sam Armstrong <armstrongflg@gmail.com>
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-27 16:48:10 -07:00
Yi Lyu 2f39f04e1f fix(codex-native): surface launch routing in the thread-startup-timeout error (#2745) (#2843)
* fix(codex-native): surface launch routing in the thread-startup-timeout error (#2745)

Signed-off-by: Yi Lyu <isabellalyu1130@gmail.com>

* Fix checks

Signed-off-by: Yi Lyu <isabellalyu1130@gmail.com>

---------

Signed-off-by: Yi Lyu <isabellalyu1130@gmail.com>
2026-07-27 23:42:24 +00:00
Zeyi (Rice) Fan 7367654b47 fix(android): resolve adb from SDK dir in runDebug task (#3376)
The runDebug, listDevices, and reverseProxy Exec tasks called
`commandLine("adb", ...)`, relying on adb being on PATH. The Gradle
daemon is long-lived and may have been started from an environment
whose PATH doesn't include platform-tools (e.g. homebrew's
android-commandlinetools), so the spawn fails with
"A problem occurred starting process 'command 'adb''" — even though
AGP's own installDebug succeeds because it resolves adb from the
SDK directory internally.

Resolve adb from android.sdkDirectory instead, mirroring AGP, so
the custom launch tasks are independent of the daemon's PATH.
2026-07-27 23:30:24 +00:00
Harry Su 4c3fcdb4f6 docs(DBSPEC): remove stale DBOS/tasks references (#2329)
* docs(DBSPEC): remove stale DBOS/tasks references

The tasks table and DBOS were removed (migration
b9c1d2e3f4a5_drop_tasks_table), but DBSPEC.md still described the
old DBOS-backed workflow design: the tasks table schema, the
try_deliver/close_inbox steering handshake, and the TaskStore
method mapping. Updated the doc to match current state — turn
state now lives in-memory in the runner (_active_turns,
_session_message_buffers), and conversation_items.response_id is
just an app-generated grouping id with no backing table.

Also added the created_by column to conversation_items, which
existed in code but was missing from the doc.

Signed-off-by: Harry Su <tiecheng.su@robinhood.com>

* docs(DBSPEC): correct FK section — no DB-enforced FKs, cleanup is explicit app code

Addresses the blocking review: the previous revision claimed an ON DELETE
CASCADE FK on conversation_items.conversation_id, but
p1a2b3c4d5e6_remove_all_fks dropped every FK (Rule R032) and
delete_conversation cleans up children before parent explicitly. Also
precision-fix response_id as harness- or app-generated per review.

Signed-off-by: Harry Su <tiecheng.su@robinhood.com>

* docs(DBSPEC): correct table count, deletion order, and position allocator

The accuracy pass left five claims that don't match the code:

- The opening line said four tables in the default schema. There are 17
  in `db_models.py`, and none sets an explicit schema — the same doc names
  labels, comments, and policies as tables a hundred lines later. Scope the
  sentence to the four tables this doc covers and point at the models as the
  full list.
- `delete_conversation` was described as deleting comments and policies
  before the conversation rows. It uses two transactions: the AP one drops
  FTS rows, items, labels, and the conversation rows; a second best-effort
  transaction then cleans up comments, policies, session permissions,
  conversation metadata, and session-scoped agents *after* the conversation
  is gone. The doc also omitted three of those tables and hid the
  best-effort tradeoff the method's own docstring calls out.
- "Turn state is not persisted to this schema at all" was overstated. The
  authoritative state is in-memory, but `persist_live_status` mirrors
  `live_status` / `pending_elicitation_count` onto
  `omnigent_conversation_metadata` so any replica can render session status.
- The "Delete agent" row documented cancelling in-flight turns for the
  agent's live sessions. No such mechanism exists: `AgentStore.delete` is a
  bare row delete with no production caller and no HTTP route, and
  session-scoped agent rows are removed by `delete_conversation`.
- The position allocator no longer runs `SELECT MAX(position) + 1`.
  `append()` reads and advances the `conversations.next_position` counter
  under `_lock_conversation`, keeping allocation O(1); the `MAX(position)`
  scan survives only as a one-time backfill for pre-counter conversations.

Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>

---------

Signed-off-by: Harry Su <tiecheng.su@robinhood.com>
Signed-off-by: SabhyaC26 <sabhyachhabria@gmail.com>
Co-authored-by: SabhyaC26 <sabhyachhabria@gmail.com>
2026-07-27 16:24:17 -07:00
Zeyi (Rice) Fan dc97ade9f5 chore(web): migrate web and electron to root pnpm workspace (#3328)
- Define a root pnpm-workspace.yaml with web/ and web/electron/ packages.
- Move npm overrides from web/package.json into workspace overrides, using a
  shared catalog: for react, react-dom, and shiki.
- Preserve 7-day dependency cooldown via settings.minimumReleaseAge: 10080.
- Delete web/package-lock.json and web/electron/package-lock.json; add the
  generated root pnpm-lock.yaml.
- Update web/electron/package.json scripts to use pnpm --filter web run build:overlay.
- Remove web/.npmrc and web/electron/.npmrc; no committed .npmrc (CI forces the
  public registry via env var).
- Add .github/actions/setup-pnpm so all workflows can share a pinned pnpm
  11.15.1 + Node setup.
- Convert lint.yml and web-tests.yml to pnpm; update ui-snapshot and e2e-ui
  workflows.
- Update the web-prettier pre-commit hook to run web/node_modules/.bin/prettier
  directly when present.
- Update justfile to prefer pnpm for Electron recipes and lockfile normalization.
- Ensure remaining npm-based workflows (editors/vscode/, .github/ci-deps/,
  deploy/cloudflare/) are untouched and continue to work.
- Add pdfjs-dist worker URL import so Vite emits the worker asset under pnpm's
  hoisted node_modules layout.
- Force shiki and its first-party packages into a single build chunk to avoid a
  Cyclic top-level import that produced a 'flatMap' runtime error in Monaco.
- Pin build-tool versions to the legacy npm lockfile (vite 8.1.0, tailwindcss
  4.3.1, jiti 2.7.0, lightningcss 1.32.0, postcss 8.5.15) so bundler behavior
  stays consistent with the pre-migration builds.
- Update tests/e2e_ui/test_pwa_build.py to omit the now-incorrect -- separator
  when forwarding --outDir to pnpm run build:embed.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 16:22:30 -07:00
Edwin He 1f66a0914b fix(native): apply routed model with the message, not a racing event (#3257)
On a claude-native session with intelligent routing on, the routed model
was selected but the user's first message was silently dropped — the model
switched, no error surfaced, but no turn ran.

The server issued TWO unsynchronized writes to the same tmux pane: a
standalone model_change event (which typed /model <routed> into the pane)
AND, separately, the user's message (typed in via inject_user_message).
These raced. The message keystrokes landed mid-switch, inject_user_message
never saw its draft, hit its submit-blind fallback, and returned without
error. Model applied, message gone.

Fix: remove the second writer by folding the switch into the message turn,
mirroring how the SDK/pi path already applies the routed model as one
operation.
- Executor (ClaudeNativeExecutor.run_turn): the routed model already
  arrives in ExecutorConfig.model and was being discarded. It is now
  applied: when config.model differs from the pane's model, type /model
  then inject the message — both under the existing _inject_lock, in
  order, exactly once. inject_user_message's prompt-ready gate + verified
  submit then guarantee delivery. _applied_model is seeded lazily from
  read_launch_model so turn 1's routed pick is compared against the spawn
  model rather than blindly re-issued.
- Server (_sessions/orchestration.py): the routed model rides in-band on
  the message (model_override, an extra field the harness MessageEvent
  forwards into ExecutorConfig.model), and the separate racing model_change
  POST is dropped. The manual composer /model picker path (PATCH ->
  model_change) is untouched.

Adds three executor tests: /model precedes the message in order under one
lock; no /model without a routed model; no /model when already on the
routed model. The ordering test fails against the prior discard-config
behavior.

Signed-off-by: Edwin He <41037314+Edwinhe03@users.noreply.github.com>
2026-07-27 23:22:15 +00:00
omnigent-ci[bot] 326bd5939f Bump version to 0.8.0.dev0 (#3377)
* Bump version to 0.8.0.dev0

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* chore(release): keep uv.lock at main's shape, stamp workspace versions only

The bump workflow's full relock rewrites every entry with new-uv metadata
churn; restoring main's lock and stamping just the workspace versions keeps
the PR reviewable. Workspace package blocks verified identical to the
relocked version.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

---------

Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-27 23:20:42 +00:00
Sabhya Chhabria 19ca227bc7 feat(polly): launch supported children in goal mode (#3362)
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-27 16:00:19 -07:00
SatoTaiga 50aa69420b fix(codex): attribute per-model usage for turns with no pinned model (#3287)
* fix(codex): attribute per-model usage for turns with no pinned model

codex_executor's TurnComplete.usage never carried a "model" field, unlike
every other relay executor (claude-sdk, cursor, copilot, openai-agents,
pi). For a codex-harness agent that pins no llm.model (e.g. Debby's
gpt head, which deliberately defers to the harness/provider default),
_accumulate_session_usage's model-resolution fallback chain had nothing
to resolve to, so the turn's flat token/cost totals still accumulated
but session_usage.by_model silently never got an entry for it.

Stamp the turn's resolved model (already in scope as run_turn's `model`
argument) onto the usage dict extracted from tokenUsage/updated, mirroring
claude_sdk_executor's observed_model pattern.

Signed-off-by: Sato Taiga <antfgxgres@gmail.com>

* test(sessions): add regression test for codex per-model usage attribution

Exercises the real _accumulate_session_usage and GET /v1/sessions/{id}
API against a codex-harness agent with no pinned llm.model (Debby's gpt
head's exact shape): a usage delta with no "model" key still accumulates
the flat total but leaves by_model empty (the bug), while one carrying
"model" (as codex_executor.py now stamps it) gets a by_model entry that
also surfaces through the session snapshot the web UI's cost panel reads.

Signed-off-by: Sato Taiga <antfgxgres@gmail.com>

---------

Signed-off-by: Sato Taiga <antfgxgres@gmail.com>
2026-07-27 15:38:08 -07:00
omnigent-ci[bot] 40ad8b73ee docs(changelog): record v0.7.0 (#3373)
Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-27 15:12:23 -07:00
Zeyi (Rice) Fan 92b1e10e53 feat(onboarding): enforce supported CLI version ranges for native harnesses (#3335)
N/A

- Added `min_version` and `max_version_exclusive` to `HarnessInstallSpec` and made `harness_cli_installed` probe `--version` when bounds are declared, so setup and dispatch fail loud for outdated CLIs.
- Implemented generic `--version` parsing + PEP 440 comparison with date-version normalization so Cursor and Hermes calendar-version strings compare correctly.
- Wired code- and changelog-derived version floors for all CLI-backed native harnesses (e.g. Claude >=2.1.161, Codex >=0.137.0, Cursor >=2026.06.02, Kimi >=1.47.0, Hermes >=2026.06.05).
- Updated the CLI setup overview and install prompt to show "Needs upgrade" and the detected/declared versions instead of claiming a present-but-outdated CLI is "not installed".
- Added the `version-too-low` readiness reason and surfaced it in the web UI badge/notice; also made Cursor native auth-aware so it now reports `needs-auth` when installed but not logged in.
- Fixed the readiness-layer lookup so `version-too-low` correctly surfaces for all native harnesses that declare a version floor (Claude, Cursor, OpenCode, Kiro, etc.) instead of falling back to `binary-missing`.
- Preserved the existing `antigravity-native` credential gate: an installed `agy` CLI without a stored Gemini credential still reports not-ready.
- Added E2E UI coverage for the new `version-too-low` warning and updated readiness unit tests for version-bound and credential-bound behavior.

```bash
uv run pytest tests/onboarding/test_harness_install.py \
              tests/onboarding/test_harness_readiness.py \
              tests/cli/test_configure_models.py \
              tests/test_codex_native.py -q

npm run --silent test -- --run src/lib/harnessSetup.test.ts src/shell/NewChatDialog.test.tsx
```

N/A — the change is mostly backend/UX copy; no new visual components.

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

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

Manual verification: ran targeted backend/web test suites after each change and confirmed `omnigent setup`/`harness_cli_installed` now report “installed (vX) but not supported” rather than “missing” for outdated CLIs.

Native harness setup now checks the installed CLI version and prompts to upgrade if it is too old.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 14:22:06 -07:00
David O'Keeffe 7048f7a38b fix(hermes): introspect state.db schema to survive cross-version column drift (#2774)
Signed-off-by: David O'Keeffe <dgokeeffe@users.noreply.github.com>
Co-authored-by: David O'Keeffe <dgokeeffe@users.noreply.github.com>
2026-07-27 20:55:17 +00:00
nhsdb 2c5d50b68b egress proxy: trust loose capath CAs, not just the cafile bundle (#3264)
The MITM egress proxy verifies upstream TLS against the system trust
store built by _system_ca_bundle(). It read only the consolidated
cafile (get_default_verify_paths().cafile/openssl_cafile) and ignored
the capath directory. Corporate MDM / IT-managed roots are commonly
installed as loose files under capath (with hashed symlinks) rather than
merged into the cafile, so they were missing from the proxy's trust
store. Any upstream host whose chain relies on such a root then failed
verification (e.g. a corp-intercepted github.com returned 502 from the
proxy) even though the host's own tools trusted it.

Read capath too: concatenate the loose PEM certs from the capath
directory onto the cafile bundle (dedup by resolved path, skip non-PEM
entries), keeping the certifi fallback when neither yields any certs.

Added tests: a CA present only as a loose capath file lands in the
bundle, and non-PEM files in capath are skipped.

Signed-off-by: Nishith Sinha <308961816+nhsdb@users.noreply.github.com>
Co-authored-by: Nishith Sinha <308961816+nhsdb@users.noreply.github.com>
2026-07-27 13:34:51 -07:00
nhsdb 3d58649e77 bwrap sandbox: bind /etc/alternatives so update-alternatives tools resolve (#3263)
Tools invoked by generic name (awk, python3, editor, pager, ...) resolve
through /usr/bin/<name> -> /etc/alternatives/<name> -> real binary. The real
binaries already live under the mounted /usr, but /etc/alternatives was not
bound, so the intermediate symlink node was missing inside the jail and the
lookup failed with 'command not found'.

Bind /etc/alternatives read-only in the default _DEFAULT_ETC_DIRS list,
alongside the existing /etc/ssl and /etc/ca-certificates dir binds. It is a
directory of symlinks (no secrets); read-only means the mapping cannot be
repointed, and every target is a binary already exposed under /usr, so this
grants no new capability -- it only restores standard name resolution.

Linux (bwrap) backend only; darwin_seatbelt is unaffected by this mechanism.

Signed-off-by: Nishith Sinha <308961816+nhsdb@users.noreply.github.com>
Co-authored-by: Nishith Sinha <308961816+nhsdb@users.noreply.github.com>
2026-07-27 20:29:27 +00:00
Anthony Ivan 638df430be fix(codex-native): keep task plans out of chat (#3249)
CI / gate (push) Failing after 1s
Doc sync / Classify and draft docs (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
2026-07-27 10:57:57 -07:00
Jakub Majorek 09a035ebb3 🐛 fix(usage): attribute native per-model cost by delta, not cumulative total (#3223)
Native harnesses (claude-native / codex-native) report a cumulative
SESSION total, not a per-model split. `_persist_native_cumulative_usage`
SET each active model's `by_model` bucket to the whole running total, so a
session that switched models mid-run double-counted the shared baseline:
the previous model kept its last cumulative snapshot while the new model
was set to the full total, and summing the buckets exceeded the session
total (e.g. total $11.91 but opus $10.80 + sonnet $11.91).

Attribute only each report's growth (new - old) to the currently-active
model instead, mirroring the relay path's per-model delta accumulation.
Per-model token and cost buckets now hold each model's own usage and sum
to the flat session total across model switches. Deltas are clamped >= 0
so a lowered / rebased report never claws usage back out of a bucket (the
flat totals are likewise monotonic-clamped).

Read-only reporting (`omni usage`, the web session sidebar) needs no
change — it reads `by_model` verbatim, so corrected data flows through.
Existing sessions keep their already-stored buckets; this corrects
attribution for turns recorded after it ships (not backfillable).

Co-authored-by: Isaac

Signed-off-by: Jakub Majorek <majorek.jakub@gmail.com>
2026-07-27 16:00:46 +00:00
Cathy Yin 7dbdb821d3 feat(web): add a harness credential from the New Chat setup dialog (M3 frontend) (#3090)
* feat(web): add a harness credential from the New Chat setup dialog (M3 frontend)

Frontend for Setup From the Web UI — turn a yellow needs-setup harness
green from the browser (Claude/Codex/Pi) via an inline equal-weight auth
form (adopt / subscription signpost / API key / gateway), plus the setup
dialog UX cleanups. Gated behind the existing harness_install_enabled cap.

Rebased onto latest main (the M3 backend #3088 is now upstream, so only
web/ + follow-up backend fixes remain) and folded in the Polly review
notes: stable option keys, clear secret fields on save, and a note that
default_model/wire_api are backend-accepted but reserved for a follow-up.

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

* fix(web): scope useHosts refocus-refetch to the setup flow (Polly review)

staleTime:0 + refetchOnWindowFocus was app-wide across ~8 useHosts
consumers, bumping /v1/hosts volume on every refocus. Make it an opt-in
refetchOnFocus flag; only the setup dialogs (NewChatDialog, HarnessSetupDialog)
that need live readiness recovery pass it. Others keep the 30s stale window.

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

* fix(web): guard the credential form against double-submit + close test gaps

Address Pat's review:
- Gate both form onSubmit handlers on !busy so hitting Enter in the field
  during an in-flight save can't re-POST the secret (the Save button was
  already disabled, but the keyboard path wasn't guarded).
- Add a double-submit-guard test, plus direct hook tests for
  useStoreCredential (path/body split, JSON detail + non-JSON error parse,
  cache patch + detect invalidation) and useDetectedCredentials
  (GET/parse, empty-body fallback, enabled/host gating).

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

* fix(web): let Pi adopt an openai-family credential too (Polly review)

Pi consumes both anthropic and openai and the daemon adopts a detected
credential under its OWN family, so a host with only $OPENAI_API_KEY could
back Pi — but the adopt filter scoped to Pi's single write-default family
(anthropic), hiding that affordance. Add harnessCredentialAdoptFamilies
(Pi -> both families) and filter the adopt row on it; the paste/gateway
paths and the cross-family guard for Claude/Codex are unchanged.

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

---------

Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>
2026-07-27 17:33:29 +07:00
Yi Lyu c1acaf885f fix(policies): scan text attachments for PII at the request gate (#2927)
Signed-off-by: Yi Lyu <isabellalyu1130@gmail.com>
Co-authored-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-27 10:19:38 +00:00
Jackson Zheng cd23178ad4 Polish sidebar header spacing (#3346)
* Polish sidebar header spacing

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(ui-snapshot): update visual baselines

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

---------

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-27 10:15:48 +00:00
Tomu Hirata 0e0bc901b5 fix(codex-native): carry hook trust across private CODEX_HOME copy (#3343)
* fix(codex-native): carry hook trust across private CODEX_HOME copy

When codex-native provisions a per-session private CODEX_HOME and copies
config.toml into it, the [hooks.state] keys inside the copy still reference
the global ~/.codex/ paths. Codex keys trust records by the absolute path of
the hooks file, so every key misses and Codex opens an interactive "Hooks need
review" prompt on every launch. Headless sub-agents can never answer it, so
the app-server never emits thread/started and the run dies on the 15s timeout.

Fix: two changes to _populate_codex_home_config:

1. Symlink hooks.json from the global home into the private home (alongside
   auth.json). This makes the user's hooks reachable at the private path.

2. After copying config.toml, rewrite [hooks.state.*] key path prefixes from
   source_dir to target_dir. The hash values are left untouched, so trust is
   neither widened nor weakened — it is only carried across the copy that
   Omnigent itself performs.

Fixes #3268.

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

* fix: gate hooks.json symlink on not minimal_config; drop redundant re import

The minimal_config path rebuilds config.toml from scratch with only
model_provider/model_providers/profiles — no [hooks.state] entries.
Symlinking hooks.json there with no trust state re-introduces the
interactive trust prompt for the title worker. Gate the symlink (and
the trust-key rewrite that gives it meaning) on not minimal_config.

Also remove the redundant `import re as _re` inside
_retarget_codex_hook_trust_keys; re is already imported at module level.

Addresses Polly review feedback on #3343.

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

* fix(codex-native): flush accepted hook trust back to global config on close

When a user accepts the hook-trust prompt inside a session, Codex writes
[hooks.state] entries into the per-session private config.toml — but those
are discarded when the session ends because the private CODEX_HOME is
ephemeral. So the prompt reappears on every launch.

Fix: in CodexNativeAppServer.close(), call _merge_codex_hook_trust_back to
read [hooks.state] from the private config.toml, translate the path keys
from the private home back to the global ~/.codex/ prefix, and upsert them
into ~/.codex/config.toml atomically. The next session's _populate_codex_home_config
copies the global config (now with the trust entries), and
_retarget_codex_hook_trust_keys translates the paths forward to the new
private home — so Codex sees the hooks as already trusted and skips the prompt.

The write is best-effort: any failure is logged as a warning rather than
raised, since the session has already ended.

Fixes #3268.

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

* fix: assign tmp before try block to avoid unbound variable warning

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

---------

Signed-off-by: Tomu Hirata <tomu@omnigent.ai>
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-27 10:08:54 +00:00
Tomu Hirata 54d8e61c01 fix(claude-sdk): surface ResultMessage is_error as ExecutorError, not assistant text (#3342)
* fix(claude-sdk): surface ResultMessage is_error as ExecutorError, not assistant text

When the Claude SDK reports a harness-level failure (e.g. an expired
login or unauthenticated session), the terminal ResultMessage carries
is_error=True and the failure text in result. The executor was ignoring
is_error and assigning result directly to response_text, so the error
appeared in the conversation as though the model had said it — with no
error item, no harness attribution, and no log line.

Fix: check is_error before touching response_text. When true, set
terminal_error (the existing path that yields ExecutorError and returns)
and log an error line naming the agent. When false, the existing
response_text assignment runs unchanged.

Also add is_error to _ResultMessageObj so the Protocol matches the
SDK's actual shape (it was only declared on _ToolResultBlockObj before).

Closes #3282

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

* fix(claude-sdk): use getattr for is_error, handle null result, add unit test

Address Polly review feedback on #3342:

- Use getattr(result_msg, 'is_error', None) instead of direct attribute
  access so that existing test doubles that only set session_id/result
  don't raise AttributeError (matching the sibling getattr calls for
  session_id and usage in the same block).

- When is_error=True but result is None/empty, fall back to a generic
  'claude-sdk harness error' message rather than silently dropping the
  failure.

- Add test_result_message_is_error_yields_executor_error: verifies that
  a ResultMessage with is_error=True is routed to ExecutorError and does
  not appear in TurnComplete.response.

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

* fix(test): wrap long assertion string to satisfy ruff E501

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-27 19:01:10 +09:00
Pat Sukprasert fdeac467eb fix(web): stop short links collapsing table columns in chat markdown (#3350)
* fix(web): stop short links collapsing table columns in chat markdown

Streamdown styles links with `wrap-anywhere` (overflow-wrap: anywhere),
which also drops the element's min-content width to a single character.
Inside its `table-layout: auto` table that let a link-only column be
squeezed to ~2ch, so a short link like "#3090" stacked one or two
characters per line while the prose columns took all the width.

Narrow links inside table cells to `break-word`: overlong URLs still
soft-wrap, but min-content stays at the longest unbreakable run so the
column can no longer be squeezed below it. Prose links keep `anywhere`.

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

* test(e2e-ui): guard markdown table link column width in the browser

The CSS fix for the collapsing "PR #" column is only observable with a
layout engine, so the vitest companion can pin the rule and its selector
scoping but not the width. This adds the browser-side half: a seeded
assistant message renders the table shape that triggered the bug — a
link-only `#` column, wide prose columns, and a full-URL column — and
asserts the short link stays on one line box, its cell is at least as
wide as the link, and a long URL still soft-wraps inside its cell.

Verified against the pre-fix stylesheet: `#3090` stacks across 5 line
boxes without the `overflow-wrap: break-word` narrowing, 1 with it.

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-27 09:58:46 +00:00
Anthony Ivan 3f357d0f0e fix(openai-agents): honor explicit Databricks profiles (#3288)
Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
2026-07-27 09:42:23 +00:00
Tomu Hirata ee2b14a35a fix(seatbelt): resolve two-hop proxy symlink when detecting CPython install root (#3344)
* fix(seatbelt): resolve two-hop proxy symlink when detecting CPython install root

When a Python interpreter is installed via `uv tool install`, the
executable is a two-layer symlink:

  ~/.local/share/uv/tools/<pkg>/bin/python  →  (proxy)
      ~/.local/share/uv/python/cpython-3.12.X-.../bin/python3.12

The literal proxy path grandparent (`tools/<pkg>/`) has no CPython
`lib/python*` markers, so `_interpreter_install_root` returned None.
`_add_topmost` then raised OSError before ever checking the resolved
path, causing every session to fail with:

  darwin_seatbelt: helper interpreter at '.../uv/tools/omnigent/bin/python'
  resolves under the unsafe ancestor '/Users'; ...

Fix: in `_add_topmost`, when the literal path yields no install root,
resolve it one level and retry `_interpreter_install_root` on the
resolved path before giving up. The resolved CPython install root
(which does carry the canonical markers) is then granted as the narrow
subpath, matching the existing behaviour for direct uv-python installs.

Also update the OSError message to say 'CPython install root' and note
that both the literal and resolved path were tried, and fix the
matching assertion in the existing test.

Fixes #3237.

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

* fix(seatbelt): grant pi_dir and $TMPDIR write root so sandboxed pi can boot

Two follow-up fixes found by running `omnigent run --harness pi` with
darwin_seatbelt enabled end-to-end:

1. with_additional_read_roots silently dropped pi_dir

   When the spec declares no read_paths, resolve_sandbox returns
   read_roots=None (meaning 'no spec-supplied grants').
   with_additional_read_roots bailed early on None, so the pi node_modules
   dir granted by _try_sandbox_pi was never added to the policy. Result:
   pi failed with 'Cannot find package .../pi-ai/index.js' because the
   seatbelt profile had no subpath rule for the nvm install tree.

   Fix: treat None as an empty list rather than 'already unrestricted' —
   the caller is explicitly widening the policy and must be honoured even
   when the spec has no grants of its own.

2. PI_CODING_AGENT_DIR was created under $TMPDIR, which wasn't granted

   _try_sandbox_pi granted /tmp as a write root, but on macOS $TMPDIR is
   /var/folders/.../T/ (not /tmp). PI_CODING_AGENT_DIR is created with
   tempfile.mkdtemp() which uses $TMPDIR, so pi got EPERM trying to write
   its extension/settings. Fix: also grant tempfile.gettempdir() alongside
   /tmp.

With all three fixes (two-hop symlink detection, read-roots None handling,
TMPDIR grant) `omnigent run /tmp/pi-sandbox-bundle --harness pi` boots and
completes a full turn end-to-end under darwin_seatbelt.

Fixes #3237.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-27 18:41:53 +09:00
Tomu Hirata 505b4f821a fix(pi): migrate Pi to Databricks v2 gateway endpoints (#3307)
* fix: route kimi and inkling through Responses API via system.ai.* ids

Kimi and inkling never send finish_reason in /chat/completions streaming
responses, causing Pi to throw 'Stream ended without finish_reason'.

These models work correctly via the Responses API at /ai-gateway/codex/v1
using their system.ai.* model ids (system.ai.kimi-k2-7-code,
system.ai.inkling).

- Add system.ai.kimi-k2-7-code and system.ai.inkling to
  _DATABRICKS_RESPONSES_MODELS in the executor
- Add _DATABRICKS_TO_SYSTEM_AI mapping in pi_native_credentials so live
  endpoint fetch translates databricks-* ids to system.ai.* and routes
  them to the gpt_responses bucket (openai-responses at /ai-gateway/codex/v1)
- Update _pi_needs_responses_api to treat system.ai.* models as responses
- Update _pi_provider_for_model to route system.ai.* to databricks-openai

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

* fix(review): restore substring reasoning fallback and fix run-path translation

Addresses Polly's review of #3307:

1. Restore 'kimi'/'inkling' to substring reasoning check in _fetch_pi_model_lists
   so unmapped variants (renamed/versioned endpoints not in _DATABRICKS_TO_SYSTEM_AI)
   still get reasoning:true — preventing silent regression.

2. Translate databricks-* model ids to system.ai.* in the executor run path
   (_build_env_and_dir) so model_override='databricks-kimi-k2-7-code' correctly
   routes to the databricks-openai (Responses API) provider, not databricks-completions.

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

* fix: move GLM to Responses API via system.ai.glm-5-2

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

* fix: move Qwen3 to Responses API via system.ai.* ids

Qwen3 returns array content with tool calls via /chat/completions causing
[object Object] errors. system.ai.qwen3-next-80b-a3b-instruct and
system.ai.qwen35-122b-a10b work correctly via the Responses API.

Also removes qwen3 from _unsupported_in_pi since it's now handled.

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

* refactor: replace hardcoded system.ai map with keyword-based detection

- Replace _DATABRICKS_TO_SYSTEM_AI exact-id dict with _databricks_to_system_ai()
  function that detects by keyword (kimi, inkling, glm-5, qwen3, qwen35) and
  derives system.ai.* id by stripping 'databricks-' prefix. Handles future model
  variants automatically without needing to update an exact-id map.

- Apply the same swap in model_catalog._fetch_databricks_listing so sys_list_models
  returns system.ai.* ids directly, letting the LLM use the correct id immediately.

- Use specific fragments (glm-5 not glm) to avoid false-positives like
  zai-org-glm-4-7 which has no system.ai.* alias.

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

* fix(review): fix _ensure_rpc selector translation; revert GLM to completions path

Addresses Polly's blocking issues:

1. Normalize model id to system.ai.* at the top of _ensure_rpc so that both
   models.json and the provider/model selector see the same id. Previously only
   _build_env_and_dir translated the id but _ensure_rpc still built the selector
   from the untranslated databricks-* id, causing 'Model not found' in Pi.

2. Revert GLM (databricks-glm-5-2) back to the completions path. GLM works fine
   via /chat/completions with finish_reason=true — moving it to the Responses API
   was unnecessary and undocumented. Removed from _SYSTEM_AI_MODEL_KEYWORDS and
   _DATABRICKS_RESPONSES_MODELS.

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

* refactor: use Unity Catalog model-services API for Pi model discovery

Replace /api/2.0/serving-endpoints with /api/2.1/unity-catalog/model-services
which returns system.ai.* model ids directly with supported_api_types metadata.

Benefits:
- No databricks-* → system.ai.* translation needed
- Authoritative API capability info: models with 'openai/v1/responses' in
  supported_api_types go to the Responses provider; others to completions
- Embeddings excluded cleanly via has_embedding check
- sys_list_models returns system.ai.* ids directly via _fetch_databricks_uc_listing

Also add _ensure_rpc id normalization so databricks-* model_override values
are translated before building the provider/model selector.

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

* fix: route all system.ai.* models through AI Gateway (omnigent-openai)

system.ai.* ids are not valid at /serving-endpoints — they only work
via the AI Gateway at /ai-gateway/codex/v1. Previously, system.ai.*
models without openai/v1/responses in UC metadata (kimi, inkling,
qwen3) were routed to omnigent-completions at /serving-endpoints,
causing 404 errors.

Route all system.ai.* models to omnigent-openai regardless of UC
supported_api_types.

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

* fix(test): update test to expect all system.ai.* models in gpt_responses

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

* fix(pi-native): surface Pi model errors as visible error items in web UI

When Pi's API call fails (e.g. 404 for unknown model id, 400 for
unsupported API type), the extension was silently returning from
message_end with no output, leaving users with an empty turn.

Post an external_conversation_item of type 'error' when message.stopReason
is 'error', so the error appears in the web UI chat.

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

* fix(tests): update model_catalog tests for Unity Catalog API format

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

* fix: revert Qwen3 from responses API - Pi sends fields that Qwen3 rejects

/ai-gateway/codex/v1/responses rejects Pi's standard Responses API fields
(parallel_tool_calls, temperature:null, top_p:null) for Qwen3, causing 400.
Route Qwen3 back to omnigent-completions until either:
- Pi adds compat flags to suppress these fields for non-standard providers
- The upstream array-content fix (earendil-works/pi#7062) lands to fix [object Object]

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

* fix: restore Qwen3 to Responses API path via system.ai.*

Pi only sends store:false in requests - the earlier 400 was from a stale
session before the routing fix. Confirmed minimal Pi request works fine
for Qwen3 via /ai-gateway/codex/v1/responses.

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

* fix(review): scope UC listing to pi path only; fix test fixtures

Polly's review correctly identified that using _fetch_databricks_uc_listing
for all Databricks providers leaks system.ai.* ids to non-pi harnesses
(claude-sdk, codex, openai-agents) that only understand databricks-* ids.

Revert model_catalog.py to use _fetch_databricks_listing (serving-endpoints)
for sys_list_models. _fetch_databricks_uc_listing remains available but is
only used internally by pi_native_credentials._fetch_pi_model_lists.

Also fix test_model_catalog.py fixtures to use the correct serving-endpoints
payload shape (databricks-* ids) rather than the UC model-services shape
(system.ai.* ids) which the non-pi listing never emits.

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

* test(model_catalog): update pi tests for UC model-services API

Pi harnesses now call `/api/2.1/unity-catalog/model-services` and return
`system.ai.*` model ids instead of `databricks-*` ids. Update the test
fixtures and expected ids to match:

- `_databricks_transport`: now serves both the serving-endpoints page
  (non-pi) and a UC model-services page (pi harness calls).
- `test_databricks_listing_filters_to_chat_llms`: expect `system.ai.*`
  ids and matching family assertions.
- `test_databricks_listing_skips_explicitly_non_ready_endpoints`: rewrite
  to use UC format (UC has no per-service readiness flag).
- `test_listing_failure_reported_and_not_cached`: switch to codex-native
  harness to test generic failure/retry without UC routing complexity.
- `pi-everything` parametrize: update expected ids to `system.ai.*`.
- `model_catalog.py`: add TTL cache for UC listings (same `_listing_cache`
  with a `"uc:"` prefixed key) so pi harness calls cache-hit correctly.

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

* fix(model_catalog): fix ruff RUF005 and E501 lint errors

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

* fix(test_model_catalog): shorten docstring to fix E501

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

* fix(pi_executor): scope system.ai.* Responses-API routing to kimi/inkling/qwen only

system.ai.claude-* and system.ai.meta-llama-* ids should route to their own
providers (Anthropic surface and completions respectively), not the Responses
API. Previously _pi_needs_responses_api returned True for *all* system.ai.*
ids, which would have routed llama to the Responses endpoint.

Fix: check _SYSTEM_AI_MODEL_KEYWORDS in the system.ai.* branch so only kimi,
inkling, and qwen3 variants return True. Claude is already caught upstream by
the "claude" substring check in _pi_provider_for_model.

Also update stale docstrings in _needs_responses_api and _unsupported_in_pi
that still mentioned qwen3 as excluded (it was re-enabled via the Responses API).

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

* feat(pi): route GLM via Responses API (system.ai.* ids)

GLM has the same finish_reason issue as Kimi/inkling on /chat/completions.
Route it through the AI Gateway Responses API by adding "glm-" to
_SYSTEM_AI_MODEL_KEYWORDS (uses "glm-" not bare "glm" to avoid matching
"zai-org-glm-4-7" which has no system.ai.* alias).

- Remove GLM from _PI_REASONING_MODEL_FRAGMENTS (reasoning:true is a
  completions-path flag; not needed for Responses API).
- Remove GLM from the reasoning:true assignment in _fetch_pi_model_lists.
- Update test: kimi no longer gets reasoning:true (Responses API path).

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

* fix(pi): remove gpt-oss from _unsupported_in_pi; it routes via Responses API

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

* fix(pi): exclude all Gemini models from Pi, not just gemini-2-5

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

* fix(pi): exclude only gemini-2-5 from Pi; other Gemini models use completions

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

* fix(pi): drop redundant qwen35 keyword; qwen3 already matches qwen35 ids

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

* refactor(pi): remove _databricks_to_system_ai; catalog always returns system.ai.* for pi

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

* fix(pi): remove reasoning:true from kimi/inkling static model entries

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

* feat(pi): route Gemini via /ai-gateway/mlflow/v1/chat/completions using system.ai.* ids

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

* fix(pi): fix _unsupported_in_pi to only exclude gemini-2-5; gemini-3+ route via mlflow

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

* refactor(pi): remove static kimi/inkling/qwen3 entries from _DATABRICKS_RESPONSES_MODELS

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

* fix(pi): route system.ai.* llama/other models to mlflow gateway; rename provider to databricks-mlflow

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

* fix(pi): use generic base URL for non-Databricks providers (OpenAI API key, LiteLLM)

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

* fix(pi): address Polly review — fix 4-tuple annotation, system.ai.gpt routing, gpt-oss exclusion, UC listing filter

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

* fix(model_override): strip system.ai.* prefix for vendor-direct providers (OpenAI key, etc.)

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-27 17:48:41 +09:00
Serena Ruan 2a60e17d89 fix(pi-native): surface unresolved Databricks credentials instead of a silent dead session (#3336)
A native Pi session routed through a Databricks gateway whose OAuth token
can't be resolved (expired refresh token) launched fine but every message
silently failed to reach the model — no reply, no error. `_databricks_pi_provider`
caught all failures in one try/except and still returned a provider whose
`!databricks auth token` apiKey fails at request time; because pi-native
dispatches turns fire-and-forget, the failure never round-tripped back as an
Omnigent error.

Split credential resolution from the (benign) model-list fetch so a genuine
auth failure carries a `credential_warning`. At terminal auto-create, surface
that warning as an `error` item via `external_conversation_item`: it renders as
the web UI's distinct error banner (not a misleading assistant bubble),
persists across reload, is a non-content item type so it never enters the next
turn's context, and posts without queuing an agent turn (safe on a session
whose model is unreachable).

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-27 15:43:32 +08:00
Abdullah Said 3c64d66aa5 feat(catalog): add claude-opus-5 to curated claude subscription models (#3275)
Claude Opus 5 (released 2026-07-24) was missing from the curated
_SUBSCRIPTION_STATIC_MODELS["claude"] list. Verified empirically against
Claude Code 2.1.220: 'claude-opus-5' -> is_error:false; the dated form
'claude-opus-5-20260724' and a 'claude-opus-5-fast' variant both return
is_error:true, so neither is added.

Placement follows the existing convention: tiers descend
fable -> opus -> sonnet -> haiku, newest version first within a family
(matching claude-sonnet-5 ahead of claude-sonnet-4-6), so opus-5 slots
between fable-5 and opus-4-8.

The web mirror (web/src/lib/claudeNativeModels.ts) needs no change: it
lists version-agnostic aliases ('opus' resolves to the latest Opus) by
design, not pinned ids.

Signed-off-by: Abdullah Said <abdullahsaid89@gmail.com>
Co-authored-by: omnigent <noreply@omnigent.ai>
2026-07-27 14:29:13 +07:00
Serena Ruan 5e62d0e44b ci(ui-snapshot): make the visual-baseline gate merge-blocking + regenerate baselines (#3338)
* ci(ui-snapshot): make the visual-baseline gate merge-blocking

The UI Snapshot visual-regression check was advisory ([non-blocking]) and
not in the required-checks set, so a UI change could land without
regenerating the committed baselines — which is how the baselines drifted
stale on main (every PR since #3311 fails the gate identically).

Register it as a required merge gate:
- Drop the "[non-blocking]" suffix from the job name.
- Add "UI Snapshot (visual baselines)" to REQUIRED and ALLOW_SKIP in
  merge-ready/required.sh, plus a workflow_for mapping. It's safe as a
  required check: a PR touching no render input skips the render via the
  `detect` job's `if` gate, and an if-skipped job reports success — so
  non-UI PRs satisfy the check instead of sitting pending. ALLOW_SKIP +
  workflow_for let the gate tell that genuine skip from a still-pending run.
- Add "UI Snapshot" to merge-ready.yml's workflow_run triggers so the gate
  re-evaluates when the snapshot workflow completes.
- Update the visual README's merge-blocking section.

This PR edits ui-snapshot.yml (a render input), so the gate runs here and
fails on the stale baselines; the `update-ui-snapshot` label regenerates
them onto this branch to turn it green.

Co-authored-by: Isaac

* test(e2e-ui): regenerate visual baselines

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-27 15:19:33 +08:00
Serena Ruan 2de2d3f888 fix(sessions): fall back to localStorage when pinning against an old server (#3332)
* fix(sessions): fall back to localStorage when pinning against an old server

A pin created in the new UI while the server is still pre-upgrade was lost
on the server upgrade. The pin toggle PATCHes `omnigent.pinned`; an old
server (no per-user pin concept) stores it as a bare label, but the
upgraded server's read path (`_labels_for_viewer`) drops every bare/
`omnigent.pinned.*` key and only surfaces the caller's own
`omnigent.pinned.<user>` key — so the bare-key pin silently vanishes. The
localStorage→server migration couldn't recover it either, since that pin
was never in localStorage.

This complements the earlier migration-gate fix (which protected pins made
*before* the UI upgrade). Now the toggle also checks `filterHonored`: when
the server can't store pins, it writes the pin to localStorage (the same
store the pre-upgrade UI used) instead of PATCHing a doomed bare key. The
pin renders immediately (sidebar unions localStorage pins) and later
migrates through `useMigrateLocalPinsToServer` like any pre-upgrade pin.
Once the server can store pins, the toggle uses the server as before.

- Move the legacy-pin localStorage helpers from Sidebar.tsx to the leaf
  sidebarNav module (+ a single-id `setLegacyPinnedConversationId`) so the
  toggle hook can use them without an import cycle.
- Tests: unit coverage for the toggle's old-server fallback (pin/unpin to
  localStorage, no PATCH; normal PATCH path once honored), and an
  end-to-end case in the backwards-compat suite that pins DURING the
  UI-before-server window and asserts it survives the server upgrade.

Co-authored-by: Isaac

* fix(sessions): surface local-write failures in the old-server pin fallback

Addresses a review note: the old-server pin toggle's localStorage write is
the pin's only persistence, but it went through the best-effort
`writeLegacyPinnedConversationIds`, which swallows write errors (e.g.
storage quota exceeded). So a failed write let the mutation report success
and the optimistic patch show the pin, while it silently vanished on reload
— with no rollback.

Split out a throwing `...OrThrow` raw write. The old-server fallback
(`setLegacyPinnedConversationId`) now uses it, so a failed write rejects the
mutation → `onError` rolls back the optimistic patch and the UI honestly
shows the pin didn't take, matching the server PATCH path. The migration's
best-effort write is unchanged (a failed write there just retries next load).

Test: the fallback rolls back the optimistic pin when the local write throws.

Co-authored-by: Isaac
2026-07-27 14:57:37 +08:00
Zeyi (Rice) Fan a7ef194c4f refactor(sandboxes): introduce contribution-based provider registry (#3330)
## Related issue

N/A

## Summary

- Add `omnigent/onboarding/sandboxes/types.py` with shared dataclasses
  (`SandboxCapabilities`, `SandboxSpec`, `SandboxInfo`, `HostContext`) and the
  new `SandboxError` exception hierarchy.
- Add `omnigent/onboarding/sandboxes/registry.py` with a contribution-based
  provider registry that mirrors `omnigent/harness_plugins.py`: built-in
  providers are declared as a `SandboxProviderContribution`, community
  packages register via the `omnigent.sandbox_providers` entrypoint group, and
  broken plugins are recorded in `load_errors` without breaking core startup.
- Add `omnigent/community/sandbox/__init__.py` as a namespace package so
  third-party providers can ship code under `omnigent.community.sandbox.*`.
- Validation enforces that community provider code lives under the community
  namespace, rejects name collisions, and checks metadata consistency.
- Add a `capabilities` property to `SandboxLauncher` that derives feature flags
  from existing class variables and overridden transport methods.
- Migrate CLI and managed-host call sites from direct class-var reads
  (`supports_cli_bootstrap`, `can_resume`, `supports_local_port_forward`) to
  the new `capabilities` object.
- Add unit tests for types, registry behavior, validation, and entrypoint
  discovery.

No provider implementations were changed; this is purely a surface-layer
refactor toward a pluggable sandbox provider interface.

## Test Plan

```bash
uv run pytest tests/onboarding/sandboxes tests/sandbox tests/cli/test_cli.py tests/server -k managed -q
pre-commit run --files omnigent/onboarding/sandboxes/types.py omnigent/onboarding/sandboxes/registry.py omnigent/onboarding/sandboxes/base.py omnigent/onboarding/sandboxes/__init__.py omnigent/onboarding/sandboxes/bootstrap.py omnigent/community/sandbox/__init__.py omnigent/cli_sandbox.py omnigent/server/managed_hosts.py tests/onboarding/sandboxes/test_types.py tests/onboarding/sandboxes/test_registry.py
```

All 779 selected tests pass and the targeted pre-commit hooks pass.

## Demo

N/A

## Type of change

- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] 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

New unit tests in `tests/onboarding/sandboxes/test_types.py` and
`tests/onboarding/sandboxes/test_registry.py` exercise the registry,
contribution validation, types, and capabilities derivation. Existing
provider and CLI tests pass unchanged, confirming backward compatibility.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 06:44:36 +00:00
Rahul Ravindranathan c1bedeaabd feat(automations): model + reasoning-effort selectors on automations (#3331)
* feat(scheduled): add Model + Reasoning-effort pickers to the task dialog

The scheduled-task create/edit dialog previously omitted model and effort,
sending only agent_id so tasks always ran with the agent's configured
defaults. Add lightweight Model + Reasoning-effort controls, gated by the
selected agent's capability exactly like the interactive New Chat dialog:
they render only for native coding agents that carry the model/effort
surface (Claude Code) and are hidden for agents without it (Codex, plain
SDK agents, etc.).

- New scheduled-local ModelEffortFields component reuses the shared option
  lists (CLAUDE_NATIVE_MODELS + the version-agnostic aliases, and
  CLAUDE_NATIVE_EFFORTS) rather than importing the 26-prop
  HarnessConfigModal, which is bound to smart-routing / cost-control /
  per-turn model loading and disproportionate for a saved task. When a host
  is pinned it uses that host's live model options; with none pinned (the
  common case) it falls back to the static Claude aliases.
- Hoist CLAUDE_NATIVE_EFFORTS into the shared HarnessConfigControls module
  so both dialogs share one source of truth.
- Wire modelOverride + reasoningEffort through create and update (both
  already round-tripped by scheduledTasksApi.ts — no client/API change).
  Unselected ("Default") omits the field on create so the fire path uses
  the agent's defaults; on edit, Default sends null to clear a prior
  override. Edit mode prefills both controls from the loaded task.

No permission/approval/cursor mode picker and no new API field: this is a
pure frontend change.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* test(automations): e2e for model + effort selectors

Extend tests/e2e_ui/scheduled/test_scheduled_tasks_page.py with UI journeys
for the model + reasoning-effort selectors added to the scheduled-task
create/edit dialog:

- controls visible + default to "Default" for a capability-gated agent
  (Claude Code)
- controls hidden (with the "uses defaults" hint) for a non-capable agent
  (seeded Codex task, asserted via the edit dialog)
- create persists a concrete Model + Effort pick (asserted via the REST API)
- create with both controls left on Default persists null overrides
- edit prefills the controls from a seeded task's stored overrides

LLM-free like the sibling tests: exercises only the dialog, REST, and the
rendered row. Uses Playwright expect() auto-waiting, no sleeps.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-26 23:36:49 -07:00
Zeyi (Rice) Fan dbfc7565e5 feat(web): remove sidebar font-size control and add appearance reset dialog (#3326)
## Related issue
N/A

## Summary
- Remove the dedicated Settings → Appearance → Sidebar → Font size card and the `lib/sidebarFontPreferences` module, since users should use the global Interface font size control instead.
- Clear the legacy `omnigent:sidebar-font-size` localStorage key on app boot so anyone who previously changed the sidebar font size falls back to the default 13px.
- Add a "Reset to defaults" button at the bottom of the Appearance section that opens a confirmation dialog and resets all appearance choices: mode, terminal theme, color palette/custom theme, workspace panel default, hide-unconfigured-harnesses toggle, and interface/code font size and family.

## Test Plan
- Updated unit tests in `web/src/pages/SettingsPage.test.tsx` covering the reset flow and the absence of the sidebar font size control.
- Added a Playwright E2E test in `tests/e2e_ui/sessions/test_appearance_reset.py` to verify the sidebar card is gone and the reset dialog restores defaults.
- To verify locally after installing web dependencies:
  - `cd web && npm run type-check`
  - `npx vitest run src/pages/SettingsPage.test.tsx`
  - `pytest tests/e2e_ui/sessions/test_appearance_reset.py`

## Demo
N/A — UI change; a screen recording of the reset confirmation dialog is recommended before merge.

## Type of change
- [ ] 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
local web dependencies are not installed in this environment, so the local type-check and vitest runs could not be executed. CI will run the web test suite on the PR branch.

## Changelog
Settings → Appearance no longer has a separate Sidebar font size control, and a new "Reset to defaults" button restores every appearance preference after confirmation.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-26 23:16:30 -07:00
Jackson Zheng 8b3856fefa Align sidebar project icons (#3317)
Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-26 23:07:06 -07:00
Serena Ruan a86fba610d feat(projects): name the project in the new-session hero, drop the tray chip (#3327)
* feat(projects): name the project in the new-session hero, drop the tray chip

When starting a session from within a project (a `?project=` landing), the
composer used to show the project as a pill in the footer tray while the hero
kept its generic "What should we do?" prompt. Move that context into the hero
instead: the heading shows the project name and Otto's eyes are swapped for the
same folder icon the sidebar uses for a project. The footer project chip
(`LandingProjectPicker`) is removed — filing on create still uses the same
`selectedProject` state, just without the redundant chip.

The folder icon renders in a fixed-height (`h-18`) box matching Otto so the
vertically-centered composer doesn't shift when toggling between the plain and
in-project landings.

Co-authored-by: Isaac

* fix(projects): clamp long project name in the new-session hero

A 100-char project name (the server-side cap) rendered at text-3xl overflowed
the centered container: the icon+heading flex row sized to its content with no
width bound, so the h1's min-w-0/line-clamp had nothing to act against. Give the
row w-full and keep the heading min-w-0 + line-clamp-2 + break-words so a long
name wraps to two lines and ellipsizes instead of overflowing. Add a test
asserting the clamp class contract on a 100-char name.

Co-authored-by: Isaac
2026-07-27 13:41:37 +08:00
Andrew Peltekci c4df88c712 fix(crash-handler): stop same-second crash reports overwriting each other (#3173)
The same-second filename collision was disambiguated by pid alone. A pid is
only unique across processes — a process that crashed more than twice within
one second reused its own pid, so every report after the first collision was
written to the same path and silently destroyed its predecessor. Saving five
reports in one second left two files on disk with three crash reports lost,
with rotation held wide enough that nothing should have been pruned.

Keep counting past the pid-suffixed name until the path is free.

test_save_report_writes_and_rotates encoded the bug: it asserted all five
returned paths still existed while rotation kept only two, which could only
hold when the collision collapsed them onto two names. It now asserts the
newest report survives its own rotation pass, and a new test pins the
no-overwrite guarantee with rotation held wide.

Signed-off-by: apeltekci <andrew@peltekci.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 05:36:43 +00:00
Anthony Ivan 96b2f6c97b docs: recommend omnidev for worktree testing (#3277)
Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
2026-07-27 05:34:56 +00:00
Serena Ruan 9835d09c1f fix(web): use lucide files icon for Files workspace tab (#3329)
Swap the Files right-rail tab glyph from FilePenLineIcon (pen-on-page) to
FilesIcon (stacked pages) to better convey the panel's contents.

Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-27 13:34:07 +08:00
Serena Ruan 1dd0c49e71 fix(sessions): don't wipe local pins when UI upgrades before server (#3323)
* fix(sessions): don't wipe local pins when UI upgrades before server

The one-time localStorage→server pin migration (#3189) trusted an
ambiguous success signal. A pre-upgrade server silently ignores the
unknown `?pinned=true` param and returns the normal (unfiltered) session
page, so the UI saw ~100 "server pins", computed an empty to-migrate set,
and cleared localStorage without ever writing a pin. After the server was
upgraded, its per-user key filter found nothing and every pin read as
unpinned — the reported data loss for UI-before-server upgrades.

Fix, entirely client-side:

- `fetchPinnedConversations` now returns `{ conversations, filterHonored }`.
  It keeps only rows actually carrying the `omnigent.pinned` label and
  reports `filterHonored: false` when the server returned unpinned rows —
  the tell-tale of an old server that ignored the filter.
- The migration is gated on `filterHonored`: it stays inert (localStorage
  untouched) against an old server and re-runs after the eventual upgrade.
  A legacy id is dropped only after its write is confirmed.
- Pinned membership is the union of the server's pins and any leftover
  localStorage pins, so a not-yet-migrated pin keeps rendering instead of
  vanishing during the UI-before-server window.

Tests: new filter-honored detection cases, a migration-gate suite, and an
end-to-end backwards-compat test that drives the real hooks across an
old→new server upgrade and asserts the pin is never lost.

Co-authored-by: Isaac

* docs(sessions): address Polly review notes on pin migration

- Document the empty-page ambiguity in `filterHonored` and why it's safe
  (an old empty page means a zero-session account; the migration PATCH to a
  deleted session 404s and the pin is retained, not lost).
- Note the window-scoped caveat that a legacy-only pin outside the loaded
  paginated window may not render a row until loaded.
- Add a regression test: a failed (404) migration write keeps the legacy
  pin in localStorage for retry.

Co-authored-by: Isaac
2026-07-27 13:20:50 +08:00
Rahul Ravindranathan f85452e4f3 feat(automations): relative next-run label + card rows (#3324)
* feat(automations): absolute next-run time + card rows

Change 1: the Automations list now shows the next run as an absolute
wall-clock time ("Next run Tomorrow at 8:00 AM" / "Today at 2:30 PM" /
"Jul 26, 8:00 AM") instead of a relative delta ("in 15h"). Adds
formatNextRunAtAbsolute() in scheduleText.ts, which only FORMATS the
server-authoritative next_run_at (rendered in the task timezone,
Today/Tomorrow bucketed in that same zone) and never recomputes which
instant is next on the client. The old relative formatNextRunAt() is
kept intact.

Change 2: each ScheduledTaskRow now renders as a card (rounded-xl
border bg-card, internal padding), and TasksPage stacks them with a
gap. All existing behavior and data-testids preserved; paused rows are
not dimmed.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* feat(automations): relative full-word next-run label

Reverses the earlier absolute-time next-run display back to a
server-sourced relative delta in full words ("Next run in 3 hours",
"Next run in 8 mins", "Next run in 2 days") per user feedback.

formatNextRunAt now emits full-word, pluralized buckets ('soon' /
'in N min(s)' / 'in N hour(s)' / 'in N day(s)'); the delta is still
computed only from the server's authoritative next_run_at, so the
"no client countdown" rule is unaffected. Removes the now-dead
formatNextRunAtAbsolute and its private helpers (safeFormat,
civilDayInZone). Card-row styling is unchanged.

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* fix(automations): live-tick the relative next-run label

The relative next-run label was frozen at its first-render `now` and
only refreshed on remount. A shared 30s useNow() clock (a module-level
singleton via useSyncExternalStore) now drives live re-renders, so the
delta counts down while the page stays open. TasksPage owns the one
ticker and passes `now` to each row, keeping the row a pure function of
props.

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* fix(automations): round next-run label to nearest unit

Flooring understated the relative next-run label near a unit boundary:
a task 1h49m away read "in 1 hour". formatNextRunAt now rounds to the
nearest minute/hour/day and promotes on carry (each threshold tests the
already-rounded value), so 1h49m reads "in 2 hours" and a delta that
rounds up to a full unit shows "in 1 hour"/"in 1 day" rather than
"in 60 mins"/"in 24 hours".

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* test(automations): e2e for live-ticking next-run countdown

Adds a Playwright test to the scheduled-tasks page suite proving the
relative next-run label re-renders on its own as time passes (the shared
useNow() ticker), with no navigation. Uses clock mocking for determinism:
pins the browser clock 40 min before the server's next_run_at, asserts
"Next run in 40 mins", fast-forwards 35 min past many 30s ticks, then
asserts the same row updated to "Next run in 5 mins". LLM-free.

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-26 22:01:01 -07:00
Zeyi (Rice) Fan a4a73ffe7f feat(cli): include auth override env var in non-loopback bind warning (#3320)
## Related issue

N/A

## Summary

- When `omnigent server` binds a non-loopback interface, it auto-enables accounts (login) mode and prints a warning.
- The warning now explicitly names `OMNIGENT_AUTH_ENABLED=0` as the override to keep single-user mode.
- Kept the warning to the canonical env var; removed any mention of the deprecated alias.
- Improved the rendered indentation so the override sentence starts on its own line.

## Test Plan

- `uv run ruff check omnigent/cli.py`
- `uv run pytest tests/cli/test_bind_auth_defaults.py -q`

Both pass.

## Demo

N/A

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] 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 `tests/cli/test_bind_auth_defaults.py` already exercises the non-loopback auto-enable path and the explicit `OMNIGENT_AUTH_ENABLED=0` override. This change only updates the warning copy.

## Changelog

`omnigent server` now tells users they can set `OMNIGENT_AUTH_ENABLED=0` to override automatic multi-user mode when binding to a non-loopback interface.
2026-07-26 20:44:11 -07:00
Zeyi (Rice) Fan f2b2f80948 refactor(server)!: remove deprecated OMNIGENT_ACCOUNTS_ENABLED env alias (#3322)
## Related issue

N/A

## Summary

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

## Test Plan

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

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

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

## Changelog

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

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

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-27 03:35:35 +00:00
Serena Ruan c53c631bae docs(projects): update PRD status — Phase 2 done, Phase 3 & 4 postponed (#3321)
Bring the Projects PRD implementation-status section in line with what has
shipped and what remains:

- Mark backend `config` hardening (size bound + non-dict coercion) as done —
  both already landed in the project store.
- Move the completed Benchmark (#3094) and Phase 2 (project defaults) items out
  of TODO into their own "Done" sections.
- Correct a stale claim that the new-session prefill machine still reads the
  `omni_project` label — it was collapsed to config-only in Phase 2. The one
  remaining UI label reader (the Settings archived-project picker) is folded
  into the Phase 4 retire-label-path step instead.
- Postpone Phase 3 (memory & context) and Phase 4 (label consolidation) with
  distinct triggers: Phase 3 waits for customer demand; Phase 4 waits until
  telemetry shows most clients have migrated to a version that writes
  `project_id`.

Co-authored-by: Isaac
2026-07-27 11:25:21 +08:00
Zeyi (Rice) Fan 5169c918c6 fix(claude-native): escape unsupported Claude Code slash commands (#3319)
## Related issue
N/A

## Summary
- Updated `inject_user_message()` in `omnigent/claude_native_bridge.py` so user messages that start with a Claude Code UI-only/unsupported slash command (`/help`, `/exit`, `/quit`, `/doctor`, `/cost`, etc.) are escaped before being pasted into the TUI.
- Escaping inserts an invisible zero-width no-break space before the leading `/`, causing Claude Code to treat the input as regular user text while the user still sees their slash.
- Supported slash commands (`/clear`, `/compact`, `/effort`, `/model`, `/ultrareview`, `/branch`, `/fork`) and unknown skill commands pass through unchanged.

## Test Plan
- Added parametrized unit test for `_escape_unsupported_slash_command`.
- Added payload test verifying `/help` gets the escape prefix and `/clear` does not.
- Ran targeted injection tests and pre-commit:
  - `uv run pytest tests/test_claude_native_bridge.py::test_escape_unsupported_slash_command tests/test_claude_native_bridge.py::test_inject_user_message_escapes_unsupported_slash_command_payload -q`
  - `uv run pytest tests/test_claude_native_bridge.py -k "inject_user_message" -q`
  - `uv run ruff check omnigent/claude_native_bridge.py tests/test_claude_native_bridge.py`
  - `uv run ruff format omnigent/claude_native_bridge.py tests/test_claude_native_bridge.py --check`
  - `uv run pre-commit run --files omnigent/claude_native_bridge.py tests/test_claude_native_bridge.py`

## 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
N/A — new unit tests directly cover the escaping decision and the payload path.

## Changelog
Unsupported Claude Code slash commands are now escaped and sent as regular user messages instead of leaving the native terminal in an undriveable state.
2026-07-27 03:16:44 +00:00
Serena Ruan 35a83b6be6 fix(projects): guard settings inputs during config load; correct worktree doc (#3316)
* fix(projects): disable settings inputs during config load; correct worktree doc

Two non-blocking follow-ups from the PR #3221 review:

- Gate the worktree toggle, workspace Browse trigger, and path input on
  `isLoading`, matching the host Select. Previously an edit made in the load
  window would be clobbered by the seeding effect once the fetch settled.
- Rewrite the `use_worktree` docstring to match the opt-in implementation
  (only `true` is written; `false` is never stored and treated as unset).

Co-authored-by: Isaac

* docs(projects): mark backend config hardening as done in PRD

The two #3108 config-hardening follow-ups (size bound + non-dict coercion)
already landed in the project store; move them from "deferred" to a  bullet
so the PRD status matches the code.

Co-authored-by: Isaac
2026-07-27 10:48:46 +08:00
Serena Ruan 77ed2a2c83 feat(projects): project settings editor + config-driven composer prefill (Phase 2) (#3221)
* feat(projects): project settings editor + config-driven composer prefill (Phase 2)

Add a "Project settings" dialog to set a project's stored session defaults
(host, working directory, agent, opt-in random worktree) and wire the new-chat
composer to prefill from that stored config, retiring the newest-session
inference so stored config is the single source of truth.

- ProjectSettingsDialog: edit + persist config {host_id, workspace, agent_id,
  use_worktree}; worktrees opt-in (default OFF, store true when on). Reuses the
  composer's host/agent pickers and filesystem browser.
- projectPrefill: collapse to config-only seeding; unset fields fall through to
  the composer's generic defaults. Honor a stored sandbox default via
  selectSandbox (gated on managed sandboxes). Remove useNewestProjectSession.
- Extract the nested-dropdown dismiss guard into a dependency-free module shared
  by the settings and scheduled-task dialogs.

Co-authored-by: Isaac

* fix(projects): repair CI — Sidebar test mocks, e2e rewrites, retire inference e2e

- Add useProjectConfig/useUpdateProjectConfig to all 10 Sidebar test mocks
  (Sidebar now mounts ProjectSettingsDialog, which calls them).
- Rewrite the settings-dialog e2e to create the project via POST /v1/projects
  instead of the flaky row-kebab move-to-project flow.
- Fix the composer-prefill e2e to stub GET /v1/sessions/projects (bare array),
  the real endpoint useProjects hits.
- Remove test_start_session_project_prefill — it exercised the newest-session
  inference path this PR retired; config-driven prefill replaces its coverage.

Co-authored-by: Isaac

* fix(projects): address review — no data-loss on failed config load; fresh prefill after save

Blocking issues from the PR review:

1. Data loss: saving the settings dialog after a failed config GET sent `{}`,
   which the server reads as "clear stored defaults". Now `useProjectConfig`'s
   isError is surfaced; a first-class project whose config failed to load blocks
   Save (with a notice), the seed effect skips a blank draft, and onSubmit bails.

2. Stale prefill after save: useUpdateProjectConfig only invalidated, so the
   composer's one-shot prefill could latch onto a stale cached config (30s
   staleTime) and drop just-saved defaults. It now setQueryData's the fresh
   config and upserts the projects list (so a promoted label-only folder
   resolves to its new id immediately).

Tests: dialog load-error blocks Save; hook seeds config + upserts list on
success; useProjectConfig disabled on null id and surfaces isError.

Co-authored-by: Isaac
2026-07-27 10:14:46 +08:00
Anthony Ivan 6c42dfe26b feat(Policy): Make dangerous shell command gating configurable, fix UI-created global policies getting skipped by default (#3297)
* Make dangerous shell command gating configurable

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

* Clarify dangerous shell policy settings

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

---------

Signed-off-by: Anthony Ivan <anthony.ivan@databricks.com>
Co-authored-by: Anthony Ivan <anthony.ivan@databricks.com>
2026-07-27 02:12:21 +00:00
Zeyi (Rice) Fan 96935e03b4 fix(web): center sidebar header buttons and soften session row hover (#3311)
* fix(web): center sidebar header buttons and soften session row hover

## Related issue
N/A

## Summary
- Vertically center section header action buttons (Projects `+`, Sessions kebab, etc.) with their titles by using `top-1/2 -translate-y-1/2` instead of `top-0.5`.
- Remove the 1 px lift on session row hover (`motion-safe:hover:-translate-y-px`) so rows stay visually anchored.
- Calm the hover flash by dropping the bouncy Otto-token transition on rows and reducing the global `--sidebar-hover` tint from 5% to 3%. Rows now use the same plain `transition-colors` pattern as the rest of the sidebar hover surfaces.
- Make `SIDEBAR_ACTIVE_HIGHLIGHT` also specify `:hover` styles so active items (current page, selected session, drop target) keep their active background on hover instead of switching to the hover tint.

## Test Plan
- `cd web && npm install && npm run dev`
- Hover over Projects/Sessions headers and confirm action buttons are vertically centered with the title text.
- Hover over active items (e.g., current page in the top nav, selected session row, current Inbox) and confirm the background stays in the active state and does not flash.
- Hover over inactive session rows and confirm the row no longer shifts up and the background highlight is subtler.

## Demo
Subtle hover/positioning polish. Verify by hovering items in the sidebar — buttons align with title baselines, rows stay still on hover, and active items don't flash.

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

## Test coverage
- [ ] 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
Visually verified by inspecting the relevant Tailwind classes and CSS variables. No test coverage changes; the existing `Sidebar.projectHeaderChevron.test.tsx` covers header layout, and the hover behavior is primarily CSS.

## Changelog
Sidebar header action buttons are now vertically centered with section titles. Session row hover is smoother, and active items no longer flash when hovered.

* test(e2e-ui): regenerate visual baselines

---------

Co-authored-by: omnigent-ci[bot] <294685417+omnigent-ci[bot]@users.noreply.github.com>
2026-07-27 01:56:48 +00:00
Zeyi (Rice) Fan 6a0e09ed42 refactor(theme): drive shell night mode from selected theme source (#3309)
## Related issue

N/A

## Summary

- Replace the web-resolved-theme bridge with a single cross-shell contract, `setThemeSource(theme)`, so the web app only reports the user's chosen theme source and each shell drives its own OS-level dark mode.
- Android: `MainActivity` now extends `AppCompatActivity`; `OmnigentBridgeListener` maps `setColorScheme` to `AppCompatDelegate.setDefaultNightMode`; system-bar icon contrast is derived from `resources.configuration.uiMode`. Removes `ResolvedColorScheme.kt`, the root-class MutationObserver, and the top-level navigation reset on init.
- iOS: Add a `ThemeSource` enum and `ThemeController` singleton inside the existing `OmnigentWebView.swift` target file to avoid `.pbxproj` edits; wire `setColorScheme` through the JS bridge and apply it via `.preferredColorScheme(...)` and `window.overrideUserInterfaceStyle`.
- Web: Update `nativeBridge.setThemeSource`, remove the `omnigent-native-ready` queue, and update `ThemeProvider`/`nativeBridge` unit tests.
- Android and web unit tests are updated to match the new contract.

## Test Plan

- iOS: `cd web/ios && xcodebuild -project Omnigent.xcodeproj -scheme Omnigent -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -configuration Debug -only-testing:OmnigentTests test`
- Android: `cd web/android && ./gradlew :app:testDebugUnitTest`
- Web: `cd web && npm install && npm run type-check && npm run test -- ThemeProvider.test.tsx nativeBridge.test.ts`

## Demo

N/A

## Type of change

- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] 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

Manual verification completed for iOS: the app builds and `OmnigentTests` passes in the iPhone 17 Pro simulator. Android and web test suites were not run end-to-end in this session, but the affected unit tests were updated in the same change.
2026-07-26 18:53:49 -07:00
Zeyi (Rice) Fan ba241c3592 feat(dev): add justfile and mobile simulator lanes (#3310)
## Related issue

N/A

## Summary

- Add a top-level `justfile` that groups common local dev tasks (`run-ios`, `run-android`, `dev`, `electron-dev`, `lint`, `normalize-locks`, etc.) with hidden `_ensure-*` / `_check-*` prerequisites.
- Add an iOS `simulator` Fastlane lane that builds the Debug .app, installs it on an already-created iOS Simulator, and launches it.
- Add Android Gradle tasks (`runDebug`, `reverseProxy`) for launching the debug APK and running `adb reverse`.
- Fix the Fastlane `xcodebuild` invocation to use camel-case `derivedDataPath` so the built `.app` is written where the lane expects it.
- Export `FASTLANE_SKIP_UPDATE_CHECK=1` in the justfile.
- Document the new `justfile` recipes concisely in `AGENTS.md`.

## Test Plan

- `just --list` shows grouped recipes.
- `just run-ios` built/launched the iOS app in the iPhone 17 Pro Simulator.
- `pre-commit` passes on the touched files.

## Demo

N/A

## Type of change

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

## Test coverage

- [ ] 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 manually by running `just run-ios` and watching the Omnigent app launch in the iOS Simulator.

## Changelog

Add a top-level `justfile` with recipes for launching the iOS Simulator, running the Android debug build, starting the omnigent dev pod, and running pre-commit/lockfile normalization.
2026-07-27 01:20:34 +00:00
Bryan Li d287e7c903 feat(web): 3D model preview for STL / 3MF / OBJ files (#3007)
* feat(web): 3D model preview for STL / 3MF / OBJ files

Selecting an .stl / .3mf / .obj file in the Files browser now renders an
interactive WebGL preview (orbit/zoom/pan) instead of the "Preview not
available for binary files" placeholder.

- Add `isModelFile()` to codeViewerHelpers (MIME-first, extension fallback),
  scoped to exactly STL/3MF/OBJ.
- New lazy-loaded `ModelViewer` component (three.js STLLoader/3MFLoader/
  OBJLoader) with camera + OrbitControls, lighting, auto-fit, loading/error
  states, and full scene teardown on unmount.
- Dispatch models before the binary-rejection branch in CodeViewer; treat
  them like images in FileViewer (diff/source-mode suppressed).
- three.js pinned at 0.185.1 and code-split into its own chunk so it stays
  out of the main bundle.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(web): address model-viewer review — unified resolver, recovery, teardown

Resolve the four blocking issues from cross-vendor review of the 3D model
preview:

1. Unified format interface: add one shared `getModelFormat(path, contentType)`
   resolver (MIME-first, extension fallback) used by BOTH `isModelFile`
   dispatch and `ModelViewer`'s loader selection, so a MIME-matched file with
   an unknown extension parses via the correct loader instead of erroring.
   `isModelFile` is now `getModelFormat(...) !== null`.
2. Error state no longer unmounts the canvas: the container is always mounted
   and the error is an overlay on top, keeping the ref alive so an
   invalid→valid prop change recovers.
3. Single idempotent `teardownScene()` called from both the init failure path
   and the effect cleanup, so a partial init (renderer/controls/context/RAF)
   can't leak on failure.
4. Empty/degenerate models (e.g. comment-only OBJ) are validated for a
   non-empty, finite bounding box before fitting; invalid bounds route to the
   error UI instead of a blank canvas.

Adds ModelViewer.test.tsx (MIME-only loader selection, malformed/empty/NaN →
error, invalid→valid recovery, failure-path + unmount teardown) and
getModelFormat unit tests.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* feat(web): theme-aware 3D model preview (light/dark)

ModelViewer previously hardcoded a neutral STL material, fixed light
intensities, and a transparent canvas, so the 3D preview ignored the app
theme. Make it theme-aware off the SAME next-themes source Monaco and the
terminal use (`useTheme().resolvedTheme`), so it tracks light/dark and
updates live when the user toggles the theme with a model open.

- Add a pure `modelViewerTheme(resolved)` map in codeViewerHelpers (mirrors
  `resolvedThemeToMonaco`): background clear color, STL default material, and
  ambient/key light intensities per mode — brighter lights in dark so the
  mesh stays legible. Shared across STL/3MF/OBJ in the one unified pipeline.
- ModelViewer seeds the scene from the active mode and keeps light/material
  handles on its resource bag so a theme toggle recolors the live scene in
  place (clear color + intensities + STL color) with no reload/reparse.
- Drop the transparent (alpha) canvas in favor of a theme-derived opaque
  background so the preview sits flush with the panel in both themes.
- Tests: three theme-awareness cases (light build, dark build, live toggle
  without rebuild) mirroring the next-themes mock pattern in
  MonacoCodeEditor.test.tsx, plus modelViewerTheme unit tests.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* test(web): 3MF MIME-only dispatch + prune package-lock churn

Add MIME-only 3MF coverage mirroring the existing STL/OBJ tests: a file
with an absent/unrecognized extension but a `model/3mf` content type must
resolve to the 3MF loader in ModelViewer and route to <ModelViewer> in
CodeViewer, exercising the shared getModelFormat() resolver.

Regenerate web/package-lock.json so the diff vs origin/main is limited to
the `three` dependency subtree — dropping unrelated resolved-URL
normalization churn from an earlier regen.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(web): dispose material textures in ModelViewer teardown

disposeObject() freed each mesh's geometry and material but not the
textures the material references (map, normalMap, roughnessMap, …), so a
textured 3MF leaked its GPU textures every time the viewer unmounted.
three.js frees neither the material nor its textures automatically.

Add disposeMaterial(), which disposes every texture slot on a material
(detected via the three.js `isTexture` flag, robust to multiple three
copies) before disposing the material itself. Extend the ModelViewer
teardown unit test with a textured-material mesh and assert its textures
are released on unmount.

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* test(e2e): cover 3D model preview in the Files browser

Add a Playwright e2e test that seeds an ASCII STL and an OBJ file, opens
each in the Files browser, and asserts the ModelViewer mounts: the
`3D preview of …` canvas host renders a <canvas>, the "Unable to render
3D model" overlay never shows (so parsing and WebGL both succeeded), and
the flow does NOT fall through to the binary placeholder or a source
view. STL exercises MIME-based routing (application/vnd.ms-pki.stl); OBJ
exercises the extension fallback. Seeded via the filesystem PUT endpoint
(no agent run), mirroring the existing image/pdf rendering e2e tests.

This satisfies the E2E UI Required gate for the model-preview feature.

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(web): resolve the 3D-viewer deps from the public npm registry

The three.js stack this PR added (three, @types/three,
@dimforge/rapier3d-compat, @tweenjs/tween.js, @types/stats.js,
@types/webxr, fflate, meshoptimizer) was locked with `resolved` URLs
pointing at an internal mirror (npm-proxy.dev.databricks.com), while the
rest of package-lock.json resolves from registry.npmjs.org. Public CI
can't reach that mirror, so `npm ci` timed out fetching
three-0.185.1.tgz (ETIMEDOUT) and failed the install-dependent checks.

Repoint just those eight `resolved` URLs to the canonical
registry.npmjs.org form. Integrity hashes are unchanged (the mirror
served identical tarballs), so this only changes where the tarballs are
fetched from, not what is installed. `npm ci --legacy-peer-deps` now
succeeds from a clean node_modules, and `npm install --package-lock-only
--legacy-peer-deps` produces no further diff, so the lockfile-up-to-date
gate stays green.

Signed-off-by: Bryan Li <bryan.li@gmail.com>

---------

Signed-off-by: Bryan Li <bryan.li@gmail.com>
2026-07-26 18:15:42 -07:00
Bryan Li bf5b3c3a61 fix(android): honor system dark mode (#3006)
* fix(android): honor system dark mode

Signed-off-by: Bryan Li <15131870+btli@users.noreply.github.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(android): sync system bar contrast

Signed-off-by: Bryan Li <15131870+btli@users.noreply.github.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(android): harden resolved theme sync

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* refactor(android): decode theme at bridge boundary

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* test(android): tighten theme bridge coverage

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(android): drop WebView algorithmic darkening

Algorithmic darkening inverts the SPA when the user forces light mode
while the OS is dark: the page's root color-scheme is then 'light', so
WebView treats it as dark-unaware and darkens it algorithmically,
leaving dark status-bar icons over a darkened page. With targetSdk >= 33
the DayNight host theme alone makes prefers-color-scheme track the OS,
so the darkening flag added nothing for the system-mode path and only
broke the forced-light path. Verified on an API 34 emulator across the
OS-light/dark x app-System/Light/Dark matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(web): keep Electron on the selected theme, not the resolved one

Reporting only resolvedTheme regressed Electron system mode: an explicit
Light selection under a light OS changes no resolved value, so no report
fired and themeSource stayed 'system' — the shell chrome then flipped
dark with the OS while the app was forced light. Report the resolved
scheme first (Android system-bar contrast) and follow with 'system'
while that is the selection: Electron keeps the last report, so it
tracks the OS in system mode and pins to explicit selections, including
ones that leave resolvedTheme unchanged. Android drops 'system' at the
bridge, so its behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix: route native themes by consumer

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(android): harden system bar theme sync

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* test(android): clean up theme bridge state

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(android): install theme bridge at document start

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(android): resync system bars on live theme changes

Signed-off-by: Bryan Li <bryan.li@gmail.com>

* style(android): format theme test

Signed-off-by: Bryan Li <bryan.li@gmail.com>

---------

Signed-off-by: Bryan Li <15131870+btli@users.noreply.github.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Co-authored-by: Bryan Li <15131870+btli@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:10:16 -07:00
Zeyi (Rice) Fan 6d32e8fdbd ci(release): skip GitHub releases for rc/dev/pre tags (#2962)
github-release.yml fired on every v[0-9]* tag push and created an
unpublished DRAFT release for rc/dev/alpha/beta tags. Nothing downstream
depended on those drafts — draft-release-notes.yml already skips rc,
finalize-release.yml refuses rc, and the Docker/homebrew/changelog
workflows fire on the tag push / release:published directly. The drafts
just accumulated (and rehearsal rcs had to be gh-release-deleted during
cleanup).

Add a guard that skips the draft-release job for rcN/devN/preN tags
(trailing digit required so a substring like 'dev' in a mistyped tag can't
trip it). Drop the now-dead alpha/beta arms — this repo only cuts rc
pre-releases — and align the same rc/dev/pre pattern + comments across
the other release-adjacent workflows for consistency. Update release.yml's
Next-steps text and RELEASING.md accordingly.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-26 15:59:43 -07:00
Zeyi (Rice) Fan b0cabcb336 fix(ios): block cross-origin redirects in the post-consent workspace probe (#3115)
## Related issue

Closes #[F-CR-7]

## Summary

- After a user consents to an unknown server (deep link) or types a server URL, `WorkspaceURLExpander.expandIfNeeded` issued a HEAD probe via `URLSession.shared` with no redirect policy, so the consented host could 3xx-redirect the probe to a different origin — including a local-network service — breaking the consent alert's promise that the app only talks to the host the user approved.
- The probe now defaults to a dedicated `URLSession` backed by `SameOriginRedirectHandler`, a `URLSessionTaskDelegate` that follows only same-origin redirects (scheme + host + port match) and blocks any cross-origin redirect by returning `nil` from `willPerformHTTPRedirection`.
- As defense in depth, `expandIfNeeded` additionally verifies `response.url`'s origin matches the approved origin, so a cross-origin response is never trusted even if a caller supplies a bare session without the redirect delegate.
- Rebased onto #3179 (F-CR-6) and deduped: removed my `--omnigent-deep-link` test hook (subsumed by #3179's `--omnigent-open-url` / `--omnigent-reset-state` seam), and consolidated the two `MockHTTPServer` copies into one shared file compiled into both test targets.

## Test Plan

- Unit: `WorkspaceURLExpanderTests.testRejectsResponseFromDifferentOrigin` returns a `server: databricks` 200 whose `url` is a different origin and asserts the URL is left unchanged.
- Integration (simulator, real local HTTP network): `WorkspaceURLExpanderRedirectTests.testBlocksCrossOriginRedirect` / `testFollowsSameOriginRedirect` assert a cross-origin redirect is blocked (response stays 302 on the approved port) and a same-origin redirect is followed. Confirmed meaningful: the cross-origin test fails when the delegate is reverted to follow-all-redirects (the vulnerable behavior).
- UI (simulator): `RedirectConsentUITests.testDeepLinkConsentOpensApprovedServer` drives the deep-link consent flow via #3179's `--omnigent-open-url` + `--omnigent-reset-state` seam and asserts the alert appears, "Open" loads the approved server's WebView.
- Ran on iPhone 17 simulator: all 8 expander/redirect tests + the UI smoke test + all 26 F-CR-6 deep-link tests pass; full project builds.
- Note: the UI test cannot exercise the redirect itself — a localhost deep link infers `http`, and the probe is https-only, so the probe never fires for loopback. The redirect policy is verified over a real local network by the integration test instead.

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Manual verification: built and ran the new unit, integration, and UI tests on the iPhone 17 simulator; confirmed all pass and that the integration test fails against the vulnerable (follow-all-redirects) baseline, proving it is a meaningful regression test. Also ran all F-CR-6 tests after the dedup to confirm no regression from #3179's shared seam.

## Changelog

The iOS app no longer follows cross-origin redirects when probing a newly approved server for the Databricks workspace mount, so a consented host can't redirect the probe to a different origin.
2026-07-26 15:57:44 -07:00
Rahul Ravindranathan 61fd72350e feat(automations): rename Scheduled Tasks UI to Automations (UI only) (#3260)
CI / Pytest (runtime-core) (push) Has been cancelled
Lint / Version lockstep check (push) Has been cancelled
Doc sync / Classify and draft docs (push) Has been cancelled
Lint / Pre-commit checks (push) Has been cancelled
OSS Scorecard / Scorecard analysis (push) Has been cancelled
UI Preview / notify (push) Has been cancelled
UI Preview / build (push) Has been cancelled
UI Preview / cleanup (push) Has been cancelled
Windows (native) / Windows smoke + unit (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
web Tests / npm test (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
UI Preview / deploy (push) Has been cancelled
CI / gate (push) Failing after 1s
Lint / gate (push) Failing after 1s
web Tests / gate (push) Failing after 1s
* feat(automations): rename Scheduled Tasks UI to Automations (display copy only)

UI-facing name is now 'Automations'; internal name (DB/CRUD/API/components/
comments/route) remains 'scheduled task'. Changes limited to user-visible
display strings in 5 source files + 2 test files.

Changed:
- TasksPage.tsx: h1, search placeholder, load error, loading text, empty states
- Sidebar.tsx: nav label "Scheduled" → "Automations"
- CommandPalette.tsx: "Go to Scheduled tasks" → "Go to Automations"
- CreateScheduledTaskDialog.tsx: dialog titles + error messages
- Test assertions updated to match new copy

No component names, file names, types, data-testids, routes, or backend
paths were altered.

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* test(e2e-ui): regenerate visual baselines for Automations rename

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* docs(scheduled-tasks): document Automations (UI) vs scheduled-task (internal) naming

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* test(ui-snapshot): regenerate visual baselines after main merge

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-25 11:02:53 -07:00
Pat Sukprasert 4788a77d54 test: stabilize two known flakes (dictation close, agent-info popover) (#3224)
Lint / gate (push) Failing after 1s
web Tests / gate (push) Failing after 0s
CI / gate (push) Failing after 1s
Doc sync / Classify and draft docs (push) Has been cancelled
OSS Scorecard / Scorecard analysis (push) Has been cancelled
Lint / Pre-commit checks (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
UI Preview / notify (push) Has been cancelled
UI Preview / build (push) Has been cancelled
UI Preview / cleanup (push) Has been cancelled
Windows (native) / Windows smoke + unit (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
Lint / Version lockstep check (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
web Tests / npm test (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
UI Preview / deploy (push) Has been cancelled
* test: stabilize two known flakes (dictation close, agent-info popover)

Two load-timing flakes that recur across PRs:

- Pytest (server-rest) test_dictation.py::test_stream_closes_take_on_
  abrupt_disconnect: on an abrupt disconnect the route offloads
  handle.close() to a thread. During teardown the loop's thread-pool
  executor may already be shutting down, so the offload raises and the
  old contextlib.suppress swallowed it — the take (and, for the remote
  engine, a worker slot) leaks. Fall back to a direct close() on the
  loop; it's a quick non-blocking free for every engine.

- E2E UI test_agent_info_popover.py: _open_popover single-clicked the
  trigger, but the button hover-opens on the click's own pointer arrival
  and the click's Radix toggle can flip it back shut past the
  HOVER_CLICK_GRACE_MS window under load, so the panel never mounts.
  Confirm the panel opened and retry the click from a closed state.

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

* test: stabilize scheduled-tasks time-picker flake

test_scheduled_task_create_edit_modal_and_time_picker had two coupled
races in the time-picker step (6/10 failures reproduced, no artificial
load needed):

- The picker is a Radix popover nested in the create-task dialog. The
  dialog's focus management can fire an interaction-outside that closes
  it the instant it mounts, so the minute cells unmount between the
  visibility check and the click (element-not-found / click timeout).
- Selecting a minute leaves the popover open, and an open floating-ui
  popover keeps recomputing its position — so the submit button (and,
  later, the edit-phase time input) stays perpetually "not stable" and
  detaches mid-click.

Extract a _pick_minute() helper that opens from a known-closed state and
retries until the cell is present, then dismisses the picker via a
click-outside (not Escape, which would bubble to the Radix Dialog and
close it) and waits for it to unmount so the layout settles before
submit. 0/12 clean + 0/8 under load after the fix.

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-25 18:17:13 +07:00
Pat Sukprasert 7a73bc30a7 ci: clear stale waiting labels after author activity (#3242)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-25 17:24:54 +07:00
Jackson Zheng 0b4153548f Prevent inline base64 from leaking into replay context (#3267)
* fix: redact base64 from compaction history

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* Harden inline base64 redaction

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

---------

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-25 00:04:14 -07:00
Tomu Hirata 4fb449187b fix(web): hide Smart Routing from native terminal sessions (#3259)
The in-session "Configure" model dropdown offered a "Smart Routing" option on
native terminal sessions (Claude Code, Codex, Pi, …). It's meaningless there:
a native CLI bakes its model into the launch argv once and can't per-turn
route, so picking it did nothing useful.

Add isNativeTerminalSession() (mirrors the server's
_native_coding_agent_for_session: native by omnigent.wrapper label OR resolved
harness) and exclude such sessions from costRoutingEligible in ChatPage, so the
Smart Routing option no longer appears in their Model dropdown. Brain-harness
sessions (claude-sdk / codex / pi, and the polly orchestrator) keep it.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-25 03:30:17 +00:00
Jackson Zheng 568d620da2 Polish sidebar session row layout (#3208) 2026-07-24 19:28:34 -07:00
Rahul Ravindranathan 039fa67089 feat(scheduled tasks): Run now, relative next-run, and Tasks-list row polish (#3218)
* feat(scheduled tasks): add windowed latest-run-status store query

Add ScheduledTaskStore.list_latest_run_status_for_tasks(ids) -> {id: status},
a single row_number()-windowed query (scheduled_at DESC, id DESC — same order
as list_runs) returning each task's most-recent run status. Powers the Tasks
list completion badge in one query instead of N per-row /runs fetches, and is
correct under overlapping run-now runs (unlike a denormalized last_run_status
column). Tasks with no runs are absent from the map.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* feat(scheduled tasks): run-now endpoint + status/next-run serializer fields

Backend for three Tasks-list run controls:

- last_run_status: _to_response now carries the task's most-recent run status
  (from the windowed store query), populated on list/get/patch. Force-fail of
  stale orphans runs BEFORE the status read so a dead run reports failed, not a
  stuck running.
- next_run_at: _to_response carries the live scheduler's authoritative next-fire
  ISO timestamp (scheduler.next_run_at) on list/get/create/patch — server-
  sourced, never client-recomputed (paused/unarmed → null).
- POST /v1/scheduled-tasks/{id}/run: an immediate manual fire that REUSES the
  shared fire path via build_run_now (same _run_fire_for_task body, dispatch/
  preflight seams, and in-flight overlap guard as the scheduler). Paused tasks
  are runnable (manual override); fire-and-forget → 202 Accepted. 409 when a
  fire is already in flight, 404 for a non-owned task, 503 when the scheduler
  subsystem is not running. Wired via app.state.scheduled_task_run_now.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* feat(scheduled tasks): status pill, run-now menu, next-run on rows

Wire the three run controls into the Tasks list UI:

- last_run_status → a completion pill on each row (Failed/Skipped/Running/
  Queued). Succeeded and never-run render NO pill (success is not noise);
  Failed is destructive, Skipped muted — matching the Paused pill styling.
- next_run_at → "Next: <time>" on the schedule subline, formatted in the
  task timezone via a new formatNextRunAt() that only FORMATS the server's
  ISO value (never client-recomputes; paused/unarmed → nothing).
- Run now → a "⋯ menu" item + useRunScheduledTaskNow mutation (POST
  /{id}/run) that invalidates the list + that task's runs so the pill
  updates. Runnable for paused tasks; row busy-disables while in flight.

scheduledTasksApi gains lastRunStatus + nextRunAt (interface + wire map)
and runScheduledTaskNow(). Unit tests: pill per status, no-pill cases,
next-run formatting (tz + calendar-day boundary), run-now mutation wiring.
e2e: new run-controls journey (Run now → recorded run + pill flips);
existing schedule-line assertions relaxed to to_contain_text now that the
server next-run renders on the same line.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* style(scheduled tasks): bump task title size/weight on rows

Make the scheduled-task row title slightly larger and bolder: text-sm →
text-base and font-semibold → font-bold. Subline, pills, and spacing are
unchanged. Updates the one TasksPage sort-order test that located the title
by its .font-semibold class to .font-bold.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* Revert "style(scheduled tasks): bump task title size/weight on rows"

This reverts commit e0195ce3c4977abdaeba5316426029f808edeef6.

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* style(scheduled tasks): title 15px, metadata 13px on rows

Trim the row title to exactly 15px and the metadata subline to exactly 13px
using arbitrary-px classes (text-[15px] / text-[13px]) — the app root scales
rem ~1.125×, so the standard text-sm/text-xs would render 15.75/13.5px and
can't hit the exact target. Weights unchanged: title font-semibold (600),
subline no weight class (inherits 400). Pills, spacing, next-run text, and the
⋯ menu are untouched.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* style(scheduled tasks): lighten metadata subline on rows

Soften the row metadata subline one notch to a lighter gray via an opacity
step on the same theme token: text-muted-foreground → text-muted-foreground/80.
Theme-aware (works in light + dark), size unchanged (13px), and the next-run
<span> keeps inheriting the same color (no own color class). Title, pills,
spacing, and the ⋯ menu are untouched.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* style(scheduled tasks): tighten row spacing 2px, remove run-status pill

- Row vertical padding py-3 → py-[11px]: trims each side 1px so the gap
  between adjacent rows drops from 24px to 22px (the list is flex-col with no
  gap, so the row padding is the whole inter-row spacing).
- Remove the last-run status pill (Failed/Skipped/Running/Queued) entirely per
  design: drop the render block, the RUN_STATUS_PILL map, the statusPill local,
  and the now-unused ScheduledTaskRunStatus import. The Paused pill is kept
  as-is. The lastRunStatus API/store field is left in place (harmless data;
  only the visual is removed). Subline, next-run text, and the ⋯ menu unchanged.

Drops the per-status pill test cases in ScheduledTaskRow.test.tsx (that UI is
gone); keeps the paused-pill, next-run, and run-now tests.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* style(scheduled tasks): relative "Next run in Xh" on rows

Switch the row next-run display from an absolute label ("Next: Today 9:00 AM")
to a compact relative delta ("Next run in 15h" / "in 6d" / "soon"):

- formatNextRunAt now returns a delta (nextRunAt − now): <60m → "in Xm" (min
  "in 1m"), <24h → "in Xh", else "in Xd", all floored; a delta below 1 min
  (imminent / clock skew) → "soon"; null/unparseable iso → null. The `timezone`
  param is dropped (a pure delta needs no zone) — call site + useMemo deps
  updated. This only formats HOW FAR AWAY the server's authoritative next_run_at
  is; it never recomputes WHICH instant is next on the client, so the old
  "no client-recomputed countdown" rule still holds.
- Row prefix "Next: " → "Next run " so it reads "Next run in 15h".

Tests: rewrote the formatNextRunAt unit tests for the relative buckets +
boundaries + "soon" + null; updated the row test to the "Next run in …" prefix;
reconciled the e2e (the old count==0 "Next run" guard flips to positively
asserting the server-derived relative label — its real intent, no client
recompute, is unchanged).

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* style(scheduled tasks): darken row hover background

Bump the full-row hover tint one notch: hover:bg-muted/50 → hover:bg-muted/70
(same theme-aware `muted` token, higher opacity). The color-mix stays
`var(--muted) N% transparent`, so in light the effective tint goes ~2.9% → 4.1%
black and in dark the alpha goes 0.5 → 0.7 — visibly stronger but still subtle.
Comment updated to match. Nothing else changes.

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-24 18:20:45 -07:00
Jackson Zheng 397aeeb293 Support background titles for native Codex (#3199) 2026-07-24 17:59:02 -07:00
Jackson Zheng 9a354b6700 fix(claude-native): durably persist compaction boundary on resume/replay (#3118) 2026-07-24 17:40:58 -07:00
Thomas Garnier 3b9d8d55a4 Add databricks_cli secretless credential proxy type (#3080)
Adds a 'databricks_cli' credential_proxy type so sandboxed tools can use
the Databricks CLI without the real OAuth/PAT token ever entering the
sandbox. The operator lists which ~/.databrickscfg profiles to proxy;
each is materialized into the sandbox as a placeholder-only .databrickscfg
(oa_cred_* token), and the L7 egress proxy swaps the placeholder for the
real token on the way out.

- Refreshing token provider (DatabricksProfileTokenProvider) re-mints
  short-lived OAuth tokens via the databricks SDK for long sessions;
  CredentialRewriteRule gains an optional secret_provider and the proxy
  resolves secrets per-swap (offloaded via run_in_executor).
- Placeholder-only files are materialized into the sandbox scratch dir
  and pointed at via DATABRICKS_CONFIG_FILE / DATABRICKS_CONFIG_PROFILE.
- Requires the 'databricks' extra and linux_bwrap (the Go CLI ignores
  SSL_CERT_FILE on macOS, so darwin_seatbelt is rejected at parse time).
- Egress stays operator-listed: the workspace host must be named in
  egress_rules, consistent with the other credential_proxy types.

Signed-off-by: mxatone <mxatone@gmail.com>
2026-07-24 17:00:52 -07:00
Zeyi (Rice) Fan e1a3fdb82f chore(release): bump omnigent-slack to 0.7.0.dev0 and add it to the lockstep version cycle (#3207)
## Related issue

N/A

## Summary

- Bring `omnigent-slack` into the lockstep release cycle (now four packages, not three): its `[project].version` was stuck at `0.1.0` while the rest of the repo moved to `0.7.0.dev0`, so the extra pin and lockfile drifted.
- Pin `omnigent-slack==0.7.0.dev0` in the root `slack` optional-dependency extra, mirroring the existing `omnigent-client==` / `omnigent-ui-sdk==` sibling pins so a published `omnigent[slack]` always pairs with the matching `omnigent-slack` release.
- Teach `scripts/update_versions.py` (the engine behind `.github/workflows/bump-version.yml`) about the 4th package: rewrite the slack `[project].version` and the extra `==` pin on every bump, and scan `[project.optional-dependencies]` (not just `[project.dependencies]`) when verifying sibling pins. Regenerate `uv.lock`.

## Test Plan

- `uv run --no-project --python 3.12 --with packaging python scripts/update_versions.py check` → prints `0.7.0.dev0` (all four packages agree, all sibling `==` pins present).
- `uv lock` → "Updated omnigent-slack v0.1.0 -> v0.7.0.dev0".
- `uv run ... python -m pytest tests/scripts/test_update_versions.py` → 13 passed (updated the test fixture + assertions for the 4th package).
- `tests/test_version.py::test_version_matches_pyproject` still passes (root pyproject == `omnigent/version.py` at `0.7.0.dev0`).

## Demo

N/A

## Type of change

- [ ] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [x] 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

Updated `tests/scripts/test_update_versions.py` to include `integrations/slack/pyproject.toml` in the `repo_copy` fixture and adjusted the lockstep assertions (5 changed files, 4 `9.9.9` occurrences in root pyproject, 1 in slack). Verified the full suite (13 tests) passes. Also ran `update_versions.py check` and `uv lock` manually to confirm lockstep + lockfile consistency.
2026-07-24 14:54:07 -07:00
Dhruv Gupta 86463e6129 docs(contributing): add Developer Certificate of Origin language and DCO file (#3252) 2026-07-24 20:27:37 +00:00
Cathy Yin 76281b9438 feat(onboarding): write a harness provider credential from the UI (M3 backend) (#3088)
CI / gate (push) Failing after 6s
Doc sync / Classify and draft docs (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
* feat(onboarding): report the installed-but-unconfigured harness state

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two review fixes on the credential-write path:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test: settle agent data before opening the picker in the install e2e

The install-flow e2e flaked (30s click timeout on the Codex row, then on the
picker trigger via an overlay pointer-interception when reopened). Root cause:
the picker opened before the /v1/agents fetch settled, racing the menu-open
against a re-render. Wait for the composer's "Set up Codex" notice (rendered
only once the Codex agent + its unconfigured host state load) BEFORE opening the
picker, then open once and click. Passes locally repeatedly.

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

* test: stop driving the agent picker in the install e2e (kill the flake)

The picker interaction was redundant — the single seeded Codex agent is already
auto-selected, so the composer's "Set up Codex" notice is present without
opening the dropdown. Driving the picker only added a menu-open-vs-async-render
race that flaked under CI load. Wait for the notice directly (generous 60s) and
click it to open the setup dialog.

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

* test: wait for network idle before asserting the setup notice (install e2e)

The "Set up Codex" notice depends on two async fetches re-rendering the
composer (/v1/agents auto-selecting the agent, /v1/hosts marking its harness
unconfigured). On loaded CI runners that chain lagged past the timeout and the
assertion raced the still-loading landing screen. Wait for network idle and the
host chip (readiness present) before asserting the notice.

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

* test: drop networkidle wait in install e2e (WS keeps network busy)

wait_for_load_state("networkidle") never fires in this app — the shell holds a
long-lived sessions/updates WebSocket, so the network is never idle. That wait
just burned its timeout and then raced the still-loading landing screen (the
"Set up Codex" notice was intermittently absent on CI). Replace it with plain
element waits (host chip, then the notice) at a generous 60s, matching every
other e2e_ui test. Passes locally repeatedly.

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

* fix(host): adopt an env credential under its own family, not the harness's

Review (isaac, MAJOR): pi consumes both the anthropic and openai families, so
the UI can offer an OpenAI env var (e.g. $OPENAI_API_KEY) as adoptable for pi.
`_handle_store_secret` derived the family solely from the harness (pi→anthropic)
and passed that to `adopt_env_credential`, so adopting that var wrote an
anthropic-family provider whose api_key_ref is env:OPENAI_API_KEY — mis-routed
to the anthropic endpoint, failing at run time. For the adopt kind, look the env
var up in the host's detected credentials and use its OWN detected family
(falling back to the harness family if absent). Adds a pi-adopts-OpenAI
regression test.

Also carry the install-flow e2e fix onto this branch: explicitly select Codex
in the picker and stub the /v1/sessions?kind=any agent scan so the seeded-DB
agents don't leak in and leave Claude Code selected (a CI-only flake).

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

* fix(host): harden the UI credential-write path (review feedback)

Addresses Polly's blocking finding + hardening notes and Pat's nits on the
store_secret/adopt path:

- BLOCKING (adopt boundary): the daemon's adopt handler fell back to the
  harness-derived family when an env var wasn't detected, and adopt_env_credential
  only checked the var was *set*. An owner hitting the raw API could name any set
  env var (a DB password, an unrelated secret) and have it persisted as a provider
  credential sent to the vendor endpoint. Now the handler refuses an env_var that
  isn't in detect_adoptable_credentials() (no fallback) — enforcing server-side the
  same "only adopt what was detected" restriction the UI presents.
- secrets.py: create the file-backend secrets.json 0600 atomically via
  os.open(O_CREAT, 0o600) instead of open()+chmod-after, which briefly left a
  freshly-created file group/world-readable. Now network-triggerable, so worth
  closing. Fixes the stale "0600 from the start" comment.
- adopt_env_credential: presence-only env check (`in os.environ`, not `.get`) so
  the "never reads the value" contract stays literally true.
- gateway base_url: reject a non-http(s) scheme at write time rather than writing
  a malformed provider entry that fails opaquely at the first turn.
- connect.py: hoist the harness_auth / provider_config imports to module top
  (no circular import) to match the sibling onboarding imports.

Adds regression tests: adopt refuses an undetected env var, gateway rejects a
non-http base_url, and secrets.json is 0600 even under a permissive umask.

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

---------

Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>
2026-07-24 17:48:43 +07:00
dependabot[bot] 983c93c6ec chore(deps-dev): bump fast-uri from 3.1.2 to 3.1.4 in /editors/vscode (#3035)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 10:21:49 +00:00
Tomu Hirata 8b2276c529 fix(docker): wire llm/policies/routing into Docker entrypoint RuntimeCaps (#3222)
The Docker entrypoint's build_app() was constructing RuntimeCaps()
bare, so the llm:, policies:, and routing: blocks in a docker
deployment's config.yaml were silently ignored. This meant:

- Builtin policies that read event["llm_client"] (e.g.
  deny_trivial_to_expensive_model) would always see None and abstain.
- default_policies declared under policies: would never fire.
- LLM-based and external routing clients were never built.

Mirror the logic from cli.py: parse_server_llm / parse_default_policies
/ routing client construction are now applied before RuntimeCaps is
passed to init_runtime, putting docker deployments on par with the
CLI-started server.

Fixes #3159

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-24 10:14:59 +00:00
Serena Ruan e491999a14 fix(sessions): strip per-user pin keys from child-session summaries (#3214)
Follow-up to #3189. Every session serialization path collapses per-user
`omnigent.pinned.<user>` keys via `_labels_for_viewer` except
`_child_session_summary_from_conversation`, which passed `conv.labels` through
raw. Child sessions aren't pinnable today (the pin affordance lives only on
top-level sidebar rows), so this is a latent gap rather than a live leak — but
if a shared child were ever pinned, its summary would expose another viewer's
pin key.

- Strip any `omnigent.pinned.<user>` key from a child summary's labels. No
  collapse-to-canonical: there's no pin to surface, just the defensive strip.
- Test: a child carrying two users' pin keys yields a summary with no pin key,
  while unrelated labels survive.
- Correct the stale `useMigrateLocalPinsToServer` docstring: the migration
  patches the pinned-list cache (like `useTogglePinnedConversation`), it does
  not invalidate the pinned query.


Co-authored-by: Isaac

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-24 17:36:07 +08:00
Tomu Hirata 1674f686fe fix(pi-executor): add supportsUsageInStreaming:false to databricks-completions (#3203)
* fix(pi-executor): add supportsUsageInStreaming:false to databricks-completions

Gemini, Qwen, inkling, and other non-OpenAI models in the databricks-completions
provider reject stream_options (which Pi sends with include_usage:true by default)
with 400 'unknown field'. Add supportsUsageInStreaming:false to suppress it,
matching what pi_native_credentials.py already does for omnigent-completions.

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

* fix(pi-executor): use openai-responses for newer GPT models (gpt-5-5, gpt-5-6-*)

Newer GPT models reject function tool calls via /chat/completions with 400.
The Databricks Responses API (/ai-gateway/codex/v1/responses) now supports
tool-result chaining on subsequent turns (previously it did not).

- Add databricks-openai provider using openai-responses at /ai-gateway/codex/v1
  for gpt-5-5, gpt-5-6-*, gpt-5-3-codex (matches pi_native_credentials routing)
- Keep databricks provider (openai-completions at /serving-endpoints) for
  older GPT models (gpt-5-4, gpt-5-4-mini) that work fine with /chat/completions
- Add _pi_needs_responses_api() helper mirroring pi_native_credentials
- Update _pi_provider_for_model() to route to databricks-openai when needed

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

* fix(pi-executor): add kimi to reasoning model fragments

kimi-k2-7-code streams output on reasoning_content channel like GLM/DeepSeek.
Without reasoning:true in the model entry Pi ignores reasoning_content and
sees an empty stream, throwing 'Stream ended without finish_reason'.

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

* fix(test): update kimi model entry to expect reasoning:true flag

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

* fix(pi-native): add reasoning:true to kimi/glm/deepseek model entries

These models stream output on reasoning_content channel. Pi's openai-completions
parser requires reasoning:true on the model entry to consume that channel;
without it the stream has no content and the turn ends with
'Stream ended without finish_reason'.

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

* fix(pi-native): exclude qwen3 from completions provider

qwen3 models return content as a typed array [{type:'reasoning',...},{type:'text',...}]
when tools are present, causing Pi's streaming handler to produce [object Object].
Same root cause as gpt-oss; same fix.

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

* fix: add inkling to reasoning model fragments and LLM detection

Both kimi and inkling stream output on reasoning_content channel with
content=null. Added inkling to _PI_REASONING_MODEL_FRAGMENTS (executor),
reasoning:true model entry condition (pi-native), and LLM name detection
tokens so it appears in the model list.

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

* refactor: use allowlist for GPT completions-compatible models

Instead of a denylist of specific model ids that need the Responses API,
maintain an allowlist of GPT models known to work with /chat/completions.
Any GPT model not in the allowlist defaults to Responses API — safer
for new models not yet explicitly tested.

The executor's _pi_needs_responses_api now delegates to the same
implementation in pi_native_credentials for a single source of truth.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-24 18:29:07 +09:00
Tomu Hirata 0c20a59ca6 feat(smart-routing): enable routing from config, drop OMNIGENT_SMART_ROUTING (#3215)
Smart routing was gated behind an OMNIGENT_SMART_ROUTING=1 opt-in on top of the
routing/llm config. The env is redundant: build the routing client whenever the
config supplies one — a server llm: block (built-in judge) or a
routing.provider=external block (external routes:select service). Remove the env
gate in cli.py and refresh the stale references in app.py, advise_models.py, and
web capabilities.ts. Server smart_routing_enabled already keyed on the resolved
client, so the /v1/info signal is unchanged.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-24 09:24:52 +00:00
Pat Sukprasert c326937443 docs(harness): break Phases 1 & 2 into a PR-by-PR breakdown (#3217)
The modular-registry proposal described the phases as thin numbered lists.
Turn them into a concrete, verified implementation plan reviewers can cost:

- Add a "Current state (verified 2026-07-24)" subsection grounding the plan
  in the tree at main (59e6b70e): data model ready but no native_providers
  field; run_<x>_native already near-uniform (only claude/codex/antigravity/
  opencode carry extra kwargs); coverage uneven across hubs (resume 10,
  chat-redirect 6, interrupt 9, stop 7); dead _HARNESS_MODULES literal still
  present; harness_catalog() emits no native-agent rows.
- Phase 1 (core-only seam): 8 PRs (1.1–1.8) in a table with scope, key files,
  dependencies, risk, and estimates. 1.1 provider model + resolver is the
  additive foundation; 1.5 runner launch/terminal-route is the risk center.
- Phase 2 (community + web): 4 PRs (2.1–2.4).
- Add an effort summary: ~26–37 engineer-days across ~12 PRs, critical path
  1.1 → 1.2 → 1.5 → 2.2 → 2.3. Refresh the Bottom line to match.

Docs-only; no code paths affected.

Co-authored-by: Isaac

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-24 16:22:56 +07:00
dependabot[bot] 85fba59e72 chore(deps-dev): bump js-yaml from 4.2.0 to 4.3.0 in /editors/vscode (#2942)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 09:11:17 +00:00
Serena Ruan 8fdce5e6d9 fix(web): remove "Create new project" from the project picker menu (#3210)
* fix(web): remove "Create new project" from the project picker menu

Projects are created via the + icon next to the Projects header in the
sidebar, so the picker's own "Create new project" row was a redundant,
second entry point. Drop it (and the inline new-project input it toggled)
from ProjectPickerMenu, leaving search, the project list, and "Remove
from <project>".

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

* test(e2e_ui): file sessions via the + button after dropping picker create

The project picker no longer offers an inline "Create new project" row, so
the e2e helpers that drove that flow broke. Rewrite `_move_to_new_project`
to create the empty project from the Projects-header + button first, then
file the session via the kebab picker by name.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-24 17:01:01 +08:00
Serena Ruan 20ec819049 feat(sessions): persist pinned sessions server-side (per-user) (#3189)
* feat(sessions): persist pinned sessions server-side as a per-user label

Pins were client-only (localStorage), so they didn't follow a user across
devices. Move them to a server-side per-user session label so a pin persists
and stays per-user even on shared sessions.

- Store: `omnigent.pinned.<user_id>` label (value = epoch-ms pin time);
  `pinned_label_key()` hashes over-long user ids to fit the 128-char key
  column. `list_conversations(pinned=True, pinned_owner=…)` filters to the
  caller's own key.
- Route: `GET /v1/sessions?pinned=true` enumerates the caller's pins
  (independent of the loaded window); PATCH rewrites the client's canonical
  `omnigent.pinned` to the caller's per-user key, and `_labels_for_viewer`
  collapses it back on read so the per-user dimension never crosses the API
  and no viewer sees another user's pin key.
- Write-integrity: reject any client-supplied suffixed `omnigent.pinned.<user>`
  key so a caller can't pin/unpin for someone else.
- Forks drop per-user pin keys by prefix (a clone must not inherit pins).
- Web: server-authoritative `usePinnedConversations` + optimistic
  `useTogglePinnedConversation`; Pinned section ordered by pin timestamp;
  one-time localStorage->server migration that retains pins whose write failed.
- Guard `relativeTime`/`absoluteTime` against non-finite input (no more "NaNy").

Co-authored-by: Isaac

* test(e2e-ui): drive visual-snapshot pins via ?pinned=true, not localStorage

The populated-sidebar visual baseline seeded the pinned session in localStorage,
but pins are now server-authoritative (GET /v1/sessions?pinned=true). Under the
new model the localStorage seed is ignored and the bare-list stub answered the
pinned query too, so every row rendered as pinned → baseline mismatch (the
non-blocking UI Snapshot job).

- Split a `?pinned=true` route out from the bare-list regex (which now also
  excludes `pinned=`, mirroring the existing `project=` exclusion) and return
  just the pinned row, carrying the canonical `omnigent.pinned` label.
- Drop the `omnigent:pinned-conversation-ids` localStorage seed.
- Apply the same fix to the pinned-project flyout baseline (it passed only by
  luck — its bare-list stub happened to return exactly the one pinned row) and
  give its row the pin label so it's explicit, not incidental.

Co-authored-by: Isaac

* fix(sessions): let read-only collaborators pin a shared session

Pinning moved server-side (per-user `omnigent.pinned.<user>` label) but the
session PATCH gated all label writes at LEVEL_EDIT, so a read-only collaborator
on a shared session could no longer pin it — a regression from the localStorage
model, which had no permission check.

- Gate a pin-only PATCH (labels == {omnigent.pinned}, no other field) at
  LEVEL_READ: pinning is a personal per-viewer preference, not an edit to the
  session, so anyone who can SEE it may pin it. Any other field keeps the
  edit/owner requirement. Unpin ("" value) is still pin-only, so it downgrades
  too. The `?pinned=true` list is already scoped `accessible_by`, so a shared
  pin surfaces on "Shared with me".
- Tests: a LEVEL_READ grantee can pin AND unpin a shared session; the downgrade
  stays narrow (a non-pin label, or a pin bundled with one, still 403s).
- Rework the access-tier comment to match the if/elif/else (READ / OWNER / EDIT).

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
Co-authored-by: Isaac

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-24 17:00:18 +08:00
Pat Sukprasert 5a84c85a39 docs(harness): sync Phase 0 completion in modular-registry proposal (#3212)
PRs #3148 (extract native terminal orchestration) and #3149 (split the
native app-session test monolith) landed the two remaining Phase 0 file
splits. Update the proposal to reflect reality:

- §1 runner hub: app.py is now ~10.1k lines (was ~20.1k) plus the new
  omnigent/runner/native/orchestration.py (~6.5k); drop the stale absolute
  line-number anchors and clarify that the dispatch arms and interrupt/stop
  closures stayed in app.py while the builders/mirrors moved out.
- Phase 0: mark both runner/app.py and the test monolith Done, noting the
  single-orchestration.py outcome (vs the proposed three-way split) and the
  nine concern-scoped test modules + shared conftest.py.
- Risk section: re-anchor the forwarder registry to _AUTO_FORWARDER_TASKS in
  its new home and note the risk now shifts to Phase 1.

Co-authored-by: Isaac

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-24 15:36:53 +07:00
Tomu Hirata 59e6b70ea1 refactor(server): split sessions.py into 8 domain sub-modules (#3194)
* refactor(server): split sessions.py into domain sub-modules

sessions.py (7799 lines) is split into 8 focused route files under
_sessions/:

  routes_core.py       — CRUD, list, WS updates, fork, switch-agent
  routes_hooks.py      — /hooks/* and /policies/evaluate
  routes_items.py      — /items and /child_sessions
  routes_resources.py  — /resources/* (terminals, files, environments)
  routes_browser.py    — /browser/*
  routes_elicitations.py — /elicitations/*
  routes_events.py     — /events, /stream, DELETE /sessions/{id}
  routes_permissions.py — /permissions/*, /owner
  routes_agent.py      — /agent, /agent/contents, /mcp

Each file exports a register_X_routes(router, ...) function.
create_sessions_router() becomes a thin delegator (~533 lines).

helpers.py gains proxy wrappers for _same_provider_family,
_agent_is_native, _agent_carries_native_fork_history,
_presentation_labels_for_agent, and _reset_runner_resources_after_switch
so existing test monkeypatches on sessions.<name> continue to work.

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

* refactor(server): move sessions/ route sub-modules out of _sessions/

Convert sessions.py to a sessions/ package and move the 8 routes_*.py
files from _sessions/ into sessions/, so the public package layout is:

  routes/sessions/__init__.py  (facade, formerly sessions.py)
  routes/sessions/routes_core.py
  routes/sessions/routes_hooks.py
  routes/sessions/routes_items.py
  routes/sessions/routes_resources.py
  routes/sessions/routes_browser.py
  routes/sessions/routes_elicitations.py
  routes/sessions/routes_events.py
  routes/sessions/routes_permissions.py
  routes/sessions/routes_agent.py

_sessions/ retains only the private internals (common, helpers,
orchestration) that do not need public names.

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

* fix(server): use facade indirection for session_stream and get_agent_cache consistently

routes_browser, routes_events, and routes_hooks were still calling
session_stream.publish() and get_agent_cache() via the direct module
binding. Apply the same facade-indirection pattern already used in
routes_core so all call sites are consistent and test monkeypatches on
sessions.session_stream / sessions.get_agent_cache are honored.

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

* fix(server): fix lint, _to_agent_object closure, and pyproject.toml exemptions

- Move _policy_type, _policy_description, _to_agent_object from inside
  register_permissions_routes closure to module-level in routes_permissions.py
  so routes_agent.py can import them directly. Fixes NameError crash on
  GET /sessions/{id}/agent in server-approvals tests and E2E tests.

- Add missing 'return router' at end of register_permissions_routes (was
  missing after the closure reorganization).

- Import the three helpers explicitly in routes_agent.py.

- Update pyproject.toml per-file-ignores to cover sessions/*.py and
  sessions/__init__.py with the same exemptions the original sessions.py
  had (ARG001, ARG002, BLE001, E501, F401, F403, F405) so pre-commit
  ruff passes.

- Run ruff format on all sessions/ sub-modules.

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

* fix(server): fix all proxy/monkeypatch misses and restore noqa directives

Route sub-modules were importing _X_impl directly instead of using the
facade proxy, causing monkeypatch(sessions, '_X', ...) to have no effect.
Fix by removing all '_X_impl as _X' imports from routes_*.py — the star-
imports from _sessions.helpers and _sessions.orchestration already bring
in the correct facade-delegating proxies.

Additional fixes:
- Access _SESSION_UPDATES_MAX_WATCHED, _SESSION_UPDATES_RESCAN_INTERVAL_S,
  _SESSION_UPDATES_HEARTBEAT_INTERVAL_S through the facade in routes_core.py
  so monkeypatch(sessions_routes, '_SESSION_UPDATES_*', N) works.
- Use _load_agent_spec_for_session proxy (not impl) in routes_resources.py.
- Access get_caps() through facade in routes_hooks.py evaluate_policy so
  monkeypatch('omnigent.server.routes.sessions.get_caps', ...) fires.
- Restore noqa: BLE001 and F401 directives in _sessions/helpers.py and
  _sessions/orchestration.py that were stripped by the RUF100 auto-fix.

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

* fix(server): delete old sessions.py, fix remaining facade proxy misses

- Delete omnigent/server/routes/sessions.py (the file was rm'd in a prior
  commit but never staged; CI was still linting it and seeing F403/F405).

- Route _HOST_BOUND_RUNNER_CONNECT_GRACE_S through the facade in
  routes_events.py (3 call sites) so monkeypatch(sessions_module,
  '_HOST_BOUND_RUNNER_CONNECT_GRACE_S', ...) is honored.

- Route _recover_subagent_status_forward_via_parent through facade
  in routes_events.py.

- Route _registered_runner_id through facade in routes_core.py.

- Route _BROWSER_ACTION_AWAIT_S through facade in routes_browser.py.

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

* fix(server): route patchable names in routes_hooks.py through facade

All five hook handlers and evaluate_policy use module-level timeout
constants (_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S etc.) and auth
helpers (_get_user_id, get_caps, get_agent_cache) that tests monkeypatch
on the sessions facade module. Access them through _sf (the facade) at
call time so monkeypatch(sessions_route, '_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S', 0.1)
and monkeypatch('omnigent.server.routes.sessions.get_caps', ...) are honored.

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

---------

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-24 08:24:25 +00:00
Kunyu Chen d8da36d081 Simplify env variables for Slack integration on Databricks apps (#3206)
Simplify env variables for Slack integration on Databricks apps
2026-07-24 00:22:39 -07:00
Rahul Ravindranathan 5972254fda feat(scheduled tasks): edit flow + text time inputs (#3186)
CI / gate (push) Failing after 2s
CI / Coverage report (push) Has been cancelled
Doc sync / Classify and draft docs (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
* feat(scheduled tasks): edit tasks

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* feat(scheduled tasks): use text time input

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* feat(scheduled tasks): add compact time picker

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* feat(scheduled tasks): refine task dialog layout

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* fix(scheduled tasks): time picker wheel-scroll + column widths

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* fix(scheduled tasks): make host field full width

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* fix(scheduled tasks): forward Input ref so time field stops reformatting while typing

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* fix(scheduled tasks): show all minutes and normalize field text

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* fix(scheduled tasks): prevent edit-modal footer buttons from being clipped

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* fix(scheduled tasks): hourly minute field placeholder 0, digits-only, clamp 59

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* test(scheduled tasks): e2e_ui coverage for create/edit modal + time picker

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* fix(scheduled tasks): make nextRunAtMs O(1) so the Tasks page loads instantly

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-23 23:46:16 -07:00
dependabot[bot] 32dbb3159d build(deps-dev): bump esbuild from 0.21.5 to 0.28.1 in /editors/vscode (#3190)
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.21.5 to 0.28.1.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.28.1)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 06:36:53 +00:00
Pat Sukprasert 513711cec0 feat(ci): add /rerun comment command to re-run failed CI without a push (#3195)
Re-running CI today means pushing an empty commit or rebasing, which fires a
push event and dismisses existing approvals (branch protection keeps
dismiss-stale-reviews on to block approve-then-swap). A `/rerun` comment
re-runs failed jobs on the existing head SHA instead -- no new commit, so
approvals survive.

Authorized to the PR author or a write-access commenter. Only re-runs the
mock-LLM `pull_request` test suites; the merge gates and Polly AI Review are
left alone. Single file (no privileged relay) because issue_comment gets a
writable base-repo token even for fork PRs.

Co-authored-by: Isaac

Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-24 13:36:39 +07:00
dependabot[bot] a28643e6d8 chore(deps): bump mcp from 1.27.2 to 1.28.1 (#2731)
Bumps [mcp](https://github.com/modelcontextprotocol/python-sdk) from 1.27.2 to 1.28.1.
- [Release notes](https://github.com/modelcontextprotocol/python-sdk/releases)
- [Changelog](https://github.com/modelcontextprotocol/python-sdk/blob/main/RELEASE.md)
- [Commits](https://github.com/modelcontextprotocol/python-sdk/compare/v1.27.2...v1.28.1)

---
updated-dependencies:
- dependency-name: mcp
  dependency-version: 1.28.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 05:53:46 +00:00
Tomu Hirata c847f5aefb fix(benchmark-pr): use marker-based comment upsert instead of --edit-last (#3197)
--edit-last edits the most recent PR comment regardless of author or
content, so it was overwriting the UI preview comment when both workflows
ran on the same PR. Switch to the same find-by-marker + PATCH approach
used by ui-preview.yml so each workflow manages its own comment.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-24 05:49:48 +00:00
dependabot[bot] 96b467d149 chore(deps): bump js-yaml (#2943)
Bumps the electron-security group with 1 update in the /web/electron directory: [js-yaml](https://github.com/nodeca/js-yaml).


Updates `js-yaml` from 4.2.0 to 4.3.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.0
  dependency-type: direct:production
  dependency-group: electron-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 12:38:48 +07:00
dependabot[bot] 0ceee06155 chore(deps): bump pillow from 12.2.0 to 12.3.0 (#2940)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.2.0 to 12.3.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.2.0...12.3.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 05:31:36 +00:00
dependabot[bot] a3a6c1e3c7 chore(deps): bump pyasn1 from 0.6.3 to 0.6.4 (#3036)
Bumps [pyasn1](https://github.com/pyasn1/pyasn1) from 0.6.3 to 0.6.4.
- [Release notes](https://github.com/pyasn1/pyasn1/releases)
- [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst)
- [Commits](https://github.com/pyasn1/pyasn1/compare/v0.6.3...v0.6.4)

---
updated-dependencies:
- dependency-name: pyasn1
  dependency-version: 0.6.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-24 05:09:11 +00:00
Jackson Zheng 5f98a88b57 Enable background session titles by default (#3191)
Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-23 21:53:40 -07:00
Pat Sukprasert 3df3843e18 ci: add waiting-on-author PR hygiene (#3183)
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-24 04:21:34 +00:00
dependabot[bot] 12adae2846 build(deps-dev): bump brace-expansion in /editors/vscode (#3174)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.6 to 5.0.8.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.8)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 04:15:46 +00:00
dependabot[bot] 4214d4b5fc build(deps-dev): bump vitest from 1.6.1 to 3.2.6 in /editors/vscode (#3176)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 1.6.1 to 3.2.6.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v3.2.6/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 3.2.6
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 04:13:15 +00:00
Sabhya Chhabria f3bf3d8a51 [polly] Add Codex goal mode (#3181)
*  feat(polly): Add Codex goal mode

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

* 🐛 fix(codex): Preserve history for fresh goals

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

---------

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-23 21:12:15 -07:00
Jackson Zheng 829c17942c Polish workspace pane layout (#3122)
* Polish workspace pane layout

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(ui-snapshot): update chat baseline

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* feat(web): add workspace tab tooltips

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(e2e): cover workspace tab tooltips

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(ui): update merged chat snapshot

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* test(ui): refresh merged chat snapshot

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* fix(web): stabilize right-pane e2e coverage

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

* fix(web): stabilize remaining e2e flows

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>

---------

Signed-off-by: Jackson Zheng <36802691+zhengwin@users.noreply.github.com>
2026-07-23 20:11:08 -07:00
Serena Ruan 9151aa9b99 fix(web): make session rename optimistic so the new name shows instantly (#3185)
* fix(web): make session rename optimistic so the new name shows instantly

Renaming a session left the stale name in the sidebar for the duration
of the PATCH round-trip: all cache patching happened in the mutation's
onSuccess, so the row only repainted once the server responded.

Move the cache overlay into onMutate so the new title paints on the next
frame, snapshot the old title for rollback, and restore it in onError.
onSuccess still reconciles with the server-confirmed title + updated_at
and keeps the deliberate no-refetch behavior (an immediate GET races the
search-index reindex). Also patch the ["project-sessions", name] caches
that project folders render from — the flat ["conversations"] overlay
never touched them, so a filed session's row stayed stale until the WS
reconcile.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

* fix(web): cancel in-flight list queries before optimistic rename overlay

Close the in-flight-reconcile clobber race flagged in review: an
already-running GET /v1/sessions reconcile poll (or a WS-triggered
fetch) could resolve after onMutate and overwrite the optimistic title
with the stale search-indexed name. Cancel the ["conversations"] and
["project-sessions"] queries in onMutate before overlaying so no
in-flight fetch can win.

Co-authored-by: Isaac
Signed-off-by: Serena Ruan <serena.rxy@gmail.com>

---------

Signed-off-by: Serena Ruan <serena.rxy@gmail.com>
2026-07-24 11:09:00 +08:00
Tomu Hirata 3ba4318f17 feat(telemetry): log agent_name for polly and debby in SessionCreatedEvent (#3152)
Add an opt-in agent_name field to SessionCreatedEvent. Only polly and
debby are populated — all other agent names are withheld to avoid leaking
user-defined agent names in telemetry.

Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
2026-07-24 11:42:45 +09:00
Yuan Tang dbcd72831f feat(sandbox): support OMNIGENT_CONTAINER_RUNTIME env var for container runtime selection (#2949)
* feat(sandbox): support OMNIGENT_CONTAINER_RUNTIME env var for container runtime selection

Allow switching the container runtime (e.g. from docker to podman) via the
OMNIGENT_CONTAINER_RUNTIME environment variable instead of requiring per-agent
YAML configuration. The per-agent container_runtime key still takes precedence
over the env var.

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

* fix(test): add missing top-level `Any` import in test_local.py

Ruff flagged F821 (undefined name) because `Any` was used in a
runtime dict annotation but only imported inside a nested function.

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

* fix(test): read version dynamically in crash handler test

The test hardcoded "0.6.0.dev0" which breaks when the installed
version diverges from the source (e.g. after a version bump).
Read omnigent.version.VERSION at runtime instead.

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

* Revert "fix(test): read version dynamically in crash handler test"

This reverts commit 53855f5c10e3573e9d1ddbfd2afb0bd76abbc91e.

* fix: address review comments on container runtime PR

- Make container_runtime field explicitly Optional to avoid misleading
  type annotation and unnecessary type-ignore
- Update parser docstring to mention OMNIGENT_CONTAINER_RUNTIME as an
  additional default source
- Update shell script header comment to say "container runtime" instead
  of "Docker"

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

* fix(test): add autouse fixture to clear OMNIGENT_CONTAINER_RUNTIME

Prevents the host environment from leaking into tests that assume
the default runtime is "docker".

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

* style: add missing blank line before autouse fixture

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

* fix: address additional review comments on container runtime PR

- Rename _ALLOWED_RUNTIMES to ALLOWED_RUNTIMES (public API used
  cross-module by the parser)
- Reject container_runtime: null in YAML instead of silently falling
  back to the env var default
- Add test for container_runtime: null rejection

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

---------

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
2026-07-23 22:22:27 -04:00
Yuan Tang 8344c18420 fix(runner): reconnect dead-but-registered native terminals before turn (#2951)
* fix(runner): reconnect dead-but-registered native terminals before turn

Signed-off-by: Yuan Tang <terrytangyuan@gmail.com>
2026-07-24 02:00:36 +00:00
Cathy Yin 1241a38e40 feat(onboarding): report the installed-but-unconfigured harness state (M2 readiness parity) (#3072)
* feat(onboarding): report the installed-but-unconfigured harness state

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

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

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

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

* docs(onboarding): clarify _family_provider_configured checks entry presence

Polly review nit: the helper returns True when a non-subscription default
provider *entry* exists, not when its secret actually resolves — an entry
pointing at an unset env:/keychain ref still reads configured (matching the
secret-blind omnigent setup overview). Reword the docstring from "usable
credential" to "a default provider entry is present" and note the
secret-blind behavior + why it's safe (launch gate is binary-only; signal
only moves toward green). No behavior change.

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

* refactor(onboarding): address review nits on readiness detection

- Hoist the provider_config import in `_family_provider_configured` to the
  module top (no circular import); update the test monkeypatch targets to the
  now-module-bound name.
- Drop the internal milestone label from a test docstring.

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

---------

Signed-off-by: xq-yin <xiaoqian.yin@databricks.com>
2026-07-24 08:03:49 +07:00
Bryan Li 4bc38b96d4 feat(sandbox): operator-configured PVC mounts for Kubernetes runners (+ fix global YAML bool-resolver leak) (#2435)
* feat(sandbox): parse and validate sandbox.kubernetes.pvc_mounts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* feat(sandbox): add pvc_mounts volumes to the runner Pod manifest

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* feat(sandbox): thread pvc_mounts through the kubernetes launcher

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* docs(deploy): document sandbox.kubernetes.pvc_mounts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* feat(sandbox): fail loud on unknown sandbox.kubernetes keys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* test(sandbox): lock in pvc_mounts collision-order, null read_only, and claim-reuse semantics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* test(sandbox): pin the reserved-mount HOME prefix to the launcher's _HOME_DIR

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* refactor(sandbox): reuse shared validators in the pvc_mounts parser

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(sandbox): close pvc_mounts reserved-path gaps from review

Reject mount_paths with exactly two leading slashes — POSIX normpath
preserves them so '//home/omnigent' passed both validation gates while
the kernel collapses '//' to '/' at mount time, shadowing HOME. Add
/opt to the reserved prefixes: the host image's omnigent venv lives at
/opt/venv and was shadowable. Both cases now covered in the fail-loud
parametrization.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>

* fix(sandbox): reject pvc_mounts paths that mount over reserved prefixes

The reserved-path check only caught mount_paths at or under a reserved
prefix, so an ancestor like /home or /var passed validation while
mounting over the HOME emptyDir mountpoint or the Secret projections.
Reject ancestors too, and reserve /run, /var/run, and /var/lock in full
so the Debian image's /var/run -> /run and /var/lock -> /run/lock
symlinks can't alias around the lexical check.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>

---------

Signed-off-by: Bryan Li <bryan.li@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:47:01 +07:00
Zeyi (Rice) Fan 7b835ed767 Add kunyuchen to maintainer list (#3172)
Adds kunyuchen to the canonical maintainer list in .github/MAINTAINER so they can approve PRs and participate in maintainer-gated workflows.
2026-07-23 17:31:21 -07:00
Zeyi (Rice) Fan d2fdafce1b fix(ios): block smuggled query/fragment separators in omnigent:// deep links (#3179)
## Related issue

Closes F-CR-6

## Summary

- `DeepLink.parse` validated the `/c/<id>` segment with only `!contains("/")`, but Foundation's `URL.path` is percent-DECODED — so `omnigent://host/c/id%3Fview=terminal` exposes `?` as a literal in the path and smuggles a query (and `%23` a fragment, `%2e%2e` a `..`, `%00` a control char) past the intended "/c/<id> only" shape. Added a denylist that rejects `?`, `#`, `/`, `.`, `%`, and control chars in the decoded id, so an encoded separator that `URL.path` decoded into one of those is dropped.
- The denylist deliberately does NOT assume the id's exact format (the server emits 32-hex uuids today, but the SPA's `/c/:id` route accepts any non-slash segment); the SPA stays the authority on id validity, and a future id scheme (ULID, nanoid, base64) won't be silently rejected. Benign non-canonical ids like `conv_abc` are accepted; only structure-smuggling is blocked.
- Documented the custom-scheme hijack risk in `DeepLink.swift`: iOS doesn't verify single ownership of `omnigent://`, so a co-installed app can read the link's host + id (metadata disclosure). For managed Databricks domains that can serve an `apple-app-site-association`, prefer verified Universal Links; the custom scheme is retained for BYO/OSS servers that can't host AASA, with the interception risk documented.

## Test Plan

- Unit tests (`OmnigentTests/DeepLinkTests`): 19 cases, all pass — including `testRejectsSmuggledQueryViaEncodedQuestionMark` (`%3F`→`?`), `testRejectsSmuggledFragmentViaEncodedHash` (`%23`→`#`), `testRejectsEncodedDotAndDotDot` (`%2e%2e`), `testRejectsControlCharacters` (`%00`/`%0A`/`%7F`), `testRejectsMalformedPercentEscape` (`%zz`), and `testAcceptsBenignNonCanonicalIds` (`conv_abc`/`x`/`not-a-uuid` are accepted — no smuggled structure).
- UI tests (`OmnigentUITests`): 6 cases via a DEBUG-only `--omnigent-open-url` launch-argument seam that routes the link through the real `handleDeepLink`/`DeepLink.parse` (XCUITest can't reliably deliver custom-scheme URLs on this toolchain). `testValidDeepLinkShowsConsent` (valid link → consent alert), `testBenignNonCanonicalIdIsAccepted` (`conv_abc` → consent alert), and rejection tests for smuggled `?`/`#`/`..`/control-char (no alert). A `--omnigent-reset-state` flag wipes persisted server state so each case starts with no known server. All pass on the iOS simulator.
- Manual simulator verification: drove `xcrun simctl openurl` against the running app with `OMNIGENT_DEEPLINK_TRACE` set; NSLog trace confirmed `ACCEPTED` for the valid link and `REJECTED` for all 5 smuggling/malformed links (smuggled `?`/`#`, `..`, control char, non-id).

## Demo

N/A

## Type of change

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

## Test coverage

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

## Coverage notes

Manually verified end-to-end on the iOS simulator: launched the app with `OMNIGENT_DEEPLINK_TRACE=1` and sent six real `omnigent://` links via `xcrun simctl openurl`. The NSLog trace showed `ACCEPTED` for the valid link and `REJECTED` for all smuggling/malformed links, proving the fix through the real `DeepLink.parse` → `handleDeepLink` path. The DEBUG-only `--omnigent-open-url` / `--omnigent-reset-state` launch-argument seam and `OMNIGENT_DEEPLINK_TRACE` NSLog logging are compiled out of Release builds (gated by `#if DEBUG`), so there is no production behavior change from the test infrastructure.
2026-07-23 17:30:33 -07:00
Enes Yilmaz 66d253eacc fix(cli): normalize Azure Databricks custom-URL workspaces to their canonical host (#2870)
* fix(cli): normalize Azure Databricks custom-URL workspaces to their canonical host

omni login (and omni host) failed for Azure Databricks workspaces with a custom
(vanity) URL like https://mydomain.azuredatabricks.net/?o=<workspace_id>: the
vanity edge 303-redirects the unauthenticated probe to /login instead of
answering, so _databricks_workspace_login_target does not recognize the
Databricks posture and login fails. The canonical host
adb-{workspace_id}.{workspace_id % 20}.azuredatabricks.net does answer, and the
?o=<workspace_id> selector already carries the id.

Rewrite the custom host to the canonical adb- form in _resolve_server_url (the
shared normalization every --server entry point uses, so omni host is covered
too). Only *.azuredatabricks.net hosts that are not already the adb- form and
carry a numeric ?o= are touched; AWS/GCP hosts, canonical URLs, and URLs without
a selector are left unchanged.

Closes #2781

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>

* fix(cli): probe before adopting the canonical Azure Databricks host

The custom-URL fix landed the canonical adb- host rewrite unconditionally in
_resolve_server_url, so a wrong synthesis could strand the user on a host they
never typed, and the unit tests only re-asserted the implementation's own
arithmetic (123 % 20 == 3), which would pass under any modulus.

Try the URL as the user gave it first. Only when that fails to resolve, and only
for an Azure vanity workspace URL carrying a numeric ?o=, synthesize the
canonical host, probe it, and adopt it if it answers. A dead synthesis now falls
back to the user's URL instead of replacing it.

The shard rule remains an observed regularity rather than a documented contract
(Microsoft calls the segment a random number and treats properties.workspaceUrl
from the ARM API as authoritative), so the probe keeps it off the load-bearing
path. Docstrings say so plainly.

Also:
- _canonicalize_azure_databricks_url is now _canonical_azure_databricks_url and
  returns None to decline, so a caller can tell "not applicable" from "no change".
- Guard the selector with isascii() as well as isdecimal(): str.isdecimal()
  accepts non-ASCII digits that int() also parses, which synthesized a
  nonsensical host.
- _probe_root reduces a URL the way _workspace_api_server_url does before
  probing. Without it the comparison against the expansion's result never
  matched (it drops the ?o= selector first, and that selector is what makes a
  URL a candidate), and the new probe requested /?o=123/v1/me.
- Replace the tautological shard assertions with five real observed
  workspace/host pairs, and drive the resolver tests through the real expansion
  with only httpx scripted, since a stubbed expander cannot catch the above.

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>

* docs(cli): drop issue-number refs from Azure canonical-host comments

The repo's comment convention says code comments should describe the
scenario, not reference issue/PR numbers. Remove the (#2781) tags from
the _canonical_azure_databricks_url / _resolve_server_url docstrings and
the vanity-URL fallback test; the surrounding prose already explains the
Azure vanity-host case without needing the external link.

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

---------

Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Co-authored-by: Pat Sukprasert <pat.sukprasert@databricks.com>
2026-07-24 00:30:22 +00:00
Sunny Yang 78b37f20de fix(runner): resolve and re-materialize file attachments on remote-runner history reload (#2085)
* fix(runner): resolve and re-materialize file attachments on remote-runner history reload

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>

* fix(runner): seed the native-session compaction anchor; tolerate malformed file metadata

Native-harness sessions skip the history reload entirely, which also
skipped seeding the last server item ID that harness compaction
persistence anchors on — compactions then silently stopped persisting.
Session create now fetches just the newest item ID (newest-first, single
item, no attachment downloads) for native harnesses.

A 200 metadata response with an unparseable body no longer aborts
attachment resolution: both resolvers (the runner's message-content
resolver and the claude-native transcript rebuild) fall back to the
content response's Content-Type for the media-type hint.

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>

* refactor(attachments): centralize file_id resolution and reference-line emission in native_attachments

The transcript rebuild and the runner each carried a full copy of the
file_id fetch-and-inline pipeline, and nine native executors repeated
the same materialize-or-marker block. Both now live in
native_attachments: resolve_file_id_block() serves the runner and the
transcript rebuild, attachment_reference_line() serves the executors,
and ATTACHMENT_MARKER_STRIP_PATTERN replaces four hand-copied forwarder
regexes. Materialized filenames are sanitized the same way as marker
names so a bracketed filename cannot break the marker consumers, and
the resume dedupe short-circuits on file size before reading bytes.

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>

* fix(attachments): replay resolved history attachments as structured content

Cold-started claude-sdk sessions flattened prior turns into a text
prefix, so a resolved historical image reached the model as the marker
[image: name, media_type, N base64 chars]. The bytes never arrived, which
leaves the #882 symptom in place for that harness: the model describes an
attachment it cannot see.

Prior-turn attachments now replay as real Anthropic image/document blocks
via the existing converter, interleaved in transcript order. Text-only
history still takes the plain-string path and renders byte-identically,
unresolved attachments keep their existing marker, and base64 still never
enters prompt text.

Materialization also derives its collision suffix from a content hash
rather than a random one, so a history carrying two distinct uploads of
the same filename keeps one file per payload instead of gaining a copy on
every transcript rebuild.

The two tests that asserted the compact-placeholder shape are replaced by
cold-reload tests: that shape is the behavior being corrected, but the
invariant those tests protected (no base64 in prompt text) is asserted
against the prompt's text blocks.

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>

* refactor(attachments): collapse duplicated prompt-shape branches

The structured and plain-text arms of _build_prompt returned the same
value whenever the latest message was multimodal, and re-scanned the
block list to decide which arm to take. Coalescing already leaves an
all-text history as one block, so the block count answers that.

Materialization's second identity check was a no-op guarding a write
that produces the same bytes, so the collision path flattens to one
branch.

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>

* refactor(tests): keep runner conftest identical to upstream

Move the file-server fake's items/failure/malformed-meta behaviors out of
the shared _FakeFileServerClient into local subclasses in the one file
that uses them, so conftest.py stays in sync with upstream and per-test
modes stay next to their tests.

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>

---------

Signed-off-by: Sunny Yang <sunnyadn1130@gmail.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-24 07:23:56 +07:00
Zeyi (Rice) Fan 7738df6fb3 fix(electron): guarantee the desktop quits after before-quit cleanup (#2972)
CI / gate (push) Failing after 1s
CI / Pytest (server-approvals) (push) Has been cancelled
CI / Pytest (server-rest) (push) Has been cancelled
CI / Pytest (slack) (push) Has been cancelled
CI / Pytest (spec-llms) (push) Has been cancelled
CI / Pytest (tools) (push) Has been cancelled
CI / Pytest (misc) (push) Has been cancelled
CI / Pytest (runner-app) (push) Has been cancelled
CI / Pytest (runtime-core) (push) Has been cancelled
CI / Pytest (runtime-harnesses) (push) Has been cancelled
CI / Pytest (server-integration) (push) Has been cancelled
CI / Pytest (stores) (push) Has been cancelled
CI / Pytest (inner-rest) (push) Has been cancelled
CI / Pytest (databricks) (push) Has been cancelled
CI / Pytest (repl-sdk) (push) Has been cancelled
CI / Pytest (runtime-policies) (push) Has been cancelled
CI / Coverage report (push) Has been cancelled
Doc sync / Classify and draft docs (push) Has been cancelled
CI / Pytest (stores-postgres) (push) Has been cancelled
CI / Pytest (stores-mysql) (push) Has been cancelled
CI / Pytest (codex-parity) (push) Has been cancelled
CI / Pytest (integration-mock) (push) Has been cancelled
The before-quit handler defers the quit until serverManager.shutdown()
finishes, then re-issues app.quit() as the *only* way the quit ever
proceeds. Re-issuing app.quit() after before-quit's preventDefault() is a
known intermittently-unreliable Electron behavior (electron/electron#4994,
#33643, #39094); when it no-ops, or shutdown hangs (a stuck
'omnigent server stop'), the app stays up with its window still open —
matching 'sometimes the app is still running and refuses to quit'.

- Hard safety cap: app.exit(0) after quitCleanupTimeoutMs (unref'd) if
  graceful cleanup + the re-issued quit haven't terminated. Normal cleanup
  (<6s) completes well under the 10s cap; it only trips when stuck.
- Evaluate resolvedCliPath() inside an async IIFE so a future throw becomes
  a rejection caught by .catch, never stranding the quit.
- Install fallback: when quitAndInstallIfPending() returns true but
  quitAndInstall() doesn't actually quit (staged update gone), a short
  app.exit(0) fallback still quits.
- unref() the periodic update-check setInterval so it can't keep the event
  loop alive at quit.

Adds two regression tests (install-fallback and cleanup-cap) via an
injectable setQuitTimeouts; harness exposes setTimeout/clearTimeout/app.exit.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-23 16:47:59 -07:00
Rahul Ravindranathan cc94a9c5e6 feat(scheduled tasks): list page + sidebar nav (#3112)
* Add scheduled tasks page

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* Remove scheduled page phase labels from comments

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* Remove scheduled Omnigent stub wiring

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* Shorten scheduled nav comment

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* Remove scheduled tab styling comment

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* Clean up scheduled task suggestions

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* test(e2e-ui): update visual baselines

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-23 16:35:37 -07:00
Dhruv Gupta 131db6276b fix(codex-native): launch on the spec's declared model, not the provider default (#3175)
The codex-native launch read the spec model only from
executor.config["model"], a key the single-file agent loader never
populates, so a custom agent's declared model: was silently replaced by
the provider default. Read the canonical executor.model first — the same
field the in-process harness and the claude/cursor native launches
consume — and keep config["model"] as a fallback for bundle specs that
pin the model inside the harness config block.

Co-authored-by: Isaac
2026-07-23 23:16:50 +00:00
Dhruv Gupta af3d18ba16 fix(loader): reject the bundle type:/config: nesting in single-file executor blocks (#3178)
* fix(loader): reject the bundle type:/config: nesting in single-file executor blocks

A single-file agent YAML written with the bundle config.yaml shape
(executor: {type: omnigent, config: {harness: ...}}) loaded without
complaint: the unknown keys were silently dropped, the declared harness
with them, and a different harness was inferred from the model prefix —
databricks-gpt-* landing on openai-agents instead of the declared
codex-native, with no diagnostics. Reject exactly type:/config: with an
error that shows the flat spelling. Other extra executor keys
(use_responses, extra, ...) keep loading — the compat loader reads them
from the raw YAML.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

* test: spell e2e fixture executors flat instead of the bundle config: nesting

Six runtime-generated single-file agent YAMLs in the e2e/e2e_ui/server
fixtures nested the harness under executor.config — the exact trap the
loader now rejects. They only worked because the dropped harness was
re-inferred from the gpt-* model prefix as the same openai-agents value.
Spell them flat so the declared harness actually flows. The two
spec_version bundle specs (approval agent, elicitation supervisor) keep
the nesting — config.harness is the correct spelling on the strict
parser path.

Co-authored-by: Isaac
Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>

---------

Signed-off-by: Dhruv Gupta <dhruv.gupta@databricks.com>
2026-07-23 16:14:11 -07:00
Sabhya Chhabria 53c0125e84 [polly] Add Claude SDK goal mode (#3084)
*  feat(polly): Add Claude SDK goal mode

- Reuse the composer Goal control for top-level Polly sessions on claude-sdk
- Send the completion condition as a native /goal command without server APIs
- Cover command dispatch, validation, read-only state, and harness gating

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

*  test(e2e-ui): Cover Polly Claude goal flow

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

---------

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
2026-07-23 16:01:43 -07:00
Kunyu Chen c8828ed62d Enhance device auth to require user login (#3156)
* Update device auth scheme to require a recent login to reduce phishing attack risks
* Device grant ui tests
2026-07-23 14:43:43 -07:00
Rahul Ravindranathan 0085334e94 feat(scheduled tasks): create-task dialog (#3123)
* feat(scheduled tasks): manual create dialog (2/3)

Stack 2 of 3 for the Scheduled Tasks page (UI-1). Builds on the data
layer (1/3). The dialog isn't mounted anywhere yet, so it type-checks
standalone.

- CreateScheduledTaskDialog.tsx: manual create form wired to POST
  /v1/scheduled-tasks. Reuses the shared AgentHarnessPicker (exported from
  NewChatDialog) with "needs setup" badges via a fallback online host;
  seed-on-open prefill (cleared on close, no stale leak); backdrop-click
  dismiss with the guard scoped to the nested-Select case only.
- ScheduleFields.tsx: frequency/time/weekday schedule builder.
- Label.tsx: small shared form label.
- CreateWithOmnigentDialog.tsx: TODO(UI-2) stub.
- NewChatDialog.tsx: export AgentHarnessPicker + add optional
  onOpenChange / content+trigger class / contentAlign props (backward
  compatible for the interactive composer).

Co-authored-by: Isaac
Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* Fix scheduled task dialog defaults and picker

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* Shorten scheduled task hourly comment

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* Remove scheduled task phase labels from comments

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* Remove scheduled dialog follow-up label comment

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

* Remove deferred scheduled Omnigent stub

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>

---------

Signed-off-by: Rahul Ravindranathan <rahulnathan73@gmail.com>
2026-07-23 14:38:40 -07:00
Pranav Setlur 34656aa806 fix(host): forward global config to the background local server (#2935)
The background server spawned by bare `omni` (`_spawn_local_server`)
launched `omnigent.cli server` without `--config`, so the server's
loader returned an empty config and never read `~/.omnigent/config.yaml`.
Its `llm:` (and `policies:`) block was invisible to the detached server,
so self-hosted smart routing silently stayed off (`sys_advise_models` ->
`router_on: false`; `/v1/info` -> `smart_routing_enabled: false`).

Forward `--config <global_config_path()>` when the file exists. Same bug
class as #2386/#2763 (Docker entrypoint dropped `policies:`); this is the
local-spawn instance.

Co-authored-by: Isaac

Signed-off-by: Pranav Setlur <psetlur@gmail.com>
2026-07-23 14:07:44 -07:00
Kunyu Chen 8285b58940 refactor(cli): replace omni integration slack start with omni integration slack --background (#3153) 2026-07-23 20:14:25 +00:00
Zeyi (Rice) Fan db11081516 fix(runner): patch heartbeat cadence on the app module (#3163)
PR #3148 extracted _session_labels_for_runner_spawn into
omnigent.runner.native.orchestration, but _SESSION_STREAM_HEARTBEAT_S
and the stream loop that reads it remained in omnigent.runner.app.

test_session_stream_emits_heartbeat_on_idle located the module to patch
via _session_labels_for_runner_spawn.__module__, which now resolves to
omnigent.runner.native.orchestration — a module that has no
_SESSION_STREAM_HEARTBEAT_S attribute — so the test raised
AttributeError and failed CI on main.

Patch omnigent.runner.app directly, which is where the heartbeat cadence
constant and its consumer actually live.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-07-23 13:02:46 -07:00
Harry Yao d18f7b95f5 claude-native: respect CLAUDE_CODE_USE_GATEWAY=1 for tool search (#3161)
When the launching process sets CLAUDE_CODE_USE_GATEWAY=1, that
gateway-aware mode keeps tool search enabled so MCP schemas load on
demand. Setting CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS alongside it
would override that mode, disabling all betas and inflating startup
token usage.

Only set CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS when gateway-aware
mode was NOT selected.

Ported from databricks-eng/universe#2298829.

Co-authored-by: harry-yao_data <harry.yao@databricks.com>
2026-07-23 12:36:02 -07:00
Aravind Segu 56d1db68af Add overridable item-data serialization seams to SqlAlchemyConversationStore (#3126)
Introduce no-op extension points on the conversation store so a subclass
can transform conversation_items.data and control search_text, without
changing OSS behavior:

- _encode_item_data(data_json): identity by default; append's data write is
  routed through it so a subclass may compress or encrypt the payload.
- _decode_item_data_batch(stored_list): identity by default; the read paths
  (list_items, list_latest_message_items_for_conversations, the FTS-ranked
  read) decode a whole page of rows through it before building entities, and
  _to_item now takes the already-decoded data. Making the read seam a batch
  (not a per-row hook) lets a subclass decode a page in one pass — e.g. a
  single bulk decrypt — instead of once per row.
- _item_search_text(item): extracts the search text as before by default;
  may return None to skip persisting search_text (and its FTS row) on a
  schema that omits the column.

Every default preserves current behavior exactly: the column stays plaintext
Text, and search/FTS are unchanged. This lets a downstream store (Databricks'
MySQL-homed conversation store) envelope-encrypt item payloads at the column
boundary while reusing append/list_items unchanged.

Co-authored-by: Isaac

Signed-off-by: aravind-segu <aravind.segu@databricks.com>
2026-07-23 10:00:25 -07:00
Pat Sukprasert afe6b3ba11 [tests] Split native app session tests by concern (#3149)
* test: split native app session tests

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

* test: address native session split review feedback

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

* test: address follow-up lint feedback

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

* test: clarify native session test scopes

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

* test: update native session helper imports

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-23 16:51:11 +00:00
Pat Sukprasert a979ec97d7 [runner] Extract native terminal orchestration (#3148)
* refactor(runner): extract native terminal orchestration

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

* fix(runner): limit native compatibility syncing

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

---------

Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
2026-07-23 16:19:00 +00:00
Sai Asish Y 750c395a50 docs(deploy): correct docker admin bootstrap flow (no auto-generated password) (#2840)
* docs(deploy): correct docker admin bootstrap flow (no auto-generated password)

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>

* docs(deploy): correct remaining generated-password and /data-persistence claims

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>

* docs(deploy): scrub generated-password flow from remaining platform guides

The Docker docs were corrected earlier, but fly / railway / render / modal /
hf-spaces still told operators to read a generated admin password out of the
logs / /data/admin-credentials — a flow that no longer exists (bootstrap never
auto-generates a password; the first admin is claimed via the web Create-admin
form or a pre-seeded OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD).

- Rewrite the first-admin step in each guide to the real flow, and drop the
  fake "Created initial admin ... password: <generated>" log block.
- Add a first-visitor security note (unauthenticated /auth/setup while no
  password-bearing account exists) to every public-facing guide; fold it into
  hf-spaces' "make the Space Public" step where the exposure is most direct.
- render: correct the disk bullet (hashes live in Postgres, not on /data) and
  the render.yaml comment that called the anchor path a password file.

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

---------

Signed-off-by: Sai Asish Y <say.apm35@gmail.com>
Signed-off-by: Pat Sukprasert <pattara.sk127@gmail.com>
Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
Co-authored-by: Isaac <isaac@example.com>
2026-07-23 16:11:36 +00:00
582 changed files with 95722 additions and 68545 deletions
@@ -71,8 +71,10 @@ Three transports, easy to confuse:
agy models # exits 0 and lists models only when signed in; else 'Please sign in'
```
`False` / non-zero → run `agy` once and sign in. agy's token lives under
`~/.gemini` (`oauth_creds.json` on macOS, `antigravity-cli/antigravity-oauth-token`
on Linux).
`~/.gemini` (`oauth_creds.json` on macOS through 1.0.10,
`antigravity-cli/antigravity-oauth-token` on Linux); agy 1.1.7+ on macOS
writes no token file and keeps the credential in the Keychain, which is why
`gemini_login_detected()` falls back to `agy models` there.
4. **`tmux` is on PATH.** The agy terminal is a runner-owned tmux pane; the CLI
attaches to it and the executor drives it via `tmux send-keys`
(`_preflight_local_tools` hard-fails without tmux).
+1
View File
@@ -10,6 +10,7 @@ dhruv0811
Edwinhe03
fanzeyi
kerryspchang
kunyuchen
lisancao
mahesh-venkatachalam
mateiz
@@ -36,7 +36,7 @@ inputs:
claude-code-version:
description: "@anthropic-ai/claude-code npm version to install."
required: false
default: 2.1.170
default: 2.1.212
runs:
using: composite
@@ -64,11 +64,15 @@ runs:
CLAUDE_CODE_VERSION: ${{ inputs.claude-code-version }}
run: |
set -euo pipefail
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli"
cd "${GITHUB_WORKSPACE}/.cc-cli"
# Install outside the checked-out tree: a repo-root package.json would
# otherwise capture this bare `npm install` and hoist it there, leaving
# this dir's node_modules empty.
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR"
cd "$CC_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Write Omnigent provider config
shell: bash
-37
View File
@@ -1,37 +0,0 @@
name: "setup-node"
description: "Set up Node and pin npm, with npm dependency caching keyed on the web lockfile."
# Single source of truth for the JS toolchain across CI. Pins npm to the
# EXACT version that regenerates the lockfile in oss-regenerate-and-smoke.yml
# (npm 11.12.1); without this, jobs use whatever npm Node 20 bundles
# (npm 10.x) and the `package-lock.json` freshness gate in lint.yml would
# flake on version-skew churn (dev/extraneous flags, metadata). Keep this
# version in lockstep with the regen workflow so generation and
# verification never diverge.
inputs:
node-version:
description: "Node version to use."
default: "20"
required: false
cache:
description: "Package-manager cache to enable (passed to actions/setup-node)."
default: "npm"
required: false
cache-dependency-path:
description: "Lockfile path used as the cache key."
default: "web/package-lock.json"
required: false
runs:
using: "composite"
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ inputs.node-version }}
cache: ${{ inputs.cache }}
cache-dependency-path: ${{ inputs.cache-dependency-path }}
- name: Pin npm
shell: bash
run: npm install -g npm@11.12.1
+21
View File
@@ -0,0 +1,21 @@
name: "setup-pnpm"
description: "Set up Node + pnpm for the web workspace"
inputs:
node-version:
description: "Node version to use."
default: "22"
required: false
runs:
using: composite
steps:
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
standalone: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ inputs.node-version }}
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"description": "Pinned npm CLIs the e2e workflow installs (claude-code, codex, pi).",
"dependencies": {
"@anthropic-ai/claude-code": "2.1.163",
"@anthropic-ai/claude-code": "2.1.212",
"@earendil-works/pi-coding-agent": "0.79.0",
"@openai/codex": "0.139.0"
}
@@ -3,7 +3,7 @@
# `e2e_matrix` and `integration_matrix`.
#
# We test `main` (the checked-out code = client + tests, always) against each
# non-rc release tag AT OR ABOVE the backcompat floor (MIN_VERSION, default
# final (non-prerelease) release tag AT OR ABOVE the backcompat floor (MIN_VERSION, default
# 0.2.0 — the first release with the mock-LLM e2e infra; see below), on BOTH
# axes — and ONLY those cells:
# (server=main, runner=<release>) — new server vs a previously-shipped runner
@@ -17,8 +17,9 @@
#
# Env in:
# VERSIONS optional comma-separated override of the version set used for
# BOTH axes (e.g. "main,v0.2.0"). Empty = main + all non-rc tags.
# Blank entries are dropped and surrounding whitespace trimmed.
# BOTH axes (e.g. "main,v0.2.0"). Empty = main + all final
# (non-prerelease) tags. Blank entries are dropped and
# surrounding whitespace trimmed.
# NUM_SHARDS e2e shard count per cell (default 4).
# Out (GITHUB_OUTPUT):
# e2e_matrix={"include":[{"server":..,"runner":..,"shard_id":..,"num_shards":..}, ...]}
@@ -61,9 +62,12 @@ if [ -n "${VERSIONS:-}" ]; then
IFS=',' read -ra raw <<<"$VERSIONS"
else
raw=("main")
# `[^a-z]rc[0-9]` so we drop vX.Y.ZrcN without over-excluding tags that merely
# contain the substring "rc" (e.g. a hypothetical "...march").
while IFS= read -r tag; do raw+=("$tag"); done < <(git tag --sort=-v:refname | grep -viE '(^|[^a-z])rc[0-9]')
# Drop pre-release tags (vX.Y.ZrcN / .devN / preN — same trio github-release.yml
# skips): they are snapshots of main, so main-vs-them is not a compat signal, and
# under the 256-job cap they would evict the oldest FINAL releases from coverage.
# `[^a-z]` guards against over-excluding tags that merely contain the substring
# (e.g. a hypothetical "...march"). An explicit VERSIONS override still accepts them.
while IFS= read -r tag; do raw+=("$tag"); done < <(git tag --sort=-v:refname | grep -viE '(^|[^a-z])(rc|dev|pre)[0-9]')
fi
# Trim whitespace, drop blanks, reject invalid tokens, drop below-floor releases.
+3
View File
@@ -33,6 +33,7 @@ REQUIRED=(
"E2E UI Tests (shard 0/3)"
"E2E UI Tests (shard 1/3)"
"E2E UI Tests (shard 2/3)"
"UI Snapshot (visual baselines)"
"Integration (claude-sdk)"
"Integration (openai-agents)"
"Integration (codex)"
@@ -63,6 +64,7 @@ ALLOW_SKIP=(
"E2E UI Tests (shard 0/3)"
"E2E UI Tests (shard 1/3)"
"E2E UI Tests (shard 2/3)"
"UI Snapshot (visual baselines)"
"Integration (claude-sdk)"
"Integration (openai-agents)"
"Integration (codex)"
@@ -80,6 +82,7 @@ workflow_for() {
"Pytest ("*) echo "CI" ;;
"E2E Tests (shard "*) echo "E2E Tests" ;;
"E2E UI Tests (shard "*) echo "E2E UI Tests" ;;
"UI Snapshot (visual baselines)") echo "UI Snapshot" ;;
"Integration ("*) echo "Integration Tests" ;;
*) echo "" ;;
esac
+318
View File
@@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""Keep the waiting-on-author pull request label actionable."""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import UTC, datetime
from email.message import Message
from typing import Any
LABEL = "waiting-on-author"
WAITING_DAYS = 7
CANONICAL_REPO = "omnigent-ai/omnigent"
MAX_CLOSURES_PER_RUN = 30
def label_names(item: dict[str, Any]) -> list[str]:
return [
label.get("name", label) if isinstance(label, dict) else label
for label in item.get("labels", [])
]
def has_waiting_label(item: dict[str, Any]) -> bool:
return LABEL in label_names(item)
def parse_time(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def days_between(start: str, end: datetime) -> int:
return int((end - parse_time(start)).total_seconds() // 86400)
def latest_waiting_label_at(timeline: list[dict[str, Any]]) -> str | None:
latest: str | None = None
for event in timeline:
if event.get("event") != "labeled" or not event.get("created_at"):
continue
label = event.get("label") or {}
name = label.get("name") if isinstance(label, dict) else label
if name != LABEL:
continue
if latest is None or parse_time(event["created_at"]) > parse_time(latest):
latest = event["created_at"]
return latest
def close_message(label_applied_at: str) -> str:
return "\n".join(
[
f"Closing this PR because it has been labeled `{LABEL}` for "
f"{WAITING_DAYS} days without an author reply or new commit.",
"",
f"The label was last applied on {label_applied_at}. If you are "
"ready to continue, please reopen this PR or open a new one.",
]
)
class GitHubAPI:
def __init__(self, token: str, repo: str):
self.token = token
self.repo = repo
def request(
self, method: str, path: str, body: dict[str, Any] | None = None
) -> tuple[Any, Message]:
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(
f"https://api.github.com{path}",
data=data,
method=method,
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with urllib.request.urlopen(request) as response:
raw = response.read()
parsed = json.loads(raw.decode()) if raw else None
return parsed, response.headers
def paginated(self, path: str) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
next_path: str | None = path
while next_path:
page, headers = self.request("GET", next_path)
items.extend(page or [])
next_path = next_link(headers.get("Link", ""))
return items
def get_pull(self, pull_number: int) -> dict[str, Any]:
pull, _ = self.request("GET", f"/repos/{self.repo}/pulls/{pull_number}")
return pull
def remove_label(self, issue_number: int, label: str) -> bool:
quoted = urllib.parse.quote(label, safe="")
try:
self.request("DELETE", f"/repos/{self.repo}/issues/{issue_number}/labels/{quoted}")
except urllib.error.HTTPError as error:
if error.code == 404:
return False
raise
return True
def list_waiting_issues(self) -> list[dict[str, Any]]:
query = urllib.parse.urlencode({"state": "open", "labels": LABEL, "per_page": 100})
return self.paginated(f"/repos/{self.repo}/issues?{query}")
def list_timeline(self, issue_number: int) -> list[dict[str, Any]]:
return self.paginated(f"/repos/{self.repo}/issues/{issue_number}/timeline?per_page=100")
def list_issue_comments(self, issue_number: int) -> list[dict[str, Any]]:
return self.paginated(f"/repos/{self.repo}/issues/{issue_number}/comments?per_page=100")
def list_review_comments(self, pull_number: int) -> list[dict[str, Any]]:
return self.paginated(f"/repos/{self.repo}/pulls/{pull_number}/comments?per_page=100")
def list_reviews(self, pull_number: int) -> list[dict[str, Any]]:
return self.paginated(f"/repos/{self.repo}/pulls/{pull_number}/reviews?per_page=100")
def list_commits(self, pull_number: int) -> list[dict[str, Any]]:
return self.paginated(f"/repos/{self.repo}/pulls/{pull_number}/commits?per_page=100")
def close_pull(self, pull_number: int) -> None:
self.request("PATCH", f"/repos/{self.repo}/pulls/{pull_number}", {"state": "closed"})
def create_comment(self, issue_number: int, body: str) -> None:
self.request("POST", f"/repos/{self.repo}/issues/{issue_number}/comments", {"body": body})
def next_link(link_header: str) -> str | None:
for part in link_header.split(","):
url_part, _, rel_part = part.partition(";")
if 'rel="next"' not in rel_part:
continue
url = url_part.strip()[1:-1]
parsed = urllib.parse.urlparse(url)
return f"{parsed.path}?{parsed.query}"
return None
def remove_waiting_label(api: GitHubAPI, issue_number: int, reason: str) -> bool:
removed = api.remove_label(issue_number, LABEL)
if removed:
print(f"Removed {LABEL} from #{issue_number}: {reason}")
else:
print(f"#{issue_number} no longer has {LABEL}; nothing to remove.")
return removed
def user_login(item: dict[str, Any]) -> str | None:
login = item.get("user", {}).get("login")
return login.lower() if login else None
def is_after(timestamp: str | None, since: str) -> bool:
return bool(timestamp and parse_time(timestamp) > parse_time(since))
def authored_after(items: list[dict[str, Any]], author: str, since: str, key: str) -> bool:
return any(user_login(item) == author and is_after(item.get(key), since) for item in items)
def commit_after(commits: list[dict[str, Any]], since: str) -> bool:
for commit in commits:
authored_at = commit.get("commit", {}).get("author", {}).get("date")
committed_at = commit.get("commit", {}).get("committer", {}).get("date")
if is_after(authored_at, since) or is_after(committed_at, since):
return True
return False
def author_activity_since_label(api: GitHubAPI, pull: dict[str, Any], since: str) -> str | None:
author = pull.get("user", {}).get("login")
if not author:
return None
author = author.lower()
pull_number = pull["number"]
if authored_after(api.list_issue_comments(pull_number), author, since, "created_at"):
return "the author commented"
if authored_after(api.list_review_comments(pull_number), author, since, "created_at"):
return "the author replied to a review comment"
if authored_after(api.list_reviews(pull_number), author, since, "submitted_at"):
return "the author submitted a review response"
if commit_after(api.list_commits(pull_number), since):
return "new commits were pushed"
return None
def clear_on_author_activity(event_name: str, payload: dict[str, Any], api: GitHubAPI) -> bool:
pull_number: int | None = None
actor: str | None = None
reason: str | None = None
author_activity = False
if event_name in {"pull_request", "pull_request_target"} and payload.get("pull_request"):
if payload.get("action") != "synchronize":
return False
pull_number = payload["pull_request"]["number"]
reason = "new commits were pushed"
author_activity = True
elif event_name == "issue_comment" and "pull_request" in payload.get("issue", {}):
pull_number = payload["issue"]["number"]
actor = payload.get("comment", {}).get("user", {}).get("login")
reason = "the author commented"
elif event_name == "pull_request_review_comment" and payload.get("pull_request"):
pull_number = payload["pull_request"]["number"]
actor = payload.get("comment", {}).get("user", {}).get("login")
reason = "the author replied to a review comment"
elif event_name == "pull_request_review" and payload.get("pull_request"):
pull_number = payload["pull_request"]["number"]
actor = payload.get("review", {}).get("user", {}).get("login")
reason = "the author submitted a review response"
else:
return False
if pull_number is None or reason is None:
return False
pull = api.get_pull(pull_number)
if pull.get("state") != "open" or not has_waiting_label(pull):
return False
if not author_activity:
author = pull.get("user", {}).get("login")
author_activity = bool(actor and author and actor.lower() == author.lower())
if not author_activity:
return False
return remove_waiting_label(api, pull_number, reason)
def close_stale_waiting_prs(api: GitHubAPI, now: datetime | None = None) -> int:
now = now or datetime.now(UTC)
closed = 0
for issue in api.list_waiting_issues():
if closed >= MAX_CLOSURES_PER_RUN:
break
if "pull_request" not in issue or not has_waiting_label(issue):
continue
try:
label_applied_at = latest_waiting_label_at(api.list_timeline(issue["number"]))
if label_applied_at is None:
print(
f"::warning::#{issue['number']} has {LABEL} but no label timestamp "
"in the timeline; skipping."
)
continue
pull = api.get_pull(issue["number"])
reason = author_activity_since_label(api, pull, label_applied_at)
if reason:
remove_waiting_label(api, issue["number"], reason)
continue
if days_between(label_applied_at, now) < WAITING_DAYS:
continue
api.close_pull(issue["number"])
api.create_comment(issue["number"], close_message(label_applied_at))
closed += 1
print(f"Closed #{issue['number']}; {LABEL} was applied at {label_applied_at}.")
except Exception as error: # noqa: BLE001 - keep the sweep moving across PRs.
print(f"::warning::Could not close #{issue['number']}: {error}")
print(f"Closed {closed} PR(s) labeled {LABEL}.")
return closed
def run(
event_name: str,
payload: dict[str, Any],
api: GitHubAPI,
repo: str,
now: datetime | None = None,
) -> None:
if repo != CANONICAL_REPO:
print(f"Skipping {repo}; waiting-on-author hygiene only runs for {CANONICAL_REPO}.")
return
if event_name in {"schedule", "workflow_dispatch"}:
close_stale_waiting_prs(api, now=now)
return
clear_on_author_activity(event_name, payload, api)
def load_event_payload() -> dict[str, Any]:
path = os.environ.get("GITHUB_EVENT_PATH")
if not path:
return {}
with open(path, encoding="utf-8") as handle:
return json.load(handle)
def main() -> int:
repo = os.environ.get("GITHUB_REPOSITORY", "")
token = os.environ.get("GITHUB_TOKEN")
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)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""Offline tests for waiting_on_author.py."""
from __future__ import annotations
import importlib.util
import pathlib
import unittest
from datetime import UTC, datetime
from typing import Any
SCRIPT_PATH = pathlib.Path(__file__).with_name("waiting_on_author.py")
SPEC = importlib.util.spec_from_file_location("waiting_on_author", SCRIPT_PATH)
waiting_on_author = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
SPEC.loader.exec_module(waiting_on_author)
def pr(
number: int = 12, author: str = "alice", labels: list[str] | None = None, state: str = "open"
) -> dict[str, Any]:
labels = [waiting_on_author.LABEL] if labels is None else labels
return {
"number": number,
"state": state,
"user": {"login": author},
"labels": [{"name": label} for label in labels],
}
def issue(number: int, labels: list[str] | None = None, is_pr: bool = True) -> dict[str, Any]:
labels = [waiting_on_author.LABEL] if labels is None else labels
item: dict[str, Any] = {"number": number, "labels": [{"name": label} for label in labels]}
if is_pr:
item["pull_request"] = {}
return item
def labeled_at(iso: str, label: str | None = None) -> dict[str, Any]:
return {
"event": "labeled",
"label": {"name": label or waiting_on_author.LABEL},
"created_at": iso,
}
class FakeAPI:
def __init__(
self,
*,
pull: dict[str, Any] | None = None,
issues: list[dict[str, Any]] | None = None,
timeline_by_issue: dict[int, list[dict[str, Any]]] | None = None,
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,
commits: dict[int, list[dict[str, Any]]] | None = None,
):
self.pull = pull or pr()
self.issues = issues or []
self.timeline_by_issue = timeline_by_issue or {}
self.issue_comments = issue_comments or {}
self.review_comments = review_comments or {}
self.reviews = reviews or {}
self.commits = commits or {}
self.removed: list[tuple[int, str]] = []
self.closed: list[int] = []
self.comments: list[tuple[int, str]] = []
def get_pull(self, pull_number: int) -> dict[str, Any]:
return self.pull | {"number": pull_number}
def remove_label(self, issue_number: int, label: str) -> bool:
self.removed.append((issue_number, label))
return True
def list_waiting_issues(self) -> list[dict[str, Any]]:
return self.issues
def list_timeline(self, issue_number: int) -> list[dict[str, Any]]:
return self.timeline_by_issue.get(issue_number, [])
def list_issue_comments(self, issue_number: int) -> list[dict[str, Any]]:
return self.issue_comments.get(issue_number, [])
def list_review_comments(self, pull_number: int) -> list[dict[str, Any]]:
return self.review_comments.get(pull_number, [])
def list_reviews(self, pull_number: int) -> list[dict[str, Any]]:
return self.reviews.get(pull_number, [])
def list_commits(self, pull_number: int) -> list[dict[str, Any]]:
return self.commits.get(pull_number, [])
def close_pull(self, pull_number: int) -> None:
self.closed.append(pull_number)
def create_comment(self, issue_number: int, body: str) -> None:
self.comments.append((issue_number, body))
class WaitingOnAuthorTest(unittest.TestCase):
def test_latest_waiting_label_at_uses_latest_matching_label(self) -> None:
self.assertEqual(
waiting_on_author.latest_waiting_label_at(
[
labeled_at("2026-07-01T00:00:00Z"),
labeled_at("2026-07-10T00:00:00Z", "other"),
labeled_at("2026-07-12T00:00:00Z"),
]
),
"2026-07-12T00:00:00Z",
)
def test_author_issue_comment_removes_waiting_label(self) -> None:
api = FakeAPI(pull=pr(author="Alice"))
waiting_on_author.clear_on_author_activity(
"issue_comment",
{"issue": {"number": 12, "pull_request": {}}, "comment": {"user": {"login": "alice"}}},
api,
)
self.assertEqual(api.removed, [(12, waiting_on_author.LABEL)])
def test_author_review_thread_reply_removes_waiting_label(self) -> None:
api = FakeAPI(pull=pr(author="alice"))
waiting_on_author.clear_on_author_activity(
"pull_request_review_comment",
{"pull_request": {"number": 12}, "comment": {"user": {"login": "alice"}}},
api,
)
self.assertEqual(api.removed, [(12, waiting_on_author.LABEL)])
def test_maintainer_comment_keeps_waiting_label(self) -> None:
api = FakeAPI(pull=pr(author="alice"))
waiting_on_author.clear_on_author_activity(
"issue_comment",
{
"issue": {"number": 12, "pull_request": {}},
"comment": {"user": {"login": "maintainer"}},
},
api,
)
self.assertEqual(api.removed, [])
def test_new_commits_remove_waiting_label(self) -> None:
api = FakeAPI(pull=pr(author="alice"))
waiting_on_author.clear_on_author_activity(
"pull_request_target",
{"action": "synchronize", "pull_request": {"number": 12}},
api,
)
self.assertEqual(api.removed, [(12, waiting_on_author.LABEL)])
def test_scheduled_sweep_closes_pr_after_7_days(self) -> None:
api = FakeAPI(
issues=[issue(20)], timeline_by_issue={20: [labeled_at("2026-07-17T00:00:00Z")]}
)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.closed, [20])
self.assertEqual(len(api.comments), 1)
self.assertIn(waiting_on_author.LABEL, api.comments[0][1])
def test_scheduled_sweep_removes_label_after_author_comment(self) -> None:
api = FakeAPI(
pull=pr(number=23, author="alice"),
issues=[issue(23)],
timeline_by_issue={23: [labeled_at("2026-07-01T00:00:00Z")]},
issue_comments={
23: [{"user": {"login": "alice"}, "created_at": "2026-07-20T00:00:00Z"}]
},
)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.removed, [(23, waiting_on_author.LABEL)])
self.assertEqual(api.closed, [])
def test_scheduled_sweep_keeps_label_after_maintainer_comment(self) -> None:
api = FakeAPI(
pull=pr(number=24, author="alice"),
issues=[issue(24)],
timeline_by_issue={24: [labeled_at("2026-07-18T00:00:00Z")]},
issue_comments={
24: [{"user": {"login": "maintainer"}, "created_at": "2026-07-20T00:00:00Z"}]
},
)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.removed, [])
self.assertEqual(api.closed, [])
def test_scheduled_sweep_removes_label_after_new_commit(self) -> None:
api = FakeAPI(
pull=pr(number=25, author="alice"),
issues=[issue(25)],
timeline_by_issue={25: [labeled_at("2026-07-01T00:00:00Z")]},
commits={25: [{"commit": {"author": {"date": "2026-07-20T00:00:00Z"}}}]},
)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.removed, [(25, waiting_on_author.LABEL)])
self.assertEqual(api.closed, [])
def test_scheduled_sweep_ignores_author_comment_before_label(self) -> None:
api = FakeAPI(
pull=pr(number=26, author="alice"),
issues=[issue(26)],
timeline_by_issue={26: [labeled_at("2026-07-17T00:00:00Z")]},
issue_comments={
26: [{"user": {"login": "alice"}, "created_at": "2026-07-10T00:00:00Z"}]
},
)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.removed, [])
self.assertEqual(api.closed, [26])
def test_scheduled_sweep_leaves_6_day_pr_open(self) -> None:
api = FakeAPI(
issues=[issue(21)], timeline_by_issue={21: [labeled_at("2026-07-18T00:00:00Z")]}
)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.closed, [])
self.assertEqual(api.comments, [])
def test_scheduled_sweep_skips_missing_label_timestamp(self) -> None:
api = FakeAPI(issues=[issue(22)], timeline_by_issue={22: []})
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(api.closed, [])
def test_scheduled_sweep_caps_closures_per_run(self) -> None:
issues = [issue(100 + idx) for idx in range(waiting_on_author.MAX_CLOSURES_PER_RUN + 3)]
timeline = {item["number"]: [labeled_at("2026-07-01T00:00:00Z")] for item in issues}
api = FakeAPI(issues=issues, timeline_by_issue=timeline)
waiting_on_author.close_stale_waiting_prs(api, now=datetime(2026, 7, 24, tzinfo=UTC))
self.assertEqual(len(api.closed), waiting_on_author.MAX_CLOSURES_PER_RUN)
if __name__ == "__main__":
unittest.main()
+13 -5
View File
@@ -158,12 +158,20 @@ jobs:
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh pr comment "${{ github.event.pull_request.number }}" \
--edit-last \
--body-file comment_body.md || \
gh pr comment "${{ github.event.pull_request.number }}" \
--body-file comment_body.md
COMMENT_MARKER="<!-- benchmark-pr-comment -->"
COMMENT_ID=$(gh api --paginate \
"repos/${REPO}/issues/${PR_NUMBER}/comments" \
--jq ".[] | select(.body | startswith(\"$COMMENT_MARKER\")) | .id" \
| head -1)
if [ -n "$COMMENT_ID" ]; then
gh api "repos/${REPO}/issues/comments/${COMMENT_ID}" \
-X PATCH -f body="$(cat comment_body.md)"
else
gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file comment_body.md
fi
- name: Upload candidate results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+1 -1
View File
@@ -139,6 +139,6 @@ jobs:
--title "Bump version to ${resolved}" \
--body "Automated version bump via \`.github/workflows/bump-version.yml\` (mode: \`${MODE}\`, input: \`${NEW_VERSION}\`).
Rewrote \`[project].version\` and sibling \`==\` pins across all three packages (\`pyproject.toml\`, \`sdks/python-client\`, \`sdks/ui\`), the runtime \`VERSION\` constant in \`omnigent/version.py\`, and regenerated \`uv.lock\`.
Rewrote \`[project].version\` and sibling \`==\` pins across all four packages (\`pyproject.toml\`, \`sdks/python-client\`, \`sdks/ui\`, \`integrations/slack\`), the runtime \`VERSION\` constant in \`omnigent/version.py\`, and regenerated \`uv.lock\`.
Generated by \`scripts/update_versions.py\`. Opened via the omnigent-ci App when configured (CI runs automatically); on the GITHUB_TOKEN fallback, re-open or push to kick CI."
+5 -5
View File
@@ -370,14 +370,14 @@ jobs:
path: .tmp-codex-parity-target/debug/codex-parity-sidecar
key: codex-parity-bin-${{ runner.os }}-${{ steps.rustc.outputs.version }}-${{ hashFiles('tests/codex_parity/sidecar/**') }}
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install codex CLI
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
npm install --ignore-scripts --prefix .github/ci-deps
pnpm install --frozen-lockfile --ignore-scripts --filter e2e-ci-deps
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
- name: Cache virtualenv
+7 -3
View File
@@ -254,10 +254,14 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
# Install outside the checked-out tree: a repo-root package.json would
# otherwise capture this bare `npm install` and hoist it there, leaving
# this dir's node_modules empty.
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR" && cd "$CC_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.212
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Write Omnigent provider config
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
+2 -1
View File
@@ -31,7 +31,8 @@ on:
- 'pyproject.toml'
- 'setup.py'
- 'uv.lock'
- 'web/package-lock.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/workflows/docker-build.yml'
permissions:
+2 -2
View File
@@ -89,14 +89,14 @@ jobs:
base="${INPUT_BASE:-}"
proceed=false; dry_run=false
# Does the tag look like a final release (vX.Y.Z, not rc/dev/alpha/beta)?
# Does the tag look like a final release (vX.Y.Z, not a pre-release)?
is_version=true
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) is_version=false ;;
esac
case "$tag" in
*rc*|*dev*|*a[0-9]*|*b[0-9]*) is_version=false ;;
*rc[0-9]*|*dev[0-9]*|*pre[0-9]*) is_version=false ;;
esac
if [ "$EVENT_NAME" = "workflow_run" ]; then
+15 -15
View File
@@ -168,8 +168,8 @@ jobs:
with:
python-version-file: ".python-version"
- name: Set up Node 20
uses: ./.github/actions/setup-node
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
@@ -225,14 +225,11 @@ jobs:
- name: Build web SPA
# Build BEFORE pytest: Vite's emptyOutDir clobbers the static dir,
# so never run it under xdist or alongside the live server.
# --legacy-peer-deps avoids re-resolving the known React 19 peer
# conflict under @emoji-mart/react.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
cd web
npm ci --legacy-peer-deps --no-audit --no-fund
npm run build
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
# Native coding-agent harness enablement: the next steps let the
# native render-parity tests boot a real Claude Code / Codex CLI. The
@@ -241,15 +238,17 @@ jobs:
# claude-code 2.1.170, NOT the 2.1.124 in .github/ci-deps: 2.1.124
# doesn't recognise the hook events the native bridge configures and
# shows a blocking startup modal that swallows the first message.
# --ignore-scripts then run install.cjs explicitly (audited: platform
# detect + same-tree hardlink, no network/exec) and put its bin on PATH.
# Runs the package's install script so the platform-specific binary is
# linked into the temporary CLI directory and put on PATH.
# (The install.cjs script was removed in the same version the upstream
# npm package no longer ships it, so lifecycle scripts are required.)
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR" && cd "$CC_CLI_DIR"
npm install --no-audit --no-fund @anthropic-ai/claude-code@2.1.212
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Install Codex CLI
# @openai/codex at the .github/ci-deps pin (same build as e2e.yml's
@@ -259,9 +258,10 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.codex-cli" && cd "${GITHUB_WORKSPACE}/.codex-cli"
CODEX_CLI_DIR="${RUNNER_TEMP}/omnigent-codex-cli"
mkdir -p "$CODEX_CLI_DIR" && cd "$CODEX_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @openai/codex@0.139.0
echo "${GITHUB_WORKSPACE}/.codex-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CODEX_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Run UI e2e tests
# --ui-skip-build: the SPA was built in the previous step.
+9 -30
View File
@@ -55,48 +55,27 @@ jobs:
with:
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Set up Node
uses: ./.github/actions/setup-node
with:
# Node 22.x per web/electron/README.md ("Prerequisites").
node-version: "22"
cache-dependency-path: web/electron/package-lock.json
- name: Verify lockfile uses public registry
# Fail fast (in seconds, not minutes) if any package-lock.json
# resolved URL points at an internal proxy that public CI runners
# can't reach — e.g. npm-proxy.cloud.databricks.com. Without this
# guard, npm ci silently times out mid-install on Windows/Linux.
# Uses the shared normalize_package_lock_registry.py script (same
# one wired into pre-commit) so CI and local checks stay in sync.
working-directory: web/electron
shell: bash
run: |
python3 ../../scripts/normalize_package_lock_registry.py --check package-lock.json
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install dependencies
working-directory: web/electron
run: npm ci --no-audit --no-fund
# The shell-owned update overlay reuses the web UpdateBanner component; it
# is built from the web app into electron/overlay/ (gitignored) and shipped
# by electron-builder (build.files). The build:<platform> scripts run it
# automatically via their `prebuild:*` hook (see web/electron/package.json)
# — this step only needs to install the web app's deps so that hook works.
- name: Install web deps (for the update overlay build)
working-directory: web
run: npm ci --legacy-peer-deps --no-audit --no-fund
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |-
pnpm install --frozen-lockfile --filter @omnigent/electron
pnpm install --frozen-lockfile --filter web
- name: Build ${{ matrix.platform }} app
working-directory: web/electron
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
# No signing credentials in CI: force an unsigned build instead of
# letting electron-builder fail hunting for a certificate.
CSC_IDENTITY_AUTO_DISCOVERY: "false"
# electron-builder downloads Electron/tooling from GitHub; the token
# lifts the anonymous rate limit that otherwise flakes downloads.
GH_TOKEN: ${{ github.token }}
run: npm run ${{ matrix.build-script }} -- --publish never
run: pnpm run ${{ matrix.build-script }} -- --publish never
# One artifact per platform bundling the COMPLETE electron-updater feed —
# the installer(s), the .blockmap electron-updater needs for differential
+53 -18
View File
@@ -2,13 +2,16 @@
# run after the prod PyPI publish succeeded and the draft notes are curated
# (designs/RELEASE-AUTOMATION.md).
#
# Deterministic gates first (all fail with actionable links):
# * the tag is a final vX.Y.Z with an unpublished draft release,
# Deterministic gates first (each fails with actionable links):
# * the tag is a final vX.Y.Z (input is normalized: `0.7.0` -> `v0.7.0`)
# with an unpublished draft release; a draft whose tag binding was lost
# to a web-UI edit (tag_name became `untagged-…`) is rebound automatically,
# * PyPI serves all three lockstep packages at the version (never advertise
# a release that isn't installable),
# * the auto/changelog/vX.Y.Z CHANGELOG PR isn't sitting open,
# * the docs sweep: no open PRs against omnigent-site's X.Y-docs staging
# branch (every doc staged this cycle is reviewed + merged/closed).
# * the auto/changelog/vX.Y.Z CHANGELOG PR isn't sitting open.
# The docs sweep (open PRs against omnigent-site's X.Y-docs staging branch) is
# ADVISORY only: it lists what is still unmerged but never blocks the publish —
# docs can land after the release, any time before the docs-publish PR merges.
#
# The publish job binds the `publish-release` environment (one-time setup:
# create it in repo settings with required reviewers). Approving it is the
@@ -64,16 +67,23 @@ jobs:
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
already_published: ${{ steps.draft.outputs.already_published }}
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Require a final vX.Y.Z tag
id: tag
env:
TAG: ${{ inputs.tag }}
RAW_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
# Normalize the input: trim whitespace, add the leading v if omitted
# (`0.7.0` -> `v0.7.0`), so a bare version doesn't fail the dispatch.
TAG="$(printf '%s' "$RAW_TAG" | tr -d '[:space:]')"
case "$TAG" in v*) ;; *) TAG="v${TAG}" ;; esac
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::${TAG} is not a final vX.Y.Z tag — rc/dev/alpha/beta releases never finalize."
echo "::error::${RAW_TAG} is not a final vX.Y.Z tag — rc/dev/pre releases never finalize."
exit 1
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
# Drafts are invisible to read-only tokens and unaddressable by tag
# (the get-by-tag endpoint 404s on drafts) — resolve by listing with the
@@ -93,11 +103,32 @@ jobs:
id: draft
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
TAG: ${{ inputs.tag }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
match="$(gh api "repos/${GITHUB_REPOSITORY}/releases" --paginate \
--jq 'map(select(.tag_name == env.TAG)) | first // empty')"
if [ -z "$match" ]; then
# Editing a draft in the web UI can silently drop its tag binding
# (tag_name becomes `untagged-…` while the name stays vX.Y.Z; bit
# v0.5.0 and v0.7.0). Recover: match the DRAFT by name and rebind —
# only ever onto a tag that already exists, so publishing can never
# mint a new tag at main.
match="$(gh api "repos/${GITHUB_REPOSITORY}/releases" --paginate \
--jq 'map(select(.draft == true and .name == env.TAG)) | first // empty')"
if [ -n "$match" ]; then
if ! gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha >/dev/null 2>&1; then
echo "::error::Draft named ${TAG} exists but the git tag does not — push the tag before finalizing."
exit 1
fi
rebind_id="$(printf '%s' "$match" | jq -r '.id')"
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${rebind_id}" \
-f tag_name="$TAG" > /dev/null
match="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${rebind_id}")"
echo "Rebound draft ${rebind_id} to ${TAG} (tag binding was lost, usually to a web-UI edit)." \
| tee -a "$GITHUB_STEP_SUMMARY"
fi
fi
if [ -z "$match" ]; then
echo "::error::No GitHub release found for ${TAG}. Did the tag push run github-release.yml?"
exit 1
@@ -118,7 +149,7 @@ jobs:
- name: Assert PyPI serves all three packages
if: steps.draft.outputs.already_published != 'true'
env:
TAG: ${{ inputs.tag }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
version="${TAG#v}"
@@ -134,7 +165,7 @@ jobs:
if: steps.draft.outputs.already_published != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
open_pr="$(gh pr list --repo "$GITHUB_REPOSITORY" --head "auto/changelog/${TAG}" \
@@ -145,11 +176,14 @@ jobs:
fi
echo "CHANGELOG PR for ${TAG}: merged or not needed."
- name: Docs sweep — no open PRs against the X.Y-docs staging branch
# Advisory only: docs frequently land after the release. The list tells
# the coordinator what must merge into X.Y-docs before the docs-publish
# PR does — it never blocks the publish itself.
- name: Docs sweep — list open PRs against the X.Y-docs staging branch
if: steps.draft.outputs.already_published != 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
TAG: ${{ inputs.tag }}
TAG: ${{ steps.tag.outputs.tag }}
SITE_REPO: ${{ github.repository_owner }}/omnigent-site
run: |
set -euo pipefail
@@ -158,16 +192,17 @@ jobs:
open="$(gh pr list --repo "$SITE_REPO" --base "$docs_branch" --state open \
--json url,title --jq '.[] | "- \(.url) \(.title)"')"
if [ -n "$open" ]; then
count="$(printf '%s\n' "$open" | grep -c .)"
{
echo "## Docs sweep failed for ${TAG}"
echo "## Docs sweep for ${TAG} — ${count} open PR(s) still target \`${docs_branch}\`"
echo ""
echo "Open PRs still target \`${docs_branch}\` on ${SITE_REPO} — review and merge/close them, then re-dispatch:"
echo "Advisory, not blocking. Merge/close these before merging the docs-publish PR:"
echo "$open"
} | tee -a "$GITHUB_STEP_SUMMARY"
echo "::error::Open doc PRs still target ${docs_branch} — see the run summary."
exit 1
echo "::warning::${count} open doc PR(s) still target ${docs_branch} (non-blocking) — see the run summary."
else
echo "Docs sweep clean: no open PRs against ${docs_branch}." | tee -a "$GITHUB_STEP_SUMMARY"
fi
echo "Docs sweep clean: no open PRs against ${docs_branch}." | tee -a "$GITHUB_STEP_SUMMARY"
# Approving this environment attests "I reviewed the curated draft notes".
publish:
@@ -189,7 +224,7 @@ jobs:
- name: Publish the draft as Latest
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
TAG: ${{ inputs.tag }}
TAG: ${{ needs.checks.outputs.tag }}
RELEASE_ID: ${{ needs.checks.outputs.release_id }}
run: |
set -euo pipefail
+11 -7
View File
@@ -238,22 +238,26 @@ jobs:
# executor adapters import at collection time.
run: uv sync --extra all --extra dev
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install binary dependencies
# Mirrors e2e.yml. ripgrep: Grep fallback for inner tests. tmux +
# bubblewrap: the e2e runner runs real agents under the linux_bwrap
# sandbox, which fails loud if `bwrap` is missing. The apparmor
# sysctl mirrors ci.yml (Ubuntu 24.04 blocks unprivileged user
# namespaces, which bwrap's unshare(CLONE_NEWUSER) needs). npm install
# with --ignore-scripts blocks postinstall; the claude-code stub needs
# its audited install.cjs run explicitly (platform detect + same-tree
# hardlink, no network/exec) for claude-sdk harness rows.
working-directory: .github/ci-deps
# namespaces, which bwrap's unshare(CLONE_NEWUSER) needs). pnpm
# install with --ignore-scripts blocks postinstall; the claude-code
# stub needs its audited install.cjs run explicitly (platform detect +
# same-tree hardlink, no network/exec) for claude-sdk harness rows.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
sudo apt-get update
sudo apt-get install -y ripgrep tmux bubblewrap
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
npm install --ignore-scripts
node node_modules/@anthropic-ai/claude-code/install.cjs
pnpm install --frozen-lockfile --ignore-scripts --filter e2e-ci-deps
node .github/ci-deps/node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
- name: Run pytest target
+13 -12
View File
@@ -162,8 +162,8 @@ jobs:
with:
python-version-file: ".python-version"
- name: Set up Node 20
uses: ./.github/actions/setup-node
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
@@ -222,20 +222,20 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
cd web
npm ci --legacy-peer-deps --no-audit --no-fund
npm run build
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
- name: Install Claude Code CLI
# Pinned to match e2e-ui.yml (2.1.170 recognises the native bridge
# hook events). --ignore-scripts then run the audited install.cjs.
# hook events). Runs lifecycle scripts so the platform-specific binary
# is linked and put on PATH.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR" && cd "$CC_CLI_DIR"
npm install --no-audit --no-fund @anthropic-ai/claude-code@2.1.212
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Install Codex CLI
# @openai/codex pinned to match e2e-ui.yml; goal-mode app-server APIs
@@ -243,9 +243,10 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.codex-cli" && cd "${GITHUB_WORKSPACE}/.codex-cli"
CODEX_CLI_DIR="${RUNNER_TEMP}/omnigent-codex-cli"
mkdir -p "$CODEX_CLI_DIR" && cd "$CODEX_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @openai/codex@0.139.0
echo "${GITHUB_WORKSPACE}/.codex-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CODEX_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Run pytest target
# Inputs validated by prep. Word-splitting on $TEST_TARGET / $EXTRA_ARGS
+26 -11
View File
@@ -28,7 +28,10 @@ on:
push:
tags:
# Version tags only (v0.2.0, v0.2.0rc1, …) — `v[0-9]*` avoids triggering
# on non-release tags like `v-infra-*`.
# on non-release tags like `v-infra-*`. Pre-release tags (rcN / devN /
# preN) still match the glob but are skipped in the job below — no
# GitHub release is created for them; their installable artifacts live
# only on PyPI, and a curated release page is reserved for the final cut.
- "v[0-9]*"
# Least privilege: creating a release requires `contents: write`; nothing here
@@ -43,9 +46,30 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Skip pre-release tags (rcN / devN / preN)
id: tag
env:
TAG: ${{ github.ref_name }}
run: |
# Pre-release tags (rcN / devN / preN) get NO GitHub release — they
# live on PyPI only, and a curated release page is reserved for the
# final cut. The tag glob above still matches them, so gate here.
# A trailing digit is required so a substring like 'dev' or 'pre' in a
# mistyped tag name can't trigger a skip by accident.
case "$TAG" in
*rc[0-9]*|*dev[0-9]*|*pre[0-9]*)
echo "Pre-release tag ${TAG} — not creating a GitHub release (rc/dev/pre releases live on PyPI only)." \
| tee -a "$GITHUB_STEP_SUMMARY"
echo "skip=true" >> "$GITHUB_OUTPUT" ;;
*)
echo "skip=false" >> "$GITHUB_OUTPUT" ;;
esac
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
if: steps.tag.outputs.skip != 'true'
- name: Draft release with a placeholder body
if: steps.tag.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
@@ -57,20 +81,11 @@ jobs:
echo "Release $TAG already exists — skipping." | tee -a "$GITHUB_STEP_SUMMARY"
exit 0
fi
# rc / dev / alpha / beta tags are flagged as pre-releases.
pre=""
case "$TAG" in
*rc*|*dev*|*a[0-9]*|*b[0-9]*) pre="--prerelease" ;;
esac
# $pre is intentionally UNQUOTED: it word-splits to nothing when empty,
# and is only ever "" or "--prerelease" (set just above, never from
# external input). Quoting it would pass an empty positional arg.
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--draft \
--verify-tag \
--notes "_Release notes are being drafted automatically — check back shortly._" \
--title "$TAG" \
$pre
--title "$TAG"
echo "Drafted release $TAG — curated notes will be filled in by draft-release-notes.yml; review and publish from the Releases page." \
| tee -a "$GITHUB_STEP_SUMMARY"
+3 -3
View File
@@ -61,14 +61,14 @@ jobs:
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
is_final=true
# Only final vX.Y.Z tags; exclude rc/dev/alpha/beta and the event's
# prerelease flag (homebrew users get stable releases from the tap).
# Only final vX.Y.Z tags; exclude rcN/devN/preN pre-releases and
# the event's prerelease flag (homebrew users get stable releases).
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) is_final=false ;;
esac
case "$tag" in
*rc*|*dev*|*a[0-9]*|*b[0-9]*) is_final=false ;;
*rc[0-9]*|*dev[0-9]*|*pre[0-9]*|*a[0-9]*|*b[0-9]*) is_final=false ;;
esac
if [ "${PRERELEASE}" = "true" ]; then is_final=false; fi
echo "is_final=${is_final}" >> "$GITHUB_OUTPUT"
+7 -3
View File
@@ -179,10 +179,14 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
# Install outside the checked-out tree: a repo-root package.json would
# otherwise capture this bare `npm install` and hoist it there, leaving
# this dir's node_modules empty.
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR" && cd "$CC_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.212
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Set LLM credentials
if: steps.creds.outputs.available == 'true'
+21 -22
View File
@@ -76,32 +76,30 @@ jobs:
# a stale lockfile). Fix locally with `uv lock`.
run: uv sync --locked --extra dev
# Sets up Node 20 and pins npm to the same major that regenerates
# the lockfile in the OSS-regen workflows, so the freshness gate
# below doesn't flake on npm version-skew churn.
- name: Set up Node.js
uses: ./.github/actions/setup-node
# Sets up Node 20 + pnpm, with pnpm dependency caching keyed on the
# workspace lockfile. pnpm is pinned in .github/actions/setup-pnpm.
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install web dependencies
working-directory: web
# Pin the npm registry to the npmjs default.
# Pin the npm registry to the npmjs default; limit to the web package
# so the Electron package's large native devDependencies are not fetched.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: npm ci --legacy-peer-deps
run: pnpm install --frozen-lockfile --filter web
# The npm equivalent of the `uv sync --locked` gate above. `npm ci`
# only checks the lockfile is CONSISTENT with package.json; it
# tolerates cosmetic drift (dev/extraneous flags, metadata) that a
# fresh resolution would rewrite. Regenerate the lockfile and fail
# if it differs from the committed one.
- name: Check web/package-lock.json is up to date
working-directory: web
# The pnpm equivalent of the `uv sync --locked` gate above.
# `pnpm install --frozen-lockfile` only checks the lockfile is CONSISTENT
# with package.json; it tolerates cosmetic drift that a fresh resolution
# would rewrite. Regenerate the lockfile and fail if it differs from the
# committed one.
- name: Check pnpm-lock.yaml is up to date
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund
git diff --exit-code package-lock.json || {
echo "::error::web/package-lock.json is out of date. Run 'npm install --package-lock-only --legacy-peer-deps' in web/ and commit the result."
pnpm install --lockfile-only
git diff --exit-code pnpm-lock.yaml || {
echo "::error::pnpm-lock.yaml is out of date. Run 'pnpm install --lockfile-only' at the repo root and commit the result."
exit 1
}
@@ -125,11 +123,12 @@ jobs:
- name: Run formatting, lint, and typing checks
run: uv run pre-commit run --all-files --show-diff-on-failure
- name: Type-check web
working-directory: web
run: npm run type-check
# Type-checking is temporarily skipped in CI while the pnpm lockfile
# settles; `pnpm --filter web run type-check` still works locally.
# - name: Type-check web
# run: pnpm --filter web run type-check
# The three packages release in lockstep (identical versions + `==` sibling
# The four packages release in lockstep (identical versions + `==` sibling
# pins). Assert agreement on every change so drift from a bad merge or
# cherry-pick — however it happened — is caught before it reaches a release.
version-lockstep:
+1 -1
View File
@@ -27,7 +27,7 @@ on:
pull_request_target:
types: [labeled]
workflow_run:
workflows: [PR Template, CI, Lint, Docker build, E2E UI Tests, E2E Tests, Integration Tests]
workflows: [PR Template, CI, Lint, Docker build, E2E UI Tests, E2E Tests, Integration Tests, UI Snapshot]
types: [completed]
check_run:
types: [completed]
+8 -5
View File
@@ -50,8 +50,10 @@ permissions:
contents: read
concurrency:
# Key by SHA so back-to-back merges each build; don't cancel mid-push.
group: oss-publish-images-${{ github.sha }}
# One build at a time: rc and final tags land minutes apart at a release cut,
# and built concurrently they race each other's layer cache cold and blow the
# job timeout. Serialized, the later build reuses the earlier one's layers.
group: oss-publish-images
cancel-in-progress: false
jobs:
@@ -67,9 +69,10 @@ jobs:
runs-on: ubuntu-latest
# Multi-arch: the linux/arm64 leg cross-builds under QEMU emulation on this
# amd64 runner, which roughly doubles the host-image build time (emulated
# npm/pip native steps). 30m was tight for two native amd64 builds; give the
# four-variant (server+host × amd64+arm64) build headroom.
timeout-minutes: 60
# npm/pip native steps). A cold-cache four-variant (server+host × amd64+
# arm64) build can exceed 60m, and hitting the timeout loses the release's
# images silently — give it real headroom.
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+13 -26
View File
@@ -1,5 +1,5 @@
# A maintainer comments `/regen` on a PR to regenerate the repo's lockfiles
# (uv.lock + web/package-lock.json) against public PyPI/npm and commit them
# (uv.lock + pnpm-lock.yaml) against public PyPI/npmjs.org and commit them
# ONTO that PR's branch. Use when the PR itself moved a dependency; complements
# oss-regenerate-and-smoke.yml (standalone rolling PR on dispatch).
#
@@ -164,28 +164,14 @@ jobs:
with:
enable-cache: false
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
# 7-day cooldown comes from uv.toml (`exclude-newer = "P7D"`), recorded as
# a relative span; an env-var cutoff would stamp an absolute date and break
# later `uv sync --locked`. npm's cooldown (web/.npmrc min-release-age=7)
# is only honored by npm >= 11.10.0; node 20 ships npm 10.x which ignores it.
# Pin the EXACT version (not a range) and keep it in lockstep with
# .github/actions/setup-node (npm 11.12.1): this workflow generates the
# lockfile and that action verifies it, so a version gap would fail the
# freshness gate in lint.yml.
- name: Ensure npm honors the dependency cooldown
run: npm install -g npm@11.12.1
# Delete package-lock.json so npm RESOLVES from scratch: min-release-age
# only filters during resolution, and --package-lock-only keeps an existing
# in-range pin without re-applying the cooldown.
# --legacy-peer-deps is REQUIRED and MUST match the flag lint.yml verifies
# with (React 18 runtime vs React 19 peers would otherwise ERESOLVE-fail,
# and a flag mismatch rewrites dev/extraneous flags, failing the gate).
- name: Regenerate lockfiles against public PyPI/npm
# 7-day cooldown comes from uv.toml (`exclude-newer = "P7D"`) and from
# pnpm-workspace.yaml (`minimumReleaseAge: 10080`), recorded as a relative
# span; an env-var cutoff would stamp an absolute date and break later
# `uv sync --locked` / `pnpm install --frozen-lockfile`.
- name: Regenerate lockfiles against public PyPI/npmjs.org
env:
REGEN_MODE: ${{ needs.authorize.outputs.mode }}
UPGRADE_PKGS: ${{ needs.authorize.outputs.pkgs }}
@@ -203,7 +189,8 @@ jobs:
else
uv lock
fi
( cd web && rm -f package-lock.json && npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund )
rm -f pnpm-lock.yaml
pnpm install --lockfile-only
# Mint the App token only AFTER `uv lock` so untrusted PR build backends
# never see it. Skipped when the App isn't configured (push then falls back
@@ -229,12 +216,12 @@ jobs:
git config user.name "omnigent-ci[bot]"
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
# --porcelain (not git diff) so first-time UNTRACKED lockfiles count too.
if [ -z "$(git status --porcelain -- uv.lock web/package-lock.json)" ]; then
if [ -z "$(git status --porcelain -- uv.lock pnpm-lock.yaml)" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Lockfiles already current — nothing to commit."
exit 0
fi
git add uv.lock web/package-lock.json
git add uv.lock pnpm-lock.yaml
git commit -m "chore(oss): regenerate public lockfiles against public PyPI/npm"
git push "https://x-access-token:${PUSH_TOKEN}@github.com/${REPO}.git" "HEAD:$HEAD_REF"
echo "changed=true" >> "$GITHUB_OUTPUT"
@@ -256,7 +243,7 @@ jobs:
upgraded=" (upgraded: $UPGRADE_PKGS)"
fi
if [ "$CHANGED" = "true" ]; then
base="✅ Regenerated \`uv.lock\`$upgraded + \`web/package-lock.json\` against public PyPI/npm and pushed to this PR."
base="✅ Regenerated \`uv.lock\`$upgraded + \`pnpm-lock.yaml\` against public PyPI/npmjs.org and pushed to this PR."
if [ "$APP_USED" = "true" ]; then
body="$base CI will re-run on the new commit."
else
+18 -32
View File
@@ -40,40 +40,26 @@ jobs:
with:
enable-cache: false
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
# 7-day cooldown comes from uv.toml (`exclude-newer = "P7D"`), recorded as
# a relative span; an env-var cutoff would stamp an absolute date and break
# later `uv sync --locked`.
# 7-day cooldown comes from uv.toml (`exclude-newer = "P7D"`) and from
# pnpm-workspace.yaml (`minimumReleaseAge: 10080`), recorded as a relative
# span; an env-var cutoff would stamp an absolute date and break later
# `uv sync --locked` / `pnpm install --frozen-lockfile`.
- name: Regenerate uv.lock
run: uv lock
# npm's cooldown (web/.npmrc `min-release-age=7`) is only honored by
# npm >= 11.10.0; node 20 ships npm 10.x which silently ignores it.
# Pin the EXACT version (not a range) and keep it in lockstep with
# .github/actions/setup-node: this workflow generates the lockfile and
# that action verifies it, so a version gap would fail the freshness
# gate in lint.yml. 11.12.1 satisfies the >= 11.10.0 cooldown floor.
- name: Ensure npm honors the dependency cooldown
run: npm install -g npm@11.12.1
# Delete the lockfile so npm RESOLVES from scratch: min-release-age only
# filters during resolution, and --package-lock-only keeps an existing
# in-range pin without re-applying the cooldown.
#
# --legacy-peer-deps is REQUIRED: the tree pins React 18 at runtime while
# much of the UI stack (and @types/react) peer-requires React 19, so npm's
# strict resolver would ERESOLVE-fail without it. It MUST match the flag the
# freshness gate in lint.yml verifies with; generating without it resolves
# the peer graph differently and rewrites the dev/devOptional/extraneous
# flags, failing that byte-exact gate.
- name: Regenerate package-lock.json
working-directory: web
# pnpm's cooldown is configured in pnpm-workspace.yaml and respected by
# the workspace root. Delete the lockfile so pnpm RESOLVES from scratch:
# --lockfile-only keeps an existing in-range pin without re-applying the
# cooldown, so we drop it first.
- name: Regenerate pnpm-lock.yaml
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
rm -f package-lock.json
npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund
rm -f pnpm-lock.yaml
pnpm install --lockfile-only
# Validate BEFORE committing: the Docker build proves the regenerated
# locks + public registries produce a working image.
@@ -106,15 +92,15 @@ jobs:
git config user.name "omnigent-ci[bot]"
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
# --porcelain (not git diff) so first-regen UNTRACKED lockfiles count too.
if [ -z "$(git status --porcelain -- uv.lock web/package-lock.json)" ]; then
if [ -z "$(git status --porcelain -- uv.lock pnpm-lock.yaml)" ]; then
echo "Lockfiles already current — nothing to PR."
exit 0
fi
# One rolling branch, force-pushed each run, so regens update a single PR.
BRANCH="automation/oss-lockfile-regen"
git checkout -b "$BRANCH"
git add uv.lock web/package-lock.json
git commit -m "chore(oss): regenerate public lockfiles against public PyPI/npm"
git add uv.lock pnpm-lock.yaml
git commit -m "chore(oss): regenerate public lockfiles against public PyPI/npmjs.org"
git push --force "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$BRANCH"
# An already-open PR just picks up the force-pushed update.
if [ -n "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')" ]; then
+12 -5
View File
@@ -171,19 +171,26 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
# Install outside the checked-out tree: a repo-root package.json would
# otherwise capture this bare `npm install` and hoist it there, leaving
# this dir's node_modules empty.
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR" && cd "$CC_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.212
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Install Codex CLI
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.codex-cli" && cd "${GITHUB_WORKSPACE}/.codex-cli"
# Install outside the checked-out tree so a repo-root package.json
# can't capture this bare `npm install` and hoist it away from here.
CODEX_CLI_DIR="${RUNNER_TEMP}/omnigent-codex-cli"
mkdir -p "$CODEX_CLI_DIR" && cd "$CODEX_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @openai/codex@0.139.0
echo "${GITHUB_WORKSPACE}/.codex-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CODEX_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Set LLM credentials
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
+3 -3
View File
@@ -75,14 +75,14 @@ jobs:
echo "dry_run=${dry_run}" >> "$GITHUB_OUTPUT"
is_final=true
# Only final vX.Y.Z tags; exclude rc/dev/alpha/beta and the
# event's prerelease flag.
# Only final vX.Y.Z tags; exclude rcN/devN/preN pre-releases and
# the event's prerelease flag.
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) is_final=false ;;
esac
case "$tag" in
*rc*|*dev*|*a[0-9]*|*b[0-9]*) is_final=false ;;
*rc[0-9]*|*dev[0-9]*|*pre[0-9]*) is_final=false ;;
esac
if [ "${PRERELEASE}" = "true" ]; then
is_final=false
+8 -11
View File
@@ -71,24 +71,21 @@ jobs:
with:
enable-cache: false
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
# 1. Build the web UI FIRST into the package tree, clean. Ordering is
# load-bearing: the wheel packages on-disk files, so the bundle must
# exist before `uv build`. `rm -rf` backstops Vite's emptyOutDir
# against stale bundles; `npm ci` installs the exact locked deps.
# `--legacy-peer-deps` matches how web's lockfile is generated and
# validated everywhere else (lint, e2e-ui, web-tests, the regen
# jobs) — required for the React 19 peer conflict; without it `npm ci`
# rejects the lockfile ("Missing: yaml@1.10.3 from lock file").
# against stale bundles; `pnpm install --frozen-lockfile --filter web`
# installs the exact locked deps for the web workspace package.
- name: Build web UI (clean, fresh)
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
rm -rf omnigent/server/static/web-ui
npm --prefix web ci --legacy-peer-deps
npm --prefix web run build # Vite outDir -> omnigent/server/static/web-ui
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build # Vite outDir -> omnigent/server/static/web-ui
# 2. Tag-driven: the tag must match the version in all three pyprojects
# and the core package's `==` sibling-SDK pins, so the lockstep
+20 -5
View File
@@ -103,13 +103,13 @@ jobs:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
# Final X.Y.Z or a PEP 440 pre-release (a/b/rc). No dev/post here.
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?$ ]]; then
# Final X.Y.Z or a PEP 440 pre-release (rc). No dev/post/alpha/beta here.
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(rc[0-9]+)?$ ]]; then
echo "::error::Invalid release version: ${VERSION} (expect 0.6.0 or 0.6.0rc1)"; exit 1
fi
major="${VERSION%%.*}"; rest="${VERSION#*.}"; minor="${rest%%.*}"
prerelease=false
case "$VERSION" in *a[0-9]*|*b[0-9]*|*rc[0-9]*) prerelease=true ;; esac
case "$VERSION" in *rc[0-9]*) prerelease=true ;; esac
{
echo "version=${VERSION}"
echo "tag=v${VERSION}"
@@ -647,7 +647,7 @@ jobs:
echo " -f ref=${TAG} -f destination=pypi -f dry-run=false # real publish"
echo ' ```'
if [ "$PRERELEASE" = "true" ]; then
echo "2. Validate the rc from PyPI (see RELEASING.md). The GitHub draft for ${TAG} stays unpublished."
echo "2. Validate the rc from PyPI (see RELEASING.md). No GitHub release is created for rc tags (rcs live on PyPI only) — skip straight to the next rc or the final cut."
else
echo "2. Merge the CHANGELOG PR, curate the ${TAG} draft notes, then dispatch finalize-release.yml (tag=${TAG})."
fi
@@ -657,7 +657,6 @@ jobs:
# never re-freezes and doc-sync keeps deriving the right X.Y-docs branch.
bump-main:
needs: [authorize, plan, cut]
if: ${{ !inputs.dry_run && needs.plan.outputs.branch_exists == 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
@@ -667,8 +666,24 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.plan.outputs.version }}
DRY_RUN: ${{ inputs.dry_run }}
BRANCH_EXISTS: ${{ needs.plan.outputs.branch_exists }}
run: |
set -euo pipefail
# Gate in shell, not a job-level `if`: a CLI/API dispatch delivers
# boolean inputs as the STRING "false", which is truthy in an
# expression, so `!inputs.dry_run` silently skipped this job at the
# v0.7.0 cut. Shell string comparison is dispatch-channel-proof and
# logs its decision instead of vanishing from the run.
if [ "$DRY_RUN" = "true" ]; then
echo "Dry run — not dispatching the main bump." | tee -a "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ "$BRANCH_EXISTS" != "false" ]; then
echo "Release branch pre-existed (not the first cut of this cycle) — main bump not needed." \
| tee -a "$GITHUB_STEP_SUMMARY"
exit 0
fi
# A cut below main's current line (a throwaway rehearsal rc, or
# resurrecting an old series for a backport) must not walk main's
# version backwards.
+134
View File
@@ -0,0 +1,134 @@
# A contributor comments `/rerun` on a PR to re-run its failed CI **on the
# existing head commit** -- no empty commit, no rebase, so no push event and
# thus no dismissed approvals (branch protection keeps dismiss-stale-reviews
# on to block approve-then-swap). Use for flaky-test recovery instead of
# pushing a throwaway commit to re-trigger checks.
#
# CI here runs entirely against the in-process mock LLM (no gateway spend), so
# a re-run costs only Actions minutes; `cancel-in-progress` on each suite caps
# concurrent burn. Polly AI Review (the one real-LLM path) is gated by
# maintainer approval elsewhere and is intentionally NOT re-run here.
#
# Authorization: the PR author (so fork contributors can re-run their own PR)
# OR a write-access commenter (OWNER/MEMBER/COLLABORATOR). `issue_comment` runs
# from the base repo, so its token is writable even for fork PRs and is not
# held behind the fork-approval gate -- unlike `pull_request_target`, this
# needs no privileged `workflow_run` relay (cf. rerun-security-gate*.yml).
name: Rerun CI on /rerun comment
on:
issue_comment:
types: [created]
# Read-only at the top level; write scopes live on the job below.
permissions:
contents: read
concurrency:
# One re-run in flight per PR; a second `/rerun` supersedes the first.
group: rerun-ci-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
rerun:
name: Re-run failed CI for the PR head
permissions:
actions: write # gh run rerun
pull-requests: read # resolve the PR head SHA
issues: write # react to the comment + post the result
# PR comment, body starts with `/rerun`, not a bot, in this repo, AND the
# commenter is the PR author or has write access. `issue.user.login` is the
# PR author; `comment.user.login` is the commenter.
if: >-
github.repository == 'omnigent-ai/omnigent'
&& github.event.issue.pull_request != null
&& startsWith(github.event.comment.body, '/rerun')
&& !endsWith(github.actor, '[bot]')
&& (
github.event.comment.author_association == 'OWNER'
|| github.event.comment.author_association == 'MEMBER'
|| github.event.comment.author_association == 'COLLABORATOR'
|| github.event.comment.user.login == github.event.issue.user.login
)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# The job `if` startsWith() also matches `/rerunfoo`; re-validate `/rerun`
# as a command (first non-space token is exactly `/rerun`, optional args).
- name: Validate command
id: cmd
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
if ! grep -qE '^[[:space:]]*/rerun([[:space:]]|$)' <<<"$COMMENT_BODY"; then
echo "::notice::Comment mentions '/rerun' but not as a command; skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- name: Acknowledge
if: steps.cmd.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
COMMENT_ID: ${{ github.event.comment.id }}
run: |
gh api "repos/$REPO/issues/comments/$COMMENT_ID/reactions" \
-f content=eyes --silent || true
- name: Re-run failed CI runs for the PR head
if: steps.cmd.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
# Integer from the payload, but sanitised to digits before shell use.
PR_NUMBER: ${{ github.event.issue.number }}
run: |
set -euo pipefail
PR_NUMBER="$(tr -dc '0-9' <<<"$PR_NUMBER")"
[ -n "$PR_NUMBER" ] || { echo "::error::Empty PR number."; exit 1; }
# Resolve the PR's CURRENT head SHA (a push could have superseded any
# SHA recorded at comment time).
SHA="$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.head.sha')"
echo "PR #$PR_NUMBER head $SHA"
# Latest run per workflow for this SHA, restricted to `pull_request`
# events -- this is the test-suite set (CI, Lint, E2E, E2E UI,
# Integration, Docker build, web Tests). It deliberately EXCLUDES the
# merge machinery (Merge Ready, Maintainer Approval, Polly) which run
# on pull_request_target / workflow_run / issue_comment, so `/rerun`
# never re-triggers a gate or the real-LLM review.
mapfile -t FAILED < <(
gh api "repos/$REPO/actions/runs?head_sha=$SHA&per_page=100" --paginate \
--jq '[.workflow_runs[] | select(.event=="pull_request")]
| group_by(.name)
| map(sort_by(.created_at) | last)
| .[] | select(.conclusion=="failure")
| "\(.id)\t\(.name)"'
)
if [ "${#FAILED[@]}" -eq 0 ]; then
echo "No failed pull_request CI runs for $SHA."
gh pr comment "$PR_NUMBER" --repo "$REPO" \
--body "🔁 \`/rerun\`: no failed CI runs on the current head (\`${SHA:0:7}\`) to re-run. If a check is stuck *pending*, it needs a push or a maintainer, not a re-run."
exit 0
fi
RERAN=""
while IFS=$'\t' read -r id name; do
[ -n "$id" ] || continue
echo "• Re-running failed jobs in '$name' (run $id)"
# --failed: re-run only the failed jobs (cheapest path for a flake).
# --repo is REQUIRED: this job has no checkout, so `gh run rerun`
# cannot infer the repo from a git remote and would fail client-side.
if gh run rerun "$id" --repo "$REPO" --failed; then
RERAN="$RERAN"$'\n'"- $name"
else
echo "::warning::Could not re-run '$name' (run $id) -- may be in progress."
RERAN="$RERAN"$'\n'"- $name ⚠️ (skipped: already running or not re-runnable)"
fi
done < <(printf '%s\n' "${FAILED[@]}")
NOTE="The \`Merge Ready\` gate re-evaluates automatically when these complete."
gh pr comment "$PR_NUMBER" --repo "$REPO" \
--body "🔁 \`/rerun\`: re-running failed jobs on \`${SHA:0:7}\`:${RERAN}"$'\n\n'"$NOTE"
+7 -3
View File
@@ -195,10 +195,14 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170
# Install outside the checked-out tree: a repo-root package.json would
# otherwise capture this bare `npm install` and hoist it there, leaving
# this dir's node_modules empty.
CC_CLI_DIR="${RUNNER_TEMP}/omnigent-cc-cli"
mkdir -p "$CC_CLI_DIR" && cd "$CC_CLI_DIR"
npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.212
node node_modules/@anthropic-ai/claude-code/install.cjs
echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH"
echo "$CC_CLI_DIR/node_modules/.bin" >> "$GITHUB_PATH"
- name: Write gateway profile (~/.databrickscfg)
if: steps.creds.outputs.available == 'true'
+3 -3
View File
@@ -4,7 +4,7 @@ name: Backwards-Compat
# suites, over the FULL pairwise (server, runner) version matrix.
#
# The version universe is `main` (the checked-out code = client + tests, always)
# plus every non-rc release tag; we cross every server version with every runner
# plus every final (non-prerelease) release tag; we cross every server version with every runner
# version. Each cell pins the server and/or runner subprocess to that build
# (a "main" axis value leaves that component on the checked-out code) while the
# client and tests stay on main. The (main, main) cell is omitted — it pins
@@ -18,13 +18,13 @@ name: Backwards-Compat
#
# Triggers:
# workflow_dispatch manual; optional `versions` CSV overrides the set.
# schedule every 12h; full pairwise over main + all non-rc tags.
# schedule every 12h; full pairwise over main + all final tags.
on:
workflow_dispatch:
inputs:
versions:
description: "Comma-separated version set for BOTH axes (e.g. 'main,v0.2.0'). Empty = main + all non-rc tags."
description: "Comma-separated version set for BOTH axes (e.g. 'main,v0.2.0'). Empty = main + all final (non-prerelease) tags."
required: false
default: ""
schedule:
+5 -4
View File
@@ -106,7 +106,7 @@ jobs:
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- uses: ./.github/actions/setup-node
- uses: ./.github/actions/setup-pnpm
- name: Build wheels (no UI)
# Build the wheels WITHOUT the SPA so they stay small (Databricks Apps
@@ -120,10 +120,11 @@ jobs:
run: bash deploy/databricks/build.sh
- name: Build UI
working-directory: web
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
npm ci --legacy-peer-deps --no-audit --no-fund
npm run build
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
- name: Package UI assets
run: |
+14 -5
View File
@@ -80,8 +80,18 @@ jobs:
ref: ${{ github.event.pull_request.head.ref }}
persist-credentials: false
- name: Set up Node 20
uses: ./.github/actions/setup-node
# The pinned Playwright image doesn't ship Node, so install Node before
# the pnpm action (pnpm/action-setup's self-installer needs a Node binary).
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Install pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 11.15.1
standalone: true
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
@@ -106,9 +116,8 @@ jobs:
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
cd web
npm ci --legacy-peer-deps --no-audit --no-fund
npm run build
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
- name: Compare the baselines (no --update-snapshots)
# Deliberately NOT --update-snapshots: that rewrites EVERY PNG, churning
+17 -9
View File
@@ -95,7 +95,7 @@ jobs:
if ! files=$(gh api "repos/$REPO/pulls/$PR/files" --paginate --jq '.[].filename'); then
echo "ui=true" >> "$GITHUB_OUTPUT"; echo "file list unavailable -> render"; exit 0
fi
pattern='^(web/|tests/e2e_ui/visual/|tests/e2e_ui/conftest\.py|\.github/actions/setup-node/|\.github/workflows/ui-snapshot\.yml|pyproject\.toml|uv\.lock)'
pattern='^(web/|tests/e2e_ui/visual/|tests/e2e_ui/conftest\.py|\.github/actions/setup-pnpm/|\.github/workflows/ui-snapshot\.yml|pnpm-lock\.yaml|pnpm-workspace\.yaml|pyproject\.toml|uv\.lock)'
if printf '%s\n' "$files" | grep -qE "$pattern"; then
echo "ui=true" >> "$GITHUB_OUTPUT"
echo "render-affecting files changed:"
@@ -106,7 +106,7 @@ jobs:
fi
ui-snapshot:
name: UI Snapshot (visual baselines) [non-blocking]
name: UI Snapshot (visual baselines)
needs: detect
# Skipped (not failed) when no render input changed -> reports SUCCESS, so a
# non-UI PR neither runs the render nor blocks a required check.
@@ -130,8 +130,18 @@ jobs:
with:
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Set up Node 20
uses: ./.github/actions/setup-node
# The pinned Playwright image doesn't ship Node, so install Node before
# the pnpm action (pnpm/action-setup's self-installer needs a Node binary).
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Install pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 11.15.1
standalone: true
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
@@ -156,14 +166,12 @@ jobs:
- name: Build web SPA
# Build BEFORE pytest: Vite's emptyOutDir clobbers the static dir, so
# never run it alongside the live server. --legacy-peer-deps avoids
# re-resolving the known React 19 peer conflict under @emoji-mart/react.
# never run it alongside the live server.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
cd web
npm ci --legacy-peer-deps --no-audit --no-fund
npm run build
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
- name: Compare (PR) or regenerate (dispatch) the visual snapshots
id: snapshot
+59 -3
View File
@@ -15,11 +15,21 @@
# from finalize-release.yml's App-token publish; `workflow_dispatch` covers
# retries and catch-up (e.g. jumping the formula straight to the newest
# version after a missed cycle).
#
# brew resolves resources through pip's `--uploaded-prior-to=P1D` window, so a
# run within 24h of the PyPI upload cannot see the new sdist and used to go
# red every release day (v0.6.0, v0.7.0). Now: runs inside that window defer
# (green, with a warning), and the nightly `schedule` catch-up — which targets
# the latest published release and no-ops when the formula is already
# current — opens the tap PR once the window has passed.
name: Update Homebrew tap
on:
release:
types: [published]
schedule:
# Nightly catch-up for the P1D window (see header). No-ops when current.
- cron: "45 23 * * *"
workflow_dispatch:
inputs:
tag:
@@ -31,7 +41,7 @@ permissions:
contents: read
concurrency:
group: update-homebrew-${{ github.event.release.tag_name || inputs.tag }}
group: update-homebrew-${{ github.event.release.tag_name || inputs.tag || 'nightly' }}
cancel-in-progress: false
jobs:
@@ -46,12 +56,18 @@ jobs:
- name: Resolve tag and finality
id: r
env:
GH_TOKEN: ${{ github.token }}
EVENT_TAG: ${{ github.event.release.tag_name }}
PRERELEASE: ${{ github.event.release.prerelease }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
tag="${INPUT_TAG:-$EVENT_TAG}"
if [ -z "$tag" ]; then
# Scheduled catch-up: target the latest published final release
# (empty when the repo has none yet — resolves to is_final=false).
tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name 2>/dev/null || echo "")"
fi
is_final=true
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
@@ -145,12 +161,29 @@ jobs:
path: tap
persist-credentials: false
- name: Skip when the formula is already at this version
id: current
working-directory: tap
env:
VERSION: ${{ steps.sdist.outputs.version }}
run: |
set -euo pipefail
# Nightly catch-up no-op: the stable url already points at this sdist.
if grep -q "omnigent-${VERSION}\.tar\.gz" Formula/omnigent.rb; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Formula already at ${VERSION} — nothing to do." | tee -a "$GITHUB_STEP_SUMMARY"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Set up Homebrew
if: steps.current.outputs.skip != 'true'
uses: Homebrew/actions/setup-homebrew@18fcb8e3e06b4247c676c506750dc95ea7226479 # 2026-07-10
with:
token: ${{ github.token }}
- name: Rewrite the formula's stable url/sha256
if: steps.current.outputs.skip != 'true'
working-directory: tap
env:
SDIST_URL: ${{ steps.sdist.outputs.url }}
@@ -172,9 +205,12 @@ jobs:
git diff --stat
- name: Regenerate the pinned Python resources
id: regen
if: steps.current.outputs.skip != 'true'
env:
HOMEBREW_NO_AUTO_UPDATE: "1"
HOMEBREW_NO_INSTALL_FROM_API: "1"
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Make the checkout visible to brew as the real tap.
@@ -185,12 +221,31 @@ jobs:
# deps (certifi/cryptography/pydantic/rpds-py and their transitive
# cffi/pycparser) and the platform-conditional google-antigravity
# wheel stanzas.
brew update-python-resources \
if ! brew update-python-resources \
--exclude-packages=certifi,cryptography,pydantic,rpds-py,cffi,pycparser,google-antigravity \
omnigent-ai/tap/omnigent
omnigent-ai/tap/omnigent; then
# brew resolves through pip's --uploaded-prior-to=P1D window: a
# release <24h old is invisible and resolution ALWAYS fails. Defer
# to the nightly catch-up instead of going red; older releases are
# real failures.
published_at="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" --jq .published_at 2>/dev/null || echo "")"
age_h=999
if [ -n "$published_at" ]; then
age_h="$(python3 -c 'import datetime, sys; d = datetime.datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00")); print(int((datetime.datetime.now(datetime.timezone.utc) - d).total_seconds() // 3600))' "$published_at")"
fi
if [ "$age_h" -lt 24 ]; then
echo "deferred=true" >> "$GITHUB_OUTPUT"
echo "::warning::Resource resolution failed with ${TAG} only ${age_h}h old — inside pip's --uploaded-prior-to=P1D window. The nightly catch-up will open the tap PR."
echo "Deferred to the nightly catch-up (${TAG} is ${age_h}h old, inside the 24h PyPI window)." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
exit 1
fi
echo "deferred=false" >> "$GITHUB_OUTPUT"
brew style omnigent-ai/tap/omnigent
- name: Assert the hand-maintained sections survived
if: steps.current.outputs.skip != 'true' && steps.regen.outputs.deferred != 'true'
working-directory: tap
run: |
set -euo pipefail
@@ -214,6 +269,7 @@ jobs:
done
- name: Open or update the tap bump PR
if: steps.current.outputs.skip != 'true' && steps.regen.outputs.deferred != 'true'
working-directory: tap
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
@@ -68,15 +68,16 @@ jobs:
with:
ref: release/vscode-v${{ inputs.version }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install, build, and package
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: |
npm ci
npm run build
npm run package
pnpm install --frozen-lockfile --filter omnigent-vscode
pnpm --filter omnigent-vscode run build
pnpm --filter omnigent-vscode run package
- name: Resolve tag and verify package.json version
id: meta
+6 -3
View File
@@ -62,6 +62,9 @@ jobs:
fetch-depth: 0
fetch-tags: true
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Validate version
env:
VERSION: ${{ inputs.version }}
@@ -80,10 +83,10 @@ jobs:
working-directory: editors/vscode
env:
VERSION: ${{ inputs.version }}
# `npm pkg set` edits ONLY package.json (unlike `npm version`, which also
# rewrites package-lock.json). Keeps the release PR to package.json +
# `pnpm pkg set` edits ONLY package.json (unlike `pnpm version`, which
# also rewrites the lockfile). Keeps the release PR to package.json +
# CHANGELOG.md.
run: npm pkg set version="$VERSION"
run: pnpm pkg set version="$VERSION"
- name: Add the CHANGELOG section (placeholder)
working-directory: editors/vscode
@@ -0,0 +1,31 @@
name: Waiting on Author Test
# Offline unit test for waiting-on-author hygiene. Runs on PR head without
# secrets or network and only when the workflow logic changes.
on:
pull_request:
paths:
- .github/scripts/waiting_on_author.py
- .github/scripts/waiting_on_author_test.py
- .github/workflows/waiting-on-author.yml
- .github/workflows/waiting-on-author-test.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: waiting-on-author-test-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run waiting-on-author unit test
run: python3 .github/scripts/waiting_on_author_test.py
+46
View File
@@ -0,0 +1,46 @@
name: Waiting on Author Hygiene
# Keeps the `waiting-on-author` PR label actionable: author activity clears it,
# and PRs that sit in that state for 7 days are closed. The workflow runs from
# trusted default-branch code and never checks out PR-authored files.
on:
pull_request_target:
types: [synchronize]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
schedule:
- cron: "0 */12 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: waiting-on-author-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
cancel-in-progress: false
jobs:
hygiene:
if: github.repository == 'omnigent-ai/omnigent'
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
+11 -13
View File
@@ -24,14 +24,14 @@ concurrency:
cancel-in-progress: true
jobs:
# Security precondition gate: npm ci/test runs the PR's own install hooks and
# Security precondition gate: pnpm install/test runs the PR's own install hooks and
# test code, so untrusted PRs are held until the scan passes (security-gate.yml).
# Trusted authors and non-PR events pass through.
gate:
uses: ./.github/workflows/security-gate.yml
npm-test:
name: npm test
web-test:
name: web test
needs: gate
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
@@ -41,31 +41,29 @@ jobs:
- name: Check out repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Node.js
uses: ./.github/actions/setup-node
- name: Set up pnpm
uses: ./.github/actions/setup-pnpm
- name: Install dependencies
working-directory: web
# Pin the npm registry to the npmjs default.
# Pin the npm registry to the npmjs default; limit to the web package
# so the Electron package's large native devDependencies are not fetched.
env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
run: npm ci --legacy-peer-deps
run: pnpm install --frozen-lockfile --filter web
- name: Check formatting
working-directory: web
run: npm run format:check
run: pnpm --filter web run format:check
- name: Run tests with coverage
working-directory: web
run: npm run test:coverage
run: pnpm --filter web run test:coverage
# Distill the v8 json-summary into a single total.txt, mirroring the
# backend's coverage-report job. ui-code-coverage.yml (privileged
# workflow_run) consumes this artifact and posts the report-only status.
- name: Summarize coverage
if: always()
working-directory: web
run: |
cd web
mkdir -p ui-coverage-summary
if [[ ! -f coverage/coverage-summary.json ]]; then
echo "::warning::No coverage-summary.json; skipping UI coverage report."
+2
View File
@@ -2,6 +2,8 @@
build/
dist/
node_modules/
.pnpm-store/
.pnpm-debug.log*
reviews/
# Generated artifact; never committed.
+8 -15
View File
@@ -36,10 +36,17 @@ repos:
types: [python]
files: ^tests/
- id: no-hardcoded-models
name: no new hardcoded LLM model ids
language: system
entry: .venv/bin/python dev/lint/lint_no_hardcoded_models.py
pass_filenames: false
files: ^((omnigent|scripts|examples|\.github|dev/lint)/.*\.(py|ya?ml|json|toml|sh)|dev/lint/hardcoded_model_allowlist\.txt)$
- id: web-prettier
name: web prettier
language: system
entry: npm --prefix web exec -- prettier --write
entry: bash -c 'test -x web/node_modules/.bin/prettier && web/node_modules/.bin/prettier --write "$@"' --
files: ^web/.*\.(css|html|js|jsx|json|md|mdx|ts|tsx|yaml|yml)$
# Exclude generated assets: web-ui build output, Xcode asset catalogs,
# and Apple Icon Composer `.icon` bundles (machine-formatted; prettier
@@ -105,20 +112,6 @@ repos:
files: ^uv\.lock$
pass_filenames: true
# Local `npm install` rewrites every `resolved` URL in
# package-lock.json to whatever registry is configured on the
# developer's machine (e.g. the Databricks npm proxy via a global
# ~/.npmrc). This OSS repo must always commit the public npm registry
# (registry.npmjs.org), so normalize it back before it lands — a
# proxy URL would make `npm ci` time out on public CI runners. Fixer:
# re-stage if it changes. Mirrors normalize-uv-lock-registry above.
- id: normalize-package-lock-registry
name: normalize package-lock.json registry to npmjs.org
language: system
entry: .venv/bin/python scripts/normalize_package_lock_registry.py
files: ^(web|web/electron|editors/vscode)/package-lock\.json$
pass_filenames: true
# Fail if routing.proto changed without regenerating the committed
# bindings (or vice versa). Verify-only, not a fixer: regen needs
# grpcio-tools, so CI's `uv sync --extra dev` enforces it (like ktlint).
+11
View File
@@ -9,6 +9,17 @@ Run the `pre-commit` hook before committing (`pre-commit run --all-files`, or
let it run on staged files via `git commit`). Fix any issues it reports so the
commit lands clean — CI runs the same checks.
## Local development shortcuts
Use `just` for common tasks; run `just --list` for grouped recipes.
- `just ensure` — install/check prerequisites
- `just run-ios` / `just run-android` — build/run mobile apps
- `just dev` / `just dev-mobile` — start the omnigent dev pod
- `just electron-dev` / `just electron-build` — Electron desktop shell
- `just lint` / `just lint-all` — run pre-commit
- `just normalize-locks` — rewrite lockfile registries to PyPI/npmjs.org
## Pull requests
When you open a pull request, fill in the repo's PR template at
+134
View File
@@ -5,6 +5,140 @@ generated at release time from each PR's `## Changelog` section, tagged by the
PR's `Type of change` (e.g. `[UI]`); the concise, curated highlights live on the
website under `/releases`.
## [v0.7.0] — 2026-07-27
- [Bug fix] Hermes thinking now appears in mirrored web conversations. (#1645)
- [Bug fix] Image and file attachments now survive session relaunches on remote host runners; attachments that fail to load show a visible marker instead of silently disappearing. (#2085)
- [UI / Feature] Voice dictation in the composer now works in Electron, Firefox, and Chromium via optional server-side transcription (`omnigent[dictation]`) — local models, live streaming partials, audio never leaves your server. (#2093)
- [UI / Bug fix / Test/CI] Hide Claude task completion control messages from conversation history while preserving them for resume context. (#2104)
- [Bug fix / Feature / Docs] Operators can mount pre-created PersistentVolumeClaims (NFS/SMB/SAN) into Kubernetes sandbox runners via `sandbox.kubernetes.pvc_mounts` (read-only by default) (#2435)
- [Bug fix / Test/CI] `/compact` no longer races when multiple compact requests hit the same session at once (#2585)
- [Bug fix / Test/CI] `sys_call_async` / `sys_cancel_async` now consistently use `handle_id` as the cancel identifier. (#2586)
- [Bug fix / Test/CI] Runner idle timeout no longer kills sessions waiting on async tools, timers, or approval prompts (#2588)
- [Bug fix] Misconfigured runner tool policies deny tool calls instead of silently allowing them (#2589)
- [UI / Feature] Slash-command menus now match any part of a command's name, so `/using-superpowers` finds `/superpowers:using-superpowers` (#2655)
- [Bug fix] Host-launched runners now reuse delegated credentials instead of repeating Databricks authentication during startup. (#2762)
- [Feature] Projects are now a first-class entity with a `/v1/projects` CRUD API (create, list, rename, delete) and per-session membership. (#2765)
- [Bug fix] Hermes forwarder introspects state.db columns to survive cross-version schema drift (#2774)
- [Feature] `omni usage` reports your LLM cost for today / the last 7 / 30 days, with a per-session per-model cost breakdown (#2787)
- [Feature / Chore] Native Claude sessions start faster by coalescing runner initialization into one handshake. (#2793)
- [UI / Bug fix / Feature / Docs / Test/CI] Claude-native launch and in-session pickers now share the selected host's live model catalog, including Claude Code's managed routes. (#2831)
- [Bug fix] Managed BoxLite sandboxes remain available after provisioning so agent launches can execute commands reliably. (#2846)
- [Feature] Server-side smart routing can now call an external `routes:select` router via `routing.provider: external`, with provider-agnostic auth (`api_key`) and model-name mapping (`model_prefix`) (#2864)
- [UI] Chat code blocks no longer load the syntax-highlighter engine until the first (#2886)
- [UI / Bug fix] The main chat "Working…" indicator now clears reliably when the session goes idle, instead of occasionally staying lit after a reply completes. (#2900)
- [Bug fix] The performance benchmark harness now records HTTP failures and continues the rest of the suite instead of aborting, and excludes fully-failed runs from the summary averages. (#2917)
- [Feature / Test/CI] Scheduled tasks can now be created without a workspace or a pinned host for non-code work (research, summaries, chat-only, MCP-only); an unset host runs on your live host at fire time, and an unset workspace defaults to the host's home directory. A pinned host is now authorized (existence + ownership) at create time rather than only at fire time. (#2946)
- [Chore / Test/CI] N/A — internal benchmark/dev tooling; no user-facing impact. (#2947)
- [Feature] Set `OMNIGENT_CONTAINER_RUNTIME=podman` to use Podman (or another supported runtime) globally instead of Docker, without editing every agent's YAML. (#2949)
- [Bug fix] Sending a message to a session whose Claude Code terminal crashed no longer (#2951)
- [Bug fix] The desktop app now always quits within a few seconds even if its background cleanup stalls or the OS re-quit is dropped. (#2972)
- [UI / Bug fix] Messages send immediately when a session's only remaining work is a background job, instead of being held in the queue until it finishes (#2974)
- [UI / Feature] Desktop update notifications now appear in a native corner toast that works (#2975)
- [Bug fix / Chore] Runner startup no longer waits several seconds for Git's optional untracked-file cache probe. (#2976)
- [Feature / Test/CI] `omnigent` benchmark harness gains `--network-delay-ms` and per-journey HTTP request counts (#2977)
- [UI / Bug fix] Pi sessions now show reasoning while it streams and after conversation history reloads. (#2979)
- [Feature] The runner log now records why the runner exited (crash traceback, signal, idle timeout, tunnel close, or parent death) (#2985)
- [UI / Feature] Set up a missing agent from the New Chat dialog with a guided, step-by-step checklist (#2987)
- [UI / Bug fix / Feature] HTTP headers can now be set and edited for HTTP MCP servers in the session agent info panel. (#2989)
- Capped unbounded DB list queries in the permission store and reduced session opens in `check_access`/`get_permission_level` from 23 to 1. (#2995)
- Deleting a conversation with many descendants now issues a single FTS DELETE instead of one per descendant. (#2999)
- [UI / Bug fix] Android auto theme and system-bar icons now stay readable with both device themes and explicit in-app theme overrides. (#3006)
- [UI / Feature] 3D model files (STL, 3MF, OBJ) now render an interactive preview in the file browser (#3007)
- [UI / Bug fix] Subagents panel Graph View now shows the same status dot colors as List View (#3009)
- [Feature / Test/CI] Scheduled-task runs now transition to a terminal state (`succeeded`/`failed`) as soon as the dispatched turn finishes, instead of staying `running` forever; run history is readable at `GET /v1/scheduled-tasks/{id}/runs`. (#3014)
- [Feature / Chore] When enabled, new sessions receive concise semantic titles in the background without adding work or latency to the active agent turn. (#3024)
- [Feature] Offload dictation speech-to-text to a remote worker with (#3025)
- [Bug fix / Docs / Test/CI] Codex-native subagents now appear in the Agents panel with their live conversations. (#3028)
- [Bug fix] Credential proxy no longer attaches injected credentials to TRACE/OPTIONS requests, and the egress proxy now honors Max-Forwards as a conformant intermediary. (#3029)
- [Docs] N/A — internal documentation cleanup. (#3031)
- [Feature] Import local Qwen, Kiro, Pi, and Kimi coding chats into Omnigent (#3032)
- [UI / Feature] Press ⌘⌥V (Ctrl+Alt+V) to toggle voice dictation from anywhere; while dictating, Enter keeps the text and Esc discards it (#3044)
- [UI / Feature] Added: "Auto · smart routing" harness option in the new-chat picker — lets the intelligent router pick both harness and model based on the task description (#3045)
- [Feature] Import existing OpenCode chats, including files and tool activity, with `omnigent import` (#3046)
- [Bug fix] Dictation streams now reliably release their worker slot when a browser disconnects abruptly. (#3048)
- [UI] New-session composer moves harness configuration into a gear-icon modal, with a cleaner agent picker (needs-setup and custom agents folded into flyouts) and Smart Routing offered as a model option. (#3050)
- [Bug fix / Feature] The Slack bot can now run against an Omnigent server deployed on Databricks Apps, (#3051)
- [Feature] Sessions can now be filed into first-class projects via `PATCH /v1/sessions/{id}` and listed with `GET /v1/sessions?project=<name>`, which dual-reads first-class membership and legacy project labels. (#3053)
- [Bug fix / Test/CI] Fixed SDK session telemetry always recording `harness: null` in server deployments not started via the CLI. (#3054)
- [Test/CI] N/A — test-only reliability change. (#3056)
- [Bug fix] Databricks OAuth CLI profiles no longer fail with a misleading "malformed profile" error; the message now explains the real fix (install `omnigent[databricks]` or refresh the OAuth session). (#3059)
- [Bug fix] An idle runner shutting down after inactivity no longer shows a scary "disconnected" error — just send a message to wake it back up. (#3060)
- [UI / Feature] The sidebar now uses first-class projects: create empty projects, rename and delete them, and file sessions into them — while existing label-based projects keep working. (#3061)
- [UI / Bug fix] The "Host is offline — click to reconnect" prompt now appears in the composer's host badge instead of a separate banner below the composer (#3062)
- [Test/CI] N/A (test-only change) (#3063)
- [Feature / Docs / Test/CI] New `databricks_cli` credential-proxy type lets sandboxed agents use the Databricks CLI without the real token entering the sandbox (#3080)
- [UI / Feature / Test/CI] Polly sessions running on Claude SDK can start Goal mode from the chat composer. (#3084)
- [UI / Bug fix] The Configure agent modal's footer no longer shows a gray background band behind Cancel/Save (#3089)
- [UI / Feature] The Sidebar is more compact and polished, with clearer status indicators and richer session details on hover. (#3092)
- [UI] Reordered the project-folder header buttons (new-session before the menu), (#3096)
- [Chore / Breaking] `omni server start` is removed; use `omni server --background` to launch the (#3105)
- [Bug fix] `omnigent server --host 0.0.0.0` now enables accounts (login) mode automatically instead of silently 401-ing every request (#3107)
- [Feature] Projects can store default session settings (host, workspace, harness, model, …) via a new `config` field on the projects API. (#3108)
- [Bug fix] Databricks-served Claude models no longer break non-streaming responses (prompt-policy and smart routing) when returning typed content blocks (#3109)
- [Feature] `omnidev omnigent <args…>` runs an omnigent command against the current checkout's pod via `uv run omnigent`, with the pod's isolated env applied (#3110)
- [UI / Feature] Configure a session's model, effort, and smart routing mid-chat from a new gear icon in the composer (#3111)
- [UI / Feature / Test/CI] Add the `/tasks` Scheduled Tasks page with sidebar navigation, task rows, empty states, suggestion chips, create-dialog entry points, and Playwright E2E coverage. (#3112)
- [Bug fix] Reading image files in a Claude Code native session no longer bloats conversation history and breaks resume/compaction on large sessions (#3113)
- [Bug fix / Test/CI] The iOS app no longer follows cross-origin redirects when probing a newly approved server for the Databricks workspace mount, so a consented host can't redirect the probe to a different origin. (#3115)
- [Bug fix] Forked native sessions (Claude Code, Codex, Pi, Qwen) again resume with their prior conversation history. (#3116)
- [UI / Feature / Test/CI] Workspace pane icons now explain themselves on hover, with a cleaner right-side session layout and compact Share action. (#3122)
- [UI / Bug fix / Feature] Add a dialog for creating recurring scheduled agent tasks. (#3123)
- [UI] Sidebar session hover flyouts and rows now align with the project rows — matching flyout style, title size, and right-edge padding. (#3124)
- [Bug fix] Resuming a session with large images stored in history no longer overflows the context window or breaks compaction, on both the SDK and native Claude Code paths (#3133)
- [Feature] `omnigent session import` loads a `session export` JSONL back into a server as a new session (#3141)
- [Feature] Telemetry now records the agent name for Polly and Debby sessions. (#3152)
- [Chore / Breaking] `omni integration slack start` is removed; use `omni integration slack --background` to launch the (#3153)
- [UI / Bug fix / Docs / Chore] Slack device login now requires a fresh password at the consent screen, closing a device-code phishing gap where an already-signed-in user could approve a login by reflex. (#3156)
- [Feature] `omnigent claude` keeps tool search enabled when launched with `CLAUDE_CODE_USE_GATEWAY=1`. (#3161)
- [Test/CI] Fix `test_session_stream_emits_heartbeat_on_idle` after `_session_labels_for_runner_spawn` was extracted into `omnigent.runner.native.orchestration`; patch the heartbeat cadence on `omnigent.runner.app` where it is defined and consumed. (#3163)
- [Bug fix] Crash reports are no longer lost when a process crashes more than once in the same second. (#3173)
- [Bug fix] Custom codex-native agents launch on the model declared in the agent spec (`executor.model`) instead of silently falling back to the provider default (#3175)
- [Bug fix] Single-file agent YAMLs that nest the executor under `type:`/`config:` (the bundle config.yaml shape) now fail at load time with the correct flat spelling, instead of silently running a harness inferred from the model prefix (#3178)
- [UI / Bug fix / Feature] Use native Codex goal mode from Polly's Goal control (#3181)
- [UI / Bug fix] Renaming a session now updates the name in the sidebar instantly instead of after a short delay. (#3185)
- [UI / Bug fix / Feature / Test/CI] Edit scheduled tasks and type exact run times, with a scrollable time picker and a consistent, fully-visible dialog. (#3186)
- [UI / Feature] Pinned sessions now persist server-side per user, so pins follow you across devices and browsers. (#3189)
- [Feature] New sessions now receive concise semantic titles automatically without additional configuration. (#3191)
- [Test/CI] `/rerun` PR comment re-runs failed CI on the current commit without dismissing approvals (#3195)
- [Feature] Native Codex sessions can now receive concise automatic background titles. (#3199)
- [Bug fix] Qwen3, inkling, and other non-OpenAI models now work in the Pi SDK executor harness (#3203)
- [Chore / Breaking] Slack-on-Databricks deploy: renamed `OMNIGENT_SLACK_WEBAUTH_BASE_URL` to `OMNIGENT_SLACK_DATABRICKS_APP_URL` (`--app-url`), removed the `WEBAUTH_PORT` / `DATABRICKS_WORKSPACE_HOST` overrides, and dropped deploy-time `uv lock` in favor of in-container `uv run`. (#3206)
- [UI / Bug fix] Sidebar session titles use the available space cleanly and reveal branch and action details only when needed. (#3208)
- [UI / Bug fix] Removed the redundant "Create new project" option from the sidebar project picker — create projects with the + icon next to Projects (#3210)
- [Feature] Smart routing now activates automatically when a server `llm:` block or an external `routing:` block is configured — no `OMNIGENT_SMART_ROUTING` env var needed (#3215)
- [UI / Feature / Test/CI] Scheduled task rows now show when each task will next run ("Next run in 15h") and a "Run now" action in the ⋯ menu to fire a task immediately, with refreshed row styling. (#3218)
- [UI / Feature] Projects now carry default session settings (host, working directory, agent, optional git worktree) that pre-fill the new-session composer. (#3221)
- [Bug fix] Fixed per-model cost attribution for native harnesses so a session's per-model (#3223)
- [UI / Bug fix] Codex task plans now stay in Tasks instead of being duplicated in chat. (#3249)
- [UI / Bug fix] Smart Routing no longer appears in the model dropdown for native terminal sessions (Claude Code, Codex, Pi), where it had no effect (#3259)
- [Bug fix] Sandboxed agents can now run tools managed by update-alternatives (awk, python3, editor, and similar) on Linux. (#3263)
- [Bug fix] Egress proxy now trusts corporate/MDM CA roots installed under the system `capath` directory, so TLS to hosts behind a corporate MITM works from a sandboxed agent. (#3264)
- [Bug fix] Large historical attachments no longer inflate replay and compaction context as inline base64 text. (#3267)
- [Docs] Contributors can now use `omnidev` as the documented worktree-safe local testing flow. (#3277)
- [Bug fix] Custom OpenAI Agents can use Unity AI Gateway Model Services with fully qualified model names when a Databricks provider or profile is configured. (#3288)
- [UI / Bug fix / Feature] Configure recoverable dangerous shell commands to ask for approval or deny execution, while always blocking catastrophic operations. (#3297)
- [Bug fix / Feature] Pi harness now routes kimi, inkling, GLM, qwen3, Gemini 3+, and Llama through the correct AI Gateway endpoints, fixing "Stream ended without finish_reason" errors and ensuring `system.ai.*` ids are used throughout. (#3307)
- [Feature] Add a top-level `justfile` with recipes for launching the iOS Simulator, running the Android debug build, starting the omnigent dev pod, and running pre-commit/lockfile normalization. (#3310)
- [UI / Bug fix] Sidebar header action buttons are now vertically centered with section titles. Session row hover is smoother, and active items no longer flash when hovered. (#3311)
- [UI / Bug fix] Aligns project folder icons and color with the rest of the sidebar. (#3317)
- [Bug fix] Unsupported Claude Code slash commands are now escaped and sent as regular user messages instead of leaving the native terminal in an undriveable state. (#3319)
- [Feature] `omnigent server` now tells users they can set `OMNIGENT_AUTH_ENABLED=0` to override automatic multi-user mode when binding to a non-loopback interface. (#3320)
- [Chore / Breaking] [Breaking] The deprecated `OMNIGENT_ACCOUNTS_ENABLED` environment variable has been removed; use `OMNIGENT_AUTH_ENABLED` instead. (#3322)
- [UI / Bug fix] Fixed pinned sessions being lost when the web UI was updated before the server. (#3323)
- [UI / Feature] Automations list: tasks now render as cards and show a live-updating relative next-run time ("Next run in 3 hours"). (#3324)
- [UI] Settings → Appearance no longer has a separate Sidebar font size control, and a new "Reset to defaults" button restores every appearance preference after confirmation. (#3326)
- [UI] Starting a session inside a project now names the project in the new-session (#3327)
- [UI] The Files workspace tab now uses a stacked-files icon. (#3329)
- [UI / Feature] Automations: scheduled tasks can now pick a model and reasoning effort in the create/edit dialog (defaults to the agent's settings). (#3331)
- [UI / Bug fix] Fixed sessions pinned in the updated web UI being lost after the server was updated. (#3332)
- [UI / Feature] Native harness setup now checks the installed CLI version and prompts to upgrade if it is too old. Cursor's missing-binary case is normalized to the same structured `binary-missing` signal as the other CLI-backed native harnesses. (#3335)
- [Bug fix] Pi sessions now show a clear error when their Databricks login has expired, instead of silently accepting messages with no reply (#3336)
- [Test/CI] N/A (internal CI change). (#3338)
- [Bug fix] claude-sdk harness now surfaces harness-level failures (expired login, auth error) as structured errors instead of storing them as assistant messages. (#3342)
- [UI] Align the sidebar brand row with the rest of the navigation. (#3346)
- [UI / Bug fix] Short links like `#3090` in chat markdown tables no longer stack one character per line (#3350)
## [Unreleased]
### Features
+78 -10
View File
@@ -28,7 +28,9 @@ Install local prerequisites first:
- `bubblewrap` (`bwrap`), **Linux only**, used to OS-sandbox those native
Claude/Codex/Pi terminals (`apt install bubblewrap` on Debian/Ubuntu). macOS
uses the built-in `seatbelt` sandbox and needs nothing extra.
- Node.js 22 LTS or newer with `npm` when working on `web/`.
- Node.js 22 LTS or newer with `pnpm` (install via `corepack enable` or
`npm install -g pnpm`) when working on `web/`.
- A Rust toolchain for the recommended `omnidev` local development supervisor.
```bash
git clone https://github.com/omnigent-ai/omnigent.git
@@ -51,24 +53,73 @@ uv run pre-commit run --all-files
When touching `web/`:
```bash
cd web && npm install && npm run lint && npm run build
cd web && pnpm install && pnpm run lint && pnpm run build
```
## Running locally
To try your changes, start a local server, register your machine as a host,
and run the frontend dev server. Use three separate terminals:
Start with the smallest relevant automated test described in [Tests](#tests).
For full-stack manual testing, use `omnidev`.
### Recommended: worktree-safe testing with `omnidev`
`omnidev` runs the current checkout's server, host, and Vite frontend in one
terminal. Each checkout path, including each worktree, gets isolated state,
configuration, database, artifacts, logs, and automatically allocated ports,
so it can run alongside your normal Omnigent installation and other worktrees.
Install the supervisor once from an up-to-date checkout:
```bash
cargo install --path dev/omnidev --force
```
Then run it from anywhere inside the branch checkout or worktree you want to
test. A fresh worktree needs its own Python environment first:
```bash
cd /path/to/omnigent-worktree
uv sync --extra all --extra dev
omnidev
```
Open the exact `ui` URL displayed in the header; do not assume the Vite port is
`5173`. Python changes under `omnigent/` reload the server and host, while
frontend changes use Vite HMR.
Run CLI commands against the development pod through the passthrough so they
use that checkout and its isolated state instead of a globally installed
`omnigent`:
```bash
omnidev omnigent config show
omnidev omnigent agent list
```
Keep `omnidev` in the foreground and quit with `q` or `Ctrl-C` so it tears down
all three processes. An interactive terminal inside an existing Omnigent
session also works; use `git rev-parse --show-toplevel` to confirm that its
current checkout is the one you intend to test.
See [`dev/omnidev/README.md`](dev/omnidev/README.md) for log controls,
clean-state testing, backend-only and LAN modes, and other options.
### Manual three-terminal fallback
Use the manual flow when you need to run or debug each component separately.
Unlike `omnidev`, it does not isolate state or allocate ports. These commands
assume the default ports are free:
```bash
# Terminal 1: local server on :6767
omnigent server
uv run omnigent server
# Terminal 2: register your machine as a host
omnigent host --server http://localhost:6767
uv run omnigent host --server http://localhost:6767
# Terminal 3: frontend dev server
cd web
npm run dev
pnpm run dev
```
Open the Vite URL from the frontend dev server, usually
@@ -81,7 +132,7 @@ The host URL can also be passed positionally (`omnigent host
http://localhost:6767`). See the [README](README.md) for more on hosts,
harnesses, and credentials.
### Backend-only local development validation
### Disposable backend-only validation
Use this when you want to validate the Python backend and local API server from
a source checkout without building the web UI, configuring provider
@@ -169,7 +220,7 @@ Two cross-cutting suites sit on top of these:
Frontend changes follow the same expectation with a different toolchain:
- Add or update a **colocated Vitest test** — a `*.test.ts`/`*.test.tsx` file
next to the component or module you changed — and run it with `npm test`.
next to the component or module you changed — and run it with `pnpm test`.
- A change to **user-facing UI behaviour** also needs a Playwright test under
`tests/e2e_ui/`. This one is enforced mechanically by the `E2E UI Required`
check, so a UI PR won't merge without a covering test (or a maintainer
@@ -177,10 +228,27 @@ Frontend changes follow the same expectation with a different toolchain:
- Styling/formatting-only changes, copy tweaks with no flow change, and
refactors with no behaviour change are exempt, same as the backend.
## Developer Certificate of Origin
To contribute to this repository, you must sign off your commits to certify
that you have the right to contribute the code and that it complies with the
open source license. If you can certify the contents of the [DCO](DCO), add a
`Signed-off-by` line to each commit message:
```
Signed-off-by: Joe Smith <joe.smith@email.com>
```
Please use your real name — pseudonymous/anonymous contributions are not
accepted. If your `user.name` and `user.email` git configs are set, `git
commit -s` adds the sign-off automatically. The DCO check on every pull
request enforces this, so unsigned commits will block merging.
## Pull requests
- Branch from `main`, keep changes focused, and include tests or docs when relevant.
- Sign off your commits with `git commit -s` (Developer Certificate of Origin).
- Sign off your commits with `git commit -s` (see
[Developer Certificate of Origin](#developer-certificate-of-origin) above).
- Fill in the PR template. For **UI / frontend changes**, check the
"UI / frontend change" box and attach a **video or images** in the `Demo`
section showing the new behaviour, so reviewers can see it without checking
+34
View File
@@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
+4 -4
View File
@@ -122,10 +122,10 @@ uv tool install -q --python 3.12 git+https://github.com/omnigent-ai/omnigent.git
- **`uv`** (required). https://docs.astral.sh/uv/getting-started/installation/
The installer offers to set this up for you.
- **`git`** (required).
- **Node.js 22 LTS or newer** with **`npm`**, for the npm-installed coding
harnesses (Claude, Codex, OpenCode, Pi). `omnigent run` installs the
harness CLI you pick.
https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
- **Node.js 22 LTS or newer** with **`npm`** (for the coding-harness CLIs
installed by `omnigent run`) and **`pnpm`** (for the web UI). You can get
both from a single Node install; pnpm is available via
`corepack enable` or `npm install -g pnpm`.
- **Kiro CLI** (optional), for `omnigent kiro`: install with
`curl -fsSL https://cli.kiro.dev/install | bash`, then sign in with Kiro.
Kiro tool approvals stay answerable in the embedded Terminal; supported
+18 -14
View File
@@ -94,8 +94,9 @@ What it does (all idempotent):
or run `uv lock` behind a proxy**; the workflow owns this now;
- commits `release: v0.6.0rc1`, tags, and pushes branch + tag with the
omnigent-ci App token, which fires the downstream automation:
`github-release.yml` (draft GH release, pre-release flagged),
`draft-release-notes.yml`, and `oss-publish-images.yml` (Docker);
`oss-publish-images.yml` (Docker; publishes the immutable version image tag),
`github-release.yml` (skips rc — no GitHub release is created for
pre-releases; rcs live on PyPI only), and `draft-release-notes.yml` (skips rc);
- on the **first** cut of a cycle (rc1), dispatches `bump-version.yml`
(post-release) — **review and merge the `main → 0.7.0.dev0` bump PR
promptly**, so `doc-sync` keeps staging to the right docs branch.
@@ -128,7 +129,8 @@ python -m venv /tmp/omni-rc && /tmp/omni-rc/bin/pip install \
/tmp/omni-rc/bin/omnigent --version # expect 0.6.0rc1
```
The rc's GitHub draft stays **unpublished** — rc drafts are never published.
No GitHub release is created for the rc — pre-releases live on PyPI only,
and a curated release page is reserved for the final cut.
Need another candidate? Repeat with `0.6.0rc2` (fixes land on `release/v0.6.0`
first, via cherry-pick PRs or direct pushes; CI runs on `release/v*` pushes).
@@ -186,9 +188,10 @@ can never be reused. So:
same inputs** — every step converges (branch exists → reused; version
stamped → no new commit; tag at the converged commit → no-op) or fails
loudly (tag elsewhere) rather than duplicating work.
- **Wrong commit tagged, nothing published yet:** delete the tag and draft
(`gh release delete vX.Y.Z`, `git push origin :refs/tags/vX.Y.Z`), then
re-dispatch `release.yml`.
- **Wrong commit tagged, nothing published yet:** delete the tag and, for a
final `vX.Y.Z` (which has a draft), the draft too —
`gh release delete vX.Y.Z`, `git push origin :refs/tags/vX.Y.Z` — then
re-dispatch `release.yml`. (rc tags have no draft to delete.)
- **rc is bad:** just cut the next rc — rcs are cheap and invisible to
default installs.
- **Prod publish partially succeeded** (e.g. two of three packages uploaded):
@@ -205,7 +208,7 @@ can never be reused. So:
To exercise the whole flow end to end without touching users, release a
deliberately **below-latest** rc on the dead `0.0` line. A below-latest rc is
inert everywhere that matters: the GitHub draft stays unpublished, Docker
inert everywhere that matters: no GitHub release is created for rc tags, Docker
publishes only the immutable version image tag (`:latest` / `:latest-rc` only
move for the highest version), the notes/site/homebrew workflows ignore rc
tags, `bump-main` skips itself (the version sorts below main's), and a
@@ -231,8 +234,8 @@ The examples below use `0.0.1rc2`; substitute the next free number.
```
2. **Execute**: re-run with `-f dry_run=false`. Expect `release/v0.0.0` + tag
`v0.0.1rc2` pushed, the tag firing the draft-release and image workflows,
and CI running on the branch push. If the CI gate rejects main's head
`v0.0.1rc2` pushed, the tag firing the image workflow (`github-release.yml`
runs but skips the rc — no draft), and CI running on the branch push. If the CI gate rejects main's head
(failing or still-pending checks), that's the gate working — wait, or
re-dispatch with `-f ref=<green sha>` / `-f skip_ci_check=true`.
Cancelled (superseded) runs only warn.
@@ -268,11 +271,11 @@ The examples below use `0.0.1rc2`; substitute the next free number.
Cleanup — delete everything the rehearsal minted on GitHub:
```bash
gh release delete v0.0.1rc2 --repo omnigent-ai/omnigent --cleanup-tag --yes
gh api -X DELETE 'repos/omnigent-ai/omnigent/git/refs/heads/release/v0.0.0'
```
Optionally delete the rehearsal image versions from GHCR. The PyPI side needs
No `gh release delete` is needed: pre-release tags no longer create a GitHub
release. Optionally delete the rehearsal image versions from GHCR. The PyPI side needs
no cleanup: the rc is invisible to default installs and only the version
number is spent — optionally yank it (*Manage → Releases → Yank*) for
tidiness.
@@ -300,8 +303,9 @@ git fetch origin && git checkout release/v0.6.0 && git pull
git tag v0.6.0rc1 && git push origin release/v0.6.0 v0.6.0rc1 # explicit tag, NOT --tags
```
Then continue from step 2 of the standard flow (secure-repo dispatches). If the
GH draft wasn't created, `gh release create vX.Y.Z --draft --verify-tag
--title vX.Y.Z` recreates it. To re-run the notes/site halves for an existing
Then continue from step 2 of the standard flow (secure-repo dispatches). For
a final `vX.Y.Z`, if the GH draft wasn't created, `gh release create vX.Y.Z
--draft --verify-tag --title vX.Y.Z` recreates it (rc tags get no draft by
design). To re-run the notes/site halves for an existing
tag, dispatch `draft-release-notes.yml` or `publish-changelog.yml` with the
`tag` input; for the tap, dispatch `update-homebrew.yml`.
+2 -2
View File
@@ -68,8 +68,8 @@ browser ───────────────► Worker (src/index.js)
```bash
cd deploy/cloudflare
npm install
npx wrangler login
pnpm install
pnpm exec wrangler login
```
## Deploy
+2 -4
View File
@@ -28,10 +28,8 @@ rm -rf omnigent/server/static/web-ui dist build omnigent.egg-info
if [[ "${SKIP_WEB_UI:-}" != "1" ]]; then
echo "==> Building web SPA into omnigent/server/static/web-ui/"
cd web
npm install
npm run build
cd "${REPO_ROOT}"
pnpm install --frozen-lockfile --filter web
pnpm --filter web run build
else
echo "==> SKIP_WEB_UI=1: skipping web build"
fi
+19 -8
View File
@@ -34,13 +34,19 @@ POSTGRES_PASSWORD=change-me-please
# instance.
#
# A) Built-in accounts (DEFAULT — no env needed for laptop testing).
# First boot auto-creates an admin user (named after the OS
# user, falling back to "admin"), prints the password to
# `docker compose logs omnigent`, and saves it to
# /data/admin-credentials on the persistent volume. Admin
# invites teammates via the web UI Members page.
# No credentials are auto-generated. First boot prints a
# "No admin yet" line pointing at the base URL; you create the
# first admin (username + password) via the web Create-admin
# form, or pre-seed it with OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD.
# Admin invites teammates via the web UI Members page.
# For any deploy behind a public domain you MUST set
# OMNIGENT_ACCOUNTS_BASE_URL — see below.
# Security note for public deployments: POST /auth/setup is
# intentionally unauthenticated while no password-bearing account
# exists, so an instance exposed before its operator reaches the
# form can be claimed by the first visitor. Pre-seed
# OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD, or keep the service
# private until setup completes.
#
# B) Native OIDC (for shops with an existing IdP).
# Set the OMNIGENT_OIDC_* vars below (at minimum
@@ -82,9 +88,14 @@ POSTGRES_PASSWORD=change-me-please
# omnigent:8000 container address.
# OMNIGENT_ACCOUNTS_BASE_URL=https://omnigent.example.com
#
# Optional: pre-seed the initial admin password instead of the
# auto-generated one. Useful for headless / CI deploys where
# the operator can't read `docker compose logs`.
# Optional: pre-seed the initial admin password so bootstrap creates
# the first admin directly, instead of waiting for someone to claim it
# through the web Create-admin form. Useful for headless / CI deploys
# where that form can't be reached interactively. Nothing is ever
# auto-generated: without this (and without an OIDC issuer) a fresh
# instance stays in the needs-setup state and prints the setup URL to
# stderr — no password appears in the logs. See the security note under
# section A above for public deployments.
# OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD=
#
# Optional: session/invite/magic TTLs. Defaults shown.
+15 -7
View File
@@ -55,7 +55,7 @@
# Must satisfy pyproject requires-python (>=3.12); 3.11 fails dependency resolution.
ARG PYTHON_VERSION=3.12
ARG NODE_VERSION=20
ARG NODE_VERSION=22
# ── Web UI builder ──────────────────────────────────────
# Builds the web SPA so `docker build` works from a clean checkout —
@@ -70,12 +70,20 @@ ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-slim AS web-builder
ARG NPM_CONFIG_REGISTRY=
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY}
WORKDIR /web/web
# Manifests first so the install layer caches across pure source edits.
COPY web/package.json web/package-lock.json ./
RUN npm install --no-audit --no-fund
COPY web/ ./
RUN npm run build
WORKDIR /web
# Workspace manifests + lockfile first so the install layer caches across pure
# source edits. The electron package JSON is included so the root workspace is
# structurally complete, but we --filter web to avoid downloading Electron.
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY web/package.json ./web/
COPY web/electron/package.json ./web/electron/
RUN npm install -g pnpm@11.15.1
RUN pnpm install --frozen-lockfile --filter web
COPY web/ ./web/
RUN pnpm --filter web run build
# ── Python builder (shared: server + host) ──────────────
# Installs the package (and its transitive native-extension deps) into
+15 -6
View File
@@ -13,7 +13,7 @@
# -f deploy/docker/Dockerfile.ubi .
ARG PYTHON_VERSION=3.12
ARG NODE_VERSION=20
ARG NODE_VERSION=22
# ── Web UI builder ──────────────────────────────────────
FROM registry.access.redhat.com/ubi9/nodejs-${NODE_VERSION} AS web-builder
@@ -21,11 +21,20 @@ ARG NPM_CONFIG_REGISTRY=
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY}
USER 0
WORKDIR /web/web
COPY web/package.json web/package-lock.json ./
RUN npm install --no-audit --no-fund
COPY web/ ./
RUN npm run build
WORKDIR /web
# Workspace manifests + lockfile first so the install layer caches across pure
# source edits. The electron package JSON is included so the root workspace is
# structurally complete, but we --filter web to avoid downloading Electron.
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY web/package.json ./web/
COPY web/electron/package.json ./web/electron/
RUN npm install -g pnpm@11.15.1
RUN pnpm install --frozen-lockfile --filter web
COPY web/ ./web/
RUN pnpm --filter web run build
# ── Python builder (shared: server + host) ──────────────
FROM registry.access.redhat.com/ubi9/python-312 AS builder
+20 -13
View File
@@ -43,40 +43,47 @@ docker compose down -v
Built-in accounts auth: no IdP to register, no proxy to host.
This is the default — `docker compose up -d` brings it up with no
extra env wiring. First boot creates an admin user (named after the
operator's OS user, falling back to `admin` in headless containers)
with a random password that lands in the container logs and on the
persistent volume at `/data/admin-credentials`.
extra env wiring. No credentials are auto-generated. On first boot,
when no admin exists yet and none was pre-seeded, the server creates
nothing and prints:
```
→ No admin yet. Open <base_url> to create the first admin account (choose a username + password).
```
You then open the web UI's **Create admin** form (it appears while no
admin exists) and pick your own username + password.
For any deploy reachable through a public domain, also set the
external URL so invite links resolve correctly:
external URL so the printed link and invite links resolve correctly:
```bash
# Add to .env (bootstrap.sh already minted the cookie secret for you):
OMNIGENT_ACCOUNTS_BASE_URL=https://omnigent.example.com
docker compose up -d
docker compose logs omnigent | grep -A4 "Created initial admin"
docker compose logs omnigent # shows the "No admin yet" line with your base URL
```
Copy the random `password` from the log line into the web UI's
login form, then:
Once you've created the admin and signed in:
- Click your username in the top-right → **Members****Invite member**.
- Share the single-use URL with the teammate; they pick their own
username and password when they redeem it.
- Sign-out lives in the same account menu.
Headless deploy (CI, Cloud Run, etc.) where you can't read the
logs? Pre-seed the password:
Headless deploy (CI, Cloud Run, etc.) where you can't reach the
Create-admin form? Pre-seed the admin password so first boot creates
the admin directly:
```bash
OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD=<your-strong-password>
```
The persistent password file is at `/data/admin-credentials` on
the `artifact-data` volume — survives `docker compose restart`,
deleted by `docker compose down -v`.
`OMNIGENT_ADMIN_CREDENTIALS_PATH` (set to `/data/admin-credentials`
in `docker-compose.yaml`) anchors the persistent state directory on
the `artifact-data` volume — it survives `docker compose restart` and
is deleted by `docker compose down -v`.
## Multi-user mode (OIDC)
+5 -4
View File
@@ -94,8 +94,9 @@ echo
echo "✓ deploy/docker/.env is ready. Next:"
echo " docker compose up -d && docker compose logs omnigent"
echo
echo " Accounts mode is the default — the first-boot admin password"
echo " lands in the logs and in /data/admin-credentials on the"
echo " persistent volume. For any public-domain deploy also set:"
echo " Accounts mode is the default — no credentials are auto-generated."
echo " First boot prints a 'No admin yet' line; open that URL and create"
echo " the first admin (username + password) via the web form. For any"
echo " public-domain deploy also set:"
echo " OMNIGENT_ACCOUNTS_BASE_URL=<your public URL>"
echo " in .env so invite links resolve to the right host."
echo " in .env so that link and invite links resolve to the right host."
+14 -6
View File
@@ -8,9 +8,11 @@
# open http://localhost:8000 # web UI; start a local runner per the prompt
#
# Auth modes (OMNIGENT_AUTH_PROVIDER):
# - accounts (DEFAULT) — built-in accounts, no IdP needed. First
# boot prints the admin password to `docker compose logs` and
# saves it to /data/admin-credentials. Set
# - accounts (DEFAULT) — built-in accounts, no IdP needed. No
# credentials are auto-generated; first boot prints a "No admin
# yet" line and you create the first admin (username + password)
# via the web Create-admin form, or pre-seed it with
# OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD. Set
# OMNIGENT_ACCOUNTS_BASE_URL for any deploy reachable behind
# a public domain (defaults to http://<HOST>:<PORT> otherwise).
# - oidc — bring your own IdP. Set OMNIGENT_OIDC_* vars — see
@@ -60,9 +62,15 @@ services:
ARTIFACT_DIR: /data/artifacts
HOST: 0.0.0.0
PORT: "8000"
# Pin the admin-credentials path to the persistent volume so
# the file survives container restarts. Empty/unset would
# write to /root/.omnigent/ inside the ephemeral container.
# Anchor the server's data dir on the persistent volume so
# file-backed operator config survives container restarts:
# the admin roster (/data/admins) and allowed-domains file
# (/data/allowed_domains), plus artifacts mounted elsewhere
# in the same volume. Account rows and password hashes live in
# PostgreSQL (the postgres-data volume), not here. The server
# resolves its data dir from this path's parent (/data);
# empty/unset would fall back to /root/.omnigent/ inside the
# ephemeral container.
OMNIGENT_ADMIN_CREDENTIALS_PATH: /data/admin-credentials
# ── Auth ─────────────────────────────────────────
+67 -1
View File
@@ -254,6 +254,25 @@ def _select_artifact_store(resolved_config: _ResolvedConfig) -> ArtifactStore:
return LocalArtifactStore(str(resolved_config.artifact_dir))
def _build_local_llm_routing_client(
server_llm: Any, # type: ignore[explicit-any] # LLMConfig | None
) -> Any | None: # type: ignore[explicit-any] # LLMRoutingClient | None
if server_llm is None:
return None
from omnigent.runtime.policies.builder import (
_build_policy_llm_client,
_resolve_server_llm_connection,
)
conn = _resolve_server_llm_connection(server_llm)
policy_client = _build_policy_llm_client(server_llm, conn)
if policy_client is None:
return None
from omnigent.server.smart_routing import LLMRoutingClient
return LLMRoutingClient(policy_client)
def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
"""Resolve config if needed, wire the stores, and build the app.
@@ -315,9 +334,56 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
cache_dir=artifact_dir / ".cache",
)
from omnigent.spec import parse_default_policies, parse_server_llm
server_llm = parse_server_llm(cfg.get("llm"))
routing_cfg = cfg.get("routing")
if isinstance(routing_cfg, dict) and routing_cfg.get("provider") == "external":
from omnigent.server.smart_routing import ExternalRoutingClient, _bearer_auth
base_url = (routing_cfg.get("base_url") or "").strip()
router_name = (routing_cfg.get("router_name") or "").strip()
api_key_raw = (routing_cfg.get("api_key") or "").strip()
profile = (routing_cfg.get("profile") or "").strip()
raw_prefixes = routing_cfg.get("model_prefix")
if isinstance(raw_prefixes, str):
raw_prefixes = [raw_prefixes]
model_prefixes = (
[p.strip() for p in raw_prefixes if isinstance(p, str) and p.strip()]
if isinstance(raw_prefixes, list)
else []
)
if base_url and router_name:
auth = None
databricks_profile: str | None = None
if api_key_raw:
from omnigent.spec import expand_env_vars
auth = _bearer_auth(expand_env_vars({"api_key": api_key_raw})["api_key"])
elif profile:
databricks_profile = profile
routing_client = ExternalRoutingClient(
base_url=base_url,
router_name=router_name,
auth=auth,
databricks_profile=databricks_profile,
model_prefixes=model_prefixes,
)
else:
routing_client = None
else:
routing_client = _build_local_llm_routing_client(server_llm)
caps = RuntimeCaps(
default_policies=parse_default_policies(cfg.get("policies")),
llm=server_llm,
routing_client=routing_client,
)
init_runtime(
agent_cache=agent_cache,
caps=RuntimeCaps(),
caps=caps,
agent_store=agent_store,
file_store=file_store,
conversation_store=conversation_store,
+14 -5
View File
@@ -35,14 +35,23 @@ Then:
1. **Memory**`fly.toml` pins a **1 GB** machine (`[[vm]] memory = "1gb"`).
The server idles around ~275 MB RSS, so Fly's 256 MB default OOM-loops.
Keep it at 1 GB (or `fly scale memory 1024 -a <your-app>` if you changed it).
2. **Admin password** prints once in the first-boot logs:
2. **Create the first admin.** No credentials are auto-generated. First boot
prints a "No admin yet" line pointing at your `*.fly.dev` URL:
```bash
fly logs -a <your-app>
```
Look for `Created initial admin account ... password: <generated>` (also
written to `/data/admin-credentials` on the volume).
3. Open `https://<your-app>.fly.dev`, log in as `admin`. The cookie secret and
base URL (`FLY_APP_NAME` -> `<app>.fly.dev`) are handled automatically.
Open `https://<your-app>.fly.dev` and use the web Create-admin form to pick
your own username + password. For a headless deploy, pre-seed
`OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD` (`fly secrets set …`) before first
boot to create the admin directly instead.
3. Log in with the admin you just created. The cookie secret and base URL
(`FLY_APP_NAME` -> `<app>.fly.dev`) are handled automatically.
> **Security note for public deployments:** `POST /auth/setup` is
> unauthenticated while no password-bearing account exists, so an instance
> exposed before you reach the Create-admin form can be claimed by the first
> visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`, or complete setup
> promptly after the deploy goes live.
## Deploy (Fly web-UI Launch)
+10 -4
View File
@@ -35,15 +35,21 @@ files plus two secrets.
| `DATABASE_URL` | variable | `sqlite:////data/artifacts/chat.db` |
| `OMNIGENT_ACCOUNTS_COOKIE_SECRET` | secret | `openssl rand -hex 32` (pin it: ephemeral disk would otherwise drop sessions on restart) |
4. The Space builds + boots. Admin password is in the Space **Logs** on first
boot. The base URL is auto-detected from `SPACE_HOST`, so it needs no manual
set.
4. The Space builds + boots. No admin credential is auto-generated: first boot
prints a "No admin yet" line to the Space **Logs**, and the Space serves a
web Create-admin form where you pick your own username + password. The base
URL is auto-detected from `SPACE_HOST`, so it needs no manual set. To create
the admin directly instead, add `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD` as a
Space secret before first boot.
5. **Log in via the direct URL** `https://<user>-<space>.hf.space` in its own
tab — not HF's embedded preview. The session cookie is `SameSite=Lax`, which
browsers won't send inside HF's cross-origin iframe, so logging in from the
embedded view loops back to `/login`. The direct URL is top-level
(same-site), so login sticks. Make the Space **Public** so the direct URL
isn't gated.
isn't gated — but note the Create-admin form is unauthenticated until the
first admin is claimed, so a public Space can be claimed by the first
visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD` (step 4) or claim
the admin immediately after it goes public.
## Want persistence / multi-user later?
@@ -138,6 +138,38 @@ writing nothing to disk — use HTTPS repository URLs. Details by provider match
| `resources` | Optional `requests` / `limits` (`cpu` / `memory`) override. |
| `in_cluster` | Optional cluster-config source: `true` (in-cluster SA only), `false` (kubeconfig only), omit (try in-cluster, then kubeconfig). |
| `kubeconfig` | Optional kubeconfig path for the out-of-cluster fallback (env: `OMNIGENT_KUBERNETES_KUBECONFIG`). |
| `pvc_mounts` | Optional pre-created PersistentVolumeClaims mounted into every runner Pod — see [Persistent storage mounts](#persistent-storage-mounts-pvc_mounts). |
## Persistent storage mounts (`pvc_mounts`)
Runner Pods are ephemeral by design — the workspace lives on an `emptyDir` and
dies with the Pod. To expose durable data (datasets, model caches, shared
output directories) mount pre-created PersistentVolumeClaims:
1. Create the PV/PVC **in the runner namespace** (`omnigent-sandboxes`) out of
band — via your GitOps repo, with whatever backend your cluster provides
(NFS/SMB CSI drivers, SAN, cloud disks). Omnigent only references the claim;
it never creates volumes, so the server RBAC stays unchanged.
2. List the claims under `sandbox.kubernetes.pvc_mounts` (see
`sandbox-config.yaml`). Mount paths may not overlap `/home/omnigent`, the
OS directories, or their ancestors (e.g. `/home`, `/var`) — the server
rejects such config at startup.
Caveats:
- **Multiple runners share writable claims concurrently** — use a
`ReadWriteMany`-capable backend (NFS/SMB/CephFS) for anything writable, and
prefer `read_only: true` (the default) everywhere else: a writable shared
mount lets one session's agent read and modify what another session wrote,
and anything written there outlives the Pod and its launch token.
- Runner Pods run as uid/gid 1000660000 with `fsGroup`. NFS `root_squash` and
SMB ownership mapping must permit that identity (export to the uid, or use
CSI mount options like `uid=`/`gid=` for SMB); `fsGroupChangePolicy:
OnRootMismatch` avoids re-chowning large exports on every start.
- `ReadWriteOnce` claims pin all runners to one node — combine with
`node_selector` deliberately, or the second Pod sits `Pending`.
- A mount visible in the Pod is not automatically visible to a harness's own
OS-level sandbox (OmniBox path grants are separate).
To verify `host_config` end to end against a live cluster, run
`python tests/e2e/integrations/deploy/kubernetes/e2e_managed_host_config.py
@@ -45,5 +45,9 @@ data:
# resources: # runner Pod sizing (defaults: 0.5-2 cpu / 1-4Gi)
# requests: {cpu: "500m", memory: "1Gi"}
# limits: {cpu: "2", memory: "4Gi"}
# pvc_mounts: # pre-created PVCs (in the runner namespace) mounted into every runner Pod
# - claim_name: omnigent-datasets
# mount_path: /mnt/datasets
# read_only: true # default true; set false only for claims meant as shared scratch
# in_cluster: true # config source: true=in-cluster SA only, false=kubeconfig only, omit=try both
# kubeconfig: /path/to/config # out-of-cluster kubeconfig (env: OMNIGENT_KUBERNETES_KUBECONFIG)
+12 -9
View File
@@ -50,23 +50,26 @@ the secret and redeploy.
The first boot runs DB migrations over the network (~1 minute on Neon).
**Get the admin password:** the first boot prints it to the app log:
**Create the first admin.** No credentials are auto-generated. First boot
prints a "No admin yet" line pointing at your `*.modal.run` URL:
```bash
modal app logs omnigent
```
```
✓ Created initial admin account (accounts auth provider).
password: <generated>
```
Open that URL and use the web Create-admin form to pick your own username +
password, then invite teammates from **Members** in the web UI.
Log in as the admin and invite teammates from **Members** in the web UI.
> To set a known admin password instead, add
> `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD=<password>` to the
> To create the admin directly instead of claiming it through the web form,
> add `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD=<password>` to the
> `omnigent-deploy` secret before the first deploy.
> **Security note for public deployments:** `POST /auth/setup` is
> unauthenticated while no password-bearing account exists, so an instance
> exposed before you reach the Create-admin form can be claimed by the first
> visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`, or complete setup
> promptly after the deploy goes live.
### Modal-specific caveats
- **2 MiB WebSocket message cap.** Modal's ingress limits WebSocket
+12 -8
View File
@@ -47,14 +47,12 @@ steps below are validated end-to-end:
reference value simply hadn't propagated yet — **redeploy** and it resolves.
(To confirm, the app service should have a `DATABASE_URL` variable
referencing the Postgres service, e.g. `${{Postgres.DATABASE_URL}}`.)
3. **Get the admin password** from the first-boot **Deploy logs** (printed once;
idempotent — later boots don't reprint):
```
✓ Created initial admin account (accounts auth provider).
password: <generated>
```
It's also written to `/data/admin-credentials`.
4. Open the URL, log in as `admin`, invite teammates from **Members**.
3. **Create the first admin.** No credentials are auto-generated. The
first-boot **Deploy logs** print a "No admin yet" line pointing at your
`*.up.railway.app` URL (printed once; idempotent — later boots don't
reprint). Open that URL and use the web Create-admin form to pick your own
username + password.
4. Log in with the admin you just created, invite teammates from **Members**.
> **`HOST` is handled automatically.** Railway injects `HOST=[::]`, which a
> socket bind can't use and which Railway's IPv4 edge can't reach; the
@@ -69,6 +67,12 @@ steps below are validated end-to-end:
> pin a known admin password, set `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`
> before first boot.
> **Security note for public deployments:** `POST /auth/setup` is
> unauthenticated while no password-bearing account exists, so an instance
> exposed before you reach the Create-admin form can be claimed by the first
> visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`, or complete setup
> promptly after the deploy goes live.
## Use your own IdP instead (OIDC)
Prefer GitHub / Google / Okta login over built-in accounts? Switch the provider
+18 -13
View File
@@ -23,8 +23,9 @@ The `render.yaml` blueprint at the repo root defines:
- **omnigent-db** (`basic-256mb` managed Postgres) — `DATABASE_URL` is injected
into the service automatically
- **artifact-data** (10 GB persistent disk) — mounted at `/data` so server
config, first-boot credentials, cookie secrets, and agent artifacts survive
redeploys. Artifacts live under `/data/artifacts`.
config, the auto-minted cookie secret, and agent artifacts survive redeploys.
Artifacts live under `/data/artifacts`. (Account rows and password hashes
live in the managed Postgres, not on the disk.)
## Quickstart (built-in accounts — the default)
@@ -34,18 +35,22 @@ mints its own cookie secret and auto-detects its public URL from Render.
1. Click the Deploy to Render button above → **Apply**. Wait ~35 min for the
image pull + health check.
2. **Get the admin password:** open the service → **Logs** and find the
first-boot block:
```
✓ Created initial admin account (accounts auth provider).
password: <generated>
```
(also written to `/data/admin-credentials` on the disk; printed once).
3. Open your `https://<service>.onrender.com` URL, log in as the admin, and
invite teammates from **Members** in the web UI.
2. **Create the first admin.** No credentials are auto-generated. Open your
`https://<service>.onrender.com` URL — a fresh instance shows a
Create-admin form where you pick your own username + password. (First-boot
**Logs** also print a "No admin yet" line with that URL.)
3. Log in as the admin you just created, and invite teammates from **Members**
in the web UI.
> To set a known admin password instead of the generated one, add
> `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD` in the dashboard before first boot.
> To create the admin directly instead of claiming it through the web form
> (e.g. a headless deploy), add `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD` in the
> dashboard before first boot.
> **Security note for public deployments:** `POST /auth/setup` is
> unauthenticated while no password-bearing account exists, so an instance
> exposed before you reach the Create-admin form can be claimed by the first
> visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`, or complete setup
> promptly after the deploy goes live.
## Use your own IdP instead (OIDC)
+56 -50
View File
@@ -46,19 +46,12 @@
The Slack integration (`integrations/slack/`) is a standalone Socket-Mode
process that calls each user's Omnigent server over HTTP + SSE
(`OmnigentClient` / `OmnigentClientPool`). Today it sends **every request
unauthenticated**: the pool is *"one unauthenticated client per server URL"*
(`omnigent.py:337`), and any server with auth enabled returns 401, which the
bot converts into a dead-end *"authentication … isn't supported yet"* setup
error (`omnigent.py:23`, `setup.py:144`).
So the bot only works against auth-disabled servers, and when it does work the
server sees a single shared anonymous identity — it cannot tell one Slack user
from another, cannot scope permissions, and cannot audit who did what.
We want each Slack user's turns to reach the Omnigent server **as that user's
own authenticated identity**, without the Slack process ever handling the
user's Omnigent credentials.
(`OmnigentClient` / `OmnigentClientPool`). Each Slack user's turns must reach
the Omnigent server **as that user's own authenticated identity** — so the
server can scope permissions and audit who did what — **without the Slack
process ever handling the user's Omnigent credentials**. An unauthenticated
client can only reach auth-disabled servers, and would present one shared
anonymous identity the server can't distinguish per user.
## Topology and trust
@@ -85,28 +78,25 @@ Role mapping:
| Resource Owner | The Slack user, authenticating in their browser |
| Out-of-band channel | Slack (delivers the verification link only) |
## What already exists (reused, not rebuilt)
## Shared substrate (reused, not rebuilt)
RFC 8628 primitives are absent (no `device_code` / `user_code` /
`verification_uri` anywhere), but the substrate is all present:
The device grant builds on existing server primitives:
- **Poll-endpoint shape** — `POST /auth/cli-login` + `GET /auth/cli-poll` with
202-pending / 200-done / 410-expired semantics (`routes/auth.py:484`).
- **Atomic single-use token redemption** — `SqlAlchemyAccountStore.redeem_token`
uses `UPDATE … WHERE redeemed_at IS NULL` + rowcount so at most one caller
wins under concurrency (`accounts_store.py:329`). The new grant store copies
this pattern.
wins under concurrency (`accounts_store.py`). The grant store follows the
same pattern.
- **Session JWT minting** — `mint_session_token(user_id, secret, ttl, provider)`
(`oidc.py:53`), HS256 with `sub`/`iat`/`exp`/`provider`.
- **Bearer validation** — `UnifiedAuthProvider._check_cookie` already accepts
(`oidc.py`), HS256 with `sub`/`iat`/`exp`/`provider`.
- **Bearer validation** — `UnifiedAuthProvider._check_cookie` accepts
`Authorization: Bearer <jwt>` and validates the same claim shape
(`auth.py:477`). Delegated access tokens validate through this path unchanged.
- **Browser consent under accounts mode** — the `accounts` provider already
(`auth.py`). Delegated access tokens validate through this path unchanged.
- **Browser consent under accounts mode** — the `accounts` provider
establishes the browser identity via its session cookie; the consent page
runs behind it. (This is why the grant mounts in accounts mode only — see
the mount restriction below.)
- **Open-redirect hardening** — `_sanitize_return_to` (`routes/auth.py:150`) is
reused for the post-login bounce back to the consent page.
- **Open-redirect hardening** — `_sanitize_return_to` (`routes/auth.py`) guards
the post-login bounce back to the consent page.
## Design decisions (agreed)
@@ -120,14 +110,10 @@ RFC 8628 primitives are absent (no `device_code` / `user_code` /
only an authorized client can drive the flow. The **browser** endpoints
(consent GET / approve / deny) are never gated by it — the user's browser
doesn't hold the secret; their trust is the session cookie + Origin check.
Unset ⇒ endpoints stay public (backward compatible).
*History:* the secret was implemented, removed, then reintroduced as
opt-in. It was removed when the Slack client accepted a **user-supplied**
server URL — shipping a shared secret to an arbitrary user-typed host was a
secret-exfiltration/SSRF path. That objection is now gone: the Slack socket
server's target is a **fixed operator config** (`OMNIGENT_SERVER_URL`), not
a user-supplied URL, so the secret only ever travels to the trusted server.
Unset ⇒ endpoints stay public (backward compatible). Shipping the secret to
the Slack client is safe because its target is a **fixed operator config**
(`OMNIGENT_SERVER_URL`), not a user-supplied URL, so the secret only ever
travels to the trusted server.
2. **Refresh tokens** — short-lived access tokens (≤ 1 h) plus a rotating,
revocable refresh token, with a 30-day absolute grant lifetime. The Slack
server refreshes silently; a stolen access token expires quickly and a grant
@@ -149,15 +135,21 @@ RFC 8628 primitives are absent (no `device_code` / `user_code` /
verification_uri_complete, # verification_uri?user_code=XYZ
expires_in: 600, interval: 5 }
3. Slack server shows the verification link in the setup modal (initiator
only). The device_code is NOT included — it never leaves the server
pair; only the user_code (in verification_uri_complete) does.
3. Slack server shows the verification link (verification_uri_complete,
code prefilled for one-click) in the setup modal (initiator only),
plus the user_code so the user can confirm the match. The
device_code is NOT included — it never leaves the server pair.
4. User clicks → Omnigent consent page (verification_uri).
Browser authenticates via the server's accounts provider.
Page shows: "<client_id> is requesting permission to act as YOU
(alice@example.com) on this Omnigent server. [Approve] [Deny]"
plus a warning to approve only a login the user personally started.
The page REQUIRES a login started for THIS flow: if the browser's
session predates the grant (session iat < grant.created_at), it
bounces through the login page with ?reauth=1 — which forces a fresh
password entry even for an already-signed-in user — and returns here.
Once re-authenticated, the page shows: "<client_id> is requesting
permission to act as YOU (alice@example.com) on this Omnigent server.
[Approve] [Deny]" plus a warning to approve only a self-started login.
The forced re-auth means a grant can't be approved by one reflexive
click on a link the user didn't personally start (see threat #2).
5. User approves → the grant is bound to the authenticated identity
(alice@…). client_id is recorded for display/audit only, never as
@@ -197,9 +189,8 @@ 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 also **owns** `mint_delegated_token` and
`DELEGATED_SCOPE` (moved here from `oidc.py`, which retains only
`mint_session_token` / `mint_session_cookie`).
mount is gated. This router **owns** `mint_delegated_token` and
`DELEGATED_SCOPE`.
- `POST /oauth/device/authorize`**public** (rate-limited). Generates a
high-entropy `device_code` (`secrets.token_urlsafe`, stored **hashed**), a
@@ -275,9 +266,8 @@ HS256 shape (so `_check_cookie` accepts them) plus four delegated-only claims:
## Slack-side changes
- **`oauth.py` (new)** — device-authorize → post ephemeral link → poll token
endpoint (respecting `interval` / `slow_down`) → store tokens. Replaces the
`AuthRequiredError` dead-end.
- **`oauth.py`** — device-authorize → post ephemeral link → poll token
endpoint (respecting `interval` / `slow_down`) → store tokens.
- **`omnigent.py`** — attach `Authorization: Bearer` per
`(server_url, slack_user_id)`; on 401, refresh once and retry; on refresh
failure, surface a re-login prompt. `OmnigentClientPool` keys clients by
@@ -285,8 +275,8 @@ HS256 shape (so `_check_cookie` accepts them) plus four delegated-only claims:
- **`store.py`** — new `oauth_tokens` table `(team_id, user_id, server_url)`
access/refresh **encrypted at rest** (key from env / secret manager, never in
the DB). `/omnigent logout``POST /oauth/revoke` + local delete.
- **`setup.py`** — validation uses the user's token; auth-enabled servers become
supported rather than rejected.
- **`setup.py`** — validation uses the user's token, so auth-enabled servers
are supported.
- **`config.py`** — holds the local encryption key for token storage.
## Security analysis
@@ -294,7 +284,7 @@ HS256 shape (so `_check_cookie` accepts them) plus four delegated-only claims:
| # | Threat | Mitigation |
|---|--------|-----------|
| 1 | `device_code` leak → token theft | Never transits Slack or the user — only `verification_uri_complete` (a `user_code`) does. Stored hashed; single-use. |
| 2 | Link misdelivery / phishing another user | Link shown to the initiator only (in their own setup modal). Consent page names the exact Omnigent identity the grant will act as and the requesting `client_id`, and warns to approve only a self-initiated login. |
| 2 | Link misdelivery / phishing another user | Link shown to the initiator only (in their own setup modal). **Consent requires a login started FOR this flow: the consent page rejects a session whose `iat` predates the grant and bounces through the login page with `reauth=1`, forcing a fresh password entry even for an already-signed-in user.** So an attacker-initiated flow can't be approved by a single reflexive click — the victim must deliberately re-enter their password against a screen naming the exact Omnigent identity and requesting `client_id`. The gate is enforced on both the consent GET and the approve POST. |
| 3 | Anyone can initiate/poll (public client) | Cheap `pending` state grants nothing until an authenticated user approves. `POST /oauth/device/authorize` is rate-limited per client IP (10/60s → 429 `slow_down`); short (10 min) `device_code` expiry; `slow_down` enforced server-side on aggressive polling; expired grants purged opportunistically. |
| 4 | Slack SQLite exfiltration → mass impersonation | Tokens **encrypted at rest**; access tokens short-lived (≤ 1 h); refresh tokens revocable. Bounded, centrally killable window. |
| 5 | Compromised Slack server acts as all users (inherent to delegation) | Reduced scope (no admin), short TTL + refresh rotation, per-grant revocation, **absolute grant lifetime (30 d) enforced on refresh** so even an un-revoked grant dies, and an `act`-claim audit trail. |
@@ -315,6 +305,17 @@ token.
When no client secret is configured the endpoints are **public**, so
initiation is open — the defense is layered, not a gate:
- **Forced re-authentication at consent.** Consent requires a login started for
THIS flow: the consent page (and the approve POST) reject a session whose
`iat` predates the grant's `created_at` and bounce through the login page with
`reauth=1`, which forces a fresh password entry even for an already-signed-in
user. This defeats the reflex-approve variant of the attack — a victim handed a
one-click link (even one with the code prefilled) still can't bind the grant
without deliberately re-entering their password against a screen naming the
exact identity and client. (`device_auth.py` `_session_iat` + the
`reauth=1` bounce; `LoginPage.tsx` suppresses its already-signed-in
auto-return under `reauth=1`.) The prefilled one-click link is therefore
retained for convenience — the re-auth step, not code handling, is the gate.
- The consent page prominently **warns** the user to approve only a login they
personally started and to match the code shown by the application.
- The delegated scope excludes admin / user-management endpoints.
@@ -322,6 +323,11 @@ initiation is open — the defense is layered, not a gate:
grant self-expires even if never revoked.
- Initiation is rate-limited per IP; nothing is granted until a real user
authenticates and approves in their own browser.
- **Startup warning.** When the grant is mounted on a multi-user (accounts)
server with `OMNIGENT_DEVICE_CLIENT_SECRET` unset, the server logs a loud
warning at startup that the authorize endpoint is public — nudging the
operator to opt into the secret rather than leaving initiation open unknowingly
(`app.py`, at the device-router mount).
Setting `OMNIGENT_DEVICE_CLIENT_SECRET` closes initiation entirely to
unauthorized callers: without the matching `X-Omnigent-Client-Secret` header,
+68 -32
View File
@@ -518,15 +518,10 @@ folder), carrying the first-class `id` when one exists.
- ✅ Tests: `projectsApi` unit tests; reworked hook tests (resolve→file,
create-on-demand, archive+unfile+delete); sidebar/composer suites updated;
server union test. Empty folders read "No sessions".
-**Deferred (kept on the name/label path via dual-read):** the new-session
prefill state machine and the Settings archived-only project picker still read
the `omni_project` label; retiring label reads from the UI is gated on the
Phase 4 backfill. TS client types are not codegen-regenerated (hand-written
`projectsApi`).
### TODO
-**Benchmark (#3094).** Added `list_projects` (sidebar project list,
dual-read union) and `list_project_sessions` (`?project=` folder fetch)
### Done (Benchmark — #3094)
-**Latency journeys + corpus seeder.** Added `list_projects` (sidebar project
list, dual-read union) and `list_project_sessions` (`?project=` folder fetch)
latency journeys to `dev/benchmarks/omnigent`, mirroring the `list_sessions`
hot read path. The corpus seeder now also seeds first-class `projects` rows
and files a configurable fraction of sessions into them (`--projects`,
@@ -534,7 +529,10 @@ folder), carrying the first-class `id` when one exists.
empty project set, and the PR benchmark regression check runs on
`dev/benchmarks/**` changes. CRUD writes remain unbenchmarked (infrequent
single-row ops).
- 🚧 **Phase 2 — project defaults (P4a).** In progress, split across PRs:
### Done (Phase 2 — project defaults, P4a)
Complete, shipped across several PRs: default host/workspace/agent + an opt-in
worktree stored on the project and seeded into the new-chat composer.
-**Backend `config` column.** Added a nullable `config` column to
`projects` (migration `b3c4d5e6f7a8`, additive with a clean downgrade) and
plumbed it through the store/entity/API. `config` is an **opaque JSON
@@ -545,38 +543,76 @@ folder), carrying the first-class `id` when one exists.
distinguishes `config=None` (leave unchanged) from `config={}` (clear), so
a rename never wipes stored defaults. Exposed on `ProjectObject` /
`CreateProjectRequest` / `UpdateProjectRequest` (openapi regenerated).
- **Seed defaults into the new-chat dialog.** Read the stored `config` and
pre-fill host/workspace/harness/model in the composer, always overridable,
silently dropping any hint that isn't currently satisfiable (e.g. the
default host is offline). Land the deferred backend hardening here, once the
config key vocabulary firms up (from the #3108 review): (a) bound the
serialized `config` size on create/update — the value is persisted verbatim
and reflected back, so an unbounded blob is a mild storage/response-size
amplifier; (b) make `_decode_config` defensive — coerce a non-dict blob
(future writer / manual DB edit) back to `{}` rather than returning it raw.
-**Replace the inference-based prefill (PR #2133).** That merged PR
prefills the composer by *inferring* defaults from the project's newest
- **Settings editor.** A "Project settings" dialog
(`web/src/shell/ProjectSettingsDialog.tsx`, reached from the project-folder
kebab menu) writes a project's `config`: host, working directory, default
agent, and an opt-in "Random worktree" toggle. The `config` shape the client
owns is `{ host_id?, workspace?, agent_id?, use_worktree? }`. Fields are
optional (an unset one stores no key); an all-default dialog clears to `{}`.
Worktrees are **opt-in** — the toggle defaults OFF and only an explicit ON is
stored as `use_worktree: true` (matching "worktrees are opt-in at create
time"). The host/agent pickers and filesystem browser reuse the composer's
components.
-**Seed defaults into the new-chat dialog.** The composer reads the stored
`config` and pre-fills host / working directory / agent, always overridable,
silently dropping any hint that isn't currently satisfiable (e.g. the default
host is offline, or the configured agent is no longer registered). An unset
field falls through to the composer's generic defaults (last host, recent
workspace, last-used agent). The prefill machine waits while the projects
list (name → id) or the config query is still loading, so a generic default
can't win the race. An opt-in worktree (`use_worktree: true`) generates a
fresh `worktree-<hex>` branch once the workspace is in place and confirmed a
git repo — including for empty projects, where the workspace comes from the
config or the home-fallback.
-**Backend `config` hardening (from the #3108 review).** Both landed in
`stores/project_store/sqlalchemy_store.py`: (a) `_encode_config` bounds the
serialized `config` at 64 KiB (`_CONFIG_MAX_SERIALIZED_LEN`) and raises
`INVALID_INPUT` past it — the value is persisted verbatim and reflected back,
so an unbounded blob is a mild storage/response-size amplifier; (b)
`_decode_config` coerces a non-dict blob (future writer / manual DB edit) back
to `{}` rather than returning it raw, so callers can always treat config as a
mapping.
-**Replaced the inference-based prefill (PR #2133).** That merged PR
prefilled the composer by *inferring* defaults from the project's newest
session (host/agent/repo + a fresh worktree branch) — an explicit non-goal
workaround for the absence of stored project defaults. Once the dialog reads
the stored `config`, retire that inference path
(`web/src/shell/projectPrefill.ts` and `useNewestProjectSession`) in favor
of the stored defaults, so there's one source of truth for a project's
defaults instead of guessing from history.
-**Phase 3 — memory & context (P4b/P4c)** — new `project_memory` /
`project_context` tables + agent read/write + injection (§8.2/§8.3).
-**Phase 4 — label consolidation (deferred; not required for the feature).**
workaround for the absence of stored project defaults. Now that the dialog
reads the stored `config`, the inference path is retired: `projectPrefill.ts`
is collapsed to config-only seeding, and `useNewestProjectSession` (plus its
`["project-newest-session"]` cache invalidations) is removed. Stored config
is the single source of truth for a project's defaults; a project with no
config prefills nothing project-specific (just the generic defaults).
### TODO
**Postponed.** Phases 12 (first-class projects + defaults) are shipped. Phases 3
and 4 below are **not scheduled**, each on a different trigger: Phase 3 waits for
customer evidence that project memory/context is wanted; Phase 4 waits until
telemetry shows most clients have migrated to a version that writes `project_id`
(so retiring the label path is safe). Both are additive and independent, so either
can start whenever its trigger lands.
- ⏸️ **Phase 3 — memory & context (P4b/P4c)** — new `project_memory` /
`project_context` tables + agent read/write + injection (§8.2/§8.3). Postponed:
the largest remaining chunk and the one with open design questions (§14 Q5/Q6);
wait for customer evidence that cross-session project memory/context is wanted
before committing to a storage + agent-surface design.
- ⏸️ **Phase 4 — label consolidation (postponed; not required for the feature).**
Because the server dual-reads (a session is "in project X" if it has *either* a
`project_id` *or* the `omni_project` label — see §13), the first-class feature
works end-to-end without touching the label path, so this whole phase is
deferred and may never be needed. Two steps, both optional:
deferred and may never be needed. Postponed until telemetry shows most clients
have migrated to a version that writes `project_id` — retiring the label path is
only safe once old label-writing clients are gone. Two steps, both optional:
- **Label → `project_id` backfill** — a **separate one-off command/migration**
converting existing `omni_project` label-projects (real production data) into
`projects` rows. Gives a clean single source of truth, but dual-read means it
is **not mandatory** — only needed if/when we decide to retire the label path.
- **Retire the label path** — remove the `omni_project` reads/writes and the
`?project=<name>` filter. Last step, if ever; do only after the backfill has
run and telemetry shows no client still writes labels. Keeping the label path
is cheap, so treat retirement as opportunistic cleanup, not a milestone.
`?project=<name>` filter. This includes the one UI reader still on the label
path: the Settings archived-only project picker
(`fetchAllArchivedProjectNames`) derives its options from the `omni_project`
label. Last step, if ever; do only after the backfill has run and telemetry
shows no client still writes labels. Keeping the label path is cheap, so
treat retirement as opportunistic cleanup, not a milestone.
## 13. Backwards compatibility (mixed server / client versions)
+60 -4
View File
@@ -106,6 +106,7 @@ swap-on-access:
| `https_basic` | `Authorization: Basic b64(user:<real>)` | swap-on-access (optional `env:`) | Generic Basic auth; `username` defaults to `x-access-token`. |
| `git_https` | `Authorization: Basic b64(user:<real>)` | swap-on-access | Preset for git-over-HTTPS; nothing in the sandbox. |
| `gh_basic` | Basic for git host, `token` for api host | swap-on-access for git; `GH_TOKEN`/`GITHUB_TOKEN` env for api | Preset for GitHub CLI + git; defaults to `github.com` + `api.github.com`. |
| `databricks_cli` | `Authorization: Bearer <real>` per workspace host | placeholder `.databrickscfg` file (one `oa_cred_*` per profile) | Preset for the Databricks CLI; takes `profiles` (+ optional `default`). See below. |
Common fields: `target`/`targets` (host + optional path glob — only the
host binds the credential; path scoping is delegated to `egress_rules`),
@@ -152,6 +153,54 @@ parser) that rejects unknown keys, enforces exactly one source key, and
checks POSIX env-var names — then converts to the `CredentialSourceSpec`
dataclass the runtime consumes.
### `databricks_cli` — profile-keyed, refreshing, file-materialized
The Databricks CLI is a fifth type that differs from the four host-keyed
primitives above:
- **Profile-keyed, not host-keyed.** It takes `profiles: [name, ...]`
(and optional `default`) instead of `target`/`targets`/`source`. The
workspace host behind each profile is only known once the parent
resolves it, so profiles are carried on `DatabricksProxySpec` rather
than in the host-keyed `entries` list. Only the listed profiles are
proxied; every other profile is invisible to the sandbox.
- **File materialization, not env injection.** The CLI gates on a local
credential (like `gh`) and `DATABRICKS_HOST`/`DATABRICKS_TOKEN` carry
only one workspace, so per-profile selection needs a config file. The
parent writes a placeholder-only `.databrickscfg` into the sandbox
scratch dir — one `[profile]` section per profile with the real `host`
and a synthetic `token = oa_cred_*` — and points `DATABRICKS_CONFIG_FILE`
at it (and `DATABRICKS_CONFIG_PROFILE` when `default` is set). The CLI
emits `Authorization: Bearer oa_cred_*`, which the proxy swaps per host.
- **Refreshing secret.** Databricks profiles are usually OAuth
(`auth_type = databricks-cli`) with a ~1h token, so the rewrite rule
holds a `DatabricksProfileTokenProvider` (via the SDK) instead of a
static secret. The proxy calls `rule.resolve_secret()` on each swap; the
provider re-mints via `Config.authenticate()` at most once per throttle
window (the SDK caches in-memory and only re-shells near expiry), so a
long session survives token expiry. The provider requires the
`databricks` extra and fails loud if it is missing.
- **Egress is operator-listed.** Reaching a workspace requires its host in
`egress_rules` (`* <host>/**`), the same as every other credential-proxy
type. The proxy does not widen egress on its own — an earlier draft
auto-added resolved hosts, but that was dropped to keep egress behavior
consistent across types (the operator declares every reachable host).
- **Linux only.** The `databricks` CLI is a Go binary and Go on macOS
ignores `SSL_CERT_FILE`, so `databricks_cli` is rejected on
`darwin_seatbelt` (same rationale as `gh_basic`); use `linux_bwrap`.
```yaml
os_env:
sandbox:
type: linux_bwrap
egress_rules:
- "* pypi.org/**"
credential_proxy:
- type: databricks_cli
profiles: [dbc-adb7b1a3-9097, oss]
default: dbc-adb7b1a3-9097
```
## Internal model
`omnigent/inner/datamodel.py`:
@@ -162,8 +211,11 @@ dataclass the runtime consumes.
type compiles down to: `host`, `scheme` (`basic`/`bearer`/`token`),
`source`, `username | None`, `inject_env: list[str]` (empty for
swap-on-access; populated only by the opt-in `env` shim).
- `CredentialProxySpec` — list of entries; attached to
- `CredentialProxySpec` — list of entries plus an optional
`databricks: DatabricksProxySpec`; attached to
`OSEnvSandboxSpec.credential_proxy`.
- `DatabricksProxySpec` / `DatabricksProfileBinding` — the profile list
(+ `default`, `config_env`) for the `databricks_cli` type.
The parser (`omnigent/spec/parser.py`, `_parse_credential_proxy`)
validates each raw entry with a pydantic boundary model
@@ -186,9 +238,13 @@ backend allow-list, and the `gh_basic`-on-macOS guard.
- `helper_env_updates` — synthetic values for each `inject_env` var
(empty for swap-on-access entries),
- `rewrites: list[CredentialRewriteRule]` — `(host, scheme,
real_secret, synthetic | None, username)` for the proxy. `synthetic`
is `None` for swap-on-access entries; it is minted (and the matching
placeholder injected) only when the entry sets `env`.
real_secret | secret_provider, synthetic | None, username)` for the
proxy. `synthetic` is `None` for swap-on-access entries; it is minted
(and the matching placeholder injected) only when the entry sets `env`
(or, for `databricks_cli`, per proxied profile). A rule carries either
a static `real_secret` or a refreshing `secret_provider` — the proxy
calls `rule.resolve_secret()` — plus, for `databricks_cli`,
`sandbox_files` (the placeholder `.databrickscfg`).
The real secret lives **only** in the parent process and the proxy's
in-memory rewrite table. It is never serialized into the
+134 -56
View File
@@ -56,25 +56,32 @@ Every blocker is **imperative per-harness dispatch** that branches on
`harness_name == "<x>-native"` or `native_agent.key == "<x>"` and does an inline
`import omnigent.<x>_native`. Grouped by hub:
### 1. The runner — `omnigent/runner/app.py` (~19.7k lines) — the epicenter
### 1. The runner — `omnigent/runner/app.py` (~10.1k lines) + `omnigent/runner/native/orchestration.py` (~6.5k) — the epicenter
Five separate 11-branch chains plus their handlers:
Phase 0 (#3148) moved the native *builders and mirrors* out of `app.py` into
`omnigent/runner/native/orchestration.py` (re-exported through
`omnigent/runner/native/__init__.py`), shrinking `app.py` from ~20.1k to
~10.1k lines. The imperative per-harness *dispatch* still lives in `app.py`;
it now calls the imported builders instead of locally-defined ones. The
coupling left to untangle:
- **Spawn-env dispatch** (`~:8887`, again `~:14124`): `if harness_name ==
"<x>-native": from omnigent.<x>_native_bridge import build_<x>_native_spawn_env`.
- **Launch dispatch** (`~:9015`): 11 branches → `_auto_create_<x>_terminal(...)`.
- **`_auto_create_<x>_terminal`** functions (11 of them) — each imports its own
`<x>_native_bridge` / `<x>_native_forwarder` / `<x>_native_permissions` and
wires the transcript forwarder + permission/usage/compaction mirrors. This is
the dominant blocker: e.g. `_supervise_cursor_native_bridges`,
- **Spawn-env dispatch** (`app.py`, 11 arms): `if harness_name ==
"<x>-native" and spawn_env is None: ... build_<x>_native_spawn_env`.
- **Launch dispatch** (`app.py`, 11 arms) → `_auto_create_<x>_terminal(...)`.
- **`_auto_create_<x>_terminal`** functions (11 of them) — now in
`runner/native/orchestration.py`; each imports its own `<x>_native_bridge` /
`<x>_native_forwarder` / `<x>_native_permissions` and wires the transcript
forwarder + permission/usage/compaction mirrors, alongside the
`_supervise_*_bridges` mirrors (`_supervise_cursor_native_bridges`,
`_supervise_goose_native_bridges`, `_supervise_hermes_native_bridges`,
`_supervise_qwen_native_bridges`.
- **Interrupt dispatch** (`~:15169`) → `_handle_<x>_native_interrupt`.
- **Stop dispatch** (`~:15262`) → `_handle_<x>_native_stop`.
- **Terminal-route dispatch** (`~:15840`): `terminal_name == "<x>"` →
`_supervise_qwen_native_bridges`). Still the dominant blocker — the split
gave it a home but the `if key ==` dispatch that reaches it is unchanged.
- **Interrupt / stop dispatch** (`app.py`) → `_handle_<x>_native_interrupt` /
`_handle_<x>_native_stop` closures (kept in `app.py`, not extracted).
- **Terminal-route dispatch** (`app.py`): `terminal_name == "<x>"` →
`_auto_create_<x>_terminal`.
- Plus the 11 `*_NATIVE_TERMINAL_ROLE` imports (`~:60`) and the cost-popup
bridge-dir dispatch (`~:12779`).
- Plus the 11 `*_NATIVE_TERMINAL_ROLE` imports and the cost-popup bridge-dir
dispatch (both in `app.py`).
### 2. Native launch — `omnigent/cli.py` (~14.5k lines)
@@ -244,18 +251,23 @@ Done:
(`sessions.py`, now 7.8k) that star-imports an impl package
(`omnigent/server/routes/_sessions/`: `common.py`, `helpers.py`,
`orchestration.py`). `create_sessions_router` stays in the facade.
Remaining (the two files still over 10k — can proceed in parallel):
- **`runner/app.py`** (~20.1k lines — the epicenter) → extract native
orchestration into `omnigent/runner/native/` (e.g. `terminals.py` for the
`_auto_create_*` builders, `supervise.py` for the `_supervise_*_bridges`
mirrors, `interrupt.py` for interrupt/stop handlers). `app.py` keeps the
(soon-to-be registry-driven) dispatch entry points and imports from the new
package.
- **`tests/runner/test_app_sessions_native.py`** (~19.0k lines) → split the
native-dispatch test suite along the same seams as the `runner/native/`
extraction so each module's tests sit beside it.
- **`runner/app.py`** ✅ (#3148) — the native builders and bridge mirrors
(`_auto_create_*_terminal`, `_supervise_*_bridges`, the transcript-forwarder
task registry, cost-popup repop tasks) moved into
`omnigent/runner/native/orchestration.py` (~6.5k lines), re-exported through
`omnigent/runner/native/__init__.py`; `app.py` imports them. `app.py` dropped
from ~20.1k to ~10.1k lines. Landed as a single `orchestration.py` rather than
the proposed `terminals.py` / `supervise.py` / `interrupt.py` three-way split —
a further sub-split can happen when the seam lands if the module stays hot.
The `if key ==` / `if harness_name ==` dispatch arms and the interrupt/stop
handler closures stayed in `app.py` (they are the entry points Phase 1
rewrites), so `app.py` is still marginally over the 10k target.
- **`tests/runner/test_app_sessions_native.py`** ✅ (#3149) — the ~19.0k-line
monolith was split into nine concern-scoped modules
(`test_app_sessions_native_{events_lifecycle,events_options,supervision,
terminal_routing,terminals_autocreate,terminals_runtime,wake_forwarders,
workflow_init,workflow_messages}.py`) plus a shared `tests/runner/conftest.py`
(~0.7k) holding the scaffolding. Each new file is under 3k lines.
Deferred (under the 10k target already; fold into Phase 1 when the seam lands):
@@ -263,40 +275,103 @@ Deferred (under the 10k target already; fold into Phase 1 when the seam lands):
`resume_dispatch.py` (they duplicate its dispatch anyway) as the first step of
collapsing the two resume paths into one.
### Current state (verified 2026-07-24, at `main` `59e6b70e`)
Grounding the plan in the actual tree, not just the coupling inventory above:
- **Data model is ready.** `NativeCodingAgent` (`harness_plugins.py:49`) is 11
frozen rows; `HarnessContribution` (`:70`) has `native_harnesses` /
`native_agents` but **no** `native_providers` field yet;
`native_coding_agents.py` already indexes rows by agent_name / harness /
wrapper_label / terminal_name. `HarnessCapabilities`
(`harness_capabilities.py:79`) exists with an optional-field extension
pattern (`steering`, `live_queue`, `images`, `compaction`) but **no**
`fork_history` axis.
- **`run_<x>_native` is already near-uniform.** All 11 are `(*, server,
session_id, <x>_args, resume_picker=..., ...)`. The divergence is only the
pass-through arg *name* plus four harnesses carrying extra kwargs: claude
(`command`, `use_claude_config`), codex (`command`, `model`, `prompt`),
antigravity (`command`, `model`, `permission_mode`), opencode (`model`). So
signature normalization is a keyword-rename with a threaded `**extra`, not a
rewrite — lower risk than "Signature uniformity" under Risks suggested.
- **Coverage is uneven across hubs** (a correctness smell the seam fixes):
`resume_dispatch._dispatch_wrapper` covers 10, `chat.py`
`_redirect_native_resume_if_needed` only 6 (missing opencode/goose/hermes/
antigravity/qwen), runner interrupt handlers 9, stop handlers 7. Routing
everything through one resolver *normalizes* coverage.
- **The dead `_HARNESS_MODULES` literal still exists** (`runtime/harnesses/
__init__.py:36`, overwritten at `:152`) — not yet deleted.
- **`harness_catalog()` (`harness_plugins.py:899`) does not emit native-agent
rows** — only `{id, label, capabilities?, setup_steps?}` per harness, no
`agent_name` / `wrapper_label` / icon. The web is still 100% literals.
Roughly **60+ hardcoded duplication points across ~12 Python + 6 TS files**
plus the five dispatch hubs remain.
### Phase 1 — Internal provider seam (core-only)
1. Add `NativeHarnessProvider`, `native_providers` field, accessors, and
`omnigent/native_dispatch.py` resolver.
2. Populate the built-in contribution with one provider per native agent,
pointing at the existing `omnigent.<x>_native` functions.
3. Normalize `run_<x>_native` to the uniform keyword signature (with aliases).
4. Rewrite each hub (table above) to resolve through the registry. Delete the
`if key ==` chains and the dead `_HARNESS_MODULES` literal.
5. Derive the §5 enumerations from `native_agents()` / capabilities.
6. Keep the validator rejecting community native metadata — nothing external
yet. All existing native harnesses now run *through* the seam. This is the
correctness-critical phase; the test bar is "every native harness behaves
identically before/after."
Built-ins keep living in core but route through the generic seam. The test bar
for every PR here is **"every native harness behaves identically before/after"**
— lean on the split native test suite (#3149) and the native e2e skills. The
validator keeps rejecting community native metadata throughout Phase 1.
| PR | Scope | Key files | Depends on | Risk | Est. |
|---|---|---|---|---|---|
| **1.1 Provider model + resolver** | Add `NativeHarnessProvider` (import-path strings), the `native_providers` field + accessors, and `omnigent/native_dispatch.py` (lazy `importlib` resolver, cached per path). Populate 11 built-in providers pointing at existing `omnigent.<x>_native` functions. Purely additive — no hub rewired yet. | `harness_plugins.py`, new `native_dispatch.py` | — | Low | 12d |
| **1.2 Signature normalization** | Give `run_<x>_native` a uniform `extra_args` spelling with a back-compat `<x>_args` alias (one-release deprecation per CLAUDE.md — name the target release). Decide the `**extra` protocol for the four special-kwarg harnesses (claude/codex/antigravity/opencode). | 11 `omnigent/<x>_native.py`, `native_dispatch.py` | 1.1 | LowMed (mechanical ×11) | 23d |
| **1.3 Resume hubs** | Collapse `resume_dispatch._dispatch_wrapper` (10 arms) and the 6 `chat.py` `_run_<x>_native_resume_redirect` helpers into one `resolve(provider.run_native)(...)` path. Deletes the redirect helpers and normalizes the 10-vs-6 coverage gap. | `resume_dispatch.py`, `chat.py` | 1.1, 1.2 | Med | 2d |
| **1.4 CLI subcommands** | Replace the 11 hand-written `@cli.command` funcs in `cli_native.py` with a loop over `native_agents()`, registering one Click command each; make `_reject_native_on_windows` a registry-driven guard. Wrinkle: per-command options (`--model`, `--command`) must come off provider/row metadata. | `cli_native.py`, `cli.py` | 1.1, 1.2 | Med | 23d |
| **1.5 Runner launch + terminal-route** | The epicenter. Replace spawn-env (22 arms), launch (11 + 3 elif), and terminal-route (11) dispatch in `app.py` with `resolve(provider.auto_create_terminal / spawn_env_builder)(...)`. **Preserve the `_supervise_*_bridges` forward-cursor / restart / double-post invariants exactly.** Likely splits into 1.5a spawn-env and 1.5b launch+route. | `runner/app.py`, `runner/native/orchestration.py` | 1.1, 1.2 | **High** | 46d |
| **1.6 Runner interrupt/stop** | Route interrupt/stop through `resolve(provider.interrupt_handler / stop_handler)`; fill the 9/7 coverage gaps so every native has both paths. | `runner/app.py` | 1.1 | Med | 2d |
| **1.7 Seeding loop** | Replace the 26 `_ensure_default_<x>_agent` / `_build_<x>_native_bundle` touchpoints in `server/app.py` with a loop materializing via `provider.materialize_agent_spec`. **`builtin_agent_id` output must stay byte-identical** so redeploy doesn't orphan seeded agents — pin this with a test. | `server/app.py`, `db/utils.py` | 1.1 | Med | 23d |
| **1.8 Derive enumerations** | Add a `fork_history: Literal["none","rebuild","preamble"]` axis to `HarnessCapabilities`; derive the §5 frozensets/dicts from `native_agents()` / capabilities (8 files, ~35 sets); delete the dead `_HARNESS_MODULES` literal. | `harness_capabilities.py`, `_omnigent_compat.py`, `harness_readiness.py`, `harness_install.py`, `model_override.py`, `model_catalog.py`, `_sessions/common.py`, `resource_registry.py`, `runtime/harnesses/__init__.py`, `tests/test_harness_capabilities.py` | 1.1 | Med | 23d |
After 1.1 + 1.2 land, PRs 1.31.8 touch mostly disjoint hubs and can proceed in
parallel. **Phase 1 subtotal: ~1725 engineer-days.**
### Phase 2 — Open to community packages
1. Flip `_validate_community_contribution` to positive validation.
2. Extend `GET /v1/harnesses` (`harness_catalog()`) to emit native-agent rows +
capabilities.
3. Drive the web off `/v1/harnesses`: delete the `nativeCodingAgents.ts`
literals, `forkHarness.ts`, and the `AgentCard` icon switch in favor of
server-supplied metadata (icon can be a capability/label field).
4. Document the native checklist in `designs/harness-plugin-interface.md`
(extend § "Native TUI Harnesses").
5. Ship an example native plugin (`examples/` or a sibling `omnigent-foo-native`)
to prove the contract end to end.
Only starts once Phase 1 has every built-in running *through* the seam.
| PR | Scope | Key files | Depends on | Risk | Est. |
|---|---|---|---|---|---|
| **2.1 Validator flip** | Replace the hard reject in `_validate_community_contribution` with positive validation: every `native_agent.key` has a matching `native_provider.key`; provider import paths start with `COMMUNITY_MODULE_PREFIX`; identity values don't collide (`_native_agent_identity_values` already checks this); `run_native` + `auto_create_terminal` are non-empty. | `harness_plugins.py` | 1.1 | LowMed | 1d |
| **2.2 `/v1/harnesses` native rows** | Extend `harness_catalog()` to emit native-agent rows + capabilities (`agent_name`, `wrapper_label`, `fork_history`, icon/label field), so the web has a server source of truth. | `harness_plugins.py`, `server/routes/harnesses.py` | 1.8 | Low | 2d |
| **2.3 Web off the endpoint** | Delete the `nativeCodingAgents.ts` literals + `HARNESS_ALIASES`, the `forkHarness.ts` sets (`NATIVE_REBUILD_HARNESSES` / `PREAMBLE_FORK_HARNESSES` now come from `fork_history`), the `AgentCard` icon switch, and the wrapper-label literals in `sessionStop.ts` / `sessionCapabilities.ts` / `codexPlanMode.ts` — all driven by `/v1/harnesses`. Needs a **demo (screenshots/recording)** per CLAUDE.md; likely splits into 2.3a fork/capabilities data-plumb and 2.3b icon/label rendering. | `web/src/lib/*`, `web/src/components/AgentCard.tsx` | 2.2 | MedHigh (largest FE) | 46d |
| **2.4 Docs + example plugin** | Extend `designs/harness-plugin-interface.md` § "Native TUI Harnesses" with the native checklist, and ship an example native plugin (`examples/` or a sibling `omnigent-foo-native`) proving the contract end to end. | `designs/harness-plugin-interface.md`, `examples/` | 2.1, 2.2 | LowMed | 23d |
**Phase 2 subtotal: ~912 engineer-days.**
### Effort summary
- **Phase 1** (internal seam): ~1725 engineer-days.
- **Phase 2** (community + web): ~912 engineer-days.
- **Total: ~2637 engineer-days** of focused work across ~12 PRs (splittable to
~14 with 1.5 and 2.3 breaking in two). Folding in review cycles, CI, and
runner e2e validation, that is realistically **~23 calendar months** done
alongside other work. The critical path is 1.1 → 1.2 → 1.5 (runner) →
2.2 → 2.3 (web); the risk center is **PR 1.5**, where the `_supervise_*_bridges`
invariants live.
### Implementation progress
Append-only ledger — one line per PR as it opens, updated to `landed` on merge.
The plan tables above stay the stable target; this tracks what has actually
shipped. **12 PRs total** (Phase 1: 1.11.8, Phase 2: 2.12.4).
| PR | Status | Link |
|---|---|---|
| 1.1 Provider model + resolver | in review | #3239 |
## Risks and open questions
- **Runner extraction is the risk center.** The `_supervise_*_bridges` mirrors
hold subtle forward-cursor / restart / double-post invariants (see the
transcript-forwarder registry at `runner/app.py:302`). Phase 0 must preserve
these exactly; lean on the existing native e2e skills
`_AUTO_FORWARDER_TASKS` transcript-forwarder registry, now in
`runner/native/orchestration.py`). Phase 0's move (#3148) preserved these
behaviorally — verified by the split native test suite (#3149) — so the
remaining risk shifts to Phase 1, where the dispatch that reaches these
mirrors gets rewritten. Lean on the existing native e2e skills
(`claude-native-ui:build-omnigent`, `pi-native-e2e-dev`, etc.).
- **Signature uniformity.** Not every native launcher is trivially uniform
(opencode has a cold-boot app-server path, codex has WS JSON-RPC). The
@@ -315,8 +390,11 @@ Deferred (under the 10k target already; fold into Phase 1 when the seam lands):
## Bottom line
The data model is ready; the work is untangling native orchestration from five
`runner/app.py` chains and four other hubs into a `NativeHarnessProvider`
behavior seam, then flipping the validator. Do the file split (Phase 0) first so
the seam lands in small, reviewable modules, then the core-only seam (Phase 1),
then community enablement (Phase 2).
The data model is ready and Phase 0 (the file splits) has landed. The remaining
work is untangling native orchestration from five `runner/app.py` chains and
four other hubs into a `NativeHarnessProvider` behavior seam, then flipping the
validator — sequenced as ~12 PRs (Phase 1: 1.11.8 core-only; Phase 2: 2.12.4
community + web), ~2637 engineer-days total. Start with the additive foundation
(1.1 provider model + resolver), which unblocks everything; the risk center is
1.5 (runner launch/terminal-route), where the `_supervise_*_bridges` invariants
live.
+86
View File
@@ -0,0 +1,86 @@
# Curated baseline for dev/lint/lint_no_hardcoded_models.py.
# Format: <path> <model-id> <allowed-count>
# Keep counts minimal; new model pins should move behind provider/catalog resolution.
.github/actions/integration-run/action.yml databricks-gpt-5-4-mini 1
.github/actions/integration-run/action.yml databricks-gpt-5-5 1
.github/actions/run-omnigent-agent/action.yml databricks-claude-opus-4-8 1
.github/scripts/ci/backcompat-pairwise-matrix.sh databricks-gpt-5-4-mini 1
.github/scripts/ci/integration-matrix.sh databricks-gpt-5-4-mini 1
.github/workflows/auto-assign-reviewer.yml databricks-claude-sonnet-4-6 1
.github/workflows/doc-sync.yml databricks-claude-opus-4-8 1
.github/workflows/e2e-ui-required.yml databricks-gpt-5-4 1
.github/workflows/feature-blog.yml databricks-gemini-3-pro-image 1
.github/workflows/flake-stress-e2e.yml databricks-gpt-5-4-mini 1
.github/workflows/flake-stress-e2e.yml databricks-gpt-5-5 1
.github/workflows/issue-triage.yml databricks-claude-sonnet-4-6 1
.github/workflows/polly-review.yml databricks-claude-opus-4-8 1
.github/workflows/polly-review.yml databricks-gpt-5-5 1
.github/workflows/security-triage.yml databricks-claude-sonnet-4-6 1
.github/workflows/vscode-release-pr.yml databricks-claude-sonnet-4-6 1
examples/kimi_hello.yaml kimi-k2-turbo 1
omnigent/chat.py databricks-gpt-5-4 1
omnigent/cli_config.py claude-opus-4-5-20251101-v1:0 1
omnigent/codex_native_app_server.py databricks-gpt-5-5 1
omnigent/cursor_native.py claude-opus-4-5 1
omnigent/cursor_native.py claude-opus-4-6 1
omnigent/cursor_native.py claude-opus-4-7 1
omnigent/cursor_native.py claude-opus-4-8 1
omnigent/cursor_native.py claude-sonnet-4-5 1
omnigent/cursor_native.py claude-sonnet-4-6 1
omnigent/cursor_native.py gpt-5.2 1
omnigent/cursor_native.py gpt-5.2-codex 1
omnigent/cursor_native.py gpt-5.3-codex 1
omnigent/cursor_native.py gpt-5.4 1
omnigent/cursor_native.py gpt-5.5 1
omnigent/inner/codex_executor.py databricks-gpt-5-5 1
omnigent/inner/codex_executor.py gpt-5.4-mini 1
omnigent/inner/databricks_executor.py databricks-claude-sonnet-4-6 1
omnigent/inner/open_responses_sdk.py gpt-5.3-codex 1
omnigent/inner/openai_agents_sdk_executor.py databricks-gpt-5-5 1
omnigent/inner/openai_agents_sdk_executor.py gpt-5.3-codex 1
omnigent/inner/pi_executor.py databricks-claude-opus-4-8 1
omnigent/inner/pi_executor.py databricks-claude-sonnet-4-5 1
omnigent/inner/pi_executor.py databricks-claude-sonnet-4-6 1
omnigent/inner/pi_executor.py databricks-gpt-5-4 1
omnigent/inner/pi_executor.py databricks-gpt-5-4-mini 1
omnigent/inner/pi_executor.py databricks-gpt-5-5 1
omnigent/inner/pi_executor.py databricks-gpt-5-5-pro 1
omnigent/kiro_native.py claude-haiku-4.5 1
omnigent/kiro_native.py claude-sonnet-4 1
omnigent/kiro_native.py claude-sonnet-4.5 1
omnigent/kiro_native.py deepseek-3.2 1
omnigent/llms/context_window.py o1 1
omnigent/llms/context_window.py o3 1
omnigent/llms/context_window.py o4 1
omnigent/model_catalog.py claude-fable-5 1
omnigent/model_catalog.py claude-haiku-4-5 1
omnigent/model_catalog.py claude-opus-4-8 1
omnigent/model_catalog.py claude-opus-5 1
omnigent/model_catalog.py claude-sonnet-4-6 1
omnigent/model_catalog.py claude-sonnet-5 1
omnigent/model_catalog.py gpt-5.4 1
omnigent/model_catalog.py gpt-5.4-mini 1
omnigent/model_catalog.py gpt-5.5 1
omnigent/onboarding/databricks_config.py databricks-claude-opus-4-8 1
omnigent/onboarding/providers/__init__.py claude-opus-4-8 1
omnigent/onboarding/providers/__init__.py gpt-5.5 1
omnigent/onboarding/providers/__init__.py kimi-k2.6 1
omnigent/onboarding/wizard.py databricks-gpt-5-4 1
omnigent/onboarding/wizard.py gpt-4o 1
omnigent/opencode_native_provider.py databricks-claude-sonnet-4-6 1
omnigent/pi_native_credentials.py databricks-claude-sonnet-4-6 1
omnigent/policies/builtins/routing.py databricks-claude-opus-4-6 1
omnigent/policies/builtins/routing.py o3 1
omnigent/server/smart_routing.py databricks-claude-haiku-4-5 3
omnigent/server/smart_routing.py databricks-claude-opus-4-8 2
omnigent/server/smart_routing.py databricks-claude-sonnet-4-6 2
omnigent/server/smart_routing.py databricks-gpt-5-4 2
omnigent/server/smart_routing.py databricks-gpt-5-4-mini 2
omnigent/server/smart_routing.py databricks-gpt-5-4-nano 2
omnigent/server/smart_routing.py databricks-gpt-5-5 3
omnigent/server/smart_routing.py databricks-gpt-5-5-pro 1
omnigent/server/smart_routing.py databricks-gpt-5-6-luna 1
omnigent/server/smart_routing.py databricks-gpt-5-6-sol 1
omnigent/server/smart_routing.py databricks-gpt-5-6-terra 1
omnigent/tools/builtins/spawn.py databricks-claude-opus-4-8 1
omnigent/tools/builtins/spawn.py system.ai.glm-5-2 1
+272
View File
@@ -0,0 +1,272 @@
"""Flag new hardcoded LLM model ids outside tests.
The codebase still has a curated baseline of model pins that predate this
check. This hook requires every path/model count to exactly match
``dev/lint/hardcoded_model_allowlist.txt`` so new pins fail and removed pins
must ratchet the baseline down.
"""
from __future__ import annotations
import ast
import re
import subprocess
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
MODEL_ID_RE = re.compile(
r"""
\b(?:
databricks-(?:claude|gpt|gemini|llama|mistral|mixtral|deepseek|qwen|kimi|dbrx|grok|meta)-[a-z0-9][a-z0-9._:/-]*
| system\.ai\.[a-z0-9][a-z0-9._:/-]*
| (?:openai/)?gpt-(?:\d|oss)[a-z0-9._:/-]*
| o[134](?:-[a-z0-9][a-z0-9._:/-]*)?
| claude-(?:opus|sonnet|haiku|fable|\d)[a-z0-9._:/-]*
| gemini-\d[a-z0-9][a-z0-9._:/-]*
| kimi-k\d[a-z0-9._:/-]*
| qwen\d[a-z0-9][a-z0-9._:/-]*
| llama-\d[a-z0-9][a-z0-9._:/-]*
| mistral-[a-z0-9][a-z0-9._:/-]*
| deepseek-[a-z0-9][a-z0-9._:/-]*
)\b
""",
re.VERBOSE,
)
TEXT_EXTENSIONS = {".json", ".toml", ".yaml", ".yml", ".sh"}
SOURCE_EXTENSIONS = {".py", *TEXT_EXTENSIONS}
SCAN_ROOTS = (
Path("omnigent"),
Path("scripts"),
Path("examples"),
Path(".github"),
Path("dev/lint"),
)
SKIP_PARTS = {
".git",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".venv",
"__pycache__",
"build",
"dist",
"node_modules",
"tests",
}
ALLOWLIST_PATH = Path("dev/lint/hardcoded_model_allowlist.txt")
@dataclass(frozen=True)
class Hit:
"""One hardcoded model occurrence."""
path: Path
line: int
model: str
def _repo_relative(path: Path) -> str:
"""Return a stable repo-relative path when possible."""
try:
return path.resolve().relative_to(Path.cwd().resolve()).as_posix()
except ValueError:
return path.as_posix()
def _target_name(node: ast.expr) -> str:
"""Return the user-visible name for an assignment target."""
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
if isinstance(node, ast.Subscript):
return _target_name(node.value)
if isinstance(node, ast.Starred):
return _target_name(node.value)
if isinstance(node, (ast.Tuple, ast.List)):
return " ".join(filter(None, (_target_name(item) for item in node.elts)))
return ""
def _key_name(node: ast.expr | None) -> str:
"""Return a literal dict key name when statically knowable."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
return node.id
return ""
def _is_model_context(node: ast.AST, parents: dict[ast.AST, ast.AST]) -> bool:
"""Return True if a string literal lives in model-selection data."""
current = node
while current in parents:
parent = parents[current]
if isinstance(parent, ast.keyword) and parent.arg and "model" in parent.arg.lower():
return True
if isinstance(parent, ast.Assign):
if any("model" in _target_name(target).lower() for target in parent.targets):
return True
elif isinstance(parent, ast.AnnAssign):
if "model" in _target_name(parent.target).lower():
return True
elif isinstance(parent, ast.Dict):
for key, value in zip(parent.keys, parent.values, strict=True):
if value is current and "model" in _key_name(key).lower():
return True
current = parent
return False
def _extract_models(text: str) -> list[str]:
"""Return hardcoded model ids in ``text``."""
return [match.group(0) for match in MODEL_ID_RE.finditer(text)]
def _scan_python(path: Path) -> list[Hit]:
"""Scan Python syntax-aware string literals in model contexts."""
try:
tree = ast.parse(path.read_text())
except (SyntaxError, UnicodeDecodeError):
return []
parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
hits: list[Hit] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Constant) or not isinstance(node.value, str):
continue
if not _is_model_context(node, parents):
continue
hits.extend(Hit(path, node.lineno, model) for model in _extract_models(node.value))
return hits
def _scan_text(path: Path) -> list[Hit]:
"""Scan config/shell files for model-looking ids on model-looking lines."""
try:
lines = path.read_text().splitlines()
except UnicodeDecodeError:
return []
hits: list[Hit] = []
for line_number, line in enumerate(lines, start=1):
if line.lstrip().startswith("#"):
continue
if "model" not in line.lower():
continue
hits.extend(Hit(path, line_number, model) for model in _extract_models(line))
return hits
def scan(path: Path) -> list[Hit]:
"""Return hardcoded model hits in ``path``."""
if (
not path.is_file()
or path.suffix not in SOURCE_EXTENSIONS
or any(part in SKIP_PARTS for part in path.parts)
):
return []
if path.suffix == ".py":
return _scan_python(path)
if path.suffix in TEXT_EXTENSIONS:
return _scan_text(path)
return []
def _iter_scannable_paths() -> list[Path]:
"""Return every tracked source/config file in the lint surface."""
output = subprocess.check_output(
["git", "ls-files", "-z", "--", *(root.as_posix() for root in SCAN_ROOTS)],
)
return [
path
for raw_path in output.decode().split("\0")
if raw_path
if (path := Path(raw_path)).suffix in SOURCE_EXTENSIONS
if not any(part in SKIP_PARTS for part in path.parts)
]
def _load_allowlist(path: Path = ALLOWLIST_PATH) -> Counter[tuple[str, str]]:
"""Load allowed ``(path, model)`` occurrence counts."""
allowed: Counter[tuple[str, str]] = Counter()
if not path.exists():
return allowed
for line_number, raw_line in enumerate(path.read_text().splitlines(), start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) != 3:
raise ValueError(f"{path}:{line_number}: expected: <path> <model> <count>")
rel_path, model, count_text = parts
key = (rel_path, model)
if key in allowed:
raise ValueError(
f"{path}:{line_number}: duplicate baseline entry for {rel_path} {model}"
)
try:
allowed[key] = int(count_text)
except ValueError as exc:
raise ValueError(
f"{path}:{line_number}: count must be an integer, got {count_text!r}"
) from exc
return allowed
def _find_new_hits(hits: list[Hit], allowed: Counter[tuple[str, str]]) -> list[Hit]:
"""Return hits whose path/model count exceeds the curated baseline."""
seen: Counter[tuple[str, str]] = Counter()
new_hits: list[Hit] = []
for hit in hits:
key = (_repo_relative(hit.path), hit.model)
seen[key] += 1
if seen[key] > allowed[key]:
new_hits.append(hit)
return new_hits
def _find_stale_allowances(
hits: list[Hit],
allowed: Counter[tuple[str, str]],
) -> Counter[tuple[str, str]]:
"""Return baseline counts that exceed the current scan results."""
actual = Counter((_repo_relative(hit.path), hit.model) for hit in hits)
return allowed - actual
def main() -> int:
"""Scan the full supported surface and require an exact baseline."""
paths = _iter_scannable_paths()
hits = [hit for path in paths for hit in scan(path)]
allowed = _load_allowlist()
new_hits = _find_new_hits(hits, allowed)
stale_allowances = _find_stale_allowances(hits, allowed)
if not new_hits and not stale_allowances:
return 0
for hit in new_hits:
sys.stdout.write(
f"{hit.path}:{hit.line}: hardcoded model id `{hit.model}`; "
"resolve from the configured provider/model catalog instead\n"
)
for (path, model), stale_count in sorted(stale_allowances.items()):
actual_count = allowed[(path, model)] - stale_count
sys.stdout.write(
f"{ALLOWLIST_PATH}: stale allowance for `{model}` in {path}: "
f"allows {allowed[(path, model)]}, found {actual_count}; "
"lower or remove the baseline entry\n"
)
sys.stdout.write(
"\nAvoid adding hardcoded model names outside tests. If this is an intentional "
"temporary pin, document why and update dev/lint/hardcoded_model_allowlist.txt "
"with the smallest path/model count.\n"
)
return 1
if __name__ == "__main__":
sys.exit(main())
+7 -7
View File
@@ -15,7 +15,7 @@ Dev tooling for Omnigent, in one binary with three surfaces:
A per-repo dev **pod** supervisor, as a single long-running terminal UI. It
replaces the three-terminal local dev flow (`omnigent server`, `omnigent host`,
`npm run dev`) with one process that:
`pnpm run dev`) with one process that:
- runs each checkout in an **isolated pod** — its own state dir, database,
artifacts, logs, and auto-allocated ports — so multiple worktrees never
@@ -31,7 +31,7 @@ replaces the three-terminal local dev flow (`omnigent server`, `omnigent host`,
## Build & run
Requires the repo's usual dev prerequisites (`uv` for Python, `npm` for the
Requires the repo's usual dev prerequisites (`uv` for Python, `pnpm` for the
web UI) plus a Rust toolchain.
```bash
@@ -50,9 +50,9 @@ Run it from anywhere inside the checkout — it walks up to the repo root
|---|---|---|
| server | `uv run omnigent --log-to-stderr server --host 127.0.0.1 --port <p> --database-uri … --artifact-location …` | Waited on via `GET /health`. |
| host | `uv run omnigent --log-to-stderr host --server http://127.0.0.1:<p>` | Started once the server is healthy. |
| vite | `npm run dev -- --host <host> --port <p> --strictPort` (cwd `web/`) | `OMNIGENT_URL` points its proxy at the pod's server. |
| vite | `pnpm run dev -- --host <host> --port <p> --strictPort` (cwd `web/`) | `OMNIGENT_URL` points its proxy at the pod's server. |
Before Vite starts (and on a manual Vite restart), omnidev runs `npm install`
Before Vite starts (and on a manual Vite restart), omnidev runs `pnpm install`
in `web/` when needed — `node_modules/` is missing, or `package.json` /
`package-lock.json` is newer than it — so a fresh checkout or a new dependency
doesn't make Vite fail its dependency scan. Output streams into the `vite` pane.
@@ -65,7 +65,7 @@ Only Omnigent's own state is isolated per pod — enough that concurrent pods
never share a database, server pidfile, or `config.yaml` — via
`OMNIGENT_DATA_DIR`, `OMNIGENT_DATABASE_URI`, `OMNIGENT_URL`, and
`OMNIGENT_CONFIG_HOME`. Everything else (your real `HOME`, credentials, and
uv/npm caches) is inherited, because the agents Omnigent runs need it. This is
uv/pnpm caches) is inherited, because the agents Omnigent runs need it. This is
deliberately lighter than the hermetic `scripts/backend-smoke.sh` sandbox,
which repoints `HOME`/`XDG_*` to touch nothing real.
@@ -192,9 +192,9 @@ omnidev shell-hook # print the daily-check snippet for your shell rc
extras), `--repo <url>`. The choice is saved to
`${XDG_CONFIG_HOME:-~/.config}/omnidev/install.toml` so `update` reuses it.
Installing from git **builds the web UI from source**, so Node 22+/npm must be
Installing from git **builds the web UI from source**, so Node 22+/pnpm must be
on PATH (the PyPI wheel ships the UI prebuilt; the git install does not).
`omnidev install` fails early with a clear message if `uv` or `npm` is missing.
`omnidev install` fails early with a clear message if `uv` or `pnpm` is missing.
### Daily update check
+5 -4
View File
@@ -73,16 +73,17 @@ impl InstallConfig {
}
/// Fail early with a clear message if the toolchain a git install needs is
/// missing. Installing from git builds the web UI from source (Node/npm),
/// missing. Installing from git builds the web UI from source (Node/pnpm),
/// unlike the PyPI wheel which ships it prebuilt.
fn preflight() -> Result<()> {
if which("uv").is_none() {
bail!("`uv` is not on PATH. Install it first: https://docs.astral.sh/uv/");
}
if which("npm").is_none() {
if which("pnpm").is_none() {
bail!(
"`npm` is not on PATH. Installing omnigent from git builds the web UI \
from source and needs Node 22+/npm. Install Node, then retry."
"`pnpm` is not on PATH. Installing omnigent from git builds the web UI \
from source and needs Node 22+/pnpm. Install Node (pnpm is \
available via `corepack enable` or `npm install -g pnpm`), then retry."
);
}
Ok(())
+4 -4
View File
@@ -90,11 +90,11 @@ impl Pod {
self.repo_root.join("web")
}
/// Whether `web/` needs `npm install` before Vite can start: either
/// Whether `web/` needs `pnpm install` before Vite can start: either
/// `node_modules/` is absent, or the lockfile / `package.json` is newer
/// than the installed tree (a dependency was added/changed since the last
/// install — the case that makes Vite's dependency scan fail).
pub fn needs_npm_install(&self) -> bool {
pub fn needs_pnpm_install(&self) -> bool {
let web = self.web_dir();
let modules = web.join("node_modules");
if !modules.is_dir() {
@@ -105,7 +105,7 @@ impl Pod {
return true;
};
// Reinstall if either manifest is newer than node_modules.
[web.join("package-lock.json"), web.join("package.json")]
[self.repo_root.join("pnpm-lock.yaml"), web.join("package.json")]
.into_iter()
.filter_map(mtime)
.any(|t| t > installed)
@@ -123,7 +123,7 @@ impl Pod {
/// The env overrides applied on top of the inherited parent env for every
/// child. We isolate omnigent's own state — the DB, data dir, and config
/// home — so concurrent pods don't share a database, pidfile, or
/// `config.yaml`. The rest (real `HOME`, credentials, uv/npm caches) is
/// `config.yaml`. The rest (real `HOME`, credentials, uv/pnpm caches) is
/// inherited, since the agents omnigent runs need it. `OMNIGENT_URL` is the
/// seam `web/vite.config.ts` reads to point its proxy at this pod's backend;
/// `OMNIGENT_CONFIG_HOME` is where the server/host/runner read `config.yaml`.
+6 -16
View File
@@ -66,32 +66,22 @@ impl ProcSpec {
}
}
/// `npm install`, from `web/`. Run before Vite when deps are missing or
/// `pnpm install`, from `web/`. Run before Vite when deps are missing or
/// stale so Vite's dependency scan doesn't fail on an unresolved import.
///
/// `--loglevel http` makes npm emit a line per package fetch even when its
/// stdout is piped (its progress bar is TTY-only), so the pane streams real
/// progress. `--no-fund --no-audit` trims the trailing noise.
pub fn npm_install(pod: &Pod) -> ProcSpec {
pub fn pnpm_install(pod: &Pod) -> ProcSpec {
ProcSpec {
program: "npm".into(),
args: vec![
"install".into(),
"--no-fund".into(),
"--no-audit".into(),
"--loglevel".into(),
"http".into(),
],
program: "pnpm".into(),
args: vec!["install".into()],
cwd: pod.web_dir(),
extra_env: Vec::new(),
}
}
/// `npm run dev -- --host <host> --port <p> --strictPort`, from `web/`.
/// `pnpm run dev -- --host <host> --port <p> --strictPort`, from `web/`.
/// `OMNIGENT_URL` (in the pod env) points Vite's proxy at this pod's backend.
pub fn vite(pod: &Pod) -> ProcSpec {
ProcSpec {
program: "npm".into(),
program: "pnpm".into(),
args: vec![
"run".into(),
"dev".into(),
+12 -14
View File
@@ -217,7 +217,7 @@ impl Supervisor {
.stderr(Stdio::piped())
.kill_on_drop(false);
// Become a session/group leader so we can signal the whole tree
// (uvicorn workers, npm -> vite children) via the negative pgid.
// (uvicorn workers, pnpm -> vite children) via the negative pgid.
unsafe {
cmd.pre_exec(|| {
libc::setsid();
@@ -272,23 +272,23 @@ impl Supervisor {
});
}
/// Run `npm install` to completion before Vite starts, but only when deps
/// Run `pnpm install` to completion before Vite starts, but only when deps
/// are missing or stale — otherwise Vite's dependency scan fails on an
/// unresolved import (e.g. a dep added to package.json but not installed).
/// Output streams into the Vite pane. A failed/absent install is logged but
/// non-fatal: we still let Vite try, so a transient npm hiccup doesn't block
/// non-fatal: we still let Vite try, so a transient pnpm hiccup doesn't block
/// the whole session.
async fn prepare_vite(&self) {
if !self.pod.needs_npm_install() {
if !self.pod.needs_pnpm_install() {
return;
}
self.set_status(ProcId::Vite, ProcStatus::Starting);
self.shared.lock().unwrap().log_proc(
ProcId::Vite,
"web deps missing or stale — running npm install".into(),
"web deps missing or stale — running pnpm install".into(),
);
let spec = ProcSpec::npm_install(&self.pod);
let spec = ProcSpec::pnpm_install(&self.pod);
let mut cmd = Command::new(&spec.program);
cmd.args(&spec.args)
.current_dir(&spec.cwd)
@@ -304,7 +304,7 @@ impl Supervisor {
self.shared
.lock()
.unwrap()
.log_proc(ProcId::Vite, format!("failed to run npm install: {e}"));
.log_proc(ProcId::Vite, format!("failed to run pnpm install: {e}"));
return;
}
};
@@ -315,9 +315,7 @@ impl Supervisor {
self.pump(ProcId::Vite, err);
}
// `--loglevel http` streams a line per package fetch, but npm still
// goes quiet during the final tree-build/link phase. A slow heartbeat
// covers those gaps so the pane never looks frozen.
// A slow heartbeat covers quiet phases so the pane never looks frozen.
let started = Instant::now();
let mut heartbeat = tokio::time::interval(Duration::from_secs(5));
heartbeat.tick().await; // the first tick fires immediately; skip it
@@ -329,17 +327,17 @@ impl Supervisor {
self.shared
.lock()
.unwrap()
.log_proc(ProcId::Vite, format!("… npm install running ({secs}s)"));
.log_proc(ProcId::Vite, format!("pnpm install running ({secs}s)"));
}
}
};
match status {
Ok(s) if s.success() => self.event(format!(
"npm install complete ({}s)",
"pnpm install complete ({}s)",
started.elapsed().as_secs()
)),
Ok(s) => self.event(format!("npm install exited {s} — starting Vite anyway")),
Err(e) => self.event(format!("npm install wait error: {e}")),
Ok(s) => self.event(format!("pnpm install exited {s} — starting Vite anyway")),
Err(e) => self.event(format!("pnpm install wait error: {e}")),
}
}
+39 -1
View File
@@ -203,6 +203,40 @@ same YAML works across platforms. For the full set of sandbox options, how to
share one policy across `sys_os_*` and terminals, and how to set up network
egress rules, see the `sandbox:` examples below and the sandbox source under `omnigent/inner/`.
### Secretless credential proxy
`sandbox.credential_proxy` lets sandboxed tools authenticate to external hosts
without the real secret ever entering the sandbox: the mandatory L7 egress proxy
attaches the credential on the way out. It requires `egress_rules` and a
network-isolating backend (`linux_bwrap` or `darwin_seatbelt`). See
`designs/SANDBOX_CREDENTIAL_PROXY.md` for the full type table.
The `databricks_cli` type proxies the Databricks CLI. List the profiles to
proxy; only those are materialized into the sandbox (with placeholder tokens)
and swapped by the proxy. As with every other credential-proxy type, you must
list each workspace host in `egress_rules` yourself — the proxy does not widen
egress on its own. OAuth tokens are refreshed for the life of the session.
Requires the `databricks` extra and `linux_bwrap` (the Go CLI ignores
`SSL_CERT_FILE` on macOS, so `darwin_seatbelt` is rejected).
```yaml
os_env:
type: caller_process
cwd: .
sandbox:
type: linux_bwrap
egress_rules:
- "* pypi.org/**" # your other egress needs
- "* dbc-adb7b1a3-9097.cloud.databricks.com/**" # the proxied workspace
credential_proxy:
- type: databricks_cli
profiles: [dbc-adb7b1a3-9097, oss]
default: dbc-adb7b1a3-9097 # optional; sets DATABRICKS_CONFIG_PROFILE
```
Inside the sandbox, `databricks --profile dbc-adb7b1a3-9097 current-user me`
works; the sandbox holds only `oa_cred_*` placeholders, never a live token.
## Tools
Tools are declared under `tools` by name.
@@ -260,11 +294,15 @@ Use `container_image` for new specs; `docker_image` remains accepted as a
deprecated alias for backwards compatibility. Set `container_runtime: podman` to
run the image with Podman instead of Docker.
The runtime can also be set globally via the `OMNIGENT_CONTAINER_RUNTIME`
environment variable (accepted values: `docker`, `podman`). The per-agent
`container_runtime` YAML key takes precedence over the environment variable.
```yaml
tools:
sandbox:
container_image: python:3.12-slim
container_runtime: podman # optional; defaults to docker
container_runtime: podman # optional; defaults to docker (or OMNIGENT_CONTAINER_RUNTIME)
```
### Sub-agent tool
+37 -5
View File
@@ -3,16 +3,21 @@
**Status:** implemented
**Supersedes:** [`cursor-native-tui-mirror-plan.md`](./cursor-native-tui-mirror-plan.md) (pane-scrape design)
**Code:** `omnigent/cursor_native_permissions.py`, the `cursor-permission-request` hook in
`omnigent/server/routes/sessions.py`, runner wiring in `omnigent/runner/app.py`,
`web/.../ApprovalCard.tsx`.
`omnigent/server/routes/sessions.py`, runner wiring in
`omnigent/runner/native/orchestration.py`, `web/.../ApprovalCard.tsx`.
## Goal / behavior
Surface an Omnigent **elicitation card whenever the `cursor-agent` TUI gates a tool call or
asks a question**, answerable from the web **or** the embedded TUI. Cursor's own native gate
stays the source of truth — **no `--force`, no JS-bundle modification**. The failure mode is
benign: if detection ever breaks, the embedded TUI prompt still works and the user answers
there.
stays the source of truth — Omnigent never modifies cursor's JS bundle and never suppresses
the TUI prompt. The failure mode is benign: if detection ever breaks, the embedded TUI prompt
still works and the user answers there.
One exception: a session the *caller* launched with `--yolo` / `--force` / `-f` has already
declared it wants no approvals, and a card mirrored to a piloted parent is a stall nobody can
click. Those sessions answer lingering gates in the pane instead — see
[Yolo sessions](#yolo-sessions-run-everything) below.
Two interaction kinds are surfaced (both ride cursor's per-call "pending" mechanism):
@@ -89,6 +94,28 @@ The pane is still used to *deliver* the verdict. Two gotchas, both handled in
doesn't park at the reason input. (The `AskQuestion` picker's "Esc to skip" dismisses
cleanly, so the question decline is a single key.)
### Yolo sessions (Run Everything)
A session launched with `--yolo` / `--force` / `-f` (`cursor_launch_args_enable_yolo`) still
occasionally leaves a pending marker behind. Mirroring that as a card stalls a piloted parent
that has no human to click it, so the supervisor answers it in the pane instead — with the
opposite default of the rest of this design, so the accept is deliberately fail-closed:
- **Only while cursor is asking.** `capture_cursor_pane` must show cursor's parenthesised
accept hint (`→ Run (once) (y)`). A stale marker with no gate rendered gets no keystroke —
`tmux send-keys y` would type a literal `y` into the composer, which then prepends itself to
whatever the user types next in the embedded terminal.
- **Bounded.** `_YOLO_ACCEPT_MAX_ATTEMPTS` tries, paced by `_YOLO_ACCEPT_RETRY_S`, at most one
keystroke per poll (cursor renders one prompt at a time).
- **Falls back to the card.** A dead pane, a send tmux rejects, or a gate still pending after
the budget all surface the ordinary ApprovalCard. The worst case is therefore today's visible
stall, never a keystroke loop.
- **`AskQuestion` is excluded** — a question is human input, not a gate `y` can answer.
- Because a gate answered this way is never seen by a human, the accept logs the tool name and
an argument preview at INFO: that line is the only record Omnigent approved the call.
The attempt counters are in-memory, so a runner restart re-tries a call that is still pending.
### AskQuestion specifics
- Rendered via the existing web form: the runner stamps the full questions as the **structured
@@ -143,6 +170,11 @@ in-memory. Byte-scanning the frames for embedded JSON reveals the pending tool c
double-surface. Low likelihood; not yet addressed.
- **Store schema is private and version-sensitive.** Confirmed against cursor-agent 2026.06.24
(and the marker present back to 2026.06.18). Failure stays benign (TUI gate authoritative).
- **Why a `--yolo` session gates at all is unconfirmed.** cursor documents `--force` as "force
allow commands unless explicitly denied", so a surviving gate may be one cursor deliberately
held back (a user deny rule, or a server-side classifier). The auto-accept above answers it
anyway, which is what the flag asks for; the pane check is what keeps that from becoming a
blind keystroke.
- **Keystroke delivery assumes the pane still shows the prompt** and the picker's key bindings
(`Down`/`Space`/`Enter`, highlight resets per question). Verified live; re-check on cursor
upgrades.
+84
View File
@@ -0,0 +1,84 @@
# Model Hardcoding Plan
## Current Curated Inventory
The current baseline lives in `dev/lint/hardcoded_model_allowlist.txt`. It is
count-based by `path` and `model-id`, so unrelated line movement does not break
the hook while net-new pins still fail.
The remaining pins fall into a few buckets:
- **CI automation:** `.github/actions/*`, `.github/scripts/ci/*`, and
`.github/workflows/*` pin Databricks gateway models for review bots, release
helpers, image generation, and integration stress jobs.
- **Harness defaults:** native/executor launch paths such as
`omnigent/pi_native_credentials.py`, `omnigent/opencode_native_provider.py`,
`omnigent/inner/*_executor.py`, and `omnigent/codex_native_app_server.py`
still carry fallback model ids.
- **Static pickers/catalogs:** `omnigent/model_catalog.py`,
`omnigent/cursor_native.py`, `omnigent/kiro_native.py`, and
`omnigent/server/smart_routing.py` encode static model choices for CLIs or
routing tiers that do not always expose a live listing API.
- **Policy and sizing logic:** `omnigent/llms/context_window.py`,
`omnigent/policies/builtins/routing.py`, and `omnigent/tools/builtins/spawn.py`
mention concrete models when mapping windows, routing examples, or dispatch
examples.
- **Examples/onboarding:** `examples/kimi_hello.yaml` and onboarding provider
prompts include concrete defaults to make first-run setup work.
## Prevention
- `dev/lint/lint_no_hardcoded_models.py` scans non-test Python/config/shell
files for concrete model ids in model-selection contexts.
- When a supported file or the baseline changes, `.pre-commit-config.yaml` runs
the hook across the full tracked lint surface so both new pins and stale
allowlist counts fail.
- New hardcoded ids fail unless the allowlist count is intentionally updated,
while removing a pin requires lowering or deleting its baseline entry.
### Scope and exclusions
The hook scans Python, YAML, JSON, TOML, and shell files under `omnigent`,
`scripts`, `examples`, `.github`, and `dev/lint`. Python uses AST context;
config and shell files use model-looking lines while ignoring comment-only
lines.
Tests, Markdown/prose, top-level files, `web` TypeScript/JavaScript, and
generated/vendor trees are intentionally outside the initial lint surface.
Tests need concrete ids as fixtures, while prose and frontend sources need
syntax-aware handling before they can be added without excessive false
positives.
This is a heuristic ratchet, not a parser-level guarantee. Python detection is
limited to model-named assignments, keyword arguments, and dictionary keys; a
model id in an unrelated positional argument or bare collection can escape the
check. Config and shell detection requires the model context and id on the same
line, so multiline/block-scalar values are also outside the initial coverage.
These gaps should be closed with syntax-aware scanners rather than broader
regexes that would make prose false-positive.
The hook intentionally scans the full tracked surface when a supported file
changes. That bounded cost is what lets it enforce exact global baseline counts
instead of only checking additions in changed files. Like the existing Ruff
hooks, local pre-commit execution assumes the repository `.venv` has been
prepared with `just ensure`; CI is the enforcement backstop.
## Migration Plan
1. **Introduce logical model intents.** Replace fallback ids with stable intents
such as `default`, `fast`, `balanced`, `large-context`, `coding`, `image`,
and `judge`.
2. **Resolve intents at runtime.** Add one resolver that maps intents to the
active provider's live catalog, with provider-specific preference rules and
clear errors when no compatible model exists.
3. **Move CI pins to configuration.** Read CI model choices from repo/org vars
or workflow inputs, with no concrete default in source-controlled workflow
code.
4. **Centralize static fallback catalogs.** Keep unavoidable static CLI catalogs
behind one module with provenance, TTL/refresh notes, and a smaller lint
exception surface.
5. **Ratchet the baseline down.** Each migration removes the corresponding
`dev/lint/hardcoded_model_allowlist.txt` entry; the lint rejects both count
increases and stale allowances.
6. **Document escape hatches.** If a temporary pin is unavoidable, require a
short rationale near the call site and the smallest allowlist count.
+1 -1
View File
@@ -80,7 +80,7 @@ The one-time setup that makes this possible is tracked below.
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
| 1 | Set `"publisher": "databricks"` in `package.json` | `editors/vscode` | — (done) |
| 2 | Maintain `CHANGELOG.md` (strip Jira refs, keep GH issue refs) | `editors/vscode` | — (done) |
| 3 | Verify the build: `npm ci && npm run build && npm run package` → valid `.vsix` | local / CI | — (done) |
| 3 | Verify the build: `pnpm install --frozen-lockfile && pnpm run build && pnpm run package` → valid `.vsix` | local / CI | — (done) |
| 4 | Release-PR workflow bumps version + CHANGELOG; a manually-dispatched release workflow builds the `.vsix` and attaches it (+`.sha256`) to a draft GitHub release | `.github/workflows/vscode-release-pr.yml`, `vscode-extension-release.yml` | — (done) |
| 5 | Ask DECO to register `omnigent-vscode` under the `databricks` publisher + add dedicated `OMNI_VSCE_TOKEN` / `OMNI_OVSX_PAT` secrets (and an `omnigent-vscode-marketplace` environment for the reviewer gate) | Slack `#dev-ecosystem-discuss` ([https://databricks.slack.com/archives/C01KSAWFXG8/p1782971196701749](https://databricks.slack.com/archives/C01KSAWFXG8/p1782971196701749)) | human approval |
| 6 | Add an `omnigent-vscode.yml` publish workflow in the secure repo, adapting the existing [`databricks-vscode.yml`](https://github.com/databricks/secure-public-registry-releases-eng/blob/main/.github/workflows/databricks-vscode.yml) (SAML SSO required) — it already does download → scan → `vsce publish` + `ovsx publish` in one workflow | `secure-public-registry-releases-eng` | DECO grant (step 5) |
+5 -5
View File
@@ -39,11 +39,11 @@ buttons and `navigator.clipboard` paths still work.)
## Build / test / package
```bash
npm ci
npm run type-check # tsc --noEmit
npm run test # vitest run
npm run build # esbuild -> dist/extension.js
npm run package # @vscode/vsce package -> omnigent-vscode-<version>.vsix
pnpm install --frozen-lockfile
pnpm run type-check # tsc --noEmit
pnpm run test # vitest run
pnpm run build # esbuild -> dist/extension.js
pnpm run package # @vscode/vsce package -> omnigent-vscode-<version>.vsix
```
Install the resulting `.vsix` via the Extensions view → "Install from VSIX…". The
-5618
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -82,8 +82,8 @@
"@types/node": "^20.14.0",
"@types/vscode": "^1.74.0",
"@vscode/vsce": "^3.2.0",
"esbuild": "^0.21.5",
"esbuild": "^0.28.1",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
"vitest": "^3.2.6"
}
}
+10
View File
@@ -121,6 +121,16 @@ prompt: |
pass the diff + contract as text); the reviewer reports issues, never edits.
- `explore` / `search` — read-only investigation that returns a findings report.
Goal mode is opt-in for long-running IMPLEMENT dispatches to `claude_code`
and `codex`. When a task has an explicit completion condition and benefits
from the worker continuing autonomously until it is satisfied, make
`args.input` one standalone `/goal <condition>` command. `/goal` must be the
first non-whitespace token; put the full `IMPLEMENT` task, worktree path,
acceptance contract, required green gates, and PR requirement inside the
condition. Do not use child goal mode for `review`, `explore`, or `search`,
or for `opencode`, `cursor`, `hermes`, or `pi`. Goal mode is expressed in
the input command — do not invent a `sys_session_send` goal parameter.
Treat real investigation as delegated work. If the human asks you to
investigate, inspect, explain, debug, audit, search, compare, summarize code
behavior, or answer a repository-specific technical question in any depth,
+5
View File
@@ -22,6 +22,11 @@ dependency).
and opens its OWN PR for the branch. Every commit the worker authors must
end with a blank line followed by the exact co-sign trailer as its final
line — `Co-authored-by: omnigent <noreply@omnigent.ai>`.
For a long-running `claude_code` or `codex` implementation with an explicit
completion condition, the `input` may instead be one standalone
`/goal <condition>` command containing that same task, worktree, acceptance
contract, green gates, and PR requirement.
Do not use child goal mode for other workers or non-implementation purposes.
Record each handle's `conversation_id`
in the registry. Emit the worktree + `sys_session_send` tool calls in THIS
turn — never end a turn having only said you will dispatch; the dispatch
+3 -7
View File
@@ -48,17 +48,13 @@ OMNIGENT_SERVER_URL=https://omnigent.example.com
# OAuth client secret. At least 32 chars of real entropy (e.g. `openssl rand
# -hex 32`); a weak value is brute-forceable and lets an attacker forge a state.
# OMNIGENT_SLACK_DATABRICKS_STATE_SECRET=
# Workspace host the OAuth app is registered in. Defaults to the injected
# DATABRICKS_HOST; set only for a laptop run where it's unset.
# OMNIGENT_SLACK_DATABRICKS_WORKSPACE_HOST=
# Requested scopes (space-separated). Defaults to all-apis; openid +
# offline_access are always added. Narrow it as far as the server proxy allows.
# OMNIGENT_SLACK_DATABRICKS_SCOPES=
# Public base URL of this bot's own Databricks App (the enrollment link base +
# OAuth redirect URI). Defaults to the injected DATABRICKS_APP_URL.
# OMNIGENT_SLACK_WEBAUTH_BASE_URL=
# Port the enrollment web server binds. Defaults to DATABRICKS_APP_PORT (8000).
# OMNIGENT_SLACK_WEBAUTH_PORT=
# OAuth redirect URI). Operator-supplied — the platform injects no app-URL var,
# and the URL only exists after the app's first deploy.
# OMNIGENT_SLACK_DATABRICKS_APP_URL=
#
# A Fernet key that encrypts each user's delegated token at rest so a stolen
# database file can't impersonate them — generate one with:
+29 -17
View File
@@ -20,11 +20,12 @@ Omnigent identity against it.
3. Add a slash command `/omnigent` (Features → Slash Commands). In Socket Mode
the request URL is ignored, so any placeholder works.
4. Install the app into the workspace.
5. Copy `.env.example` to `.env` and fill in the two Slack tokens
(`OMNIGENT_SLACK_BOT_TOKEN`, `OMNIGENT_SLACK_APP_TOKEN`) and your Omnigent
server URL (`OMNIGENT_SERVER_URL`). If your server sets
5. Set the two Slack tokens (`OMNIGENT_SLACK_BOT_TOKEN`,
`OMNIGENT_SLACK_APP_TOKEN`) and your Omnigent server URL
(`OMNIGENT_SERVER_URL`) as **environment variables**. If your server sets
`OMNIGENT_DEVICE_CLIENT_SECRET`, set the same value here so the bot is
accepted as an authorized device-grant client.
accepted as an authorized device-grant client. See **Configuration** below
for how the bot reads config.
6. Run the bot — see **Running the bot** below.
## Required scopes
@@ -80,21 +81,28 @@ Under **Event Subscriptions → Subscribe to bot events**, add:
With the `omni` CLI installed, the Slack bot is managed as a background daemon:
```bash
omni integration slack # run in the foreground (Ctrl-C to stop)
omni integration slack start # run in the background (detached)
omni integration slack status # is the background bot running?
omni integration slack stop # stop the background bot
omni integration slack logs # print the background bot's log path
omni integration slack logs -f # follow the log (like tail -f)
omni integration slack # run in the foreground (Ctrl-C to stop)
omni integration slack --background # run in the background (detached)
omni integration slack status # is the background bot running?
omni integration slack stop # stop the background bot
omni integration slack logs # print the background bot's log path
omni integration slack logs -f # follow the log (like tail -f)
```
`omni integration slack start` spawns a detached daemon and returns
immediately; `status`/`stop`/`logs` manage it. Running `start` again while it's
already up is a no-op that reports the existing process.
`omni integration slack --background` spawns a detached daemon and returns
immediately; `status`/`stop`/`logs` manage it. Running `--background` again
while it's already up is a no-op that reports the existing process.
### Configuration
All configuration (the two Slack tokens, `OMNIGENT_SERVER_URL`, and the
optional `OMNIGENT_DEVICE_CLIENT_SECRET` / `OMNIGENT_SLACK_TOKEN_ENCRYPTION_KEY`)
comes from the environment and the `.env` file — the CLI only launches the bot.
comes from **real environment variables** — the bot does **not** read a `.env`
file itself. For local dev, either export the vars, or launch under a tool that
injects a `.env` — e.g. `uv run --env-file .env omni integration slack`, or
`export $(grep -v '^#' .env | xargs)` before running. In production the
Docker / Databricks deploy sets them directly. `.env.example` documents the
full set of variables to copy from.
The bot lives in the separate `omnigent-slack` package, which must be installed
**in the same environment as** `omni` for the `omni integration slack` commands
@@ -104,7 +112,7 @@ to find it. Install it as the `slack` extra of omnigent:
uv tool install "omnigent[slack]" # or, from a source checkout: uv sync --extra slack
```
Set `LOG_LEVEL=DEBUG` in `.env` when diagnosing why Slack events are not producing replies.
Set `LOG_LEVEL=DEBUG` in the environment when diagnosing why Slack events are not producing replies.
## Per-user setup flow
@@ -140,8 +148,12 @@ command.
The bot **auto-detects the server's auth mode** (an unauthenticated `GET /v1/me`, exactly as the `omnigent login` CLI does) and picks the matching flow:
- `accounts` **mode****OAuth 2.0 Device Authorization Grant** (RFC 8628).
The modal shows a verification link + code; the user approves a consent page
in their browser. The server issues a short-lived, session-scoped delegated
The modal shows a one-click login link (code prefilled) and the short code to
confirm; the user opens the link and approves a consent page in their browser.
(The consent page **forces a fresh password entry** before it will approve —
even if the user is already signed in — so a link the user didn't personally
start can't be approved by reflex.) The server issues a short-lived,
session-scoped delegated
token plus a rotating refresh token, so the bot silently refreshes and the
token can't reach admin endpoints. **The Omnigent server must have the device
grant enabled** (`OMNIGENT_DEVICE_GRANT_ENABLED=1` — it is default-off);
@@ -2,8 +2,9 @@
.databricks/
# NOTE: the per-deploy app payload deploy.py generates under src/
# (src/*.whl, src/pyproject.toml, src/uv.lock) is deliberately NOT ignored.
# (src/*.whl, src/pyproject.toml) is deliberately NOT ignored.
# `databricks bundle deploy` respects .gitignore for its file sync, so
# ignoring those would silently drop them from the upload and the app would
# fail with "No module named 'omnigent_slack'". Keep them untracked (don't
# `git add` them) rather than gitignored.
# `git add` them) rather than gitignored. No uv.lock is generated — the app
# resolves deps in-container via `uv run`.
+28 -35
View File
@@ -14,18 +14,17 @@ for the full design, and the integration `[README.md](../../README.md)` for how
the bot works otherwise.
Unlike the server app, the bot needs **no Lakebase and no UC volume** — it's a
stateless pure-PyPI package. Mirroring the server deploy, `deploy.py` builds an
`omnigent_slack` wheel, generates an app-level `src/pyproject.toml` + `src/uv.lock`
that point at it, copies the wheel into `src/`, then runs `databricks bundle
deploy` + `bundle run`. The Databricks Apps runtime installs the source
directory with `uv sync`, so the app imports `omnigent_slack` from the built
wheel. Runs unchanged from a laptop; re-runnable.
stateless pure-PyPI package. `deploy.py` builds an `omnigent_slack` wheel,
generates an app-level `src/pyproject.toml` that points at it (with the bot's
runtime deps inlined from the source pyproject), copies the wheel into `src/`,
then runs `databricks bundle deploy` + `bundle run`. No lockfile is generated:
the app starts with `uv run`, so the Databricks Apps runtime resolves
dependencies in-container at boot. Runs unchanged from a laptop; re-runnable.
> The generated `src/*.whl`, `src/pyproject.toml`, and `src/uv.lock` are kept
> **untracked but not git-ignored** — `bundle deploy` respects `.gitignore` for
> its file sync, so git-ignoring them would silently drop them from the upload
> and the app would fail with `ModuleNotFoundError: No module named
> 'omnigent_slack'`.
> The generated `src/*.whl` and `src/pyproject.toml` are kept **untracked but
> not git-ignored** — `bundle deploy` respects `.gitignore` for its file sync,
> so git-ignoring them would silently drop them from the upload and the app
> would fail with `ModuleNotFoundError: No module named 'omnigent_slack'`.
## Prerequisites
@@ -91,7 +90,7 @@ databricks secrets put-secret omnigent-slack databricks_state_secret \
> the token's scopes must be a superset of the target server app's scopes, so
> `all-apis` always works; `openid` + `offline_access` are added automatically),
> and register the redirect URI **`<this-app-url>/auth/callback`**
> (the same app URL you pass as `--webauth-base-url`). Because the app URL only
> (the same app URL you pass as `--app-url`). Because the app URL only
> exists after the first deploy, register the redirect URI between the first and
> second deploy passes.
@@ -139,19 +138,17 @@ uv run python integrations/slack/deploy/databricks/deploy.py \
--secret-scope omnigent-slack \
--oauth-client-id <oauth-app-client-id> \
--server-url https://<server-app>.databricksapps.com \
--webauth-base-url "${APP_URL}"
--app-url "${APP_URL}"
```
`deploy.py` builds the wheel, writes `src/pyproject.toml` + `src/uv.lock`, copies
the wheel into `src/`, runs `bundle deploy --target prod`, then
`bundle run omnigent-slack --target prod`. Pass `--skip-run` to deploy without
starting, or `--skip-build` to reuse the existing `src/` wheel + lock. Subsequent
redeploys are a single invocation (keep `--webauth-base-url`).
> On the Databricks network, public PyPI is blocked, so point uv at the internal
> proxy for the lock step — either `--index-url https://pypi-proxy.cloud.databricks.com/simple`
> or `UV_INDEX_URL=…` (the lock is then normalized back to public PyPI for
> reproducibility). See [go/pypi-registry-access](http://go/pypi-registry-access).
`deploy.py` builds the wheel, writes `src/pyproject.toml` (the bot pinned to the
co-located wheel, with its runtime deps inlined from the source pyproject),
copies the wheel into `src/`, runs `bundle deploy --target prod`, then
`bundle run omnigent-slack --target prod`. No lockfile is generated: the app
starts with `uv run`, so the Apps runtime resolves dependencies in-container at
boot. Pass `--skip-run` to deploy without starting, or `--skip-build` to reuse
the existing `src/` wheel + pyproject. Subsequent redeploys are a single
invocation (keep `--app-url`).
## After deploy
@@ -169,9 +166,8 @@ redeploys are a single invocation (keep `--webauth-base-url`).
## How it works
- The app binds `DATABRICKS_APP_PORT` (8000) with the OAuth callback web server
(`omnigent_slack/webauth.py`) and, in the same process, runs the Socket-Mode
bot that connects out to Slack.
- The app runs the OAuth callback web server (`omnigent_slack/webauth.py`) and,
in the same process, the Socket-Mode bot that connects out to Slack.
- **Custom U2M OAuth app (authorization code + PKCE).** The enrollment link is
the workspace `/oidc/v1/authorize` URL; the user signs in and Databricks
redirects back to `/auth/callback` with a single-use, PKCE-bound code. The bot
@@ -203,12 +199,10 @@ Environment wired by `databricks.yml` (secrets via `value_from`, rest inline):
| `OMNIGENT_SLACK_DATABRICKS_CLIENT_SECRET`| secret | Custom U2M OAuth app client secret |
| `OMNIGENT_SLACK_DATABRICKS_STATE_SECRET` | secret | HMAC key signing the enrollment `state` |
| `OMNIGENT_SLACK_DATABRICKS_SCOPES` | inline (optional) | Requested scopes (default `all-apis`; must be a superset of the server app's scopes; `openid` + `offline_access` forced on) |
| `OMNIGENT_SLACK_DATABRICKS_WORKSPACE_HOST` | inline (optional) | OAuth app's workspace host (defaults to `DATABRICKS_HOST`) |
| `OMNIGENT_SLACK_SERVER_AUTH` | inline | `databricks` (selects the OAuth mode) |
| `OMNIGENT_SERVER_URL` | `--server-url` | Omnigent server the bot drives |
| `OMNIGENT_SLACK_WEBAUTH_BASE_URL` | `--webauth-base-url` | This app's public URL — link base + redirect URI |
| `OMNIGENT_SLACK_DATABRICKS_APP_URL` | `--app-url` | This app's public URL — link base + redirect URI |
| `OMNIGENT_DATA_DIR` | inline | Ephemeral SQLite store dir |
| `DATABRICKS_APP_PORT` | Databricks runtime | Port the callback server binds (8000) |
@@ -218,13 +212,12 @@ Environment wired by `databricks.yml` (secrets via `value_from`, rest inline):
| Symptom | Cause | Fix |
| --- | --- | --- |
| `ModuleNotFoundError: No module named 'omnigent_slack'` | The wheel/`pyproject.toml`/`uv.lock` were git-ignored, so `bundle deploy` didn't sync them | Ensure `src/*.whl`, `src/pyproject.toml`, `src/uv.lock` are untracked but NOT git-ignored; re-run `deploy.py` (not `--skip-build` on a clean `src/`) |
| `uv lock` fails with a PyPI DNS error | Public PyPI blocked on the Databricks network | Re-run with `UV_INDEX_URL=https://pypi-proxy.cloud.databricks.com/simple` |
| App install fails; `/logz` shows an `exclude-newer` re-resolve then a PyPI timeout | Runtime's uv `exclude-newer` cutoff differs from the lock's | Read the cutoff from `/logz` and pass it via `--exclude-newer <cutoff>`, then redeploy |
| `ModuleNotFoundError: No module named 'omnigent_slack'` | The wheel/`pyproject.toml` were git-ignored, so `bundle deploy` didn't sync them | Ensure `src/*.whl`, `src/pyproject.toml` are untracked but NOT git-ignored; re-run `deploy.py` (not `--skip-build` on a clean `src/`) |
| App fails to boot; `/logz` shows a `uv run` resolve error or PyPI timeout | Dependency resolution runs in-container at boot; the runtime couldn't reach PyPI | Confirm the app egress can reach PyPI (or the Databricks proxy); retry the `bundle run` |
| Sign-in ends on an OAuth error page (redirect mismatch) | The OAuth app's redirect URI ≠ `<this-app-url>/auth/callback` | Register the exact `/auth/callback` URL on the custom OAuth app |
| Sign-in page says the link was already used or expired | The redirect was replayed, or the bot restarted between link-issue and callback (in-memory PKCE verifier lost) | Run `/omnigent` again for a fresh link |
| Enrolled, but turns fail auth against the server | User lacks access to the server app, or the token's scopes don't satisfy the server proxy | Grant the user server-app access; widen `OMNIGENT_SLACK_DATABRICKS_SCOPES` if the server proxy needs more |
| App boots but Slack shows no sign-in link | `--webauth-base-url` not passed (the app URL only exists after first deploy) | Re-deploy with `--webauth-base-url "$(databricks apps get <app> -o json | jq -r .url)"` |
| App boots but Slack shows no sign-in link | `--app-url` not passed (the app URL only exists after first deploy) | Re-deploy with `--app-url "$(databricks apps get <app> -o json | jq -r .url)"` |
| App can't read secrets | App SP missing scope ACL | `databricks secrets put-acl <scope> <sp> READ`, redeploy |
| Plan shows destroy/replace of the app | `--app-name` mismatch vs. tracked state | Re-check `--app-name`; state is per-app under `root_path` |
@@ -237,9 +230,9 @@ Environment wired by `databricks.yml` (secrets via `value_from`, rest inline):
| File | Purpose |
| --- | --- |
| `databricks.yml` | DAB bundle config — app resource, secrets, env. |
| `deploy.py` | Orchestrator: build wheel → write `pyproject.toml`/`uv.lock` → deploy + run. |
| `deploy.py` | Orchestrator: build wheel → write `pyproject.toml` → deploy + run. |
| `src/app.py` | App entry point — runs `omnigent_slack.app.run()`. |
| `src/app.yaml` | App startup config (command + env). |
| `src/*.whl`, `src/pyproject.toml`, `src/uv.lock` | Generated per deploy by `deploy.py`; untracked, not git-ignored. |
| `src/*.whl`, `src/pyproject.toml` | Generated per deploy by `deploy.py`; untracked, not git-ignored. |
@@ -23,7 +23,7 @@ variables:
Custom U2M OAuth app client id. Public (not a secret) — passed inline.
server_url:
description: "Base URL of the Omnigent server app (the bot talks to this)."
webauth_base_url:
app_url:
description: >
This bot app's own public URL, used to build the enrollment link posted
into Slack. The platform does NOT inject an app-URL env var, so it must be
@@ -77,7 +77,7 @@ resources:
permission: READ
config:
command: ["python", "app.py"]
command: ["uv", "run", "python", "app.py"]
env:
# Slack credentials + at-rest encryption + enrollment state secret.
- name: OMNIGENT_SLACK_BOT_TOKEN
@@ -96,17 +96,15 @@ resources:
# from the OAuth client secret so rotating one doesn't affect the other.
- name: OMNIGENT_SLACK_DATABRICKS_STATE_SECRET
value_from: databricks-state-secret
# Databricks web-auth mode + non-secret config. The workspace host
# defaults to the platform-injected DATABRICKS_HOST, and the bot's own
# public URL to DATABRICKS_APP_URL, so they need no value here.
# Databricks web-auth mode + non-secret config.
- name: OMNIGENT_SLACK_SERVER_AUTH
value: databricks
- name: OMNIGENT_SERVER_URL
value: ${var.server_url}
# This app's own public URL (the enrollment link base). The platform
# injects no app-URL env var, so it's passed explicitly.
- name: OMNIGENT_SLACK_WEBAUTH_BASE_URL
value: ${var.webauth_base_url}
- name: OMNIGENT_SLACK_DATABRICKS_APP_URL
value: ${var.app_url}
# SQLite store on ephemeral disk — tokens are encrypted and
# re-enrolled after a restart, so no durable volume is needed.
- name: OMNIGENT_DATA_DIR
+53 -94
View File
@@ -1,12 +1,16 @@
#!/usr/bin/env python3
"""Deploy the Omnigent Slack bot to a Databricks App via Asset Bundles.
Mirrors the server deploy (``deploy/databricks/deploy.py``): builds a wheel
for the ``omnigent-slack`` package, generates an app-level ``pyproject.toml`` +
``uv.lock`` that point at that wheel, copies the wheel into ``src/``, then wraps
``databricks bundle deploy`` + ``databricks bundle run``. The Databricks Apps
runtime installs the source directory with ``uv sync``, so the app imports
``omnigent_slack`` from the built wheel — not from loose source files.
Builds a wheel for the ``omnigent-slack`` package, generates an app-level
``pyproject.toml`` that depends on that wheel (with the bot's runtime deps
inlined from the source ``pyproject.toml``), copies the wheel into ``src/``,
then wraps ``databricks bundle deploy`` + ``databricks bundle run``.
No lockfile is generated or committed: the app starts with ``uv run``, so the
Databricks Apps runtime resolves dependencies in-container at boot (the same
pattern as the ``databricks/app-templates`` examples). This keeps the deploy
step offline-simple — no ``uv lock``, no registry normalization, no
``--exclude-newer`` juggling to match the runtime's pinned cutoff.
Simpler than the server deploy: one wheel, pure-PyPI deps, no Lakebase / UC
volume and no cross-package version lockstep.
@@ -26,7 +30,6 @@ creation, user-authorization enablement, and the enrollment flow).
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
@@ -34,6 +37,8 @@ import sys
import time
from pathlib import Path
import tomllib
# Must match resources.apps.<key> and bundle.name in databricks.yml.
_BUNDLE_RESOURCE_KEY = "omnigent-slack"
@@ -42,9 +47,6 @@ _DIST_NAME = "omnigent-slack"
_WHEEL_PREFIX = "omnigent_slack-"
_APP_REQUIRES_PYTHON = ">=3.12,<3.13"
# Public PyPI by default. Set UV_INDEX_URL to lock against a private mirror or
# proxy (e.g. the Databricks internal proxy) instead.
_UV_DEFAULT_INDEX_URL = "https://pypi.org/simple"
def _log(msg: str) -> None:
@@ -135,21 +137,28 @@ def _toml_string(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def _write_uv_dependency_files(
wheel: Path,
deploy_version: str,
index_url: str | None = None,
exclude_newer: str | None = None,
) -> None:
"""Copy the wheel into src/ and write the app pyproject.toml + uv.lock.
def _read_runtime_dependencies() -> list[str]:
"""Read the bot's runtime deps from its source pyproject.toml.
The Apps runtime runs ``uv sync`` in the synced source directory, so it
needs a ``pyproject.toml`` whose only dependency is the bot, sourced from
the co-located wheel, plus a matching ``uv.lock``.
Inlining them into the generated app pyproject (rather than relying on the
wheel's own metadata) keeps the app's dependency set visible and in lockstep
with the package: ``uv run`` resolves this list plus the wheel in-container.
"""
data = tomllib.loads((_slack_root() / "pyproject.toml").read_text())
deps = data.get("project", {}).get("dependencies", [])
if not deps:
_fail("no [project.dependencies] found in integrations/slack/pyproject.toml")
return list(deps)
:param index_url: Optional index URL forwarded to the lock step.
:param exclude_newer: Optional uv ``--exclude-newer`` cutoff forwarded to
the lock step.
def _write_app_pyproject(wheel: Path, deploy_version: str) -> None:
"""Copy the wheel into src/ and write the app pyproject.toml (no lockfile).
The app starts with ``uv run``, so the Apps runtime resolves this project
in-container at boot — no ``uv.lock`` is generated or shipped. The generated
project pins the bot to the co-located wheel and inlines its runtime deps
(read from the source pyproject) so the resolved set is explicit and stays in
sync with the package.
"""
src = _src_dir()
_sweep_src_wheels()
@@ -160,72 +169,42 @@ def _write_uv_dependency_files(
requirements = src / "requirements.txt"
if requirements.exists():
requirements.unlink()
# A stale lockfile from an older wheel-and-lock deploy would pin the wrong
# version; drop it so `uv run` resolves fresh in-container.
lockfile = src / "uv.lock"
if lockfile.exists():
lockfile.unlink()
deps = [f"{_DIST_NAME}=={deploy_version}", *_read_runtime_dependencies()]
dep_lines = "".join(f" {_toml_string(d)},\n" for d in deps)
pyproject = (
"[project]\n"
'name = "omnigent-slack-databricks-app"\n'
'version = "0.0.0"\n'
f"requires-python = {_toml_string(_APP_REQUIRES_PYTHON)}\n"
"dependencies = [\n"
f' "{_DIST_NAME}=={deploy_version}",\n'
f"{dep_lines}"
"]\n\n"
# Not an installable package itself — just an environment for `uv run`.
"[tool.uv]\n"
"package = false\n\n"
"[tool.uv.sources]\n"
f"{_DIST_NAME} = {{ path = {_toml_string('./' + wheel.name)} }}\n"
)
(src / "pyproject.toml").write_text(pyproject)
_log("src/pyproject.toml:\n" + pyproject)
_run_uv_lock(src, index_url, exclude_newer)
def _run_uv_lock(
src: Path, index_url: str | None = None, exclude_newer: str | None = None
) -> None:
"""Generate src/uv.lock, then normalize its registry to public PyPI.
Locking honors the index override (``--index-url`` or ``UV_INDEX_URL``,
default public PyPI) so a Databricks-network machine can resolve via the
internal proxy; the normalize step then rewrites every registry URL back to
public PyPI so the uploaded lock is canonical, mirroring the server deploy.
:param index_url: Explicit index URL (from ``--index-url``); falls back to
``UV_INDEX_URL`` then public PyPI.
:param exclude_newer: Optional uv ``--exclude-newer`` cutoff. The Apps runtime
pins a global cutoff; passing the same one keeps the in-container
re-resolve from refetching (and timing out) on PyPI. Omitted → no cutoff.
"""
index_url = index_url or os.environ.get("UV_INDEX_URL") or _UV_DEFAULT_INDEX_URL
env = os.environ.copy()
env.pop("UV_INDEX", None)
env.pop("UV_DEFAULT_INDEX", None)
env["UV_INDEX_URL"] = index_url
cmd = ["uv", "lock", "--python", "3.12", "--index-url", index_url]
if exclude_newer:
cmd += ["--exclude-newer", exclude_newer]
_log(f"$ {' '.join(cmd)}")
subprocess.run(cmd, cwd=src, env=env, check=True)
# Rewrite proxy/registry URLs to public PyPI so the uploaded lock is
# reproducible regardless of the machine that generated it.
normalize = _repo_root() / "scripts" / "normalize_uv_lock_registry.py"
if normalize.exists():
_log("normalizing uv.lock registry → public PyPI")
subprocess.run([sys.executable, str(normalize), str(src / "uv.lock")], env=env)
def _repo_root() -> Path:
# integrations/slack/deploy/databricks/deploy.py → repo root (4 parents up).
return Path(__file__).resolve().parents[4]
def _bundle_vars(args: argparse.Namespace) -> list[str]:
# The app's own URL isn't known until it exists — empty on the first
# deploy, then passed via --webauth-base-url on the second (see main()).
webauth_base_url = args.webauth_base_url or ""
# deploy, then passed via --app-url on the second (see main()).
app_url = args.app_url or ""
pairs = {
"app_name": args.app_name,
"secret_scope": args.secret_scope,
"oauth_client_id": args.oauth_client_id,
"server_url": args.server_url.rstrip("/"),
"webauth_base_url": webauth_base_url.rstrip("/"),
"app_url": app_url.rstrip("/"),
}
out: list[str] = []
for key, value in pairs.items():
@@ -268,7 +247,7 @@ def main() -> None:
help="Custom U2M OAuth app client id (public; passed inline, not a secret).",
)
parser.add_argument(
"--webauth-base-url",
"--app-url",
default=None,
help=(
"This app's own public URL (the enrollment link base). Unknown "
@@ -282,30 +261,10 @@ def main() -> None:
default=None,
help="Explicit PEP 440 version to stamp. Default: <base>.post<unix-ts>.",
)
parser.add_argument(
"--index-url",
default=None,
help=(
"PyPI index URL for the uv lock step. Overrides UV_INDEX_URL; "
"defaults to public PyPI. On the Databricks network use the proxy: "
"https://pypi-proxy.cloud.databricks.com/simple (the lock is "
"normalized back to public PyPI afterward)."
),
)
parser.add_argument(
"--exclude-newer",
default=None,
help=(
"uv --exclude-newer cutoff (e.g. 2026-07-19T00:00:00Z) for the lock "
"step. Match the Apps runtime's pinned cutoff so the in-container "
"re-resolve doesn't refetch (and time out) on PyPI — read it from "
"/logz. Omit for no cutoff."
),
)
parser.add_argument(
"--skip-build",
action="store_true",
help="Reuse the existing src/ wheel + lock — skip the wheel build.",
help="Reuse the existing src/ wheel + pyproject — skip the wheel build.",
)
parser.add_argument(
"--skip-run",
@@ -314,11 +273,11 @@ def main() -> None:
)
args = parser.parse_args()
if not args.webauth_base_url:
if not args.app_url:
_log(
"WARNING: --webauth-base-url not set. The enrollment link needs this "
"WARNING: --app-url not set. The enrollment link needs this "
"app's public URL, which only exists after the first deploy. Re-run "
"with --webauth-base-url once you can read it: "
"with --app-url once you can read it: "
f"databricks apps get {args.app_name} -o json | jq -r .url"
)
@@ -327,12 +286,12 @@ def main() -> None:
original_pyproject = _stamp_version(deploy_version)
try:
wheel = _build_wheel()
_write_uv_dependency_files(wheel, deploy_version, args.index_url, args.exclude_newer)
_write_app_pyproject(wheel, deploy_version)
finally:
# Restore the working-tree version so the deploy leaves no diff.
(_slack_root() / "pyproject.toml").write_text(original_pyproject)
else:
_log("--skip-build: reusing existing src/ wheel + lock")
_log("--skip-build: reusing existing src/ wheel + pyproject")
if not list(_src_dir().glob(f"{_WHEEL_PREFIX}*.whl")):
_fail("no wheel in src/ to reuse; run without --skip-build first")
@@ -1,10 +1,11 @@
"""Databricks Apps entry point for the Omnigent Slack bot.
Runs the Socket-Mode bot and, in Databricks web-auth mode, the enrollment web
server that binds ``DATABRICKS_APP_PORT``. The package source (``omnigent_slack``)
is copied next to this file by ``deploy.py``; its runtime deps come from the
generated ``requirements.txt``. Startup failures are logged and the process is
held open briefly so the platform captures them in ``/logz``.
server. The ``omnigent_slack`` package is installed from the wheel ``deploy.py``
copies next to this file; the app's ``uv run`` command resolves it (and the
inlined runtime deps) from the generated ``pyproject.toml`` in-container at boot.
Startup failures are logged and the process is held open briefly so the platform
captures them in ``/logz``.
"""
from __future__ import annotations
@@ -1,4 +1,4 @@
command: ["python", "app.py"]
command: ["uv", "run", "python", "app.py"]
env:
- name: OMNIGENT_SLACK_SERVER_AUTH
value: databricks
+1 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "omnigent-slack"
version = "0.1.0"
version = "0.8.0.dev0"
description = "Slack Socket Mode bot that drives Omnigent sessions."
readme = "README.md"
requires-python = ">=3.12"
@@ -14,7 +14,6 @@ dependencies = [
"cryptography>=42.0.0",
"httpx>=0.28.0",
"pydantic-settings>=2.10.0",
"python-dotenv>=1.1.0",
"slack-bolt>=1.29.0",
"slack-sdk>=3.43.0",
]
+16 -4
View File
@@ -1,9 +1,9 @@
from __future__ import annotations
import logging
import sys
from typing import Any
from dotenv import load_dotenv
from slack_bolt.adapter.socket_mode.aiohttp import AsyncSocketModeHandler
from slack_bolt.async_app import AsyncApp
@@ -16,7 +16,7 @@ from omnigent_slack.approvals import (
route_elicitation_click,
)
from omnigent_slack.auth_manager import AuthManager, pack_user_key
from omnigent_slack.config import load_settings
from omnigent_slack.config import ConfigError, load_settings
from omnigent_slack.databricks_oauth import DatabricksOAuthClient
from omnigent_slack.omnigent import OmnigentClientPool
from omnigent_slack.service import SlackOmnigentService
@@ -27,8 +27,20 @@ from omnigent_slack.webauth import WebAuthServer
async def run() -> None:
load_dotenv()
settings = load_settings()
# Config comes from real environment variables only — mirroring `omni
# server` (the core CLI loads no .env). Whatever populates the environment
# (your shell, `uv run`, the Docker/Databricks deploy) is the single source
# of truth; there is no in-app .env loading. See integrations/slack/README.
#
# A missing/invalid config raises ConfigError with an operator-friendly,
# pre-formatted message. Print it plainly and exit non-zero — no traceback,
# no logging setup (which hasn't run yet). SystemExit(2) is the conventional
# "usage/config" exit code and is what the foreground CLI surfaces.
try:
settings = load_settings()
except ConfigError as exc:
print(f"omnigent-slack: {exc}", file=sys.stderr)
raise SystemExit(2) from None
level = getattr(logging, settings.log_level.upper(), logging.INFO)
# force=True so this wins even when an entry point (e.g. the Databricks App
# wrapper) already called basicConfig at import — otherwise a second
+114 -41
View File
@@ -4,9 +4,19 @@ import os
from pathlib import Path
from typing import Literal
from pydantic import Field, field_validator, model_validator
from pydantic import Field, ValidationError, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class ConfigError(Exception):
"""A configuration problem stated in operator-friendly terms.
Raised by :func:`load_settings` instead of surfacing a raw pydantic
``ValidationError`` (internal field names + a traceback). The message is
safe and useful to print straight to a terminal.
"""
# Auth posture the bot assumes for its Omnigent server. ``auto`` probes the
# server (the historical behaviour — device grant / OIDC ticket). ``databricks``
# is for a server fronted by the Databricks Apps proxy (header mode), which the
@@ -85,9 +95,13 @@ def _local_data_dir() -> Path:
class Settings(BaseSettings):
# Config is read from real environment variables only — no ``env_file``.
# This mirrors ``omni server`` / the core CLI, which load no ``.env``:
# whatever populates the environment (shell, ``uv run``, the Docker /
# Databricks deploy) is the single source of truth. For local dev, export
# the vars or run under a tool that injects them (e.g. ``uv run`` reading a
# ``.env``). See integrations/slack/README.
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
@@ -148,18 +162,6 @@ class Settings(BaseSettings):
validation_alias="OMNIGENT_SLACK_SERVER_AUTH",
)
# Databricks workspace host the custom U2M OAuth app is registered in, e.g.
# ``https://my-workspace.cloud.databricks.com``. The bot hits its
# ``/oidc/v1/authorize`` and ``/oidc/v1/token`` endpoints. Distinct from
# server_url (the *.databricksapps.com app). Defaults to the platform-
# injected DATABRICKS_HOST when unset (the OAuth app lives in the same
# workspace the bot runs in); required — directly or via DATABRICKS_HOST —
# in databricks mode.
databricks_workspace_host: str | None = Field(
default_factory=lambda: _normalize_host(os.environ.get("DATABRICKS_HOST")),
validation_alias="OMNIGENT_SLACK_DATABRICKS_WORKSPACE_HOST",
)
# Custom U2M OAuth app credentials (client id + secret) registered in the
# workspace above. The client id is public; the secret authenticates the
# token/refresh calls. Both required in databricks mode.
@@ -195,18 +197,12 @@ class Settings(BaseSettings):
)
# Public base URL of this bot's own Databricks App (where the enrollment
# page is reachable), used to build the link posted into Slack. Defaults to
# DATABRICKS_APP_URL when the platform injects it.
databricks_webauth_base_url: str | None = Field(
# page is reachable), used to build the link posted into Slack and as the
# OAuth redirect base. The operator must supply it: the platform injects no
# app-URL env var, and the app's URL only exists after its first deploy.
databricks_app_url: str | None = Field(
default=None,
validation_alias="OMNIGENT_SLACK_WEBAUTH_BASE_URL",
)
# Port the enrollment web server binds. Databricks Apps route to
# DATABRICKS_APP_PORT (8000 by convention); honour it by default.
databricks_webauth_port: int = Field(
default_factory=lambda: int(os.environ.get("DATABRICKS_APP_PORT", "8000")),
validation_alias="OMNIGENT_SLACK_WEBAUTH_PORT",
validation_alias="OMNIGENT_SLACK_DATABRICKS_APP_URL",
)
@field_validator("server_url")
@@ -226,10 +222,32 @@ class Settings(BaseSettings):
)
return value
@property
def databricks_workspace_host(self) -> str | None:
"""Workspace host the custom U2M OAuth app is registered in.
The bot hits this host's ``/oidc/v1/authorize`` and ``/oidc/v1/token``
endpoints. Distinct from ``server_url`` (the *.databricksapps.com app).
Read from the platform-injected ``DATABRICKS_HOST`` — the OAuth app lives
in the same workspace the bot runs in. A scheme-less value is defaulted
to https; ``None`` when unset (a laptop run must export ``DATABRICKS_HOST``).
"""
return _normalize_host(os.environ.get("DATABRICKS_HOST"))
@property
def databricks_webauth_port(self) -> int:
"""Port the enrollment web server binds.
Databricks Apps route inbound traffic to ``DATABRICKS_APP_PORT`` (8000
by convention) and inject it into the container, so the server binds
that. Falls back to 8000 for a laptop run where it's unset.
"""
return int(os.environ.get("DATABRICKS_APP_PORT", "8000"))
@property
def webauth_base_url(self) -> str | None:
"""Public base URL of this bot's enrollment page (for the Slack link)."""
base = self.databricks_webauth_base_url or os.environ.get("DATABRICKS_APP_URL")
base = self.databricks_app_url
return base.strip().rstrip("/") if base else None
@property
@@ -248,14 +266,6 @@ class Settings(BaseSettings):
"""Requested scopes with ``openid`` + ``offline_access`` forced on."""
return _normalize_oauth_scopes(self.databricks_oauth_scopes)
@field_validator("databricks_workspace_host")
@classmethod
def _normalize_workspace_host(cls, value: str | None) -> str | None:
# A scheme-less host (e.g. DATABRICKS_HOST, or an operator typing just the
# hostname) is defaulted to https. The model validator then enforces https
# for any non-loopback host.
return _normalize_host(value)
@model_validator(mode="after")
def _check_databricks_config(self) -> Settings:
"""Fail fast when databricks mode is missing required config.
@@ -271,9 +281,9 @@ class Settings(BaseSettings):
("OMNIGENT_SLACK_DATABRICKS_CLIENT_ID", self.databricks_oauth_client_id),
("OMNIGENT_SLACK_DATABRICKS_CLIENT_SECRET", self.databricks_oauth_client_secret),
("OMNIGENT_SLACK_DATABRICKS_STATE_SECRET", self.databricks_state_secret),
# workspace_host defaults to DATABRICKS_HOST (injected on the
# platform); still required for a laptop run where it's unset.
("OMNIGENT_SLACK_DATABRICKS_WORKSPACE_HOST", self.databricks_workspace_host),
# workspace_host is DATABRICKS_HOST (injected on the platform);
# still required for a laptop run where it's unset.
("DATABRICKS_HOST", self.databricks_workspace_host),
)
if not value
]
@@ -291,7 +301,7 @@ class Settings(BaseSettings):
host = self.databricks_workspace_host or ""
if host.startswith("http://") and not _is_loopback_url(host):
raise ValueError(
"OMNIGENT_SLACK_DATABRICKS_WORKSPACE_HOST must use https:// "
"DATABRICKS_HOST must use https:// "
"(plaintext exposes the client secret and lets an on-path "
"attacker forge the id_token identity)"
)
@@ -303,7 +313,7 @@ class Settings(BaseSettings):
base = self.webauth_base_url or ""
if base.startswith("http://") and not _is_loopback_url(base):
raise ValueError(
"OMNIGENT_SLACK_WEBAUTH_BASE_URL must use https:// "
"OMNIGENT_SLACK_DATABRICKS_APP_URL must use https:// "
"(it is the OAuth redirect target — plaintext would expose the "
"authorization code and the consent page's identity data)"
)
@@ -319,5 +329,68 @@ class Settings(BaseSettings):
return self
# Required env vars → a short human label, so a missing-config error can name
# exactly what to set. Only the fields with no default are truly required.
_REQUIRED_ENV_VARS: dict[str, str] = {
"OMNIGENT_SLACK_BOT_TOKEN": "Slack bot token (xoxb-…)",
"OMNIGENT_SLACK_APP_TOKEN": "Slack app-level token (xapp-…)",
"OMNIGENT_SERVER_URL": "Omnigent server URL (https://…)",
}
def load_settings() -> Settings:
return Settings() # type: ignore[call-arg]
"""Load settings from the environment, with an operator-friendly error.
A missing/invalid config raises :class:`ConfigError` carrying a message
fit to print directly — naming the missing environment variables and how
to set them — instead of a raw pydantic ``ValidationError`` traceback.
Config is read from real environment variables only (no ``.env`` loading);
see the module docstring / integrations/slack/README.
"""
try:
return Settings() # type: ignore[call-arg]
except ValidationError as exc:
# Separate the two failure kinds so the message is precise: a required
# var not set at all (pydantic "missing") vs. a value that failed a
# validator (bad URL, bad auth mode, …).
missing: list[str] = []
invalid: list[str] = []
for err in exc.errors():
# The field's env alias is the useful name to show; fall back to
# the field name when a loc isn't a known field.
field = str(err["loc"][0]) if err["loc"] else ""
env_name = _env_alias_for(field)
if err["type"] == "missing":
missing.append(env_name)
else:
invalid.append(f"{env_name}: {err['msg']}")
lines: list[str] = []
if missing:
lines.append("Missing required configuration. Set these environment variables:")
for name in missing:
label = _REQUIRED_ENV_VARS.get(name, "")
lines.append(f"{name}" + (f"{label}" if label else ""))
if invalid:
if lines:
lines.append("")
lines.append("Invalid configuration:")
lines.extend(f"{item}" for item in invalid)
lines.append(
"\nThe bot reads config from the environment (it does NOT load a .env "
"file itself). Export the variables, or launch under a tool that "
"injects them — e.g. `uv run --env-file .env omni integration slack`. "
"See integrations/slack/.env.example for the full set."
)
raise ConfigError("\n".join(lines)) from exc
def _env_alias_for(field_name: str) -> str:
"""Return the env-var alias for a Settings field (fallback: the field name).
The friendly error names the environment variable the operator sets (e.g.
``OMNIGENT_SERVER_URL``), not the internal snake_case field (``server_url``).
"""
info = Settings.model_fields.get(field_name)
alias = getattr(info, "validation_alias", None) if info is not None else None
return alias if isinstance(alias, str) else field_name.upper()

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