diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5353c496..3f3eacae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ env: jobs: build-test: - name: build + test (non-gateway) + name: build + test runs-on: ubuntu-24.04 timeout-minutes: 60 steps: @@ -35,29 +35,26 @@ jobs: - name: Cache cargo registry + target uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - # The gateway is an intentionally excluded, separately preserved WIP - # surface. These exact packages are the release unit and the truthful - # blocking quality boundary for this mission. - name: Format check - run: cargo fmt -p omega-core -p omega-tui -p omega -- --check + run: cargo fmt --all -- --check - name: Clippy - run: cargo clippy --locked -p omega-core -p omega-tui -p omega --all-targets -- -D warnings + run: cargo clippy --locked --workspace --all-targets -- -D warnings - name: Build (warnings are errors) - run: cargo build --release --locked -p omega-core -p omega-tui -p omega + run: cargo build --release --locked --workspace env: RUSTFLAGS: "-D warnings" - name: Test - run: cargo test --locked -p omega-core -p omega-tui -p omega + run: cargo test --locked --workspace - name: Shell syntax shell: bash run: | while IFS= read -r -d '' script; do bash -n "$script" - done < <(find . -type f -name '*.sh' -not -path './target/*' -not -path './crates/omega-gateway/*' -print0) + done < <(find . -type f -name '*.sh' -not -path './target/*' -print0) - name: Workflow graph contracts run: bash scripts/check-workflows.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bac8a4f4..640a787e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,8 +77,8 @@ jobs: toolchain: 1.97.1 targets: ${{ matrix.target }} - - name: Build omega from the pinned lockfile - run: cargo build --release --locked --bin omega --target ${{ matrix.target }} + - name: Build OmegaOS binaries from the pinned lockfile + run: cargo build --release --locked --bin omega --bin omega-gatewayd --target ${{ matrix.target }} - name: Resolve the exact locked rmux revision id: rmux @@ -115,11 +115,12 @@ jobs: test "$(git -C "${{ steps.rmux.outputs.path }}" rev-parse HEAD)" = "${{ steps.rmux.outputs.rev }}" cargo build --release --locked --manifest-path "${{ steps.rmux.outputs.path }}/Cargo.toml" --target "${{ matrix.target }}" - - name: Package deterministic omega + rmux archive + - name: Package deterministic OmegaOS + rmux archive shell: bash run: | mkdir -p dist cp "target/${{ matrix.target }}/release/omega" dist/omega + cp "target/${{ matrix.target }}/release/omega-gatewayd" dist/omega-gatewayd cp "${{ steps.rmux.outputs.path }}/target/${{ matrix.target }}/release/rmux" dist/rmux python3 - "$GITHUB_SHA" "${{ steps.rmux.outputs.rev }}" "${{ matrix.target }}" <<'PY' import hashlib @@ -151,13 +152,13 @@ jobs: with output.open('wb') as raw: with gzip.GzipFile(filename='', mode='wb', fileobj=raw, mtime=0) as zipped: with tarfile.open(fileobj=zipped, mode='w', format=tarfile.USTAR_FORMAT) as archive: - for name in ('omega', 'rmux', 'BUILD-INFO.json'): + for name in ('omega', 'omega-gatewayd', 'rmux', 'BUILD-INFO.json'): path = root / name info = archive.gettarinfo(str(path), arcname=name) info.uid = info.gid = 0 info.uname = info.gname = '' info.mtime = 0 - info.mode = 0o755 if name in {'omega', 'rmux'} else 0o644 + info.mode = 0o755 if name in {'omega', 'omega-gatewayd', 'rmux'} else 0o644 with path.open('rb') as source: archive.addfile(info, source) PY @@ -288,7 +289,9 @@ jobs: raise SystemExit(f'checksum mismatch for {name}') archive = root / f'omega-{target}.tar.gz' with tarfile.open(archive, mode='r:gz') as bundle: - if set(bundle.getnames()) != {'omega', 'rmux', 'BUILD-INFO.json'}: + if set(bundle.getnames()) != { + 'omega', 'omega-gatewayd', 'rmux', 'BUILD-INFO.json' + }: raise SystemExit(f'unexpected archive members for {target}: {bundle.getnames()}') build_info = json.load(bundle.extractfile('BUILD-INFO.json')) if build_info.get('schema_version') != 1: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1717d75d..4b9a0f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ for [semantic versioning](https://semver.org) once it reaches 1.0. Until then, ## [Unreleased] +## [0.1.14] — 2026-08-24 + +### Provider compatibility and installation + +- Updated Codex, Claude, Hermes, Kimi, Gemini, Antigravity, GLM, Pi, and + OpenRouter launch contracts and current model catalogs. +- Added streamed Codex gateway chat, provider-aware resume/resurrection, + version diagnostics, safe Codex 0.149 migration, and configurable hook trust. +- Restored fresh-install parity: Codex is provisioned as the default, gateway + binaries ship in release archives, Rust is pinned, and managed assets prune + stale files without discarding operator configuration. +- Expanded CI to the complete workspace and made gateway/project/skill tests + hermetic. Published the matching npm bootstrap as `omega-os@1.5.13`. + ### Documentation and release operations - Aligned current operator docs with the runtime registries: 7 Laws, 52 diff --git a/Cargo.lock b/Cargo.lock index ff4e6c2f..b4204d62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1342,7 +1342,7 @@ dependencies = [ [[package]] name = "omega" -version = "0.1.13" +version = "0.1.14" dependencies = [ "anyhow", "base64", @@ -1368,7 +1368,7 @@ dependencies = [ [[package]] name = "omega-core" -version = "0.1.13" +version = "0.1.14" dependencies = [ "anyhow", "blake3", @@ -1401,7 +1401,7 @@ dependencies = [ [[package]] name = "omega-gateway" -version = "0.1.0" +version = "0.1.14" dependencies = [ "anyhow", "axum", @@ -1429,7 +1429,7 @@ dependencies = [ [[package]] name = "omega-tui" -version = "0.1.13" +version = "0.1.14" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index ab6362ed..3e4f281f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.1.13" +version = "0.1.14" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/agentik-os/OmegaOS" diff --git a/GUIDE.md b/GUIDE.md index b7c37667..6ead6508 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -9,7 +9,7 @@ ## 1. What OmegaOS is -OmegaOS is a provider-neutral control plane for a fleet of coding agents on one box. New sessions use OpenAI Codex by default, while Claude Code, Gemini, Pi, Hermes, and GLM remain selectable. It +OmegaOS is a provider-neutral control plane for a fleet of coding agents on one box. New sessions use OpenAI Codex by default, while Claude Code, Google Antigravity, enterprise/API-key Gemini CLI, OpenRouter/Pi, Hermes, GLM, and Kimi remain selectable. It turns a Linux machine (typically a VPS) into a place where you dispatch work in one sentence and a hierarchy of agents: the Atlas service routes, an oracle per project that plans, ephemeral workers that edit — executes it in parallel under @@ -88,7 +88,7 @@ Seven tabs (cycle with arrow keys; `Tab` toggles focus between panels): | **Sessions** | Live session list with roles and progress; the right panel mirrors the selected pane and accepts chat input. Codex/OpenAI panes preserve ANSI color, reflow long Unicode input to the visible width, paint the real cursor row, and show the persisted provider identity. Kill, lock, rename, attach. | | **Projects** | Registered projects, planner creation, Telegram topic controls, open/dispatch, and guarded deletion. | | **OS** | The 24-product operative-system registry with static readiness evidence and MASTER prompt launch. | -| **Menu** | Launch actions: new Claude/Codex/Gemini/Pi/Hermes/GLM/terminal session, **[N] New Project**, dispatch to an oracle, refresh, protection toggle, kill / kill-all / nuclear cleanup, restart, quit. | +| **Menu** | Launch actions: new Claude/Codex/Gemini/Antigravity/Pi/Hermes/GLM/Kimi/terminal session, **[N] New Project**, dispatch to an oracle, refresh, protection toggle, kill / kill-all / nuclear cleanup, restart, quit. | | **System** | Laws, rules, agent roles, skills, and documentation. | | **Help** | Keybindings and usage hints. | | **Settings** | Theme gallery (live preview — see [docs/THEMES.md](docs/THEMES.md)), provider/model config, API keys, agent installs, the Monitor group (billing, accounts, bot status, provisioning keys wizard). | diff --git a/README.es-ES.md b/README.es-ES.md index ae7b6bfe..5da165f7 100644 --- a/README.es-ES.md +++ b/README.es-ES.md @@ -8,7 +8,7 @@ Un plano de control en el terminal para ejecutar en paralelo una flota de agente [![CI](https://github.com/agentik-os/OmegaOS/actions/workflows/ci.yml/badge.svg)](https://github.com/agentik-os/OmegaOS/actions/workflows/ci.yml) ![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg) ![Built with Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg) -OmegaOS no es una biblioteca que importas. Lo instalas en una máquina Linux y obtienes el comando `omega`, una TUI para vigilar y matar sesiones, una capa de orquestación que reparte el trabajo a los agentes y un puente de Telegram para el control desde el móvil. Las sesiones nuevas usan OpenAI Codex por defecto. Claude Code, Gemini, Pi, Hermes y GLM siguen siendo opciones explícitas. Cada agente recibe un contexto de políticas compacto, tipado y acotado al rol, compilado a partir de la misma doctrina. +OmegaOS no es una biblioteca que importas. Lo instalas en una máquina Linux y obtienes el comando `omega`, una TUI para vigilar y matar sesiones, una capa de orquestación que reparte el trabajo a los agentes y un puente de Telegram para el control desde el móvil. Las sesiones nuevas usan OpenAI Codex por defecto. Claude Code, Google Antigravity, Gemini CLI para Enterprise/API, OpenRouter/Pi, Hermes, GLM y Kimi siguen siendo opciones explícitas. Cada agente recibe un contexto de políticas compacto, tipado y acotado al rol, compilado a partir de la misma doctrina. Versión actual: mira [CHANGELOG.md](CHANGELOG.md) (`omega -V` en una máquina ya instalada). Lo uso a diario; da por hecho que te encontrarás asperezas. @@ -28,7 +28,7 @@ cd OmegaOS ./install.sh ``` -El instalador descarga binarios `rmux` + `omega` precompilados para tu plataforma cuando hay una versión publicada (verificados por checksum), y si no, compila desde el código fuente — así un clon nuevo siempre reproduce el sistema, solo que más rápido cuando existe un binario. Fuerza la compilación desde el código fuente con `OMEGA_FROM_SOURCE=1 ./install.sh`. +El instalador descarga binarios `rmux` + `omega` + `omega-gatewayd` precompilados para tu plataforma cuando hay una versión publicada (verificados por checksum), y si no, compila desde el código fuente — así un clon nuevo siempre reproduce el sistema, solo que más rápido cuando existe un binario. Fuerza la compilación desde el código fuente con `OMEGA_FROM_SOURCE=1 ./install.sh`. ## Actualización @@ -63,7 +63,7 @@ El stack se instala solo; lo único que queda es la parte personal. **`omega gui imprime el paso a paso completo** (también guardado en `~/.omega/GETTING-STARTED.md`, y mostrado al final de la instalación). En resumen: -1. **Conecta Codex** *(obligatorio para el runtime por defecto)*: ejecuta `codex login` y luego comprueba con `codex login status`. Claude sigue siendo opcional a través de `claude` y `/login`. +1. **Conecta Codex** *(obligatorio para el runtime por defecto)*: ejecuta `omega codex-login` y luego comprueba con `omega codex-login-status`. Claude sigue siendo opcional mediante `claude auth login`. 2. **Control remoto por Telegram** *(recomendado)* — el token de [@BotFather](https://t.me/BotFather), tu id de [@userinfobot](https://t.me/userinfobot) y, después, `OMEGA_TG_TOKEN= omega telegram setup --user-id ` (la forma con variable de entorno mantiene el token fuera de la lista de procesos). Para un tema por proyecto: grupo + temas (Topics) activados + bot administrador → `/setupgroup` → `/sync`. 3. **Claves de servicio** *(opcional)* — `~/.omega/provisioning/services.env` (Vercel / GitHub / Convex / Stripe / OpenAI para voz) alimenta el aprovisionamiento automático de aplicaciones nuevas. 4. **Añade un proyecto** — `omega` → **[N] New Project**, Telegram → *Import from GitHub*, o basta con dejar un repo en `~/Station//`. @@ -246,7 +246,7 @@ Prefiero que lo sepas antes de entrar. - **Pensado para Linux.** Desarrollado en un VPS sin entorno gráfico. Sin Windows. macOS recibe correcciones de verdad (servicios launchd, ruta de Homebrew), pero está menos rodado. - La TUI da por hecho un terminal de 256 colores. En uno de 16 colores se verá feo. -- El runtime de agente por defecto es OpenAI Codex, así que la CLI `codex` tiene que tener la sesión iniciada. Claude Code, Gemini, Pi, Hermes y GLM son alternativas explícitas admitidas. +- El runtime de agente por defecto es OpenAI Codex, así que la CLI `codex` tiene que tener la sesión iniciada. Claude Code, Antigravity, Gemini CLI para Enterprise/API, OpenRouter/Pi, Hermes, GLM y Kimi son alternativas admitidas. - **Una sola máquina.** El daemon rmux es local. No hay orquestación multi-host. - Es 0.1.x. Lo uso a diario, pero encontrarás asperezas con las que yo todavía no me he topado. diff --git a/README.fr.md b/README.fr.md index 8d606674..606f6cfc 100644 --- a/README.fr.md +++ b/README.fr.md @@ -8,7 +8,7 @@ Un plan de contrôle en terminal pour piloter en parallèle une flotte d'agents [![CI](https://github.com/agentik-os/OmegaOS/actions/workflows/ci.yml/badge.svg)](https://github.com/agentik-os/OmegaOS/actions/workflows/ci.yml) ![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg) ![Built with Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg) -OmegaOS n'est pas une bibliothèque qu'on importe. On l'installe sur une machine Linux et on récupère la commande `omega`, une TUI pour surveiller et tuer les sessions, une couche d'orchestration qui distribue le travail aux agents, et un pont Telegram pour piloter le tout depuis son téléphone. Les nouvelles sessions utilisent OpenAI Codex par défaut. Claude Code, Gemini, Pi, Hermes et GLM restent disponibles comme choix explicites. Chaque agent reçoit un contexte de règles compact, typé et cadré à son rôle, compilé depuis la même doctrine. +OmegaOS n'est pas une bibliothèque qu'on importe. On l'installe sur une machine Linux et on récupère la commande `omega`, une TUI pour surveiller et tuer les sessions, une couche d'orchestration qui distribue le travail aux agents, et un pont Telegram pour piloter le tout depuis son téléphone. Les nouvelles sessions utilisent OpenAI Codex par défaut. Claude Code, Google Antigravity, Gemini CLI pour comptes Enterprise/API, OpenRouter/Pi, Hermes, GLM et Kimi restent disponibles comme choix explicites. Chaque agent reçoit un contexte de règles compact, typé et cadré à son rôle, compilé depuis la même doctrine. Version courante : voir [CHANGELOG.md](CHANGELOG.md) (`omega -V` sur une machine installée). Je m'en sers tous les jours ; il faut s'attendre à quelques aspérités. @@ -28,7 +28,7 @@ cd OmegaOS ./install.sh ``` -L'installateur télécharge des binaires `rmux` + `omega` précompilés pour la plateforme courante lorsqu'une release est publiée (vérifiés par somme de contrôle), et retombe sinon sur une compilation depuis les sources — un clone frais reproduit donc toujours le système, simplement plus vite quand un binaire existe. Pour forcer la compilation depuis les sources : `OMEGA_FROM_SOURCE=1 ./install.sh`. +L'installateur télécharge des binaires `rmux` + `omega` + `omega-gatewayd` précompilés pour la plateforme courante lorsqu'une release est publiée (vérifiés par somme de contrôle), et retombe sinon sur une compilation depuis les sources — un clone frais reproduit donc toujours le système, simplement plus vite quand un binaire existe. Pour forcer la compilation depuis les sources : `OMEGA_FROM_SOURCE=1 ./install.sh`. ## Mise à jour @@ -65,7 +65,7 @@ La stack s'installe toute seule ; il ne reste que les pièces personnelles. `~/.omega/GETTING-STARTED.md`, et affiché à la fin de l'installation). En résumé : -1. **Connecter Codex** *(indispensable pour le runtime par défaut)* : lancer `codex login`, puis vérifier avec `codex login status`. Claude reste optionnel, via `claude` et `/login`. +1. **Connecter Codex** *(indispensable pour le runtime par défaut)* : lancer `omega codex-login`, puis vérifier avec `omega codex-login-status`. Claude reste optionnel via `claude auth login`. 2. **Pilotage Telegram à distance** *(recommandé)* — le token vient de [@BotFather](https://t.me/BotFather), l'identifiant de [@userinfobot](https://t.me/userinfobot), puis `OMEGA_TG_TOKEN= omega telegram setup --user-id ` (la forme avec variable d'environnement garde le token hors de la liste des processus). Pour un topic par projet : groupe + Topics activés + bot administrateur → `/setupgroup` → `/sync`. 3. **Clés de services** *(facultatif)* — `~/.omega/provisioning/services.env` (Vercel / GitHub / Convex / Stripe / OpenAI pour la voix) alimente le provisionnement automatique des nouvelles applications. 4. **Ajouter un projet** — `omega` → **[N] New Project**, Telegram → *Import from GitHub*, ou simplement déposer un dépôt sous `~/Station//`. @@ -248,7 +248,7 @@ Autant les connaître avant de se lancer. - **Linux d'abord.** Développé sur un VPS sans interface graphique. Pas de Windows. macOS reçoit de vrais correctifs (services launchd, chemin Homebrew) mais est moins éprouvé. - La TUI suppose un terminal 256 couleurs. Sur un terminal 16 couleurs, ce sera moche. -- Le runtime d'agent par défaut est OpenAI Codex. La CLI `codex` doit donc être connectée. Claude Code, Gemini, Pi, Hermes et GLM sont des alternatives explicites prises en charge. +- Le runtime d'agent par défaut est OpenAI Codex. La CLI `codex` doit donc être connectée. Claude Code, Antigravity, Gemini CLI pour Enterprise/API, OpenRouter/Pi, Hermes, GLM et Kimi sont des alternatives prises en charge. - **Une seule machine.** Le daemon rmux est local. Il n'y a pas d'orchestration multi-hôtes. - C'est du 0.1.x. Je m'en sers tous les jours, mais on tombera sur des aspérités que je n'ai pas encore rencontrées. diff --git a/README.md b/README.md index 4eb258fb..985ef7f7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A terminal control plane for running a fleet of AI coding agents in parallel, wh [![CI](https://github.com/agentik-os/OmegaOS/actions/workflows/ci.yml/badge.svg)](https://github.com/agentik-os/OmegaOS/actions/workflows/ci.yml) ![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg) ![Built with Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg) -OmegaOS is not a library you import. You install it on a Linux box and you get the `omega` command, a TUI for watching and killing sessions, an orchestration layer that hands work to agents, and a Telegram bridge for phone control. New sessions use OpenAI Codex by default. Claude Code, Gemini, Pi, Hermes, and GLM remain explicit choices. Every agent receives a compact, typed, role-scoped policy context compiled from the same doctrine. +OmegaOS is not a library you import. You install it on a Linux box and you get the `omega` command, a TUI for watching and killing sessions, an orchestration layer that hands work to agents, and a Telegram bridge for phone control. New sessions use OpenAI Codex by default. Claude Code, Google Antigravity, enterprise/API-key Gemini CLI, OpenRouter/Pi, Hermes, GLM, and Kimi remain explicit choices. Every agent receives a compact, typed, role-scoped policy context compiled from the same doctrine. Current version: see [CHANGELOG.md](CHANGELOG.md) (`omega -V` on an installed box). I run it daily; expect rough edges. @@ -26,7 +26,7 @@ cd OmegaOS ./install.sh ``` -The installer downloads prebuilt `rmux` + `omega` binaries for your platform when a release is published (verified by checksum), and falls back to building from source otherwise — so a fresh clone always reproduces the system, just faster when a binary exists. Force a source build with `OMEGA_FROM_SOURCE=1 ./install.sh`. +The installer downloads prebuilt `rmux` + `omega` + `omega-gatewayd` binaries for your platform when a release is published (verified by checksum), and falls back to building from source otherwise — so a fresh clone always reproduces the system, just faster when a binary exists. Force a source build with `OMEGA_FROM_SOURCE=1 ./install.sh`. ## Updating @@ -62,7 +62,7 @@ The stack installs itself; only the personal pieces are left. **`omega guide` prints the full step-by-step** (also saved at `~/.omega/GETTING-STARTED.md`, and shown at the end of the install). In short: -1. **Connect Codex** *(required for the default runtime)*: run `codex login`, then check with `codex login status`. Claude remains optional through `claude` and `/login`. +1. **Connect Codex** *(required for the default runtime)*: run `omega codex-login`, then check with `omega codex-login-status`. Claude remains optional through `claude auth login`. 2. **Telegram remote** *(recommended)* — token from [@BotFather](https://t.me/BotFather), your id from [@userinfobot](https://t.me/userinfobot), then `OMEGA_TG_TOKEN= omega telegram setup --user-id ` (the env form keeps the token out of the process list). For one-topic-per-project: group + Topics on + bot admin → `/setupgroup` → `/sync`. 3. **Service keys** *(optional)* — `~/.omega/provisioning/services.env` (Vercel / GitHub / Convex / Stripe / OpenAI-for-voice) powers auto-provisioning of new apps. 4. **Add a project** — `omega` → **[N] New Project**, Telegram → *Import from GitHub*, or just drop a repo under `~/Station//`. @@ -76,7 +76,7 @@ are machine-specific and must come from your own run: ``` OmegaOS doctor - [+] binary omega 0.1.13 + [+] binary omega 0.1.14 [+] doctrine 7 Laws + 52 Rules ... machine-specific checks follow ... ``` @@ -295,7 +295,7 @@ I'd rather you know these going in. - **Linux-first.** Developed on a headless VPS. No Windows. macOS gets real fixes (launchd services, Homebrew path) but is less exercised. - The TUI assumes a 256-color terminal. On a 16-color terminal it'll be ugly. -- The default agent runtime is OpenAI Codex, so the `codex` CLI must be logged in. Claude Code, Gemini, Pi, Hermes, and GLM are supported explicit alternatives. +- The default agent runtime is OpenAI Codex, so the `codex` CLI must be logged in. Claude Code, Antigravity, enterprise/API-key Gemini CLI, OpenRouter/Pi, Hermes, GLM, and Kimi are supported alternatives. - **Single machine.** The rmux daemon is local. There's no multi-host orchestration. - It's 0.1.x. I use it daily, but you'll find rough edges I haven't hit yet. diff --git a/README.ru.md b/README.ru.md index b3c67585..973fd46c 100644 --- a/README.ru.md +++ b/README.ru.md @@ -8,7 +8,7 @@ [![CI](https://github.com/agentik-os/OmegaOS/actions/workflows/ci.yml/badge.svg)](https://github.com/agentik-os/OmegaOS/actions/workflows/ci.yml) ![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg) ![Built with Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg) -OmegaOS — это не библиотека, которую вы импортируете. Вы ставите её на Linux-машину и получаете команду `omega`, TUI для наблюдения за сессиями и их завершения, слой оркестрации, который раздаёт работу агентам, и мост в Telegram для управления с телефона. Новые сессии по умолчанию используют OpenAI Codex. Claude Code, Gemini, Pi, Hermes и GLM остаются явными вариантами. Каждый агент получает компактный типизированный контекст политик для своей роли, скомпилированный из одной доктрины. +OmegaOS — это не библиотека, которую вы импортируете. Вы ставите её на Linux-машину и получаете команду `omega`, TUI для наблюдения за сессиями и их завершения, слой оркестрации, который раздаёт работу агентам, и мост в Telegram для управления с телефона. Новые сессии по умолчанию используют OpenAI Codex. Claude Code, Google Antigravity, Gemini CLI для Enterprise/API, OpenRouter/Pi, Hermes, GLM и Kimi остаются явными вариантами. Каждый агент получает компактный типизированный контекст политик для своей роли, скомпилированный из одной доктрины. Текущая версия — см. [CHANGELOG.md](CHANGELOG.md) (`omega -V` на установленной машине). Я гоняю её каждый день; будьте готовы к шероховатостям. @@ -28,7 +28,7 @@ cd OmegaOS ./install.sh ``` -Установщик скачивает готовые бинарники `rmux` + `omega` под вашу платформу, когда для неё опубликован релиз (с проверкой по контрольной сумме), а иначе откатывается на сборку из исходников — так что свежий клон всегда воспроизводит систему, просто быстрее, когда бинарник есть. Принудительная сборка из исходников — `OMEGA_FROM_SOURCE=1 ./install.sh`. +Установщик скачивает готовые бинарники `rmux` + `omega` + `omega-gatewayd` под вашу платформу, когда для неё опубликован релиз (с проверкой по контрольной сумме), а иначе откатывается на сборку из исходников — так что свежий клон всегда воспроизводит систему, просто быстрее, когда бинарник есть. Принудительная сборка из исходников — `OMEGA_FROM_SOURCE=1 ./install.sh`. ## Обновление @@ -64,7 +64,7 @@ omega config set auto_update off # вообще ничего не делат пошаговый разбор** (он же сохраняется в `~/.omega/GETTING-STARTED.md` и показывается в конце установки). Коротко: -1. **Подключите Codex** *(обязательно для среды исполнения по умолчанию)*: выполните `codex login`, затем проверьте через `codex login status`. Claude остаётся опциональным — через `claude` и `/login`. +1. **Подключите Codex** *(обязательно для среды исполнения по умолчанию)*: выполните `omega codex-login`, затем проверьте через `omega codex-login-status`. Claude остаётся опциональным через `claude auth login`. 2. **Управление из Telegram** *(рекомендуется)* — токен у [@BotFather](https://t.me/BotFather), ваш id у [@userinfobot](https://t.me/userinfobot), дальше `OMEGA_TG_TOKEN= omega telegram setup --user-id ` (форма с переменной окружения держит токен вне списка процессов). Чтобы на каждый проект был свой топик: группа + включённые Topics + бот-админ → `/setupgroup` → `/sync`. 3. **Ключи сервисов** *(опционально)* — `~/.omega/provisioning/services.env` (Vercel / GitHub / Convex / Stripe / OpenAI для голоса) обеспечивает автопровижининг новых приложений. 4. **Добавьте проект** — `omega` → **[N] New Project**, Telegram → *Import from GitHub*, или просто положите репозиторий в `~/Station//`. @@ -247,7 +247,7 @@ omega_dir=~/.omega # протокол ставится в ~/.omega/skil - **Linux в первую очередь.** Разрабатывалось на headless-VPS. Windows нет. На macOS есть настоящие фиксы (launchd-сервисы, путь Homebrew), но обкатки там заметно меньше. - TUI рассчитан на терминал с 256 цветами. На 16-цветном будет уродливо. -- Среда исполнения агентов по умолчанию — OpenAI Codex, так что в CLI `codex` должен быть выполнен вход. Claude Code, Gemini, Pi, Hermes и GLM — поддерживаемые явные альтернативы. +- Среда исполнения агентов по умолчанию — OpenAI Codex, так что в CLI `codex` должен быть выполнен вход. Claude Code, Antigravity, Gemini CLI для Enterprise/API, OpenRouter/Pi, Hermes, GLM и Kimi — поддерживаемые альтернативы. - **Одна машина.** Демон rmux локален. Оркестрации между хостами нет. - Это 0.1.x. Я пользуюсь этим каждый день, но вы найдёте шероховатости, на которые я ещё не натыкался. diff --git a/README.zh.md b/README.zh.md index 266df4a4..9baaa09f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -8,7 +8,7 @@ [![CI](https://github.com/agentik-os/OmegaOS/actions/workflows/ci.yml/badge.svg)](https://github.com/agentik-os/OmegaOS/actions/workflows/ci.yml) ![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg) ![Built with Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg) -OmegaOS 不是一个供你 import 的库。你把它装在一台 Linux 机器上,得到的是 `omega` 命令、一个用来盯着会话并随手 kill 掉它们的 TUI,以及一层把活儿派给 agent 的编排逻辑。还附带一个 Telegram 桥接,方便你用手机来驱动它。新会话默认使用 OpenAI Codex。Claude Code、Gemini、Pi、Hermes 和 GLM 仍可显式选择。每个 agent 都会收到由同一 doctrine 编译出的紧凑、类型化、按角色裁剪的策略上下文。 +OmegaOS 不是一个供你 import 的库。你把它装在一台 Linux 机器上,得到的是 `omega` 命令、一个用来盯着会话并随手 kill 掉它们的 TUI,以及一层把活儿派给 agent 的编排逻辑。还附带一个 Telegram 桥接,方便你用手机来驱动它。新会话默认使用 OpenAI Codex。Claude Code、Google Antigravity、面向 Enterprise/API 的 Gemini CLI、OpenRouter/Pi、Hermes、GLM 和 Kimi 仍可显式选择。每个 agent 都会收到由同一 doctrine 编译出的紧凑、类型化、按角色裁剪的策略上下文。 当前版本见 [CHANGELOG.md](CHANGELOG.md)(在已安装的机器上运行 `omega -V`)。我每天都在用它,粗糙的地方在所难免。 @@ -28,7 +28,7 @@ cd OmegaOS ./install.sh ``` -只要已经发布了对应版本,安装器就会下载适配你所在平台的预编译 `rmux` + `omega` 二进制(用 checksum 校验),否则就退回到从源码构建——所以一次全新的 clone 总能复现出这套系统,只是有二进制的时候会更快。想强制从源码构建就用 `OMEGA_FROM_SOURCE=1 ./install.sh`。 +只要已经发布了对应版本,安装器就会下载适配你所在平台的预编译 `rmux` + `omega` + `omega-gatewayd` 二进制(用 checksum 校验),否则就退回到从源码构建——所以一次全新的 clone 总能复现出这套系统,只是有二进制的时候会更快。想强制从源码构建就用 `OMEGA_FROM_SOURCE=1 ./install.sh`。 ## 更新 @@ -62,7 +62,7 @@ omega config set auto_update off # 什么都不做 会打印完整的分步指引**(同时保存在 `~/.omega/GETTING-STARTED.md`, 安装结束时也会显示一遍)。简而言之: -1. **接上 Codex** *(默认运行时必需)*:跑 `codex login`,然后用 `codex login status` 确认。Claude 仍然是可选的,走 `claude` 和 `/login`。 +1. **接上 Codex** *(默认运行时必需)*:跑 `omega codex-login`,然后用 `omega codex-login-status` 确认。Claude 仍然是可选的,使用 `claude auth login`。 2. **Telegram 远程控制** *(推荐)*——token 从 [@BotFather](https://t.me/BotFather) 拿,你的 id 从 [@userinfobot](https://t.me/userinfobot) 拿,然后 `OMEGA_TG_TOKEN= omega telegram setup --user-id `(用环境变量的写法能让 token 不出现在进程列表里)。想要一个项目一个话题:建群 + 打开 Topics + 把机器人设为管理员 → `/setupgroup` → `/sync`。 3. **服务密钥** *(可选)*——`~/.omega/provisioning/services.env`(Vercel / GitHub / Convex / Stripe / 给语音用的 OpenAI)驱动新 app 的自动 provisioning。 4. **加一个项目**——`omega` → **[N] New Project**,Telegram → *Import from GitHub*,或者干脆把一个仓库丢到 `~/Station//` 下面。 diff --git a/config/providers.default.toml b/config/providers.default.toml new file mode 100644 index 00000000..f1f9cd8e --- /dev/null +++ b/config/providers.default.toml @@ -0,0 +1,32 @@ +# OmegaOS provider overrides +# +# This file intentionally contains no credentials and no active overrides. +# Uncomment only values you want to pin; otherwise each CLI keeps its native +# account/model defaults. Secrets are written with: +# omega config set .api_key +# +# [codex] +# model = "gpt-5.6" +# bypass_hook_trust = true +# +# [claude] +# model = "opus" +# effort = "high" +# +# [gemini] +# model = "auto" +# +# [antigravity] +# model = "" +# effort = "medium" +# dangerously_skip_permissions = true +# +# [hermes] +# provider = "openrouter" +# model = "anthropic/claude-opus-5" +# +# [glm] +# model = "glm-5.3" +# +# [kimi] +# model = "kimi-for-coding" diff --git a/config/systemd/omega-telegram.service b/config/systemd/omega-telegram.service deleted file mode 100644 index dc3bfd5e..00000000 --- a/config/systemd/omega-telegram.service +++ /dev/null @@ -1,18 +0,0 @@ -[Unit] -Description=OmegaOS Telegram bridge (omega telegram run) — persistent phone interface -Documentation=https://github.com/agentik-os/OmegaOS -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -# Rust bridge — replaces the legacy python aisb-bot.service. Reads its token -# from ~/.omega/telegram.toml (set via `omega telegram setup`). Crash-loops -# harmlessly until the token exists, then serves. -ExecStart=%h/.local/bin/omega telegram run -Restart=always -RestartSec=10 -Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin - -[Install] -WantedBy=default.target diff --git a/crates/omega-cli/src/main.rs b/crates/omega-cli/src/main.rs index c5445560..c0361688 100644 --- a/crates/omega-cli/src/main.rs +++ b/crates/omega-cli/src/main.rs @@ -145,6 +145,9 @@ enum Commands { /// Don't actually run the installer — just print the command #[arg(long)] dry_run: bool, + /// Run the upstream installer even when the binary already exists + #[arg(long, visible_alias = "upgrade")] + force: bool, }, /// Open the read-only AISB Telegram conversation viewer. @@ -927,10 +930,12 @@ async fn main() -> Result<()> { let cli = Cli::parse(); let filter = || -> Result { - Ok(tracing_subscriber::EnvFilter::from_default_env() - .add_directive("omega=info".parse()?)) + Ok(tracing_subscriber::EnvFilter::from_default_env().add_directive("omega=info".parse()?)) }; - match command_renders_tui(&cli.command).then(tui_log_writer).flatten() { + match command_renders_tui(&cli.command) + .then(tui_log_writer) + .flatten() + { Some(file) => tracing_subscriber::fmt() .with_env_filter(filter()?) .with_target(false) @@ -1035,7 +1040,11 @@ async fn main() -> Result<()> { Some(Commands::Projects { json }) => cmd_projects(json), Some(Commands::Marketing(action)) => cmd_marketing(action), Some(Commands::TrustDir { dir }) => cmd_trust_dir(dir.as_deref()), - Some(Commands::Install { agent, dry_run }) => cmd_install(&agent, dry_run), + Some(Commands::Install { + agent, + dry_run, + force, + }) => cmd_install(&agent, dry_run, force), Some(Commands::AisbView) => cmd_aisb_view().await, Some(Commands::Config { action }) => cmd_config(action), // Two features, one command word. A bare `omega monitor` is the @@ -2125,9 +2134,9 @@ async fn run_tui_loop( continue; } } - // The optional viewer auto-respawns. The Telegram bridge is unaffected - // (its persistent claude_stream subprocess handles - // chat independently of the rmux session). + // The optional viewer auto-respawns. The standalone + // Bun Telegram service handles chat independently + // of this rmux session. if is_master && cfg.auto_spawn_master { let cwd = std::env::current_dir() .ok() @@ -3818,7 +3827,7 @@ bind-key z display-popup -E -w 100% -h 100% "omega menu" Ok(()) } -fn cmd_install(agent_name: &str, dry_run: bool) -> Result<()> { +fn cmd_install(agent_name: &str, dry_run: bool, force: bool) -> Result<()> { let agent = omega_core::agents::Agent::from_name(agent_name) .ok_or_else(|| anyhow::anyhow!("Unknown agent: {}", agent_name))?; @@ -3829,9 +3838,11 @@ fn cmd_install(agent_name: &str, dry_run: bool) -> Result<()> { ) })?; - if agent.is_available() && !dry_run { + if agent.is_available() && !dry_run && !force { println!("[+] {} is already installed.", agent.display_name()); - println!(" Re-run with `--dry-run` to see the install command anyway."); + println!( + " Re-run with `--force` to update/reinstall, or `--dry-run` to inspect the command." + ); return Ok(()); } @@ -3868,11 +3879,11 @@ fn cmd_install(agent_name: &str, dry_run: bool) -> Result<()> { agent.display_name() ); } else { - println!( - "\n[!] Installer reported success but `{}` is not on PATH yet.", - agent.name() + anyhow::bail!( + "installer exited successfully but `{}` is still unavailable; \ + verify the installer output and expected binary locations, then retry", + agent.binary_name() ); - println!(" You may need to restart your shell or add the binary directory to PATH."); } // Auto-sync: wire the new LLM into ~/.omega/ centralized config @@ -4807,6 +4818,11 @@ enum ConfigAction { Set { key: String, value: String }, /// Show all provider configs Show, + /// Make a provider (and optional model) the global default for new sessions + Activate { + provider: String, + model: Option, + }, /// List the canonical providers (no arg) or a provider's known models (one per /// line). SSOT for any UI building a model picker (TUI, Telegram) so the curated /// lists live ONLY in providers.rs::models_for / all_providers. @@ -4886,6 +4902,33 @@ fn cmd_config(action: ConfigAction) -> Result<()> { println!("[+] Set {} = {}", key, displayed); println!("Applies to all newly spawned sessions."); } + ConfigAction::Activate { provider, model } => { + let agent = omega_core::agents::Agent::from_name(&provider) + .ok_or_else(|| anyhow::anyhow!("provider {provider:?} has no launch adapter"))?; + let provider = agent.name(); + + if provider != "shell" { + let mut cfg = ProvidersConfig::try_load() + .context("cannot load provider config for activation")?; + set_config_value( + &mut cfg, + &format!("{provider}.model"), + model.as_deref().unwrap_or(""), + )?; + cfg.save()?; + } + + let mut runtime = omega_core::config::OmegaConfig::load() + .context("cannot load OmegaOS runtime config for activation")?; + runtime.agent_command = provider.to_string(); + runtime.save()?; + let active = omega_core::providers::ActiveModel::set(provider, model.as_deref())?; + println!( + "[+] Active provider = {} / {}", + active.active_provider, active.active_model + ); + println!("Applies globally to newly spawned sessions."); + } ConfigAction::Models { provider } => match provider { // No provider → the canonical provider list. With one → its known models. // Empty list (unknown provider) prints nothing and exits 0 so callers can @@ -4950,8 +4993,14 @@ fn get_config_value(cfg: &omega_core::providers::ProvidersConfig, key: &str) -> ("codex", "model") => cfg.codex.model.clone(), ("codex", "api_key") => redacted_secret(&cfg.codex.api_key), ("codex", "base_url") => cfg.codex.base_url.clone(), + ("codex", "bypass_hook_trust") => cfg.codex.bypass_hook_trust.to_string(), ("gemini", "model") => cfg.gemini.model.clone(), ("gemini", "api_key") => redacted_secret(&cfg.gemini.api_key), + ("antigravity", "model") => cfg.antigravity.model.clone(), + ("antigravity", "effort") => cfg.antigravity.effort.clone(), + ("antigravity", "dangerously_skip_permissions") => { + cfg.antigravity.dangerously_skip_permissions.to_string() + } ("pi", "provider") => cfg.pi.provider.clone(), ("pi", "model") => cfg.pi.model.clone(), ("pi", "api_key") => redacted_secret(&cfg.pi.api_key), @@ -4960,6 +5009,7 @@ fn get_config_value(cfg: &omega_core::providers::ProvidersConfig, key: &str) -> ("openrouter", "model") => cfg.openrouter.model.clone(), ("openrouter", "api_key") => redacted_secret(&cfg.openrouter.api_key), ("openrouter", "base_url") => cfg.openrouter.base_url.clone(), + ("hermes", "provider") => cfg.hermes.provider.clone(), ("hermes", "model") => cfg.hermes.model.clone(), ("hermes", "api_key") => redacted_secret(&cfg.hermes.api_key), ("kimi", "model") => cfg.kimi.model.clone(), @@ -4991,8 +5041,20 @@ fn set_config_value( ("codex", "model") => cfg.codex.model = value.to_string(), ("codex", "api_key") => cfg.codex.api_key = value.to_string(), ("codex", "base_url") => cfg.codex.base_url = value.to_string(), + ("codex", "bypass_hook_trust") => { + cfg.codex.bypass_hook_trust = value + .parse::() + .with_context(|| format!("invalid boolean {value:?}; expected true or false"))?; + } ("gemini", "model") => cfg.gemini.model = value.to_string(), ("gemini", "api_key") => cfg.gemini.api_key = value.to_string(), + ("antigravity", "model") => cfg.antigravity.model = value.to_string(), + ("antigravity", "effort") => cfg.antigravity.effort = value.to_string(), + ("antigravity", "dangerously_skip_permissions") => { + cfg.antigravity.dangerously_skip_permissions = value + .parse::() + .with_context(|| format!("invalid boolean {value:?}; expected true or false"))?; + } ("pi", "provider") => cfg.pi.provider = value.to_string(), ("pi", "model") => cfg.pi.model = value.to_string(), ("pi", "api_key") => cfg.pi.api_key = value.to_string(), @@ -5001,6 +5063,7 @@ fn set_config_value( ("openrouter", "model") => cfg.openrouter.model = value.to_string(), ("openrouter", "api_key") => cfg.openrouter.api_key = value.to_string(), ("openrouter", "base_url") => cfg.openrouter.base_url = value.to_string(), + ("hermes", "provider") => cfg.hermes.provider = value.to_string(), ("hermes", "model") => cfg.hermes.model = value.to_string(), ("hermes", "api_key") => cfg.hermes.api_key = value.to_string(), ("kimi", "model") => cfg.kimi.model = value.to_string(), @@ -13026,7 +13089,12 @@ fn cmd_update(check: bool, dir: Option<&str>) -> Result<()> { .env("OMEGA_FROM_SOURCE", "1") .status()?; if !status.success() { - anyhow::bail!("install.sh failed — your previous install is untouched"); + anyhow::bail!( + "install.sh failed after the checkout was updated; some idempotent install steps may \ + already have run. Fix the reported error, then re-run `omega update` (or \ + `cd {} && ./install.sh`) to converge the installation", + src.display() + ); } println!("\n✓ OmegaOS updated. Restart a running TUI (Menu → R) to pick up the new binary."); @@ -15995,7 +16063,8 @@ fn export_rules_to(rules_dir: &std::path::Path, verbose: bool) -> Result Ok(all.len()) } -const DOCTRINE_BEGIN: &str = ""; +const DOCTRINE_BEGIN: &str = + ""; const DOCTRINE_END: &str = ""; /// Replace (or append) the generated doctrine block in a single-file agent @@ -16110,6 +16179,27 @@ async fn cmd_reconcile(report_only: bool) -> Result<()> { Err(e) => needs_human.push(format!("could not re-export doctrine: {e}")), } + // Provider CLIs may replace a credential symlink atomically during + // refresh. Reconcile through omega-core so the fresher valid Claude + // token wins; the retired shell migration always preferred canonical + // state and could restore a consumed refresh token. + match omega_core::credentials::CredentialStore::new() + .and_then(|store| store.ensure_legacy_symlink("claude")) + { + Ok(()) => println!(" [+] Claude credential topology reconciled"), + Err(e) => needs_human.push(format!("could not reconcile Claude credentials: {e}")), + } + + match omega_core::codex_trust::migrate_retired_approval_policy() { + Ok(true) => { + println!(" [+] Codex retired approval_policy migrated to on-request") + } + Ok(false) => {} + Err(e) => needs_human.push(format!( + "could not migrate Codex approval_policy removed in 0.149: {e}" + )), + } + // Whatever installed this binary may not have recorded which commit it // was. Without that record nothing downstream can prove staleness. if let Err(e) = cmd_update_record_installed(None) { @@ -17622,8 +17712,16 @@ mod phase1_tests { #[test] fn bounded_capture_kills_a_setsid_descendant_and_never_reports_success() { let token = new_graph_process_token().unwrap(); + let dir = TestDir::new("setsid-descendant"); + let marker = dir.path().join("started"); let mut command = std::process::Command::new("bash"); - command.arg("-c").arg("setsid sh -c 'sleep 2' & exit 0"); + command + .env("OMEGA_TEST_DESCENDANT_MARKER", &marker) + .arg("-c") + .arg( + "setsid sh -c 'printf ready > \"$OMEGA_TEST_DESCENDANT_MARKER\"; sleep 2' & \ + while [ ! -s \"$OMEGA_TEST_DESCENDANT_MARKER\" ]; do sleep 0.01; done; exit 0", + ); let started = std::time::Instant::now(); let (result, _, _) = run_bounded_capture_with_token( &mut command, diff --git a/crates/omega-core/src/agents.rs b/crates/omega-core/src/agents.rs index 1ecea2d3..706428f3 100644 --- a/crates/omega-core/src/agents.rs +++ b/crates/omega-core/src/agents.rs @@ -57,7 +57,7 @@ pub struct LaunchOptions { pub resume_conversation: bool, // ── Claude-only smart features (2026-w20+) ──────────────────────── - // Other providers (Gemini, Codex, GLM, Pi, Hermes) ignore these + // Other providers (Gemini, Antigravity, Codex, GLM, Pi, Hermes) ignore these // fields silently because their CLIs don't have equivalents. We // pass them only when Agent::Claude. /// `/goal` condition (v2.1.139+) — Claude auto-loops until this @@ -89,7 +89,8 @@ pub struct LaunchOptions { // These all keep the TTY attachable (rmux pane). Emitted ONLY when // set, in the Agent::Claude arm. Headless-only flags (stream-json / // --print / --input-format / --include-partial-messages) are NOT here — - // they live on Lane B (claude_stream.rs) where there is no human attach. + // they live on Lane B (omega-gateway/chat_driver.rs) where there is no + // human attach. /// `--session-id ` — deterministic session id for resume/dedupe. /// Must be a valid UUID; we generate+persist one per oracle in ~/.omega/state. pub session_id: Option, @@ -135,7 +136,9 @@ pub enum Agent { Claude, Codex, Gemini, + Antigravity, Pi, + OpenRouter, Hermes, Glm, Kimi, @@ -148,7 +151,9 @@ impl Agent { Agent::Claude, Agent::Codex, Agent::Gemini, + Agent::Antigravity, Agent::Pi, + Agent::OpenRouter, Agent::Hermes, Agent::Glm, Agent::Kimi, @@ -161,7 +166,9 @@ impl Agent { Agent::Claude => "claude", Agent::Codex => "codex", Agent::Gemini => "gemini", + Agent::Antigravity => "antigravity", Agent::Pi => "pi", + Agent::OpenRouter => "openrouter", Agent::Hermes => "hermes", Agent::Glm => "glm", Agent::Kimi => "kimi", @@ -174,7 +181,9 @@ impl Agent { Agent::Claude => "Claude Code (Anthropic)", Agent::Codex => "Codex (OpenAI)", Agent::Gemini => "Gemini (Google)", + Agent::Antigravity => "Antigravity (Google)", Agent::Pi => "Pi (earendil-works)", + Agent::OpenRouter => "OpenRouter (via Pi)", Agent::Hermes => "Hermes (Nous Research)", Agent::Glm => "GLM (Z.AI / Zhipu)", Agent::Kimi => "Kimi (Moonshot AI)", @@ -182,12 +191,29 @@ impl Agent { } } + /// Executable used by this adapter. GLM intentionally shares Claude Code; + /// Antigravity's product/provider name differs from its `agy` binary. + pub fn binary_name(&self) -> &'static str { + match self { + Agent::Claude | Agent::Glm => "claude", + Agent::Codex => "codex", + Agent::Gemini => "gemini", + Agent::Antigravity => "agy", + Agent::Pi | Agent::OpenRouter => "pi", + Agent::Hermes => "hermes", + Agent::Kimi => "kimi", + Agent::Shell => "bash", + } + } + pub fn from_name(s: &str) -> Option { match s.to_lowercase().as_str() { "claude" => Some(Agent::Claude), "codex" => Some(Agent::Codex), "gemini" => Some(Agent::Gemini), + "antigravity" | "agy" => Some(Agent::Antigravity), "pi" => Some(Agent::Pi), + "openrouter" => Some(Agent::OpenRouter), "hermes" => Some(Agent::Hermes), "glm" => Some(Agent::Glm), "kimi" => Some(Agent::Kimi), @@ -213,25 +239,31 @@ impl Agent { "if command -v npm >/dev/null 2>&1; then mkdir -p \"$HOME/.npm-global\" && npm install -g --prefix \"$HOME/.npm-global\" @anthropic-ai/claude-code; elif [ -x \"$HOME/.bun/bin/bun\" ]; then \"$HOME/.bun/bin/bun\" add -g @anthropic-ai/claude-code; else echo 'Need Node.js or bun first (run: curl -fsSL https://bun.sh/install | bash)'; exit 1; fi", ), Agent::Claude => Some( - "T=$(mktemp) && curl -fsSL https://claude.ai/install.sh -o \"$T\" && bash \"$T\"; rm -f \"$T\"", + "T=$(mktemp) || exit $?; curl -fsSL https://claude.ai/install.sh -o \"$T\" && bash \"$T\"; R=$?; rm -f \"$T\"; exit $R", ), // Official standalone installer (same shape as Claude's above), NOT // `npm i -g @openai/codex`: the npm build lacks the managed standalone // package at ~/.codex/packages/standalone that `codex remote-control` // requires, so an npm-installed Codex cannot be driven from the phone. Agent::Codex => Some( - "T=$(mktemp) && curl -fsSL https://chatgpt.com/codex/install.sh -o \"$T\" && CODEX_NON_INTERACTIVE=1 sh \"$T\"; rm -f \"$T\"", + "T=$(mktemp) || exit $?; curl -fsSL https://chatgpt.com/codex/install.sh -o \"$T\" && CODEX_NON_INTERACTIVE=1 sh \"$T\"; R=$?; rm -f \"$T\"; exit $R", ), Agent::Gemini => Some( "if command -v npm >/dev/null 2>&1; then mkdir -p \"$HOME/.npm-global\" && npm install -g --prefix \"$HOME/.npm-global\" @google/gemini-cli; elif [ -x \"$HOME/.bun/bin/bun\" ]; then \"$HOME/.bun/bin/bun\" add -g @google/gemini-cli; else echo 'Need Node.js or bun first (run: curl -fsSL https://bun.sh/install | bash)'; exit 1; fi", ), + Agent::Antigravity => Some( + "T=$(mktemp) || exit $?; curl -fsSL https://antigravity.google/cli/install.sh -o \"$T\" && bash \"$T\"; R=$?; rm -f \"$T\"; exit $R", + ), // Pi: install the npm package directly (the curl|sh installer runs a // TTY animation that fails in a non-interactive pane → `pi` never landed). Agent::Pi => Some( "if command -v npm >/dev/null 2>&1; then mkdir -p \"$HOME/.npm-global\" && npm install -g --prefix \"$HOME/.npm-global\" @earendil-works/pi-coding-agent; elif [ -x \"$HOME/.bun/bin/bun\" ]; then \"$HOME/.bun/bin/bun\" add -g @earendil-works/pi-coding-agent; else echo 'Need Node.js or bun first (run: curl -fsSL https://bun.sh/install | bash)'; exit 1; fi", ), + Agent::OpenRouter => Some( + "if command -v npm >/dev/null 2>&1; then mkdir -p \"$HOME/.npm-global\" && npm install -g --prefix \"$HOME/.npm-global\" @earendil-works/pi-coding-agent; elif [ -x \"$HOME/.bun/bin/bun\" ]; then \"$HOME/.bun/bin/bun\" add -g @earendil-works/pi-coding-agent; else echo 'Need Node.js or bun first (run: curl -fsSL https://bun.sh/install | bash)'; exit 1; fi", + ), Agent::Hermes => Some( - "T=$(mktemp) && curl -fsSL https://hermes-agent.nousresearch.com/install.sh -o \"$T\" && bash \"$T\" && hermes setup; rm -f \"$T\"", + "T=$(mktemp) || exit $?; curl -fsSL https://hermes-agent.nousresearch.com/install.sh -o \"$T\" && bash \"$T\"; R=$?; rm -f \"$T\"; exit $R", ), Agent::Kimi => Some( "T=$(mktemp) && curl -fsSL https://code.kimi.com/kimi-code/install.sh -o \"$T\" && bash \"$T\"; R=$?; rm -f \"$T\"; exit $R", @@ -258,8 +290,18 @@ impl Agent { Agent::Gemini => { Some("npm uninstall -g --prefix \"$HOME/.npm-global\" @google/gemini-cli") } - Agent::Pi => Some("rm -f $(which pi) && rm -rf ~/.pi"), - Agent::Hermes => Some("rm -f $(which hermes) && rm -rf ~/.hermes"), + // Keep ~/.gemini/antigravity-cli and keyring credentials intact. + Agent::Antigravity => Some("rm -f \"$(command -v agy)\""), + // Remove only the package; preserve native auth/config/session data. + Agent::Pi => Some( + "npm uninstall -g --prefix \"$HOME/.npm-global\" @earendil-works/pi-coding-agent", + ), + // Shares the Pi binary; removing it here would break Pi sessions. + Agent::OpenRouter => None, + // Upstream uninstaller handles venv/FHS binaries and gateway + // services while preserving ~/.hermes unless the user requests a + // full wipe inside Hermes itself. + Agent::Hermes => Some("hermes uninstall"), // GLM shares the Claude Code binary — there is nothing GLM-specific to // uninstall. Removing it would wrongly delete the user's Claude Code. Agent::Glm => None, @@ -274,7 +316,9 @@ impl Agent { Agent::Claude => Some("https://claude.ai/code"), Agent::Codex => Some("https://github.com/openai/codex"), Agent::Gemini => Some("https://github.com/google-gemini/gemini-cli"), + Agent::Antigravity => Some("https://antigravity.google/docs/cli/overview/"), Agent::Pi => Some("https://pi.dev/"), + Agent::OpenRouter => Some("https://openrouter.ai/"), Agent::Hermes => Some("https://hermes-agent.nousresearch.com/"), Agent::Glm => Some("https://www.z.ai/"), Agent::Kimi => Some("https://www.kimi.com/code/docs/en/kimi-code-cli/"), @@ -331,7 +375,19 @@ impl Agent { pick(&["OPENAI_API_KEY", "OPENAI_BASE_URL"]) } } - Agent::Gemini => pick(&["GOOGLE_API_KEY", "GEMINI_API_KEY"]), + // A lingering API key environment variable forces Gemini CLI away + // from its cached OAuth account. Protect native OAuth the same way + // Codex protects ChatGPT login from OPENAI_API_KEY overrides. + Agent::Gemini => { + if gemini_has_native_oauth() { + Vec::new() + } else { + pick(&["GOOGLE_API_KEY", "GEMINI_API_KEY"]) + } + } + // Antigravity authenticates through its native keyring / Google + // sign-in flow. Never leak Gemini API-key state into that session. + Agent::Antigravity => Vec::new(), // GLM = Claude Code redirected to Z.AI. Supply the exact native // variable directly so no secret-bearing shell expansion is needed. Agent::Glm => { @@ -357,6 +413,7 @@ impl Agent { } s } + Agent::OpenRouter => pick(&["OPENROUTER_API_KEY", "OPENROUTER_BASE_URL"]), Agent::Hermes => { let mut s = pick(&["OPENROUTER_API_KEY", "OPENROUTER_BASE_URL"]); if !cfg.hermes.api_key.is_empty() { @@ -588,11 +645,20 @@ impl Agent { // dark rmux/omega TUI it blends in. Quoted so the ';' is one env // value, not a shell separator. let trust_prefix = "omega trust-dir \"$PWD\" >/dev/null 2>&1; "; + // Codex >=0.147 makes --approve-for-me a complete permission + // preset: it sets workspace-write + on-request itself and + // explicitly CONFLICTS with a separate --sandbox flag. Omega + // installs known hooks. Detached panes bypass the otherwise + // blocking review by default; operators with additional + // untrusted hooks can disable that explicit provider setting. let mut args = format!( - "{}{}COLORFGBG='15;0' codex --strict-config --sandbox workspace-write \ - --approve-for-me --no-alt-screen", + "{}{}COLORFGBG='15;0' codex --strict-config --approve-for-me", env_prefix, trust_prefix ); + if providers.codex.bypass_hook_trust { + args.push_str(" --dangerously-bypass-hook-trust"); + } + args.push_str(" --no-alt-screen"); if let Some(model) = nonempty(&providers.codex.model) { args.push_str(&format!(" --model {}", shell_quote(model))); } @@ -606,10 +672,13 @@ impl Agent { for writable in &providers.codex.additional_writable_dirs { args.push_str(&format!(" --add-dir {}", shell_quote(writable))); } + if opts.resume_conversation { + args.push_str(" resume --last"); + } match initial_prompt { Some(p) => format!( "bash -c {}", - shell_quote(&format!("{} {}; exec bash", args, shell_quote(p))) + shell_quote(&format!("{} -- {}; exec bash", args, shell_quote(p))) ), None => format!("bash -c {}", shell_quote(&format!("{}; exec bash", args))), } @@ -618,22 +687,59 @@ impl Agent { let model_arg = nonempty(&providers.gemini.model) .map(|model| format!(" --model {}", shell_quote(model))) .unwrap_or_default(); + let resume_arg = if opts.resume_conversation { + " --resume latest" + } else { + "" + }; match initial_prompt { Some(p) => format!( "bash -c {}", shell_quote(&format!( - "{}gemini{} {}; exec bash", + "{}gemini{}{} --prompt-interactive {}; exec bash", env_prefix, model_arg, + resume_arg, shell_quote(p) )) ), None => format!( "bash -c {}", - shell_quote(&format!("{}gemini{}; exec bash", env_prefix, model_arg)) + shell_quote(&format!( + "{}gemini{}{}; exec bash", + env_prefix, model_arg, resume_arg + )) ), } } + Agent::Antigravity => { + let mut args = format!("{}agy", env_prefix); + if providers.antigravity.dangerously_skip_permissions { + args.push_str(" --dangerously-skip-permissions"); + } + if let Some(model) = nonempty(&providers.antigravity.model) { + args.push_str(&format!(" --model {}", shell_quote(model))); + } + if let Some(effort) = nonempty(&providers.antigravity.effort) { + args.push_str(&format!(" --effort {}", shell_quote(effort))); + } + if opts.resume_conversation { + args.push_str(" --continue"); + } + match initial_prompt { + Some(prompt) => format!( + "bash -c {}", + shell_quote(&format!( + "{} --prompt-interactive {}; exec bash", + args, + shell_quote(prompt) + )) + ), + None => { + format!("bash -c {}", shell_quote(&format!("{}; exec bash", args))) + } + } + } Agent::Pi => { // (b) Use the CONFIGURED pi.provider + pi.model; fall back to the // catalog defaults only when unset (was hardcoded @@ -653,42 +759,109 @@ impl Agent { shell_quote(provider), shell_quote(&model) ); + let resume_arg = if opts.resume_conversation { + " --continue" + } else { + "" + }; match initial_prompt { Some(p) => format!( "bash -c {}", shell_quote(&format!( - "{}pi {} {}; exec bash", + "{}pi {}{} -- {}; exec bash", env_prefix, pi_args, + resume_arg, shell_quote(p) )) ), None => format!( "bash -c {}", - shell_quote(&format!("{}pi {}; exec bash", env_prefix, pi_args)) + shell_quote(&format!( + "{}pi {}{}; exec bash", + env_prefix, pi_args, resume_arg + )) + ), + } + } + Agent::OpenRouter => { + let model = if providers.openrouter.model.is_empty() { + ProvidersConfig::default_model("openrouter") + } else { + providers.openrouter.model.as_str() + }; + let resume_arg = if opts.resume_conversation { + " --continue" + } else { + "" + }; + let args = format!( + "--provider openrouter --model {}{}", + shell_quote(model), + resume_arg + ); + match initial_prompt { + Some(prompt) => format!( + "bash -c {}", + shell_quote(&format!( + "{}pi {} -- {}; exec bash", + env_prefix, + args, + shell_quote(prompt) + )) + ), + None => format!( + "bash -c {}", + shell_quote(&format!("{}pi {}; exec bash", env_prefix, args)) ), } } Agent::Hermes => { - // (c) Pass --model when hermes.model is configured (was ignored). + // Hermes has required an explicit `chat` subcommand for + // one-shot prompts since before v0.20. A bare positional prompt + // is parsed as an invalid subcommand. Keep no-prompt sessions + // interactive and use the documented query lane for dispatch. + let hermes_provider = if !providers.hermes.provider.trim().is_empty() { + Some(providers.hermes.provider.trim()) + } else if !providers.hermes.api_key.is_empty() + || !providers.openrouter.api_key.is_empty() + || !providers.openrouter.base_url.is_empty() + { + Some("openrouter") + } else { + None + }; + let provider_arg = hermes_provider + .map(|provider| format!(" --provider {}", shell_quote(provider))) + .unwrap_or_default(); let hermes_args = if providers.hermes.model.is_empty() { String::new() } else { format!(" --model {}", shell_quote(&providers.hermes.model)) }; + let resume_arg = if opts.resume_conversation { + " --continue" + } else { + "" + }; match initial_prompt { Some(p) => format!( "bash -c {}", shell_quote(&format!( - "{}hermes{} {}; exec bash", + "{}hermes chat{}{}{} -q {}; exec bash", env_prefix, + provider_arg, hermes_args, + resume_arg, shell_quote(p) )) ), None => format!( "bash -c {}", - shell_quote(&format!("{}hermes{}; exec bash", env_prefix, hermes_args)) + shell_quote(&format!( + "{}hermes chat{}{}{}; exec bash", + env_prefix, provider_arg, hermes_args, resume_arg + )) ), } } @@ -712,23 +885,29 @@ impl Agent { opts.permission_mode.as_deref(), providers.glm.dangerously_skip_permissions, )?; + let resume_arg = if opts.resume_conversation { + " --continue" + } else { + "" + }; match initial_prompt { Some(p) => format!( "bash -c {}", shell_quote(&format!( - "{} {}CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude{}{} {}; exec bash", + "{} {}CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude{}{}{} {}; exec bash", env_prefix, trust_prefix, perms, model_arg, + resume_arg, shell_quote(p) )) ), None => format!( "bash -c {}", shell_quote(&format!( - "{} {}CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude{}{}; exec bash", - env_prefix, trust_prefix, perms, model_arg + "{} {}CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 claude{}{}{}; exec bash", + env_prefix, trust_prefix, perms, model_arg, resume_arg )) ), } @@ -755,7 +934,7 @@ impl Agent { Some(p) => format!( "bash -c {}", shell_quote(&format!( - "{}kimi --auto{}{} --prompt {}; exec bash", + "{}kimi{}{} --prompt {}; exec bash", env_prefix, model_arg, resume_arg, @@ -801,12 +980,27 @@ impl Agent { has_cmd("gemini") || std::path::Path::new(&format!("{}/.npm-global/bin/gemini", home)).exists() } + Agent::Antigravity => { + has_cmd("agy") + || std::path::Path::new(&format!("{}/.local/bin/agy", home)).exists() + || std::path::Path::new(&format!("{}/.gemini/antigravity-cli/bin/agy", home)) + .exists() + } Agent::Pi => { has_cmd("pi") || std::path::Path::new(&format!("{}/.local/bin/pi", home)).exists() || std::path::Path::new(&format!("{}/.npm-global/bin/pi", home)).exists() } - Agent::Hermes => has_cmd("hermes"), + Agent::OpenRouter => { + has_cmd("pi") + || std::path::Path::new(&format!("{}/.local/bin/pi", home)).exists() + || std::path::Path::new(&format!("{}/.npm-global/bin/pi", home)).exists() + } + Agent::Hermes => { + has_cmd("hermes") + || std::path::Path::new(&format!("{}/.local/bin/hermes", home)).exists() + || std::path::Path::new(&format!("{}/.hermes/bin/hermes", home)).exists() + } Agent::Kimi => { has_cmd("kimi") || std::path::Path::new(&format!("{}/.local/bin/kimi", home)).exists() @@ -874,6 +1068,32 @@ fn claude_available(home: &str) -> bool { || std::path::Path::new(&format!("{}/.npm-global/bin/claude", home)).exists() } +fn gemini_settings_select_oauth(raw: &str) -> bool { + serde_json::from_str::(raw) + .ok() + .and_then(|value| { + value + .pointer("/security/auth/selectedType") + .or_else(|| value.get("selectedAuthType")) + .and_then(serde_json::Value::as_str) + .map(str::to_ascii_lowercase) + }) + .is_some_and(|selected| selected.starts_with("oauth") || selected == "login-with-google") +} + +fn gemini_has_native_oauth() -> bool { + let Some(home) = dirs::home_dir() else { + return false; + }; + let gemini_home = home.join(".gemini"); + if gemini_home.join("oauth_creds.json").is_file() { + return true; + } + std::fs::read_to_string(gemini_home.join("settings.json")) + .ok() + .is_some_and(|raw| gemini_settings_select_oauth(&raw)) +} + fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } @@ -888,6 +1108,19 @@ mod tests { .unwrap() } + #[test] + fn gemini_auth_selection_distinguishes_oauth_from_api_keys() { + assert!(gemini_settings_select_oauth( + r#"{"security":{"auth":{"selectedType":"oauth-personal"}}}"# + )); + assert!(gemini_settings_select_oauth( + r#"{"selectedAuthType":"login-with-google"}"# + )); + assert!(!gemini_settings_select_oauth( + r#"{"security":{"auth":{"selectedType":"gemini-api-key"}}}"# + )); + } + // The worker/oracle identity contract: when LaunchOptions.session_name is // set, the generated Claude command MUST carry `--name ` so the // Claude conversation shares the rmux session's deterministic identity @@ -967,10 +1200,11 @@ mod tests { !cmd.contains("NO_COLOR") && cmd.contains("COLORFGBG=") && cmd.contains("15;0") - && cmd.contains("codex --strict-config --sandbox workspace-write") + && cmd.contains("codex --strict-config --approve-for-me") && cmd.contains("--approve-for-me") + && !cmd.contains("--sandbox") && cmd.contains("--add-dir") - && !cmd.contains("dangerously-bypass") + && cmd.contains("--dangerously-bypass-hook-trust") && cmd.contains("--no-alt-screen"), "Codex launch must keep color and stay terminal-safe: {cmd}" ); @@ -1018,7 +1252,7 @@ mod tests { } #[test] - fn kimi_uses_current_auto_prompt_and_model_override_contract() { + fn kimi_prompt_uses_implicit_auto_policy_and_model_override_contract() { let providers = ProvidersConfig { kimi: crate::providers::KimiConfig { model: "kimi-for-coding".to_string(), @@ -1048,8 +1282,84 @@ mod tests { assert!(!cmd.contains("KIMI_MODEL_API_KEY"), "{cmd}"); assert!(!cmd.contains("key with ' quote; $(touch nope)"), "{cmd}"); assert!(!cmd.contains("export KIMI_API_KEY="), "{cmd}"); - assert!(cmd.contains("kimi --auto"), "{cmd}"); assert!(cmd.contains("--prompt"), "{cmd}"); + assert!(!cmd.contains("kimi --auto"), "{cmd}"); + } + + #[test] + fn kimi_interactive_session_uses_auto_policy() { + let cmd = launch(Agent::Kimi, None, LaunchOptions::default()); + assert!(cmd.contains("kimi --auto"), "{cmd}"); + assert!(!cmd.contains("--prompt"), "{cmd}"); + } + + #[test] + fn hermes_prompt_uses_chat_query_subcommand() { + let providers = ProvidersConfig { + hermes: crate::providers::HermesConfig { + provider: "openrouter".to_string(), + ..Default::default() + }, + ..Default::default() + }; + let cmd = Agent::Hermes + .launch_command_with_providers( + Some("inspect the repository"), + LaunchOptions::default(), + &providers, + ) + .unwrap(); + assert!(cmd.contains("hermes chat --provider"), "{cmd}"); + assert!(cmd.contains("openrouter"), "{cmd}"); + assert!(cmd.contains(" -q "), "{cmd}"); + } + + #[test] + fn gemini_prompt_stays_in_an_interactive_session() { + let cmd = launch( + Agent::Gemini, + Some("inspect the repository"), + LaunchOptions::default(), + ); + assert!(cmd.contains("--prompt-interactive"), "{cmd}"); + } + + #[test] + fn antigravity_prompt_stays_interactive_and_autonomous() { + let cmd = launch( + Agent::Antigravity, + Some("inspect the repository"), + LaunchOptions::default(), + ); + assert!(cmd.contains("agy --dangerously-skip-permissions"), "{cmd}"); + assert!(cmd.contains("--prompt-interactive"), "{cmd}"); + assert!(!cmd.contains(" -p "), "{cmd}"); + } + + #[test] + fn patrol_resume_is_mapped_for_every_conversational_adapter() { + let opts = LaunchOptions { + resume_conversation: true, + ..Default::default() + }; + for (agent, expected) in [ + (Agent::Claude, "--continue"), + (Agent::Codex, "resume --last"), + (Agent::Gemini, "--resume latest"), + (Agent::Antigravity, "--continue"), + (Agent::Pi, "--continue"), + (Agent::OpenRouter, "--continue"), + (Agent::Hermes, "--continue"), + (Agent::Glm, "--continue"), + (Agent::Kimi, "--continue"), + ] { + let command = launch(agent, None, opts.clone()); + assert!( + command.contains(expected), + "{} resume mapping missing {expected}: {command}", + agent.name() + ); + } } #[test] diff --git a/crates/omega-core/src/aisb.rs b/crates/omega-core/src/aisb.rs index 1e8cc855..5c0d12fe 100644 --- a/crates/omega-core/src/aisb.rs +++ b/crates/omega-core/src/aisb.rs @@ -16,7 +16,8 @@ pub const MASTER_SESSION_NAME: &str = "aisb-master"; /// The session is a PURE READ-ONLY VIEWER: it `tail -F`s the conversation log /// the brain stream writes, so the user can WATCH the live Telegram exchange /// in the TUI. It is NOT the brain and NOT interactive (the brain is the -/// Telegram bot's own SDK subprocess — see `claude_stream.rs`). +/// Telegram bot's own headless subprocess — see +/// `omega-gateway/src/chat_driver.rs` and `telegram-bot/omega-tg-bot.ts`). /// /// Returns true if a new session was created, false if it already existed. pub async fn ensure_viewer(mgr: &SessionManager, working_dir: &str) -> Result { @@ -26,7 +27,8 @@ pub async fn ensure_viewer(mgr: &SessionManager, working_dir: &str) -> Result PreResetReport { let candidates = [ "credentials/claude.json", "credentials/codex.json", - "credentials/gemini.json", + "providers.toml", "provisioning/services.env", "provisioning/clerk-pool.env", "config/vercel-tokens.json", diff --git a/crates/omega-core/src/codex_trust.rs b/crates/omega-core/src/codex_trust.rs index 8447ee37..692b2922 100644 --- a/crates/omega-core/src/codex_trust.rs +++ b/crates/omega-core/src/codex_trust.rs @@ -17,6 +17,29 @@ use std::path::Path; use toml_edit::{value, DocumentMut, Item, Table}; +fn config_path() -> std::io::Result { + let home = dirs::home_dir() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no home dir"))?; + let codex_home = std::env::var("CODEX_HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| home.join(".codex")); + Ok(codex_home.join("config.toml")) +} + +fn write_config_atomic(cfg_path: &Path, doc: &DocumentMut, purpose: &str) -> std::io::Result<()> { + if let Some(parent) = cfg_path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = cfg_path.with_extension(format!("toml.{purpose}-{}", std::process::id())); + std::fs::write(&tmp, doc.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)); + } + std::fs::rename(&tmp, cfg_path) +} + /// Mark `dir` as trusted in `~/.codex/config.toml` (atomic temp+rename). /// /// Returns `Ok(true)` if the file was updated, `Ok(false)` if the folder was @@ -24,12 +47,7 @@ use toml_edit::{value, DocumentMut, Item, Table}; /// the file untouched — the worst case is the prompt showing once, never a /// clobbered Codex config. pub fn trust_dir(dir: &Path) -> std::io::Result { - let home = dirs::home_dir() - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no home dir"))?; - let codex_home = std::env::var("CODEX_HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(|_| home.join(".codex")); - let cfg_path = codex_home.join("config.toml"); + let cfg_path = config_path()?; let mut doc: DocumentMut = if cfg_path.exists() { std::fs::read_to_string(&cfg_path)? @@ -68,19 +86,36 @@ pub fn trust_dir(dir: &Path) -> std::io::Result { } entry["trust_level"] = value("trusted"); - if let Some(parent) = cfg_path.parent() { - std::fs::create_dir_all(parent)?; + // Atomic write: the Codex app never reads a torn config. + write_config_atomic(&cfg_path, &doc, "trust")?; + Ok(true) +} + +fn migrate_retired_approval_policy_in(doc: &mut DocumentMut) -> bool { + if doc.get("approval_policy").and_then(Item::as_str) != Some("untrusted") { + return false; } - // Atomic write: temp file in the same dir + rename, so the Codex app never - // reads a torn config and a crash never truncates the real one. - let tmp = cfg_path.with_extension(format!("toml.trust-{}", std::process::id())); - std::fs::write(&tmp, doc.to_string())?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)); + // Codex 0.149 removed this public value and refuses to start while it is + // present. `on-request` is the upstream migration and keeps an explicit + // approval boundary instead of silently deleting the setting. + doc["approval_policy"] = value("on-request"); + true +} + +/// Migrate the approval policy removed by Codex 0.149 without round-tripping +/// or disturbing any unrelated config owned by Codex/plugins. +pub fn migrate_retired_approval_policy() -> std::io::Result { + let cfg_path = config_path()?; + if !cfg_path.exists() { + return Ok(false); } - std::fs::rename(&tmp, &cfg_path)?; + let mut doc: DocumentMut = std::fs::read_to_string(&cfg_path)? + .parse() + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + if !migrate_retired_approval_policy_in(&mut doc) { + return Ok(false); + } + write_config_atomic(&cfg_path, &doc, "approval-policy")?; Ok(true) } @@ -115,6 +150,7 @@ pub fn is_chatgpt_session() -> bool { #[cfg(test)] mod tests { + use super::migrate_retired_approval_policy_in; use toml_edit::{value, DocumentMut, Item, Table}; /// The edit must add the trust entry WITHOUT disturbing the MCP servers, @@ -160,4 +196,25 @@ mod tests { ); assert!(!out.contains("untrusted")); } + + #[test] + fn migrates_only_the_retired_top_level_approval_policy() { + let mut doc: DocumentMut = r#" +approval_policy = "untrusted" +model = "gpt-5.6" + +[projects."/tmp/project"] +trust_level = "untrusted" +"# + .parse() + .unwrap(); + assert!(migrate_retired_approval_policy_in(&mut doc)); + assert_eq!(doc["approval_policy"].as_str(), Some("on-request")); + assert_eq!(doc["model"].as_str(), Some("gpt-5.6")); + assert_eq!( + doc["projects"]["/tmp/project"]["trust_level"].as_str(), + Some("untrusted") + ); + assert!(!migrate_retired_approval_policy_in(&mut doc)); + } } diff --git a/crates/omega-core/src/credentials.rs b/crates/omega-core/src/credentials.rs index bc62e010..5dce4fee 100644 --- a/crates/omega-core/src/credentials.rs +++ b/crates/omega-core/src/credentials.rs @@ -1056,12 +1056,10 @@ pub fn legacy_path_for(provider: &str) -> Option { match provider { "codex" => Some(codex_home_dir().join("auth.json")), "claude" => Some(dirs::home_dir()?.join(".claude").join(".credentials.json")), - "gemini" => Some( - dirs::home_dir()? - .join(".config") - .join("gemini") - .join("oauth_creds.json"), - ), + // Legacy Gemini CLI file location. Gemini 0.56+ may migrate this into + // its native hybrid/keyring store; Omega does not try to symlink that + // modern store, but diagnostics must still name the real legacy path. + "gemini" => Some(dirs::home_dir()?.join(".gemini").join("oauth_creds.json")), // api-key providers store config in providers.toml; no legacy path. _ => None, } diff --git a/crates/omega-core/src/dispatch.rs b/crates/omega-core/src/dispatch.rs index 5550f41f..89d857f4 100644 --- a/crates/omega-core/src/dispatch.rs +++ b/crates/omega-core/src/dispatch.rs @@ -1500,9 +1500,10 @@ impl Dispatcher { // an interactive pane waiting for the operator to accept the plan, the exact // friction the operator rejects). The "plan" is a working method enforced by // the oracle doctrine (build the todo list, finish 100%), NOT a permission - // gate. Leave permission_mode unset → the base command keeps - // --dangerously-skip-permissions, so the oracle plans-and-proceeds fully - // autonomously across every complexity tier. + // gate. Leave permission_mode unset → the base command selects + // Claude Code's native `auto` mode, which reviews actions without + // waiting for a human. Full bypass remains an explicit provider + // opt-in only. opts.permission_mode = None; // --brief enables the SendUserMessage agent→user tool so the oracle can // push a structured note to the human (oracle-only; workers stay silent). @@ -1702,13 +1703,16 @@ impl Dispatcher { &state.project, ); - let agent = - crate::agents::Agent::from_name(&self.config.agent_command).ok_or_else(|| { - anyhow::anyhow!( - "configured agent `{}` is unknown; refusing to resurrect on an implicit provider", - self.config.agent_command - ) - })?; + let recorded_provider = crate::session::read_session_provider(oracle_name); + let provider = recorded_provider + .as_deref() + .unwrap_or(self.config.agent_command.as_str()); + let agent = crate::agents::Agent::from_name(provider).ok_or_else(|| { + anyhow::anyhow!( + "configured agent `{}` is unknown; refusing to resurrect on an implicit provider", + provider + ) + })?; let mut prompt = build_resume_prompt(&state, &self.config.state_dir); // THE FUNNEL — a resurrected oracle gets its Oracle-scoped doctrine too. // Narrowed to THIS mission (rules::agent_context_block_for_mission): @@ -1756,10 +1760,10 @@ impl Dispatcher { // passing it alongside a fresh --session-id was a silent no-op. The // crashed oracle's context is rebuilt from the mission brief + // on-disk state instead. - // A resurrected oracle is AUTONOMOUS exactly like a fresh dispatch - // (None → --dangerously-skip-permissions): never gate on the operator. - // ("auto" used to prompt on risky ops — the exact friction the operator - // rejects: every OmegaOS session must run fully bypass-permissions.) + // A resurrected oracle uses the same non-blocking native `auto` + // policy as a fresh dispatch. Full permission bypass remains an + // explicit provider setting, never an implicit resurrection side + // effect. opts.permission_mode = None; opts.exclude_dynamic_prompt_sections = true; opts.session_id = Some(resolve_session_id( @@ -1794,12 +1798,7 @@ impl Dispatcher { .await?; } else { self.session_mgr - .create_agent_session( - oracle_name, - &work_dir, - &self.config.agent_command, - Some(&prompt), - ) + .create_agent_session(oracle_name, &work_dir, agent.name(), Some(&prompt)) .await?; } Ok(ResurrectOutcome::Resurrected) diff --git a/crates/omega-core/src/doctor.rs b/crates/omega-core/src/doctor.rs index 61a1e3bd..21d3a53e 100644 --- a/crates/omega-core/src/doctor.rs +++ b/crates/omega-core/src/doctor.rs @@ -211,8 +211,8 @@ fn rmux_socket_path() -> Option { None } -/// Claude Code hooks: scripts present under `~/.omega/hooks` AND registered in -/// `~/.claude/settings.json` (PostToolUse track-tool-use + Stop stop-verify). +/// Claude/Codex hooks: scripts present under `~/.omega/hooks` and registered +/// on both provider surfaces. fn check_hooks(config: &OmegaConfig) -> Check { let hooks_dir = config .state_dir @@ -228,6 +228,7 @@ fn check_hooks(config: &OmegaConfig) -> Check { ("omega-session-contract.sh", "omega-session-contract"), ("omega-prompt-scan.sh", "omega-prompt-scan"), ("omega-plan-mirror.sh", "omega-plan-mirror"), + ("omega-audit-guard.sh", "omega-audit-guard"), ("omega_plan_state.py", ""), // shared parser, not registered anywhere ("track-tool-use.sh", "track-tool-use"), ]; @@ -236,14 +237,22 @@ fn check_hooks(config: &OmegaConfig) -> Check { .map(|h| h.join(".claude/settings.json")) .and_then(|p| std::fs::read_to_string(p).ok()) .unwrap_or_default(); + let codex_hooks = dirs::home_dir() + .map(|home| home.join(".codex/hooks.json")) + .and_then(|path| std::fs::read_to_string(path).ok()) + .unwrap_or_default(); let mut missing_files = Vec::new(); - let mut unregistered = Vec::new(); + let mut unregistered_claude = Vec::new(); + let mut unregistered_codex = Vec::new(); for (file, marker) in REQUIRED { if !hooks_dir.join(file).exists() { missing_files.push(*file); } else if !marker.is_empty() && !settings.contains(marker) { - unregistered.push(*file); + unregistered_claude.push(*file); + } + if !marker.is_empty() && hooks_dir.join(file).exists() && !codex_hooks.contains(marker) { + unregistered_codex.push(*file); } } @@ -257,19 +266,20 @@ fn check_hooks(config: &OmegaConfig) -> Check { ), ); } - if !unregistered.is_empty() { + if !unregistered_claude.is_empty() || !unregistered_codex.is_empty() { return Check::warn( "hooks", format!( - "present but NOT registered in settings.json: {} (re-run install.sh; needs jq)", - unregistered.join(", ") + "present but not registered (Claude: {}; Codex: {}) — re-run install.sh; needs jq", + unregistered_claude.join(", "), + unregistered_codex.join(", ") ), ); } Check::ok( "hooks", format!( - "{} hooks present + registered (finish-guard armed)", + "{} hooks present + registered for Claude and Codex (finish-guard armed)", REQUIRED.len() ), ) @@ -303,13 +313,17 @@ fn effective_containment( ) } } - Agent::Codex => Check::ok( + Agent::Codex if providers.codex.bypass_hook_trust => Check::ok( "agent containment", format!( - "Codex: strict config, workspace-write sandbox, approve-for-me; state+locks and {} configured extra writable root(s)", + "Codex: strict config, approve-for-me preset (workspace-write + auto-review), hook-trust bypass; state+locks and {} configured extra writable root(s)", providers.codex.additional_writable_dirs.len() ), ), + Agent::Codex => Check::warn( + "agent containment", + "Codex hook-trust bypass disabled; a new/changed hook can block a detached pane", + ), Agent::Glm => { if providers.glm.dangerously_skip_permissions { Check::warn( @@ -331,7 +345,20 @@ fn effective_containment( "agent containment", "Gemini: provider-native policy applies; OmegaOS adds no separate filesystem sandbox", ), - Agent::Pi | Agent::Hermes => Check::warn( + Agent::Antigravity => { + if providers.antigravity.dangerously_skip_permissions { + Check::warn( + "agent containment", + "Antigravity: explicit HIGH-RISK permission bypass enabled for detached Omega sessions", + ) + } else { + Check::warn( + "agent containment", + "Antigravity: provider-native approval policy may block a detached session", + ) + } + } + Agent::Pi | Agent::OpenRouter | Agent::Hermes => Check::warn( "agent containment", format!( "{}: provider-native tool policy applies; OmegaOS adds no separate filesystem sandbox", @@ -345,6 +372,92 @@ fn effective_containment( } } +fn minimum_agent_version(agent: crate::agents::Agent) -> Option { + use crate::agents::Agent; + let raw = match agent { + // Opus 5 support starts here. + Agent::Claude | Agent::Glm => "2.1.219", + // --approve-for-me is stable from 0.147 onward. + Agent::Codex => "0.147.0", + // First stable Gemini 3.1 model support. + Agent::Gemini => "0.31.0", + // Stable structured/headless and prompt-interactive contract. + Agent::Antigravity => "1.1.8", + // `--` end-of-options support used by the launch adapter. + Agent::Pi | Agent::OpenRouter => "0.84.3", + Agent::Hermes => "0.20.0", + Agent::Kimi => "0.38.0", + Agent::Shell => return None, + }; + semver::Version::parse(raw).ok() +} + +fn parse_cli_version(raw: &str) -> Option { + raw.split_whitespace().find_map(|token| { + let candidate = token + .trim_matches(|character: char| { + !character.is_ascii_alphanumeric() + && character != '.' + && character != '-' + && character != '+' + }) + .trim_start_matches('v'); + semver::Version::parse(candidate).ok() + }) +} + +fn agent_version_check(agent: crate::agents::Agent) -> Check { + let name = format!("{} version", agent.name()); + let Some(minimum) = minimum_agent_version(agent) else { + return Check::ok(&name, "local shell"); + }; + let output = std::process::Command::new(agent.binary_name()) + .arg("--version") + .output(); + let Ok(output) = output else { + return Check::warn( + &name, + format!("could not execute {} --version", agent.binary_name()), + ); + }; + let combined = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let Some(version) = parse_cli_version(&combined) else { + return Check::warn( + &name, + format!( + "unrecognized version output from {}: {}", + agent.binary_name(), + combined.trim() + ), + ); + }; + if !output.status.success() { + return Check::warn( + &name, + format!( + "{} --version exited {:?} (reported {version})", + agent.binary_name(), + output.status.code() + ), + ); + } + if version < minimum { + Check::fail( + &name, + format!( + "{version} is older than supported minimum {minimum}; run: omega install {} --force", + agent.name() + ), + ) + } else { + Check::ok(&name, format!("{version} (minimum {minimum})")) + } +} + fn agents_override_files( cwd: &std::path::Path, codex_home: &std::path::Path, @@ -596,6 +709,20 @@ pub async fn run_all(config: &OmegaConfig) -> Vec { )), } + // 4a. Validate every installed provider CLI against the oldest version + // whose flags Omega emits. Presence alone previously let an old binary + // fail later with an opaque "unknown option" inside a detached pane. + let mut checked_binaries = std::collections::BTreeSet::new(); + for agent in crate::agents::Agent::all().iter().copied() { + if matches!(agent, crate::agents::Agent::Shell) + || !agent.is_available() + || !checked_binaries.insert(agent.binary_name()) + { + continue; + } + checks.push(agent_version_check(agent)); + } + // 4b. Codex topology. A real native file beside a canonical credential is // an explicit split, not a healthy login. This is a cheap local check; it // never sends a provider request. @@ -1425,6 +1552,22 @@ pub fn overall(checks: &[Check]) -> Health { mod tests { use super::*; + #[test] + fn parses_current_provider_version_output_shapes() { + assert_eq!( + parse_cli_version("codex-cli 0.149.1"), + semver::Version::parse("0.149.1").ok() + ); + assert_eq!( + parse_cli_version("Hermes Agent v0.20.5 (2026.8.19)"), + semver::Version::parse("0.20.5").ok() + ); + assert_eq!( + parse_cli_version("2.1.241 (Claude Code)"), + semver::Version::parse("2.1.241").ok() + ); + } + #[test] fn agents_override_detection_is_layered_and_read_only() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/omega-core/src/failover.rs b/crates/omega-core/src/failover.rs index cdbfdad3..81ec2769 100644 --- a/crates/omega-core/src/failover.rs +++ b/crates/omega-core/src/failover.rs @@ -54,6 +54,24 @@ impl FailoverReason { Self::Unknown => NextAction::Retry, } } + + /// Secret-safe message suitable for a remote client. Raw provider errors + /// stay in local logs because they may echo credentials, prompts, or file + /// content. + pub fn user_message(&self) -> &'static str { + match self { + Self::RateLimit => "provider rate limit reached; retry after backoff", + Self::Auth => "provider authentication failed; re-authenticate this agent", + Self::UpstreamError => "provider service is unavailable; retry or switch provider", + Self::Network => "provider network request failed; check connectivity and retry", + Self::ContextOverflow => "agent context is full; compact or start a new session", + Self::ModelUnavailable => "selected model is unavailable; choose a current model", + Self::InvalidToolCall => "provider rejected an agent tool call; inspect local logs", + Self::ContentRefused => "provider refused the request under its content policy", + Self::BudgetExhausted => "provider budget is exhausted", + Self::Unknown => "agent turn failed; inspect local gateway logs", + } + } } /// What the orchestrator should do based on a classified failure. @@ -198,5 +216,8 @@ mod tests { FailoverReason::BudgetExhausted.next_action(), NextAction::Stop ); + assert!(FailoverReason::Auth + .user_message() + .contains("re-authenticate")); } } diff --git a/crates/omega-core/src/mcp_servers.rs b/crates/omega-core/src/mcp_servers.rs index 4a33b2a6..9f7fe6c7 100644 --- a/crates/omega-core/src/mcp_servers.rs +++ b/crates/omega-core/src/mcp_servers.rs @@ -17,7 +17,7 @@ //! worker/team pane run in. None of the headless-only flags //! (`--print` / `--output-format` / `--input-format` / //! `--include-partial-messages`) appear here; those belong to **Lane B** -//! (`omega-cli::claude_stream`), the Telegram Master brain with no human attach +//! (`omega-gateway::chat_driver` / the Bun Telegram bot), with no human attach //! point. Wiring MCP servers therefore keeps the pane fully attachable. //! //! ## Path policy diff --git a/crates/omega-core/src/orchestration.rs b/crates/omega-core/src/orchestration.rs index 9ec21811..41978e34 100644 --- a/crates/omega-core/src/orchestration.rs +++ b/crates/omega-core/src/orchestration.rs @@ -57,10 +57,10 @@ pub const V3_ACCEPTANCE_PENDING: &str = pub fn provider_family_for_agent(agent: Agent) -> crate::rules::ProviderFamily { match agent { - Agent::Claude => crate::rules::ProviderFamily::Claude, + Agent::Claude | Agent::Glm => crate::rules::ProviderFamily::Claude, Agent::Codex => crate::rules::ProviderFamily::Codex, - Agent::Gemini => crate::rules::ProviderFamily::Gemini, - Agent::Pi | Agent::Hermes | Agent::Glm | Agent::Kimi | Agent::Shell => { + Agent::Gemini | Agent::Antigravity => crate::rules::ProviderFamily::Gemini, + Agent::Pi | Agent::OpenRouter | Agent::Hermes | Agent::Kimi | Agent::Shell => { crate::rules::ProviderFamily::Other } } diff --git a/crates/omega-core/src/providers.rs b/crates/omega-core/src/providers.rs index 4e26a535..fe7da511 100644 --- a/crates/omega-core/src/providers.rs +++ b/crates/omega-core/src/providers.rs @@ -20,6 +20,8 @@ pub struct ProvidersConfig { #[serde(default)] pub gemini: GeminiConfig, #[serde(default)] + pub antigravity: AntigravityConfig, + #[serde(default)] pub glm: GlmConfig, #[serde(default)] pub openrouter: OpenRouterConfig, @@ -47,6 +49,12 @@ impl fmt::Debug for ProvidersConfig { .field("codex_has_api_key", &!self.codex.api_key.is_empty()) .field("gemini_model", &self.gemini.model) .field("gemini_has_api_key", &!self.gemini.api_key.is_empty()) + .field("antigravity_model", &self.antigravity.model) + .field("antigravity_effort", &self.antigravity.effort) + .field( + "antigravity_dangerously_skip_permissions", + &self.antigravity.dangerously_skip_permissions, + ) .field("glm_model", &self.glm.model) .field("glm_has_api_key", &!self.glm.api_key.is_empty()) .field("openrouter_model", &self.openrouter.model) @@ -96,6 +104,10 @@ pub struct PiConfig { #[derive(Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct HermesConfig { + /// Hermes provider id. Empty means OpenRouter when an Omega-managed key + /// is configured, otherwise Hermes' own native configuration decides. + #[serde(default)] + pub provider: String, #[serde(default)] pub model: String, #[serde(default)] @@ -129,7 +141,7 @@ pub struct ClaudeConfig { pub dangerously_skip_permissions: bool, } -#[derive(Clone, Default, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct CodexConfig { #[serde(default)] @@ -142,6 +154,22 @@ pub struct CodexConfig { /// OmegaOS's state/lock directories. Values must be absolute paths. #[serde(default)] pub additional_writable_dirs: Vec, + /// Run enabled hooks without persisted trust for Omega-managed detached + /// sessions. This also trusts other enabled hooks, so operators can disable + /// it when they prefer Codex's interactive review. + pub bypass_hook_trust: bool, +} + +impl Default for CodexConfig { + fn default() -> Self { + Self { + model: String::new(), + api_key: String::new(), + base_url: String::new(), + additional_writable_dirs: Vec::new(), + bypass_hook_trust: true, + } + } } #[derive(Clone, Default, Serialize, Deserialize)] @@ -153,6 +181,33 @@ pub struct GeminiConfig { pub api_key: String, } +#[derive(Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AntigravityConfig { + /// Optional account-visible model slug from `agy models`. + #[serde(default)] + pub model: String, + /// Optional reasoning effort: low, medium, or high. + #[serde(default)] + pub effort: String, + /// Antigravity otherwise asks for tool approvals. Detached Omega sessions + /// need an explicit, visible high-risk opt-in for full autonomy. + #[serde(default)] + pub dangerously_skip_permissions: bool, +} + +impl Default for AntigravityConfig { + fn default() -> Self { + Self { + model: String::new(), + effort: String::new(), + // An Omega-managed session may be detached and has no operator at + // the approval prompt. Selecting this provider is the opt-in. + dangerously_skip_permissions: true, + } + } +} + #[derive(Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct GlmConfig { @@ -205,7 +260,7 @@ macro_rules! impl_redacted_provider_debug { } impl_redacted_provider_debug!(PiConfig, "PiConfig", [provider, model]); -impl_redacted_provider_debug!(HermesConfig, "HermesConfig", [model]); +impl_redacted_provider_debug!(HermesConfig, "HermesConfig", [provider, model]); impl_redacted_provider_debug!(OpenRouterConfig, "OpenRouterConfig", [model]); impl_redacted_provider_debug!( ClaudeConfig, @@ -215,9 +270,22 @@ impl_redacted_provider_debug!( impl_redacted_provider_debug!( CodexConfig, "CodexConfig", - [model, additional_writable_dirs] + [model, additional_writable_dirs, bypass_hook_trust] ); impl_redacted_provider_debug!(GeminiConfig, "GeminiConfig", [model]); +impl fmt::Debug for AntigravityConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AntigravityConfig") + .field("model", &self.model) + .field("effort", &self.effort) + .field( + "dangerously_skip_permissions", + &self.dangerously_skip_permissions, + ) + .finish() + } +} impl_redacted_provider_debug!( GlmConfig, "GlmConfig", @@ -283,6 +351,11 @@ impl ProvidersConfig { } pub(crate) fn validate(&self) -> Result<()> { + if !self.antigravity.effort.is_empty() + && !matches!(self.antigravity.effort.as_str(), "low" | "medium" | "high") + { + anyhow::bail!("invalid antigravity.effort; expected low, medium, or high"); + } match self.kimi.provider_type.as_str() { "kimi" | "anthropic" | "openai" => {} _ => anyhow::bail!("invalid kimi.provider_type; expected kimi, anthropic, or openai"), @@ -312,6 +385,7 @@ impl ProvidersConfig { "claude" => &self.claude.api_key, "codex" => &self.codex.api_key, "gemini" => &self.gemini.api_key, + "antigravity" => "", "glm" => &self.glm.api_key, "openrouter" => &self.openrouter.api_key, "pi" => &self.pi.api_key, @@ -386,6 +460,7 @@ impl ProvidersConfig { "claude", "codex", "gemini", + "antigravity", "glm", "openrouter", "pi", @@ -404,9 +479,14 @@ impl ProvidersConfig { pub fn default_model(provider: &str) -> &'static str { match provider { "claude" => "opus", - "codex" => "gpt-5.5-codex", - "gemini" => "gemini-3.1-pro", - "glm" => "glm-5.1", + // Let Codex resolve the current recommended GPT-5.6 variant for + // the account while still exposing explicit Sol/Terra/Luna picks. + "codex" => "gpt-5.6", + // Gemini CLI's account-aware router is safer than pinning a preview + // model an OAuth/API-key account may not be entitled to. + "gemini" => "auto", + "antigravity" => "auto", + "glm" => "glm-5.3", // Operator directive 2026-07-24: Claude Opus 5 is THE default brain // everywhere a tier has not been deliberately pinned (R-MODEL). "openrouter" | "pi" | "hermes" => "anthropic/claude-opus-5", @@ -430,17 +510,29 @@ impl ProvidersConfig { "haiku", "fable", ], - // June 2026: gpt-5.5-codex = Codex default; gpt-5.2-codex stays the - // API-key-only fallback (5.5 needs ChatGPT sign-in). - "codex" => vec!["gpt-5.5-codex", "gpt-5.5", "gpt-5.2-codex"], - // 3.1+ line uses bare ids (no -preview); 2.5-pro kept as fallback. + "codex" => vec![ + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + ], + // Prefer account-aware aliases and only list model ids accepted by + // Gemini CLI 0.56 / the current Gemini API catalog. "gemini" => vec![ - "gemini-3.1-pro", - "gemini-3.1-flash", + "auto", + "pro", + "flash", + "gemini-3.1-pro-preview", + "gemini-3.6-flash", "gemini-3.5-flash", + "gemini-3.1-flash-lite", "gemini-2.5-pro", ], - "glm" => vec!["glm-5.1", "glm-5", "glm-4.6"], + // `agy models` is account-scoped and changes independently of + // OmegaOS. Empty means UI callers offer a free-text field. + "antigravity" => vec![], + "glm" => vec!["glm-5.3", "glm-5-turbo", "glm-4.7"], // Pi and Hermes both route through OpenRouter, so they share the // same curated OpenRouter model IDs — this gives them an arrow-key // picker (no typing) instead of the free-text fallback. @@ -451,7 +543,7 @@ impl ProvidersConfig { "anthropic/claude-opus-4.8", "openai/gpt-5.5", "google/gemini-3.1-pro-preview", - "z-ai/glm-5.1", + "z-ai/glm-5.3", "deepseek/deepseek-chat", // Cloaked/stealth listing (added 2026-08-23): a reasoning model // aimed at coding and sustained agentic work — 1M context, 131k @@ -477,8 +569,9 @@ impl ProvidersConfig { /// Auth type for a provider: "oauth" | "api_key" | "config". pub fn auth_type(provider: &str) -> &'static str { match provider { - "claude" | "gemini" => "oauth", - "codex" | "glm" | "openrouter" | "pi" | "hermes" => "api_key", + "claude" | "gemini" | "antigravity" => "oauth", + "codex" => "oauth_or_api_key", + "glm" | "openrouter" | "pi" | "hermes" => "api_key", "kimi" => "oauth_or_api_key", "shell" => "local", _ => "unknown", @@ -522,6 +615,14 @@ impl ProvidersConfig { ProviderCapability::Vision, ProviderCapability::LongContext, ][..], + "antigravity" => &[ + ProviderCapability::Reasoning, + ProviderCapability::CodeEditing, + ProviderCapability::ToolCalling, + ProviderCapability::Delegation, + ProviderCapability::Vision, + ProviderCapability::LongContext, + ][..], "glm" | "openrouter" | "pi" | "hermes" => &[ ProviderCapability::Reasoning, ProviderCapability::CodeEditing, @@ -673,8 +774,9 @@ fn provider_rank(provider: &str) -> usize { .unwrap_or(usize::MAX) } -/// Track the per-Telegram-chat active model selection. -/// Persisted to `~/.omega/state/telegram-active-model.json`. +/// Mirror the global provider/model selected for newly spawned sessions. +/// Persisted for diagnostics and non-Rust clients; `OmegaConfig::agent_command` +/// remains the launch authority. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ActiveModel { @@ -707,6 +809,12 @@ impl Default for ActiveModel { impl ActiveModel { fn path() -> PathBuf { + crate::config::omega_dir() + .join("state") + .join("active-model.json") + } + + fn legacy_path() -> PathBuf { crate::config::omega_dir() .join("state") .join("telegram-active-model.json") @@ -727,6 +835,16 @@ impl ActiveModel { pub fn try_load() -> Result { let path = Self::path(); + if path.exists() { + return Self::load_from(&path); + } + let legacy = Self::legacy_path(); + if legacy.exists() { + let mut model = Self::load_from(&legacy)?; + // The revision belongs to the legacy authority, not the new path. + model.source_revision = None; + return Ok(model); + } Self::load_from(&path) } @@ -802,6 +920,7 @@ mod provider_capability_tests { "claude", "codex", "gemini", + "antigravity", "glm", "openrouter", "pi", @@ -868,6 +987,28 @@ mod provider_capability_tests { assert!(!env.contains_key("MOONSHOT_API_KEY")); } + #[test] + fn current_cli_catalog_avoids_retired_or_fabricated_model_ids() { + assert_eq!(ProvidersConfig::default_model("codex"), "gpt-5.6"); + assert!(!ProvidersConfig::models_for("codex").contains(&"gpt-5.5-codex")); + assert_eq!(ProvidersConfig::default_model("gemini"), "auto"); + assert!(ProvidersConfig::models_for("gemini").contains(&"gemini-3.1-pro-preview")); + assert!(!ProvidersConfig::models_for("gemini").contains(&"gemini-3.1-pro")); + assert_eq!(ProvidersConfig::default_model("glm"), "glm-5.3"); + } + + #[test] + fn antigravity_is_first_class_and_uses_native_oauth() { + assert!(ProvidersConfig::is_known("antigravity")); + assert_eq!(ProvidersConfig::auth_type("antigravity"), "oauth"); + assert!(ProvidersConfig::models_for("antigravity").is_empty()); + assert!( + ProvidersConfig::default() + .antigravity + .dangerously_skip_permissions + ); + } + #[test] fn malformed_provider_config_fails_and_cannot_be_overwritten() { let tmp = tempfile::tempdir().unwrap(); @@ -952,7 +1093,7 @@ mod provider_capability_tests { let tmp = tempfile::tempdir().unwrap(); let victim = tmp.path().join("victim.toml"); let authority = tmp.path().join("providers.toml"); - std::fs::write(&victim, "[codex]\nmodel = \"gpt-5.5-codex\"\n").unwrap(); + std::fs::write(&victim, "[codex]\nmodel = \"gpt-5.6\"\n").unwrap(); let original_mode = std::fs::metadata(&victim).unwrap().permissions().mode() & 0o777; symlink(&victim, &authority).unwrap(); @@ -1049,7 +1190,7 @@ mod provider_capability_tests { let authority = tmp.path().join("active-model.json"); std::fs::write( &victim, - r#"{"active_provider":"codex","active_model":"gpt-5.5-codex"}"#, + r#"{"active_provider":"codex","active_model":"gpt-5.6"}"#, ) .unwrap(); let original_mode = std::fs::metadata(&victim).unwrap().permissions().mode() & 0o777; diff --git a/crates/omega-core/src/rules.rs b/crates/omega-core/src/rules.rs index a776110a..9761eb0d 100644 --- a/crates/omega-core/src/rules.rs +++ b/crates/omega-core/src/rules.rs @@ -1705,12 +1705,9 @@ mod tests { fn every_provider_receives_byte_identical_doctrine() { for scope in [RuleScope::Master, RuleScope::Oracle, RuleScope::Worker] { for mission in [None, Some("ship the feature and verify production")] { - let reference = compile_rule_context_for_provider( - scope, - mission, - ProviderFamily::Neutral, - ) - .expect("neutral context must compile"); + let reference = + compile_rule_context_for_provider(scope, mission, ProviderFamily::Neutral) + .expect("neutral context must compile"); for provider in [ ProviderFamily::Claude, @@ -1724,7 +1721,10 @@ mod tests { compiled.markdown, reference.markdown, "{provider:?} received different doctrine than neutral at {scope:?}" ); - assert_eq!(compiled.digest, reference.digest, "{provider:?} digest differs"); + assert_eq!( + compiled.digest, reference.digest, + "{provider:?} digest differs" + ); } } } diff --git a/crates/omega-core/src/session.rs b/crates/omega-core/src/session.rs index eda1fc69..66024641 100644 --- a/crates/omega-core/src/session.rs +++ b/crates/omega-core/src/session.rs @@ -368,7 +368,7 @@ fn record_session_provider(name: &str, agent: Agent) -> Result<()> { .with_context(|| format!("recording provider for session {name}")) } -fn read_session_provider(name: &str) -> Option { +pub(crate) fn read_session_provider(name: &str) -> Option { let payload: serde_json::Value = serde_json::from_slice(&std::fs::read(session_provider_path(name)).ok()?).ok()?; let recorded_session = payload.get("session")?.as_str()?; diff --git a/crates/omega-core/src/skill_registry.rs b/crates/omega-core/src/skill_registry.rs index 29ac807c..ecbf6fff 100644 --- a/crates/omega-core/src/skill_registry.rs +++ b/crates/omega-core/src/skill_registry.rs @@ -1006,8 +1006,7 @@ pub struct SkillRegistry { impl SkillRegistry { /// Create a registry from the default skills directory (~/.omega/skills/). pub fn discover_default() -> Result { - let home = dirs::home_dir().context("no home directory")?; - let skills_dir = home.join(".omega").join("skills"); + let skills_dir = crate::config::omega_dir().join("skills"); Self::discover(&skills_dir) } diff --git a/crates/omega-gateway/Cargo.toml b/crates/omega-gateway/Cargo.toml index 069a847f..82a678bf 100644 --- a/crates/omega-gateway/Cargo.toml +++ b/crates/omega-gateway/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "omega-gateway" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true [[bin]] name = "omega-gatewayd" diff --git a/crates/omega-gateway/src/account_login.rs b/crates/omega-gateway/src/account_login.rs index 6a762269..06a91dc6 100644 --- a/crates/omega-gateway/src/account_login.rs +++ b/crates/omega-gateway/src/account_login.rs @@ -147,7 +147,9 @@ pub fn poll_login_complete(account: &Account, slot: &Path) -> bool { /// [`begin_claude_login`] and [`begin_codex_login`] — the only difference /// between the two providers is which command gets built. fn begin_login(mut cmd: Command) -> Result { - cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); let mut child = cmd.spawn()?; let stdout = child.stdout.take().expect("stdout was piped"); @@ -247,7 +249,10 @@ pub fn logout(account: &Account, slot: &Path) -> Result<()> { .output()?, }; if !output.status.success() { - bail!("logout failed: {}", String::from_utf8_lossy(&output.stderr).trim()); + bail!( + "logout failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); } Ok(()) } @@ -326,8 +331,14 @@ mod tests { #[test] fn parse_auth_status_compact_json_form() { - assert_eq!(parse_auth_status("{\"loggedIn\":false}"), AuthStatus::LoggedOut); - assert_eq!(parse_auth_status("{\"loggedIn\":true}"), AuthStatus::LoggedIn); + assert_eq!( + parse_auth_status("{\"loggedIn\":false}"), + AuthStatus::LoggedOut + ); + assert_eq!( + parse_auth_status("{\"loggedIn\":true}"), + AuthStatus::LoggedIn + ); } // --- parse_login_url (pure) --- @@ -370,7 +381,11 @@ mod tests { fn status_parses_fake_claude_logged_in() { let _g = LOCK.lock().unwrap(); let dir = tempfile::tempdir().unwrap(); - let bin = fake_bin(dir.path(), "fake-claude", "echo 'Logged in as test@example.com'"); + let bin = fake_bin( + dir.path(), + "fake-claude", + "echo 'Logged in as test@example.com'", + ); std::env::set_var("OMEGA_CLAUDE_BIN", &bin); let account = test_account(AccountKind::Claude); @@ -399,7 +414,10 @@ mod tests { #[test] fn status_missing_binary_is_unknown() { let _g = LOCK.lock().unwrap(); - std::env::set_var("OMEGA_CLAUDE_BIN", "/nonexistent/definitely-not-a-binary-xyz"); + std::env::set_var( + "OMEGA_CLAUDE_BIN", + "/nonexistent/definitely-not-a-binary-xyz", + ); let account = test_account(AccountKind::Claude); let slot = tempfile::tempdir().unwrap(); diff --git a/crates/omega-gateway/src/accounts.rs b/crates/omega-gateway/src/accounts.rs index f25a3fc6..8b8b76ad 100644 --- a/crates/omega-gateway/src/accounts.rs +++ b/crates/omega-gateway/src/accounts.rs @@ -19,7 +19,8 @@ use std::sync::{Arc, Mutex}; pub fn valid_slug(s: &str) -> bool { !s.is_empty() && s.len() <= 32 - && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && s.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } /// Handle over `/accounts`: every read/write goes straight to @@ -47,7 +48,10 @@ impl AccountStore { let accounts_dir = gateway_dir.join("accounts"); std::fs::create_dir_all(&accounts_dir).ok(); harden_dir(&accounts_dir); - Self { accounts_dir, lock: Arc::new(Mutex::new(())) } + Self { + accounts_dir, + lock: Arc::new(Mutex::new(())), + } } fn registry_path(&self) -> PathBuf { @@ -76,7 +80,9 @@ impl AccountStore { match serde_json::from_str(&text) { Ok(list) => list, Err(e) => { - tracing::error!("corrupted accounts.json ({e}); quarantining instead of overwriting"); + tracing::error!( + "corrupted accounts.json ({e}); quarantining instead of overwriting" + ); self.quarantine_corrupt_registry(&path); Vec::new() } @@ -117,7 +123,11 @@ impl AccountStore { } else { harden_file(&tmp); if let Err(e) = std::fs::rename(&tmp, &path) { - tracing::error!("failed to rename {} -> {}: {e}", tmp.display(), path.display()); + tracing::error!( + "failed to rename {} -> {}: {e}", + tmp.display(), + path.display() + ); } else { harden_file(&path); } @@ -208,7 +218,9 @@ impl AccountStore { /// The current default account for `kind`, if any. pub fn default_for(&self, kind: AccountKind) -> Option { - self.read_registry().into_iter().find(|a| a.kind == kind && a.is_default) + self.read_registry() + .into_iter() + .find(|a| a.kind == kind && a.is_default) } } @@ -235,7 +247,9 @@ mod tests { fn create_slot_roundtrip() { let dir = tempfile::tempdir().unwrap(); let store = AccountStore::open(dir.path()); - let account = store.create_slot("work-1", "Work account", AccountKind::Claude).unwrap(); + let account = store + .create_slot("work-1", "Work account", AccountKind::Claude) + .unwrap(); assert_eq!(account.slug, "work-1"); assert_eq!(account.label, "Work account"); @@ -251,7 +265,9 @@ mod tests { fn create_slot_rejects_invalid_slug() { let dir = tempfile::tempdir().unwrap(); let store = AccountStore::open(dir.path()); - assert!(store.create_slot("../x", "bad", AccountKind::Claude).is_err()); + assert!(store + .create_slot("../x", "bad", AccountKind::Claude) + .is_err()); assert!(store.create_slot("UP", "bad", AccountKind::Claude).is_err()); } @@ -259,8 +275,12 @@ mod tests { fn create_slot_rejects_duplicate_slug() { let dir = tempfile::tempdir().unwrap(); let store = AccountStore::open(dir.path()); - store.create_slot("work-1", "Work", AccountKind::Claude).unwrap(); - assert!(store.create_slot("work-1", "Again", AccountKind::Claude).is_err()); + store + .create_slot("work-1", "Work", AccountKind::Claude) + .unwrap(); + assert!(store + .create_slot("work-1", "Again", AccountKind::Claude) + .is_err()); } #[test] @@ -285,13 +305,22 @@ mod tests { fn first_of_kind_is_default_independently_per_kind() { let dir = tempfile::tempdir().unwrap(); let store = AccountStore::open(dir.path()); - let claude1 = store.create_slot("c1", "Claude 1", AccountKind::Claude).unwrap(); + let claude1 = store + .create_slot("c1", "Claude 1", AccountKind::Claude) + .unwrap(); assert!(claude1.is_default); - let codex1 = store.create_slot("x1", "Codex 1", AccountKind::Codex).unwrap(); - assert!(codex1.is_default, "first Codex account is its own kind's default"); + let codex1 = store + .create_slot("x1", "Codex 1", AccountKind::Codex) + .unwrap(); + assert!( + codex1.is_default, + "first Codex account is its own kind's default" + ); - let claude2 = store.create_slot("c2", "Claude 2", AccountKind::Claude).unwrap(); + let claude2 = store + .create_slot("c2", "Claude 2", AccountKind::Claude) + .unwrap(); assert!(!claude2.is_default, "second Claude account is not default"); } @@ -299,9 +328,15 @@ mod tests { fn set_default_moves_default_within_kind_other_kind_untouched() { let dir = tempfile::tempdir().unwrap(); let store = AccountStore::open(dir.path()); - store.create_slot("c1", "Claude 1", AccountKind::Claude).unwrap(); - store.create_slot("c2", "Claude 2", AccountKind::Claude).unwrap(); - let codex1 = store.create_slot("x1", "Codex 1", AccountKind::Codex).unwrap(); + store + .create_slot("c1", "Claude 1", AccountKind::Claude) + .unwrap(); + store + .create_slot("c2", "Claude 2", AccountKind::Claude) + .unwrap(); + let codex1 = store + .create_slot("x1", "Codex 1", AccountKind::Codex) + .unwrap(); assert!(store.set_default("c2")); @@ -326,8 +361,12 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let store = AccountStore::open(dir.path()); assert!(store.default_for(AccountKind::Claude).is_none()); - store.create_slot("c1", "Claude 1", AccountKind::Claude).unwrap(); - store.create_slot("c2", "Claude 2", AccountKind::Claude).unwrap(); + store + .create_slot("c1", "Claude 1", AccountKind::Claude) + .unwrap(); + store + .create_slot("c2", "Claude 2", AccountKind::Claude) + .unwrap(); store.set_default("c2"); assert_eq!(store.default_for(AccountKind::Claude).unwrap().slug, "c2"); } @@ -336,7 +375,9 @@ mod tests { fn remove_deletes_dir_and_registry_entry() { let dir = tempfile::tempdir().unwrap(); let store = AccountStore::open(dir.path()); - store.create_slot("work-1", "Work", AccountKind::Claude).unwrap(); + store + .create_slot("work-1", "Work", AccountKind::Claude) + .unwrap(); let slot_dir = store.slot_dir("work-1"); assert!(slot_dir.exists()); @@ -357,14 +398,21 @@ mod tests { let dir = tempfile::tempdir().unwrap(); { let store = AccountStore::open(dir.path()); - store.create_slot("work-1", "Work", AccountKind::Claude).unwrap(); - store.create_slot("work-2", "Work 2", AccountKind::Claude).unwrap(); + store + .create_slot("work-1", "Work", AccountKind::Claude) + .unwrap(); + store + .create_slot("work-2", "Work 2", AccountKind::Claude) + .unwrap(); store.set_default("work-2"); } let store2 = AccountStore::open(dir.path()); assert_eq!(store2.list().len(), 2); - assert_eq!(store2.default_for(AccountKind::Claude).unwrap().slug, "work-2"); + assert_eq!( + store2.default_for(AccountKind::Claude).unwrap().slug, + "work-2" + ); } #[cfg(unix)] @@ -373,14 +421,20 @@ mod tests { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let store = AccountStore::open(dir.path()); - store.create_slot("work-1", "Work", AccountKind::Claude).unwrap(); + store + .create_slot("work-1", "Work", AccountKind::Claude) + .unwrap(); let slot_dir = dir.path().join("accounts").join("work-1"); let dir_mode = std::fs::metadata(&slot_dir).unwrap().permissions().mode() & 0o777; assert_eq!(dir_mode, 0o700, "slot dir must be 0700"); let registry_path = dir.path().join("accounts").join("accounts.json"); - let registry_mode = std::fs::metadata(®istry_path).unwrap().permissions().mode() & 0o777; + let registry_mode = std::fs::metadata(®istry_path) + .unwrap() + .permissions() + .mode() + & 0o777; assert_eq!(registry_mode, 0o600, "accounts.json must be 0600"); } @@ -395,18 +449,33 @@ mod tests { // (a) reading a corrupt registry returns empty/usable rather than // panicking or propagating the parse error. let listed = store.list(); - assert!(listed.is_empty(), "a corrupt registry must read back as an empty, usable list"); + assert!( + listed.is_empty(), + "a corrupt registry must read back as an empty, usable list" + ); // (b) the original bad bytes now live at accounts.json.corrupt, // untouched, instead of being clobbered by the next write. let corrupt_path = dir.path().join("accounts").join("accounts.json.corrupt"); - assert!(corrupt_path.exists(), "the corrupt file must be quarantined"); - assert_eq!(std::fs::read(&corrupt_path).unwrap(), bad_bytes, "quarantined bytes must be untouched"); - assert!(!registry_path.exists(), "the corrupt path is vacated by the rename"); + assert!( + corrupt_path.exists(), + "the corrupt file must be quarantined" + ); + assert_eq!( + std::fs::read(&corrupt_path).unwrap(), + bad_bytes, + "quarantined bytes must be untouched" + ); + assert!( + !registry_path.exists(), + "the corrupt path is vacated by the rename" + ); // The store keeps working normally afterward (a fresh write does not // collide with the quarantined file). - store.create_slot("work-1", "Work", AccountKind::Claude).unwrap(); + store + .create_slot("work-1", "Work", AccountKind::Claude) + .unwrap(); assert_eq!(store.list().len(), 1); } @@ -433,7 +502,9 @@ mod tests { let handle_a = std::thread::spawn(move || { for i in 0..iterations { barrier_a.wait(); - store_a.create_slot(&format!("race-a-{i}"), "A", AccountKind::Claude).unwrap(); + store_a + .create_slot(&format!("race-a-{i}"), "A", AccountKind::Claude) + .unwrap(); } }); @@ -442,7 +513,9 @@ mod tests { let handle_b = std::thread::spawn(move || { for i in 0..iterations { barrier_b.wait(); - store_b.create_slot(&format!("race-b-{i}"), "B", AccountKind::Codex).unwrap(); + store_b + .create_slot(&format!("race-b-{i}"), "B", AccountKind::Codex) + .unwrap(); } }); @@ -460,7 +533,11 @@ mod tests { "lost race-b-{i}: concurrent create_slot dropped an account (I-6)" ); } - assert_eq!(listed.len(), iterations * 2, "registry must contain every created account, no lost updates"); + assert_eq!( + listed.len(), + iterations * 2, + "registry must contain every created account, no lost updates" + ); } #[test] @@ -470,7 +547,11 @@ mod tests { std::fs::create_dir_all(&accounts_dir).unwrap(); // Pre-seed an existing quarantine file, as if an earlier corruption // was already quarantined. - std::fs::write(accounts_dir.join("accounts.json.corrupt"), b"first corruption").unwrap(); + std::fs::write( + accounts_dir.join("accounts.json.corrupt"), + b"first corruption", + ) + .unwrap(); let store = AccountStore::open(dir.path()); let registry_path = accounts_dir.join("accounts.json"); diff --git a/crates/omega-gateway/src/auth.rs b/crates/omega-gateway/src/auth.rs index 0e0ec320..cd27134f 100644 --- a/crates/omega-gateway/src/auth.rs +++ b/crates/omega-gateway/src/auth.rs @@ -1,9 +1,9 @@ +use crate::fsperm::{harden_dir, harden_file}; +use crate::util::random_hex; use anyhow::Context; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; -use crate::fsperm::{harden_dir, harden_file}; -use crate::util::random_hex; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Device { @@ -36,7 +36,10 @@ impl DeviceStore { let path = dir.join("devices.json"); let devices = match std::fs::read_to_string(&path) { Ok(text) => serde_json::from_str(&text).unwrap_or_else(|e| { - tracing::warn!("corrupted {}: {e}; starting with empty device list", path.display()); + tracing::warn!( + "corrupted {}: {e}; starting with empty device list", + path.display() + ); Vec::new() }), Err(_) => Vec::new(), @@ -64,15 +67,23 @@ impl DeviceStore { pub fn verify(&self, token: &str) -> Option { let hash = sha256_hex(token); - self.devices.iter().find(|d| !d.revoked && d.token_sha256 == hash).cloned() + self.devices + .iter() + .find(|d| !d.revoked && d.token_sha256 == hash) + .cloned() } pub fn revoke(&mut self, device_id: &str) -> bool { let mut hit = false; for d in self.devices.iter_mut() { - if d.id == device_id { d.revoked = true; hit = true; } + if d.id == device_id { + d.revoked = true; + hit = true; + } + } + if hit { + self.save(); } - if hit { self.save(); } hit } @@ -115,8 +126,12 @@ impl PairingCode { /// same valid code, remove_file succeeds for exactly one of them. pub fn consume(dir: &Path, code: &str) -> bool { let path = dir.join("pairing.json"); - let Ok(text) = std::fs::read_to_string(&path) else { return false }; - let Ok(pc) = serde_json::from_str::(&text) else { return false }; + let Ok(text) = std::fs::read_to_string(&path) else { + return false; + }; + let Ok(pc) = serde_json::from_str::(&text) else { + return false; + }; let live = pc.code == code && chrono::DateTime::parse_from_rfc3339(&pc.expires_at) .map(|t| t > chrono::Utc::now()) diff --git a/crates/omega-gateway/src/chat_driver.rs b/crates/omega-gateway/src/chat_driver.rs index e47ccc27..03e149b3 100644 --- a/crates/omega-gateway/src/chat_driver.rs +++ b/crates/omega-gateway/src/chat_driver.rs @@ -1,65 +1,77 @@ -//! Chat process driver — spawns a headless CLI agent (`claude -p`) and parses -//! its NDJSON stdout stream into typed [`ChatStreamServerMsg`] frames. +//! Chat process driver — spawns Claude (`-p --output-format stream-json`) or +//! Codex (`exec --json`) and parses either NDJSON stream into typed +//! [`ChatStreamServerMsg`] frames. //! //! Three pure/composable pieces: //! - [`agent_command`] builds the child-process invocation (no I/O, unit-testable). //! - [`parse_line`] parses one NDJSON stdout line into a [`ParsedLine`] (no I/O). //! - [`run_turn`] spawns the process, drives the read loop, and forwards frames. //! -//! KNOWN LIMIT: `ChatAgent::Codex` streaming JSON support is not implemented — -//! [`run_turn`] intercepts it before ever building or spawning a process, so a -//! Codex chat never spawns `claude`. - use crate::protocol::{ChatAgent, ChatMeta, ChatStreamServerMsg}; use serde_json::Value; use std::path::Path; use std::process::Stdio; use std::time::Duration; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; use tokio::sync::mpsc::Sender; -/// Builds the child-process command for a Claude chat turn. -/// -/// Only meaningful for `ChatAgent::Claude` — [`run_turn`] intercepts -/// `ChatAgent::Codex` before ever calling this. The precondition is -/// enforced with a real (non-debug-only) `assert!` so misuse is caught in -/// every build, not just debug ones, rather than silently spawning `claude` -/// for a Codex chat. -/// -/// Program is `$OMEGA_CHAT_BIN` when set, else `claude`. Args: -/// `-p --output-format stream-json --verbose`, plus -/// `--resume ` when `meta.provider_session_id` is -/// `Some`, plus `--model ` when `model` is `Some`. `current_dir` is -/// `meta.cwd`; when `account_dir` is `Some`, `CLAUDE_CONFIG_DIR` is set. +/// Builds the provider-specific headless command for one chat turn. pub fn agent_command( meta: &ChatMeta, user_text: &str, model: Option<&str>, account_dir: Option<&Path>, ) -> Command { - assert!( - meta.agent == ChatAgent::Claude, - "agent_command is Claude-only; Codex is handled in run_turn" - ); - - let program = std::env::var("OMEGA_CHAT_BIN").unwrap_or_else(|_| "claude".to_string()); - let mut cmd = Command::new(program); - cmd.arg("-p") - .arg(user_text) - .arg("--output-format") - .arg("stream-json") - .arg("--verbose"); - if let Some(session_id) = &meta.provider_session_id { - cmd.arg("--resume").arg(session_id); - } - if let Some(model) = model { - cmd.arg("--model").arg(model); - } + let mut cmd = match meta.agent { + ChatAgent::Claude => { + let program = std::env::var("OMEGA_CHAT_BIN").unwrap_or_else(|_| "claude".to_string()); + let mut command = Command::new(program); + command + .arg("-p") + .arg(user_text) + .arg("--output-format") + .arg("stream-json") + .arg("--verbose"); + if let Some(session_id) = &meta.provider_session_id { + command.arg("--resume").arg(session_id); + } + if let Some(model) = model { + command.arg("--model").arg(model); + } + if let Some(dir) = account_dir { + command.env("CLAUDE_CONFIG_DIR", dir); + } + command + } + ChatAgent::Codex => { + let program = std::env::var("OMEGA_CODEX_CHAT_BIN") + .or_else(|_| std::env::var("OMEGA_CHAT_BIN")) + .unwrap_or_else(|_| "codex".to_string()); + let mut command = Command::new(program); + command.args([ + "exec", + "--skip-git-repo-check", + "--approve-for-me", + "--dangerously-bypass-hook-trust", + "--json", + ]); + if let Some(model) = model { + command.arg("--model").arg(model); + } + if let Some(session_id) = &meta.provider_session_id { + command.arg("resume").arg(session_id); + } + // Prompt is written to stdin after spawn so it never appears in + // process listings and cannot be parsed as an option. + command.arg("-"); + if let Some(dir) = account_dir { + command.env("CODEX_HOME", dir); + } + command + } + }; cmd.current_dir(&meta.cwd); - if let Some(dir) = account_dir { - cmd.env("CLAUDE_CONFIG_DIR", dir); - } cmd } @@ -75,6 +87,19 @@ pub enum ParsedLine { Session(String), } +fn classified_error(diagnostic: &str) -> ParsedLine { + let reason = omega_core::failover::classify(None, diagnostic); + tracing::warn!( + ?reason, + action = ?reason.next_action(), + provider_error = %diagnostic, + "provider returned an error result" + ); + ParsedLine::Frame(ChatStreamServerMsg::Error { + message: reason.user_message().to_string(), + }) +} + /// Parses one NDJSON stdout line from `claude -p --output-format stream-json`. /// Pure and I/O-free: unparseable or irrelevant lines yield an empty `Vec`, never a panic. /// @@ -148,19 +173,21 @@ pub fn parse_line(line: &str) -> Vec { } } if !text.is_empty() { - out.push(ParsedLine::Frame(ChatStreamServerMsg::AssistantMessage { text })); + out.push(ParsedLine::Frame(ChatStreamServerMsg::AssistantMessage { + text, + })); } out } Some("result") => { let is_error = v.get("is_error").and_then(Value::as_bool).unwrap_or(false); if is_error { - let message = v + let diagnostic = v .get("result") .and_then(Value::as_str) .unwrap_or("agent turn failed") .to_string(); - vec![ParsedLine::Frame(ChatStreamServerMsg::Error { message })] + vec![classified_error(&diagnostic)] } else { vec![ParsedLine::Frame(ChatStreamServerMsg::TurnDone)] } @@ -169,6 +196,83 @@ pub fn parse_line(line: &str) -> Vec { } } +/// Parse one `codex exec --json` JSONL event. +pub fn parse_codex_line(line: &str) -> Vec { + let Ok(value) = serde_json::from_str::(line.trim()) else { + return Vec::new(); + }; + match value.get("type").and_then(Value::as_str) { + Some("thread.started") => value + .get("thread_id") + .and_then(Value::as_str) + .map(|id| vec![ParsedLine::Session(id.to_string())]) + .unwrap_or_default(), + Some("item.started") | Some("item.completed") => { + let Some(item) = value.get("item") else { + return Vec::new(); + }; + let item_type = item.get("type").and_then(Value::as_str).unwrap_or("tool"); + if value.get("type").and_then(Value::as_str) == Some("item.completed") + && item_type == "agent_message" + { + return item + .get("text") + .and_then(Value::as_str) + .map(|text| { + vec![ParsedLine::Frame(ChatStreamServerMsg::AssistantMessage { + text: text.to_string(), + })] + }) + .unwrap_or_default(); + } + if item_type == "error" { + let diagnostic = item + .get("message") + .or_else(|| item.get("text")) + .and_then(Value::as_str) + .unwrap_or("Codex item failed"); + return vec![classified_error(diagnostic)]; + } + if matches!( + item_type, + "command_execution" + | "file_change" + | "mcp_tool_call" + | "collab_tool_call" + | "web_search" + | "todo_list" + ) { + return vec![ParsedLine::Frame(ChatStreamServerMsg::ToolEvent { + name: item_type.to_string(), + detail: Some(compact_json(item)), + })]; + } + Vec::new() + } + Some("turn.completed") => vec![ParsedLine::Frame(ChatStreamServerMsg::TurnDone)], + Some("turn.failed") | Some("error") => { + let diagnostic = value + .get("error") + .and_then(|error| { + error + .as_str() + .or_else(|| error.get("message").and_then(Value::as_str)) + }) + .or_else(|| value.get("message").and_then(Value::as_str)) + .unwrap_or("Codex turn failed"); + vec![classified_error(diagnostic)] + } + _ => Vec::new(), + } +} + +fn parse_agent_line(agent: ChatAgent, line: &str) -> Vec { + match agent { + ChatAgent::Claude => parse_line(line), + ChatAgent::Codex => parse_codex_line(line), + } +} + /// Renders a `serde_json::Value` as a compact one-line string, for use as a /// `ToolEvent`'s `detail`. fn compact_json(v: &Value) -> String { @@ -183,7 +287,10 @@ fn compact_json(v: &Value) -> String { /// `run_turn`'s `process_group(0)` on its `Command`). async fn kill_process_group(pid: u32) { let _ = tokio::task::spawn_blocking(move || { - std::process::Command::new("kill").arg("--").arg(format!("-{pid}")).status() + std::process::Command::new("kill") + .arg("--") + .arg(format!("-{pid}")) + .status() }) .await; } @@ -194,8 +301,6 @@ async fn kill_process_group(pid: u32) { /// timeout, and (via `kill_on_drop`) if this future is itself dropped/cancelled. /// A final `TurnDone` is always sent if the stream didn't already carry one. /// -/// KNOWN LIMIT: `ChatAgent::Codex` never spawns a process — it sends an -/// `Error` then `TurnDone` and returns `None` immediately. pub async fn run_turn( meta: &ChatMeta, user_text: &str, @@ -204,20 +309,14 @@ pub async fn run_turn( timeout: Duration, tx: Sender, ) -> Option { + let mut cmd = agent_command(meta, user_text, model, account_dir); if meta.agent == ChatAgent::Codex { - let _ = tx - .send(ChatStreamServerMsg::Error { - message: "codex chat not yet supported".to_string(), - }) - .await; - let _ = tx.send(ChatStreamServerMsg::TurnDone).await; - return None; + cmd.stdin(Stdio::piped()); + } else { + cmd.stdin(Stdio::null()); } - - let mut cmd = agent_command(meta, user_text, model, account_dir); - cmd.stdin(Stdio::null()); cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::null()); + cmd.stderr(Stdio::piped()); cmd.kill_on_drop(true); // I-3: place the child in its own process group so a nested process it // spawns (Claude may spawn nested tool processes) stays reachable by a @@ -243,23 +342,46 @@ pub async fn run_turn( // used to reach a nested process via the whole-group kill on every kill // path below. let child_pid = child.id(); + if meta.agent == ChatAgent::Codex { + let mut stdin = child.stdin.take().expect("Codex stdin was piped"); + if stdin.write_all(user_text.as_bytes()).await.is_err() || stdin.shutdown().await.is_err() { + let _ = child.kill().await; + let _ = tx + .send(ChatStreamServerMsg::Error { + message: "failed to send the turn to Codex".to_string(), + }) + .await; + let _ = tx.send(ChatStreamServerMsg::TurnDone).await; + return None; + } + } let stdout = child.stdout.take().expect("stdout was piped"); + let mut stderr = child.stderr.take().expect("stderr was piped"); + let stderr_task = tokio::spawn(async move { + let mut captured = String::new(); + let _ = stderr.read_to_string(&mut captured).await; + captured + }); let mut lines = BufReader::new(stdout).lines(); let mut session_id: Option = None; let mut sent_turn_done = false; + let mut sent_error = false; let read_loop = async { loop { match lines.next_line().await { Ok(Some(line)) => { - for parsed in parse_line(&line) { + for parsed in parse_agent_line(meta.agent, &line) { match parsed { ParsedLine::Session(id) => session_id = Some(id), ParsedLine::Frame(frame) => { if matches!(frame, ChatStreamServerMsg::TurnDone) { sent_turn_done = true; } + if matches!(frame, ChatStreamServerMsg::Error { .. }) { + sent_error = true; + } if tx.send(frame).await.is_err() { // Receiver dropped: the caller no longer // wants frames, so stop reading and kill @@ -299,7 +421,36 @@ pub async fn run_turn( return session_id; } - let _ = child.wait().await; + let status = child.wait().await.ok(); + let stderr = stderr_task.await.unwrap_or_default(); + if status.as_ref().is_some_and(|status| !status.success()) && !sent_error { + let detail = stderr.trim(); + let diagnostic = if detail.is_empty() { + format!( + "agent process exited with status {}", + status + .as_ref() + .and_then(std::process::ExitStatus::code) + .unwrap_or(-1) + ) + } else { + // Provider CLIs can emit very large diagnostics. Keep the client + // log bounded while retaining the actionable beginning. + detail.chars().take(2_000).collect() + }; + let reason = omega_core::failover::classify(None, &diagnostic); + tracing::warn!( + ?reason, + action = ?reason.next_action(), + provider_error = %diagnostic, + "headless provider turn failed" + ); + let _ = tx + .send(ChatStreamServerMsg::Error { + message: reason.user_message().to_string(), + }) + .await; + } if !sent_turn_done { let _ = tx.send(ChatStreamServerMsg::TurnDone).await; } @@ -338,10 +489,19 @@ mod tests { std::env::remove_var("OMEGA_CHAT_BIN"); let std_cmd = cmd.as_std(); - assert_eq!(std_cmd.get_program().to_str().unwrap(), "/usr/bin/fake-claude"); + assert_eq!( + std_cmd.get_program().to_str().unwrap(), + "/usr/bin/fake-claude" + ); let args: Vec<&str> = std_cmd.get_args().map(|a| a.to_str().unwrap()).collect(); - assert_eq!(args, vec!["-p", "hello", "--output-format", "stream-json", "--verbose"]); - assert_eq!(std_cmd.get_current_dir().unwrap().to_str().unwrap(), "/tmp/proj"); + assert_eq!( + args, + vec!["-p", "hello", "--output-format", "stream-json", "--verbose"] + ); + assert_eq!( + std_cmd.get_current_dir().unwrap().to_str().unwrap(), + "/tmp/proj" + ); } #[tokio::test] @@ -381,15 +541,60 @@ mod tests { assert!(args.windows(2).any(|w| w == ["--model", "claude-fable-5"])); } - #[test] - #[should_panic(expected = "agent_command is Claude-only")] - fn agent_command_panics_for_codex_agent() { - // Real (non-debug-only) assert: this must fire in every build - // profile, not just debug, since run_turn's own short-circuit is - // the primary guard and this is the belt-and-suspenders backstop. + #[tokio::test] + async fn agent_command_builds_codex_exec_json_with_stdin_prompt() { + let _g = LOCK.lock().await; + std::env::set_var("OMEGA_CODEX_CHAT_BIN", "/usr/bin/fake-codex"); let mut meta = test_meta(None); meta.agent = ChatAgent::Codex; - let _ = agent_command(&meta, "hi", None, None); + let dir = std::path::PathBuf::from("/tmp/codex-account"); + let cmd = agent_command(&meta, "secret prompt", Some("gpt-5.6-sol"), Some(&dir)); + std::env::remove_var("OMEGA_CODEX_CHAT_BIN"); + + let std_cmd = cmd.as_std(); + assert_eq!( + std_cmd.get_program().to_str().unwrap(), + "/usr/bin/fake-codex" + ); + let args: Vec<&str> = std_cmd + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert!(args.starts_with(&[ + "exec", + "--skip-git-repo-check", + "--approve-for-me", + "--dangerously-bypass-hook-trust", + "--json", + ])); + assert!(args + .windows(2) + .any(|pair| pair == ["--model", "gpt-5.6-sol"])); + assert_eq!(args.last(), Some(&"-")); + assert!(!args.contains(&"secret prompt")); + let (_, value) = std_cmd + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new("CODEX_HOME")) + .expect("CODEX_HOME should be set"); + assert_eq!(value.unwrap().to_str(), Some("/tmp/codex-account")); + } + + #[tokio::test] + async fn agent_command_resumes_codex_provider_session() { + let _g = LOCK.lock().await; + std::env::set_var("OMEGA_CODEX_CHAT_BIN", "/usr/bin/fake-codex"); + let mut meta = test_meta(Some("0199a213-81c0-7800-8aa1-bbab2a035a53")); + meta.agent = ChatAgent::Codex; + let cmd = agent_command(&meta, "continue", None, None); + std::env::remove_var("OMEGA_CODEX_CHAT_BIN"); + let args: Vec<&str> = cmd + .as_std() + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert!(args + .windows(2) + .any(|pair| { pair == ["resume", "0199a213-81c0-7800-8aa1-bbab2a035a53"] })); } #[tokio::test] @@ -518,7 +723,10 @@ mod tests { let line = r#"{"type":"result","is_error":false,"stop_reason":"end_turn","result":"PONG","session_id":"s1"}"#; let out = parse_line(line); assert_eq!(out.len(), 1); - assert!(matches!(&out[0], ParsedLine::Frame(ChatStreamServerMsg::TurnDone))); + assert!(matches!( + &out[0], + ParsedLine::Frame(ChatStreamServerMsg::TurnDone) + )); } #[test] @@ -528,7 +736,7 @@ mod tests { assert_eq!(out.len(), 1); match out.remove(0) { ParsedLine::Frame(ChatStreamServerMsg::Error { message }) => { - assert_eq!(message, "boom"); + assert_eq!(message, "agent turn failed; inspect local gateway logs"); } _ => panic!("expected Error frame"), } @@ -555,4 +763,39 @@ mod tests { fn parse_line_empty_is_ignored() { assert!(parse_line("").is_empty()); } + + #[test] + fn parse_codex_thread_and_agent_message() { + let mut session = parse_codex_line( + r#"{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}"#, + ); + assert!(matches!( + session.remove(0), + ParsedLine::Session(ref id) if id == "0199a213-81c0-7800-8aa1-bbab2a035a53" + )); + + let mut message = parse_codex_line( + r#"{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"PONG"}}"#, + ); + assert!(matches!( + message.remove(0), + ParsedLine::Frame(ChatStreamServerMsg::AssistantMessage { ref text }) if text == "PONG" + )); + } + + #[test] + fn parse_codex_tool_and_turn_completion() { + let tool = parse_codex_line( + r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"cargo test","status":"completed"}}"#, + ); + assert!(matches!( + &tool[0], + ParsedLine::Frame(ChatStreamServerMsg::ToolEvent { name, detail }) + if name == "command_execution" && detail.as_deref().is_some_and(|value| value.contains("cargo test")) + )); + assert!(matches!( + &parse_codex_line(r#"{"type":"turn.completed","usage":{}}"#)[0], + ParsedLine::Frame(ChatStreamServerMsg::TurnDone) + )); + } } diff --git a/crates/omega-gateway/src/chat_store.rs b/crates/omega-gateway/src/chat_store.rs index 833e5aac..f6bd29ff 100644 --- a/crates/omega-gateway/src/chat_store.rs +++ b/crates/omega-gateway/src/chat_store.rs @@ -6,7 +6,7 @@ //! Each `/chats//` dir is hardened to 0700. use crate::fsperm::{harden_dir, harden_file}; -use crate::protocol::{ChatAgent, ChatMeta, ChatMessage}; +use crate::protocol::{ChatAgent, ChatMessage, ChatMeta}; use crate::util::random_hex; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -29,7 +29,10 @@ impl ChatStore { let chats_dir = gateway_dir.join("chats"); std::fs::create_dir_all(&chats_dir).ok(); harden_dir(&chats_dir); - Self { chats_dir, active_turns: Mutex::new(HashSet::new()) } + Self { + chats_dir, + active_turns: Mutex::new(HashSet::new()), + } } /// Marks chat `id` as having a turn in flight. Returns `true` and @@ -78,7 +81,11 @@ impl ChatStore { } else { harden_file(&tmp); if let Err(e) = std::fs::rename(&tmp, &path) { - tracing::error!("failed to rename {} -> {}: {e}", tmp.display(), path.display()); + tracing::error!( + "failed to rename {} -> {}: {e}", + tmp.display(), + path.display() + ); } else { harden_file(&path); } @@ -121,11 +128,15 @@ impl ChatStore { return metas; }; for entry in entries.flatten() { - let Ok(file_type) = entry.file_type() else { continue }; + let Ok(file_type) = entry.file_type() else { + continue; + }; if !file_type.is_dir() { continue; } - let Some(id) = entry.file_name().to_str().map(str::to_string) else { continue }; + let Some(id) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; if let Some(meta) = self.get(&id) { metas.push(meta); } @@ -157,7 +168,10 @@ impl ChatStore { return; }; use std::io::Write; - let file = std::fs::OpenOptions::new().create(true).append(true).open(&path); + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path); match file { Ok(mut f) => { if let Err(e) = writeln!(f, "{line}") { @@ -202,7 +216,12 @@ impl ChatStore { /// reached (nothing older exists). `limit` is clamped server-side /// regardless of what the caller asks, so a hostile `limit` cannot /// force an unbounded read. - pub fn tail_page(&self, id: &str, before: Option, limit: usize) -> (Vec, Option) { + pub fn tail_page( + &self, + id: &str, + before: Option, + limit: usize, + ) -> (Vec, Option) { const MAX_LIMIT: usize = 500; const CHUNK_SIZE: u64 = 64 * 1024; let limit = limit.min(MAX_LIMIT); @@ -266,7 +285,11 @@ impl ChatStore { let take = limit.min(lines.len()); let window = &lines[lines.len() - take..]; - let next_cursor = if lines.len() > take { Some(window[0].0) } else { None }; + let next_cursor = if lines.len() > take { + Some(window[0].0) + } else { + None + }; let mut messages = Vec::with_capacity(take); for (_, bytes) in window.iter().rev() { @@ -301,7 +324,12 @@ mod tests { fn create_then_get_roundtrip() { let dir = tempfile::tempdir().unwrap(); let store = ChatStore::open(dir.path()); - let meta = store.create(ChatAgent::Claude, "/tmp/proj".to_string(), Some("hi".to_string()), None); + let meta = store.create( + ChatAgent::Claude, + "/tmp/proj".to_string(), + Some("hi".to_string()), + None, + ); let fetched = store.get(&meta.id).expect("chat should exist"); assert_eq!(fetched.id, meta.id); @@ -348,7 +376,9 @@ mod tests { ) .unwrap(); - let meta = store.get("legacy1").expect("legacy meta.json should still parse"); + let meta = store + .get("legacy1") + .expect("legacy meta.json should still parse"); assert!(meta.account_slug.is_none()); } @@ -367,11 +397,19 @@ mod tests { store.append_message( &meta.id, - &ChatMessage { role: "user".to_string(), text: "first".to_string(), ts: "t1".to_string() }, + &ChatMessage { + role: "user".to_string(), + text: "first".to_string(), + ts: "t1".to_string(), + }, ); store.append_message( &meta.id, - &ChatMessage { role: "assistant".to_string(), text: "second".to_string(), ts: "t2".to_string() }, + &ChatMessage { + role: "assistant".to_string(), + text: "second".to_string(), + ts: "t2".to_string(), + }, ); let transcript = store.transcript(&meta.id); @@ -409,7 +447,11 @@ mod tests { // bump a's updated_at past b's by appending to it store.append_message( &a.id, - &ChatMessage { role: "user".to_string(), text: "bump".to_string(), ts: "t".to_string() }, + &ChatMessage { + role: "user".to_string(), + text: "bump".to_string(), + ts: "t".to_string(), + }, ); let listed = store.list(); @@ -424,11 +466,20 @@ mod tests { let store = ChatStore::open(dir.path()); let meta = store.create(ChatAgent::Claude, "/tmp".to_string(), None, None); - assert!(store.try_start_turn(&meta.id), "first call should start the turn"); - assert!(!store.try_start_turn(&meta.id), "second call while active must be rejected"); + assert!( + store.try_start_turn(&meta.id), + "first call should start the turn" + ); + assert!( + !store.try_start_turn(&meta.id), + "second call while active must be rejected" + ); store.end_turn(&meta.id); - assert!(store.try_start_turn(&meta.id), "after end_turn, a new turn may start"); + assert!( + store.try_start_turn(&meta.id), + "after end_turn, a new turn may start" + ); } #[test] @@ -447,7 +498,10 @@ mod tests { let b = store.create(ChatAgent::Claude, "/tmp".to_string(), None, None); assert!(store.try_start_turn(&a.id)); - assert!(store.try_start_turn(&b.id), "a different chat id must not be blocked by a's active turn"); + assert!( + store.try_start_turn(&b.id), + "a different chat id must not be blocked by a's active turn" + ); } #[test] @@ -460,7 +514,10 @@ mod tests { store.set_provider_session(&meta.id, "claude-session-abc"); let fetched = store.get(&meta.id).unwrap(); - assert_eq!(fetched.provider_session_id.as_deref(), Some("claude-session-abc")); + assert_eq!( + fetched.provider_session_id.as_deref(), + Some("claude-session-abc") + ); } #[test] @@ -468,10 +525,19 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let meta = { let store = ChatStore::open(dir.path()); - let meta = store.create(ChatAgent::Claude, "/tmp".to_string(), Some("persisted".to_string()), None); + let meta = store.create( + ChatAgent::Claude, + "/tmp".to_string(), + Some("persisted".to_string()), + None, + ); store.append_message( &meta.id, - &ChatMessage { role: "user".to_string(), text: "hello".to_string(), ts: "t1".to_string() }, + &ChatMessage { + role: "user".to_string(), + text: "hello".to_string(), + ts: "t1".to_string(), + }, ); meta }; @@ -534,13 +600,20 @@ mod tests { let meta = store.create(ChatAgent::Claude, "/tmp".to_string(), None, None); store.append_message( &meta.id, - &ChatMessage { role: "user".to_string(), text: "hi".to_string(), ts: "t1".to_string() }, + &ChatMessage { + role: "user".to_string(), + text: "hi".to_string(), + ts: "t1".to_string(), + }, ); let (messages, cursor) = store.tail_page(&meta.id, None, 10); assert_eq!(messages.len(), 1); assert_eq!(messages[0].text, "hi"); - assert!(cursor.is_none(), "one message fits well under the limit -> no next page"); + assert!( + cursor.is_none(), + "one message fits well under the limit -> no next page" + ); } #[test] @@ -551,7 +624,11 @@ mod tests { for i in 0..3 { store.append_message( &meta.id, - &ChatMessage { role: "user".to_string(), text: format!("m{i}"), ts: format!("t{i}") }, + &ChatMessage { + role: "user".to_string(), + text: format!("m{i}"), + ts: format!("t{i}"), + }, ); } @@ -560,7 +637,10 @@ mod tests { assert_eq!(messages[0].text, "m2", "newest first"); assert_eq!(messages[1].text, "m1"); assert_eq!(messages[2].text, "m0"); - assert!(cursor.is_none(), "transcript has exactly `limit` messages -> no next page"); + assert!( + cursor.is_none(), + "transcript has exactly `limit` messages -> no next page" + ); } #[test] @@ -571,7 +651,11 @@ mod tests { for i in 0..5 { store.append_message( &meta.id, - &ChatMessage { role: "user".to_string(), text: format!("m{i}"), ts: format!("t{i}") }, + &ChatMessage { + role: "user".to_string(), + text: format!("m{i}"), + ts: format!("t{i}"), + }, ); } @@ -583,7 +667,11 @@ mod tests { let cursor = cursor.expect("2 older messages remain -> a next_cursor must be returned"); let (page2, cursor2) = store.tail_page(&meta.id, Some(cursor), 3); - assert_eq!(page2.len(), 2, "the next older page has exactly the 2 remaining messages, no gap, no dupe"); + assert_eq!( + page2.len(), + 2, + "the next older page has exactly the 2 remaining messages, no gap, no dupe" + ); assert_eq!(page2[0].text, "m1"); assert_eq!(page2[1].text, "m0"); assert!(cursor2.is_none()); @@ -598,7 +686,11 @@ mod tests { for i in 0..total { store.append_message( &meta.id, - &ChatMessage { role: "user".to_string(), text: format!("m{i}"), ts: format!("t{i}") }, + &ChatMessage { + role: "user".to_string(), + text: format!("m{i}"), + ts: format!("t{i}"), + }, ); } @@ -610,12 +702,18 @@ mod tests { collected_newest_first.extend(page.into_iter().map(|m| m.text)); cursor = next; } - assert!(cursor.is_none(), "the transcript divides evenly into exactly 3 pages of 3"); + assert!( + cursor.is_none(), + "the transcript divides evenly into exactly 3 pages of 3" + ); let mut chronological = collected_newest_first; chronological.reverse(); let expected: Vec = (0..total).map(|i| format!("m{i}")).collect(); - assert_eq!(chronological, expected, "3 pages of 3, reversed, must equal the full transcript"); + assert_eq!( + chronological, expected, + "3 pages of 3, reversed, must equal the full transcript" + ); } #[test] @@ -625,20 +723,35 @@ mod tests { let meta = store.create(ChatAgent::Claude, "/tmp".to_string(), None, None); store.append_message( &meta.id, - &ChatMessage { role: "user".to_string(), text: "one".to_string(), ts: "t1".to_string() }, + &ChatMessage { + role: "user".to_string(), + text: "one".to_string(), + ts: "t1".to_string(), + }, ); { use std::io::Write; - let mut f = std::fs::OpenOptions::new().append(true).open(store.transcript_path(&meta.id)).unwrap(); + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(store.transcript_path(&meta.id)) + .unwrap(); writeln!(f, "{{not valid json").unwrap(); } store.append_message( &meta.id, - &ChatMessage { role: "assistant".to_string(), text: "three".to_string(), ts: "t3".to_string() }, + &ChatMessage { + role: "assistant".to_string(), + text: "three".to_string(), + ts: "t3".to_string(), + }, ); let (messages, cursor) = store.tail_page(&meta.id, None, 10); - assert_eq!(messages.len(), 2, "the corrupted middle line must be skipped, not panic"); + assert_eq!( + messages.len(), + 2, + "the corrupted middle line must be skipped, not panic" + ); assert_eq!(messages[0].text, "three", "newest first"); assert_eq!(messages[1].text, "one"); assert!(cursor.is_none()); @@ -660,7 +773,9 @@ mod tests { const N: usize = 400_000; { use std::io::Write; - let mut writer = std::io::BufWriter::new(std::fs::File::create(store.transcript_path(&meta.id)).unwrap()); + let mut writer = std::io::BufWriter::new( + std::fs::File::create(store.transcript_path(&meta.id)).unwrap(), + ); for i in 0..N { let msg = ChatMessage { role: "user".to_string(), @@ -671,8 +786,13 @@ mod tests { } writer.flush().unwrap(); } - let file_len = std::fs::metadata(store.transcript_path(&meta.id)).unwrap().len(); - assert!(file_len > 10_000_000, "transcript should be tens of MB, got {file_len} bytes"); + let file_len = std::fs::metadata(store.transcript_path(&meta.id)) + .unwrap() + .len(); + assert!( + file_len > 10_000_000, + "transcript should be tens of MB, got {file_len} bytes" + ); let bound = std::time::Duration::from_millis(250); @@ -682,10 +802,17 @@ mod tests { eprintln!("tail_page on a {file_len}-byte ({N}-message) transcript took {elapsed_huge:?}"); assert_eq!(messages.len(), 20); - assert_eq!(messages[0].text, format!("message number {}", N - 1), "newest first"); + assert_eq!( + messages[0].text, + format!("message number {}", N - 1), + "newest first" + ); assert_eq!(messages[19].text, format!("message number {}", N - 20)); assert!(next_cursor.is_some()); - assert!(elapsed_huge < bound, "tail_page on a huge transcript took {elapsed_huge:?}, expected < {bound:?}"); + assert!( + elapsed_huge < bound, + "tail_page on a huge transcript took {elapsed_huge:?}, expected < {bound:?}" + ); // A small transcript's tail_page call should land in the same rough // ballpark -- proof that latency does not scale with total file size. @@ -693,7 +820,11 @@ mod tests { for i in 0..20 { store.append_message( &small.id, - &ChatMessage { role: "user".to_string(), text: format!("small {i}"), ts: format!("t{i}") }, + &ChatMessage { + role: "user".to_string(), + text: format!("small {i}"), + ts: format!("t{i}"), + }, ); } let start2 = std::time::Instant::now(); @@ -703,7 +834,10 @@ mod tests { assert_eq!(messages2.len(), 20); assert!(cursor2.is_none()); - assert!(elapsed_small < bound, "tail_page on a small transcript took {elapsed_small:?}, expected < {bound:?}"); + assert!( + elapsed_small < bound, + "tail_page on a small transcript took {elapsed_small:?}, expected < {bound:?}" + ); } #[cfg(unix)] @@ -715,11 +849,23 @@ mod tests { let meta = store.create(ChatAgent::Claude, "/tmp".to_string(), None, None); store.append_message( &meta.id, - &ChatMessage { role: "user".to_string(), text: "hi".to_string(), ts: "t".to_string() }, + &ChatMessage { + role: "user".to_string(), + text: "hi".to_string(), + ts: "t".to_string(), + }, ); - let transcript_path = dir.path().join("chats").join(&meta.id).join("transcript.jsonl"); - let mode = std::fs::metadata(&transcript_path).unwrap().permissions().mode() & 0o777; + let transcript_path = dir + .path() + .join("chats") + .join(&meta.id) + .join("transcript.jsonl"); + let mode = std::fs::metadata(&transcript_path) + .unwrap() + .permissions() + .mode() + & 0o777; assert_eq!(mode, 0o600, "transcript.jsonl must be 0600"); } } diff --git a/crates/omega-gateway/src/config.rs b/crates/omega-gateway/src/config.rs index 2af842f9..c8121b5d 100644 --- a/crates/omega-gateway/src/config.rs +++ b/crates/omega-gateway/src/config.rs @@ -38,7 +38,10 @@ pub fn gateway_dir() -> PathBuf { if let Ok(dir) = std::env::var("OMEGA_GATEWAY_DIR") { return PathBuf::from(dir); } - dirs::home_dir().expect("no home dir").join(".omega").join("gateway") + dirs::home_dir() + .expect("no home dir") + .join(".omega") + .join("gateway") } /// `$OMEGA_HOME` when set, else the real `$HOME` (`dirs::home_dir()`) — the @@ -86,7 +89,11 @@ mod tests { #[test] fn file_overrides_partial_fields() { let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("gateway.toml"), "bind = \"127.0.0.1:9999\"\n").unwrap(); + std::fs::write( + dir.path().join("gateway.toml"), + "bind = \"127.0.0.1:9999\"\n", + ) + .unwrap(); let cfg = GatewayConfig::load(dir.path()); assert_eq!(cfg.bind, "127.0.0.1:9999"); assert_eq!(cfg.stream_lines, 200); diff --git a/crates/omega-gateway/src/deposit.rs b/crates/omega-gateway/src/deposit.rs index f1c20bb0..4f38fe5d 100644 --- a/crates/omega-gateway/src/deposit.rs +++ b/crates/omega-gateway/src/deposit.rs @@ -32,7 +32,12 @@ pub struct DepositConfig { impl Default for DepositConfig { fn default() -> Self { Self { - boxes: vec!["Home".into(), "AltReality".into(), "Omega".into(), "Box".into()], + boxes: vec![ + "Home".into(), + "AltReality".into(), + "Omega".into(), + "Box".into(), + ], fanout_secrets: false, } } @@ -160,11 +165,18 @@ pub fn deposit( }); { use std::io::Write; - let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&index_path)?; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&index_path)?; writeln!(f, "{line}")?; } - Ok(DepositOutcome { file: filename, boxes: reached, held }) + Ok(DepositOutcome { + file: filename, + boxes: reached, + held, + }) } /// The exact secret-detection regex the real Telegram DEPOSIT bot uses @@ -194,7 +206,13 @@ fn looks_secret(filename: &str) -> bool { fn sanitize_filename(name: &str) -> String { let sanitized: String = name .chars() - .map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' { c } else { '_' }) + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' { + c + } else { + '_' + } + }) .collect(); if sanitized.is_empty() { "file".to_string() @@ -259,7 +277,11 @@ fn write_unique_inbox_file( let uniq = random_hex(3); let filename = format!("{ts}_{uniq}_{truncated}"); let path = inbox_dir.join(&filename); - match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { Ok(mut f) => { use std::io::Write; f.write_all(bytes)?; @@ -319,9 +341,19 @@ mod tests { let outcome = deposit(dir.path(), "notes.txt", b"hello", Some("omega"), false).unwrap(); assert_eq!(outcome.boxes, vec!["Omega".to_string()]); - assert!(dir.path().join("deposit").join("Omega").join(&outcome.file).exists()); + assert!(dir + .path() + .join("deposit") + .join("Omega") + .join(&outcome.file) + .exists()); for b in ["Home", "AltReality", "Box"] { - assert!(!dir.path().join("deposit").join(b).join(&outcome.file).exists()); + assert!(!dir + .path() + .join("deposit") + .join(b) + .join(&outcome.file) + .exists()); } } @@ -330,8 +362,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let err = deposit(dir.path(), "notes.txt", b"hello", Some("Nowhere"), false).unwrap_err(); assert!(err.to_string().contains("Nowhere")); - assert!(!dir.path().join("inbox").exists(), "inbox must stay untouched on validation failure"); - assert!(!dir.path().join("deposit").exists(), "no box dir may be created on validation failure"); + assert!( + !dir.path().join("inbox").exists(), + "inbox must stay untouched on validation failure" + ); + assert!( + !dir.path().join("deposit").exists(), + "no box dir may be created on validation failure" + ); } #[test] @@ -344,7 +382,8 @@ mod tests { assert!(dir.path().join("inbox").join(&outcome.file).exists()); for b in ["Home", "AltReality", "Omega", "Box"] { let box_dir = dir.path().join("deposit").join(b); - let has_files = box_dir.exists() && std::fs::read_dir(&box_dir).unwrap().next().is_some(); + let has_files = + box_dir.exists() && std::fs::read_dir(&box_dir).unwrap().next().is_some(); assert!(!has_files, "{b} must not receive a held file"); } } @@ -357,7 +396,12 @@ mod tests { assert!(!outcome.held); assert_eq!(outcome.boxes.len(), 4); for b in ["Home", "AltReality", "Omega", "Box"] { - assert!(dir.path().join("deposit").join(b).join(&outcome.file).exists()); + assert!(dir + .path() + .join("deposit") + .join(b) + .join(&outcome.file) + .exists()); } } @@ -417,13 +461,27 @@ mod tests { // see only "aaa...a" (100 a's) and `looks_secret`'s `\.pem$` anchor // would never match. let original_name = format!("{}.pem", "a".repeat(110)); - let outcome = deposit(dir.path(), &original_name, b"-----BEGIN KEY-----", None, false).unwrap(); + let outcome = deposit( + dir.path(), + &original_name, + b"-----BEGIN KEY-----", + None, + false, + ) + .unwrap(); - assert!(outcome.held, "a long filename ending in .pem must still be detected as secret"); - assert!(outcome.boxes.is_empty(), "a held file must reach zero boxes"); + assert!( + outcome.held, + "a long filename ending in .pem must still be detected as secret" + ); + assert!( + outcome.boxes.is_empty(), + "a held file must reach zero boxes" + ); for b in ["Home", "AltReality", "Omega", "Box"] { let box_dir = dir.path().join("deposit").join(b); - let has_files = box_dir.exists() && std::fs::read_dir(&box_dir).unwrap().next().is_some(); + let has_files = + box_dir.exists() && std::fs::read_dir(&box_dir).unwrap().next().is_some(); assert!(!has_files, "{b} must not have received the held file"); } } @@ -450,8 +508,14 @@ mod tests { let inbox_dir = dir.path().join("inbox"); let bytes_a = std::fs::read(inbox_dir.join(&outcome_a.file)).unwrap(); let bytes_b = std::fs::read(inbox_dir.join(&outcome_b.file)).unwrap(); - assert_eq!(bytes_a, payload_a, "first deposit's inbox content must be exactly its own payload"); - assert_eq!(bytes_b, payload_b, "second deposit's inbox content must be exactly its own payload"); + assert_eq!( + bytes_a, payload_a, + "first deposit's inbox content must be exactly its own payload" + ); + assert_eq!( + bytes_b, payload_b, + "second deposit's inbox content must be exactly its own payload" + ); #[cfg(unix)] { @@ -461,8 +525,18 @@ mod tests { for b in ["Home", "AltReality", "Omega", "Box"] { let box_path = dir.path().join("deposit").join(b).join(&outcome.file); assert!(box_path.exists(), "{b} must have received {}", outcome.file); - assert_eq!(ino(&box_path), inbox_ino, "{b} copy of {} is not a hard link", outcome.file); - assert_eq!(std::fs::read(&box_path).unwrap(), *payload, "{b} copy of {} has wrong content", outcome.file); + assert_eq!( + ino(&box_path), + inbox_ino, + "{b} copy of {} is not a hard link", + outcome.file + ); + assert_eq!( + std::fs::read(&box_path).unwrap(), + *payload, + "{b} copy of {} has wrong content", + outcome.file + ); } } } @@ -470,7 +544,17 @@ mod tests { #[test] fn looks_secret_matches_the_telegram_bot_regex() { - for name in ["id_rsa", "id_ed25519", "service.pem", ".env", "my.key", "app.p12", "a_secret_note.txt", "TOKEN.txt", "private-key.bin"] { + for name in [ + "id_rsa", + "id_ed25519", + "service.pem", + ".env", + "my.key", + "app.p12", + "a_secret_note.txt", + "TOKEN.txt", + "private-key.bin", + ] { assert!(looks_secret(name), "{name} should look secret"); } for name in ["notes.txt", "photo.jpg", "report.pdf"] { diff --git a/crates/omega-gateway/src/events.rs b/crates/omega-gateway/src/events.rs index b6e58d3b..1b14a917 100644 --- a/crates/omega-gateway/src/events.rs +++ b/crates/omega-gateway/src/events.rs @@ -81,14 +81,19 @@ pub fn diff_missions( current .iter() .filter(|m| prev.get(&m.key).map(|u| u != &m.updated_at).unwrap_or(true)) - .map(|m| GatewayEvent::MissionUpdated { key: m.key.clone(), updated_at: m.updated_at.clone() }) + .map(|m| GatewayEvent::MissionUpdated { + key: m.key.clone(), + updated_at: m.updated_at.clone(), + }) .collect() } /// `cfg.stream_interval_ms` × [`MISSION_POLL_MULTIPLIER`], floored at /// [`MISSION_POLL_MIN_MS`]. fn mission_poll_interval(cfg: &GatewayConfig) -> Duration { - Duration::from_millis((cfg.stream_interval_ms * MISSION_POLL_MULTIPLIER).max(MISSION_POLL_MIN_MS)) + Duration::from_millis( + (cfg.stream_interval_ms * MISSION_POLL_MULTIPLIER).max(MISSION_POLL_MIN_MS), + ) } /// Spawns the two long-lived background loops that keep `hub` alive for @@ -109,7 +114,9 @@ pub fn spawn_background_emitters(hub: EventHub, cfg: &GatewayConfig) { tokio::spawn(async move { let mut cache: HashMap = HashMap::new(); loop { - let current = tokio::task::spawn_blocking(missions::list).await.unwrap_or_default(); + let current = tokio::task::spawn_blocking(missions::list) + .await + .unwrap_or_default(); for ev in diff_missions(&cache, ¤t) { if let GatewayEvent::MissionUpdated { key, updated_at } = &ev { cache.insert(key.clone(), updated_at.clone()); @@ -122,7 +129,9 @@ pub fn spawn_background_emitters(hub: EventHub, cfg: &GatewayConfig) { tokio::spawn(async move { loop { - hub.emit(GatewayEvent::Heartbeat { ts: chrono::Utc::now().to_rfc3339() }); + hub.emit(GatewayEvent::Heartbeat { + ts: chrono::Utc::now().to_rfc3339(), + }); tokio::time::sleep(HEARTBEAT_INTERVAL).await; } }); @@ -136,7 +145,10 @@ mod tests { async fn subscriber_receives_emitted_alert() { let hub = EventHub::new(); let mut rx = hub.subscribe(); - hub.emit(GatewayEvent::Alert { message: "boom".into(), ts: "t1".into() }); + hub.emit(GatewayEvent::Alert { + message: "boom".into(), + ts: "t1".into(), + }); let ev = rx.recv().await.unwrap(); match ev { GatewayEvent::Alert { message, ts } => { @@ -211,9 +223,9 @@ mod tests { prev.insert("oracle-a".to_string(), "t1".to_string()); // unchanged prev.insert("oracle-c".to_string(), "old".to_string()); // changed let current = vec![ - mission("oracle-a", "t1"), // unchanged: no event - mission("oracle-b", "t1"), // new: event - mission("oracle-c", "new"), // changed: event + mission("oracle-a", "t1"), // unchanged: no event + mission("oracle-b", "t1"), // new: event + mission("oracle-c", "new"), // changed: event ]; let mut keys: Vec = diff_missions(&prev, ¤t) .into_iter() diff --git a/crates/omega-gateway/src/lib.rs b/crates/omega-gateway/src/lib.rs index 7fd11d35..4c318783 100644 --- a/crates/omega-gateway/src/lib.rs +++ b/crates/omega-gateway/src/lib.rs @@ -17,8 +17,8 @@ pub mod routes_accounts; pub mod routes_agents; pub mod routes_audit; pub mod routes_box; -pub mod routes_cloud; pub mod routes_chat; +pub mod routes_cloud; pub mod routes_config; pub mod routes_deposit; pub mod routes_dispatch; diff --git a/crates/omega-gateway/src/main.rs b/crates/omega-gateway/src/main.rs index 40c053d0..197fafbf 100644 --- a/crates/omega-gateway/src/main.rs +++ b/crates/omega-gateway/src/main.rs @@ -27,9 +27,7 @@ enum Command { /// List chats (id, title, agent, updated_at) Chats, /// Revoke a device by id (its token stops verifying immediately) - Revoke { - device_id: String, - }, + Revoke { device_id: String }, /// List account slots (slug, label, kind, default, live auth status) Accounts, /// Create a new isolated credential slot (kind: claude|codex) @@ -101,7 +99,10 @@ async fn main() -> anyhow::Result<()> { } else { println!("{:<18} {:<20} {:<28} REVOKED", "ID", "NAME", "CREATED_AT"); for d in devices { - println!("{:<18} {:<20} {:<28} {}", d.id, d.name, d.created_at, d.revoked); + println!( + "{:<18} {:<20} {:<28} {}", + d.id, d.name, d.created_at, d.revoked + ); } } } @@ -137,7 +138,10 @@ async fn main() -> anyhow::Result<()> { if accounts.is_empty() { println!("no accounts"); } else { - println!("{:<16} {:<20} {:<8} {:<8} STATUS", "SLUG", "LABEL", "KIND", "DEFAULT"); + println!( + "{:<16} {:<20} {:<8} {:<8} STATUS", + "SLUG", "LABEL", "KIND", "DEFAULT" + ); for a in accounts { let slot = store.slot_dir(&a.slug); let status = match account_login::status(&a, &slot) { @@ -169,7 +173,10 @@ async fn main() -> anyhow::Result<()> { match kind { AccountKind::Claude => { println!("next step, log this slot in — either:"); - println!(" run: CLAUDE_CONFIG_DIR={} claude auth login", slot_dir.display()); + println!( + " run: CLAUDE_CONFIG_DIR={} claude auth login", + slot_dir.display() + ); println!(" or: complete login via the app's account pairing flow"); } AccountKind::Codex => { @@ -220,7 +227,11 @@ fn code_hash(code: &str) -> String { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(code.as_bytes()); - hasher.finalize().iter().map(|b| format!("{b:02x}")).collect() + hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect() } /// The Convex HTTP mutation envelope for `boxPairing:redeem`. Built as data so @@ -327,7 +338,9 @@ mod tests { ); let hashed = code_hash("a1b2c3d4"); assert_eq!(hashed.len(), 64); - assert!(hashed.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + assert!(hashed + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); assert!(!hashed.contains("a1b2c3d4")); } @@ -346,7 +359,13 @@ mod tests { #[test] fn redeem_request_carries_a_tailscale_host_when_there_is_one() { - let body = redeem_request("a1b2c3d4", "b", "L", 4477, Some("station.tail64d114.ts.net")); + let body = redeem_request( + "a1b2c3d4", + "b", + "L", + 4477, + Some("station.tail64d114.ts.net"), + ); assert_eq!(body["args"]["tailscaleHost"], "station.tail64d114.ts.net"); } @@ -372,8 +391,14 @@ mod tests { #[test] fn cli_parses_account_add_subcommand_with_positional_args() { - let cli = - Cli::try_parse_from(["omega-gatewayd", "account-add", "work-a", "Work A", "claude"]).unwrap(); + let cli = Cli::try_parse_from([ + "omega-gatewayd", + "account-add", + "work-a", + "Work A", + "claude", + ]) + .unwrap(); match cli.command { Some(Command::AccountAdd { slug, label, kind }) => { assert_eq!(slug, "work-a"); diff --git a/crates/omega-gateway/src/missions.rs b/crates/omega-gateway/src/missions.rs index b56a20c6..9e4f62fc 100644 --- a/crates/omega-gateway/src/missions.rs +++ b/crates/omega-gateway/src/missions.rs @@ -30,7 +30,10 @@ pub fn ledger_dir() -> PathBuf { if let Ok(dir) = std::env::var("OMEGA_STATE_DIR") { return PathBuf::from(dir); } - dirs::home_dir().expect("no home dir").join(".omega").join("state") + dirs::home_dir() + .expect("no home dir") + .join(".omega") + .join("state") } #[derive(Deserialize)] @@ -60,7 +63,12 @@ struct LedgerFile { /// First line of `text`, truncated to `max_chars` characters. Empty for /// empty/whitespace-only input. fn first_line_truncated(text: &str, max_chars: usize) -> String { - text.lines().next().unwrap_or("").chars().take(max_chars).collect() + text.lines() + .next() + .unwrap_or("") + .chars() + .take(max_chars) + .collect() } /// A filename counts as a top-level oracle ledger when it is @@ -80,11 +88,15 @@ pub fn list() -> Vec { return missions; }; for entry in entries.flatten() { - let Ok(file_type) = entry.file_type() else { continue }; + let Ok(file_type) = entry.file_type() else { + continue; + }; if !file_type.is_file() { continue; } - let Some(name) = entry.file_name().to_str().map(str::to_string) else { continue }; + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; if !is_top_level_oracle_ledger(&name) { continue; } @@ -120,7 +132,10 @@ pub fn list() -> Vec { tasks: parsed .tasks .into_iter() - .map(|t| MissionTask { title: t.title, status: t.status }) + .map(|t| MissionTask { + title: t.title, + status: t.status, + }) .collect(), updated_at: parsed.ts, }); @@ -144,14 +159,20 @@ mod tests { fn ledger_dir_env_override() { let _g = LOCK.lock().unwrap(); std::env::set_var("OMEGA_STATE_DIR", "/tmp/omega-state-test-override"); - assert_eq!(ledger_dir(), PathBuf::from("/tmp/omega-state-test-override")); + assert_eq!( + ledger_dir(), + PathBuf::from("/tmp/omega-state-test-override") + ); std::env::remove_var("OMEGA_STATE_DIR"); } #[test] fn list_on_missing_dir_returns_empty() { let _g = LOCK.lock().unwrap(); - std::env::set_var("OMEGA_STATE_DIR", "/tmp/nonexistent-omega-state-dir-xyz-123"); + std::env::set_var( + "OMEGA_STATE_DIR", + "/tmp/nonexistent-omega-state-dir-xyz-123", + ); assert!(list().is_empty()); std::env::remove_var("OMEGA_STATE_DIR"); } @@ -197,20 +218,38 @@ mod tests { }"#, ); // Malformed: must be skipped, not fatal. - write_ledger(dir.path(), "oracle-broken.progress.json", "{ not valid json"); + write_ledger( + dir.path(), + "oracle-broken.progress.json", + "{ not valid json", + ); // Foreign JSON (missing required fields entirely): also skipped. - write_ledger(dir.path(), "oracle-foreign.progress.json", r#"{"unrelated":"shape"}"#); + write_ledger( + dir.path(), + "oracle-foreign.progress.json", + r#"{"unrelated":"shape"}"#, + ); let missions = list(); std::env::remove_var("OMEGA_STATE_DIR"); - assert_eq!(missions.len(), 2, "worker + malformed + foreign ledgers must be excluded"); - assert_eq!(missions[0].key, "oracle-verba", "sorted updated_at desc: 08-09 before 08-08"); + assert_eq!( + missions.len(), + 2, + "worker + malformed + foreign ledgers must be excluded" + ); + assert_eq!( + missions[0].key, "oracle-verba", + "sorted updated_at desc: 08-09 before 08-08" + ); assert_eq!(missions[1].key, "oracle-dentistrygpt"); let dentistry = &missions[1]; assert_eq!(dentistry.project.as_deref(), Some("dentistrygpt")); - assert_eq!(dentistry.title.as_deref(), Some("Audit code reset vs addition")); + assert_eq!( + dentistry.title.as_deref(), + Some("Audit code reset vs addition") + ); assert_eq!(dentistry.done, 6); assert_eq!(dentistry.total, 6); assert_eq!(dentistry.updated_at, "2026-08-08T08:48:20Z"); @@ -232,8 +271,12 @@ mod tests { #[test] fn top_level_oracle_ledger_filename_matching() { - assert!(is_top_level_oracle_ledger("oracle-dentistrygpt.progress.json")); - assert!(!is_top_level_oracle_ledger("oracle-dentistrygpt-worker-1.progress.json")); + assert!(is_top_level_oracle_ledger( + "oracle-dentistrygpt.progress.json" + )); + assert!(!is_top_level_oracle_ledger( + "oracle-dentistrygpt-worker-1.progress.json" + )); assert!(!is_top_level_oracle_ledger("something-else.json")); assert!(!is_top_level_oracle_ledger("oracle-x.json")); } diff --git a/crates/omega-gateway/src/omega_cli.rs b/crates/omega-gateway/src/omega_cli.rs index 0a5de2bd..596a70c3 100644 --- a/crates/omega-gateway/src/omega_cli.rs +++ b/crates/omega-gateway/src/omega_cli.rs @@ -28,7 +28,9 @@ pub fn omega_bin() -> PathBuf { if let Ok(bin) = std::env::var("OMEGA_BIN") { return PathBuf::from(bin); } - dirs::home_dir().expect("no home dir").join(".local/bin/omega") + dirs::home_dir() + .expect("no home dir") + .join(".local/bin/omega") } /// Captured output of an `omega` subprocess invocation. @@ -97,7 +99,9 @@ pub fn run_with_timeout(args: &[&str], timeout: Duration) -> Result Result) -> std::fmt::Result { - write!(f, "omega {} timed out after {}s and was killed", self.args, self.timeout.as_secs()) + write!( + f, + "omega {} timed out after {}s and was killed", + self.args, + self.timeout.as_secs() + ) } } @@ -185,7 +197,10 @@ pub fn is_timeout(e: &anyhow::Error) -> bool { /// kill_process_group`, run synchronously since this function (unlike that /// one) is itself always called from a plain OS thread, never a tokio task. fn kill_process_group_sync(pid: u32) { - let _ = Command::new("kill").arg("--").arg(format!("-{pid}")).status(); + let _ = Command::new("kill") + .arg("--") + .arg(format!("-{pid}")) + .status(); } /// Default outer wall-clock bound on a WS-stream endpoint's WHOLE @@ -298,7 +313,10 @@ mod tests { let _g = LOCK.lock().unwrap(); std::env::set_var("OMEGA_BIN", "/no/such/binary/anywhere"); let err = run_with_timeout(&["x"], Duration::from_secs(5)).unwrap_err(); - assert!(!is_timeout(&err), "a spawn failure must never be classified as a timeout"); + assert!( + !is_timeout(&err), + "a spawn failure must never be classified as a timeout" + ); std::env::remove_var("OMEGA_BIN"); } @@ -320,7 +338,10 @@ mod tests { // return, ever happens. install_fake_omega( dir.path(), - &format!("bash -c 'sleep 2; touch \"{}\"' &\nwait\n", marker.display()), + &format!( + "bash -c 'sleep 2; touch \"{}\"' &\nwait\n", + marker.display() + ), ); let err = run_with_timeout(&["x"], Duration::from_millis(200)).unwrap_err(); diff --git a/crates/omega-gateway/src/protocol.rs b/crates/omega-gateway/src/protocol.rs index bd304488..e85b9233 100644 --- a/crates/omega-gateway/src/protocol.rs +++ b/crates/omega-gateway/src/protocol.rs @@ -157,14 +157,20 @@ pub enum AccountLoginServerMsg { #[derive(Serialize, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ChatStreamServerMsg { - Delta { text: String }, - AssistantMessage { text: String }, + Delta { + text: String, + }, + AssistantMessage { + text: String, + }, ToolEvent { name: String, detail: Option, }, TurnDone, - Error { message: String }, + Error { + message: String, + }, } #[derive(Deserialize, JsonSchema)] @@ -1144,6 +1150,7 @@ pub struct CodexConfigEntry { pub model: String, pub api_key_set: bool, pub base_url: String, + pub bypass_hook_trust: bool, } /// Mirrors `omega_core::providers::GeminiConfig`, `api_key` redacted. @@ -1153,6 +1160,14 @@ pub struct GeminiConfigEntry { pub api_key_set: bool, } +/// Mirrors `omega_core::providers::AntigravityConfig`. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct AntigravityConfigEntry { + pub model: String, + pub effort: String, + pub dangerously_skip_permissions: bool, +} + /// Mirrors `omega_core::providers::GlmConfig`, `api_key` redacted. #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct GlmConfigEntry { @@ -1179,8 +1194,18 @@ pub struct PiConfigEntry { /// Mirrors `omega_core::providers::HermesConfig`, `api_key` redacted. #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct HermesConfigEntry { + pub provider: String, + pub model: String, + pub api_key_set: bool, +} + +/// Mirrors `omega_core::providers::KimiConfig`, `api_key` redacted. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct KimiConfigEntry { pub model: String, pub api_key_set: bool, + pub base_url: String, + pub provider_type: String, } /// `GET /v1/config` and `PUT /v1/config` response body — the full @@ -1192,10 +1217,12 @@ pub struct ConfigResponse { pub claude: ClaudeConfigEntry, pub codex: CodexConfigEntry, pub gemini: GeminiConfigEntry, + pub antigravity: AntigravityConfigEntry, pub glm: GlmConfigEntry, pub openrouter: OpenRouterConfigEntry, pub pi: PiConfigEntry, pub hermes: HermesConfigEntry, + pub kimi: KimiConfigEntry, } /// Body of `PUT /v1/config` — one `provider.field` key/value pair, matching @@ -1456,10 +1483,12 @@ pub struct Protocol { pub claude_config_entry: ClaudeConfigEntry, pub codex_config_entry: CodexConfigEntry, pub gemini_config_entry: GeminiConfigEntry, + pub antigravity_config_entry: AntigravityConfigEntry, pub glm_config_entry: GlmConfigEntry, pub openrouter_config_entry: OpenRouterConfigEntry, pub pi_config_entry: PiConfigEntry, pub hermes_config_entry: HermesConfigEntry, + pub kimi_config_entry: KimiConfigEntry, pub config_response: ConfigResponse, pub config_set_request: ConfigSetRequest, pub telegram_status_response: TelegramStatusResponse, diff --git a/crates/omega-gateway/src/relay.rs b/crates/omega-gateway/src/relay.rs index e0547c06..a0416b95 100644 --- a/crates/omega-gateway/src/relay.rs +++ b/crates/omega-gateway/src/relay.rs @@ -1234,7 +1234,10 @@ mod tests { .unwrap(); let forwarded_text = String::from_utf8(forwarded).unwrap(); assert!(forwarded_text.starts_with("HTTP/1.1 200 OK\r\n")); - assert!(forwarded_text.contains(r#"{"ok":true,"version":"0.1.0"}"#)); + assert!(forwarded_text.contains(&format!( + r#"{{"ok":true,"version":"{}"}}"#, + env!("CARGO_PKG_VERSION") + ))); gateway_task.abort(); } } diff --git a/crates/omega-gateway/src/rmux.rs b/crates/omega-gateway/src/rmux.rs index a87d99da..4d0efd5a 100644 --- a/crates/omega-gateway/src/rmux.rs +++ b/crates/omega-gateway/src/rmux.rs @@ -6,7 +6,9 @@ pub fn rmux_bin() -> PathBuf { if let Ok(bin) = std::env::var("OMEGA_RMUX_BIN") { return PathBuf::from(bin); } - dirs::home_dir().expect("no home dir").join(".local/bin/rmux") + dirs::home_dir() + .expect("no home dir") + .join(".local/bin/rmux") } fn run(args: &[&str]) -> Result { @@ -19,7 +21,11 @@ fn run(args: &[&str]) -> Result { pub fn list_sessions() -> Result> { let out = run(&["ls", "-F", "#S"])?; - Ok(out.lines().map(str::to_string).filter(|l| !l.is_empty()).collect()) + Ok(out + .lines() + .map(str::to_string) + .filter(|l| !l.is_empty()) + .collect()) } pub fn capture_pane(session: &str, lines: u32) -> Result { diff --git a/crates/omega-gateway/src/routes_accounts.rs b/crates/omega-gateway/src/routes_accounts.rs index 875ee090..74efba5d 100644 --- a/crates/omega-gateway/src/routes_accounts.rs +++ b/crates/omega-gateway/src/routes_accounts.rs @@ -11,7 +11,8 @@ use crate::account_login::{self, AuthStatus, LoginOutcome}; use crate::accounts; use crate::protocol::{ - Account, AccountCreateRequest, AccountKind, AccountLoginServerMsg, AccountWithStatus, ApiKeyRequest, + Account, AccountCreateRequest, AccountKind, AccountLoginServerMsg, AccountWithStatus, + ApiKeyRequest, }; use crate::server::AppState; use axum::{ @@ -125,8 +126,10 @@ pub async fn apikey( } let slot = state.accounts.slot_dir(&slug); let api_key = req.api_key; - let result = - tokio::task::spawn_blocking(move || account_login::codex_login_with_api_key(&slot, &api_key)).await; + let result = tokio::task::spawn_blocking(move || { + account_login::codex_login_with_api_key(&slot, &api_key) + }) + .await; match result { Ok(Ok(())) => StatusCode::OK, Ok(Err(e)) => { @@ -156,7 +159,10 @@ pub async fn login( ws.on_upgrade(move |socket| login_loop(socket, slug, state)) } -async fn send_login_frame(socket: &mut WebSocket, frame: &AccountLoginServerMsg) -> Result<(), axum::Error> { +async fn send_login_frame( + socket: &mut WebSocket, + frame: &AccountLoginServerMsg, +) -> Result<(), axum::Error> { let text = serde_json::to_string(frame).expect("serialize AccountLoginServerMsg"); socket.send(Message::Text(text.into())).await } @@ -183,7 +189,9 @@ async fn login_loop(mut socket: WebSocket, slug: String, state: AppState) { let Some(account) = state.accounts.get(&slug) else { let _ = send_login_frame( &mut socket, - &AccountLoginServerMsg::Error { message: "unknown account".to_string() }, + &AccountLoginServerMsg::Error { + message: "unknown account".to_string(), + }, ) .await; let _ = socket.send(Message::Close(None)).await; @@ -206,15 +214,22 @@ async fn login_loop(mut socket: WebSocket, slug: String, state: AppState) { let outcome = match begin_result { Ok(Ok(outcome)) => outcome, Ok(Err(e)) => { - let _ = - send_login_frame(&mut socket, &AccountLoginServerMsg::Error { message: e.to_string() }).await; + let _ = send_login_frame( + &mut socket, + &AccountLoginServerMsg::Error { + message: e.to_string(), + }, + ) + .await; let _ = socket.send(Message::Close(None)).await; return; } Err(e) => { let _ = send_login_frame( &mut socket, - &AccountLoginServerMsg::Error { message: format!("login task panicked: {e}") }, + &AccountLoginServerMsg::Error { + message: format!("login task panicked: {e}"), + }, ) .await; let _ = socket.send(Message::Close(None)).await; @@ -233,7 +248,10 @@ async fn login_loop(mut socket: WebSocket, slug: String, state: AppState) { } LoginOutcome::Url(url, child) => { _child_reaper = ChildReaper(Some(child)); - if send_login_frame(&mut socket, &AccountLoginServerMsg::LoginUrl { url }).await.is_err() { + if send_login_frame(&mut socket, &AccountLoginServerMsg::LoginUrl { url }) + .await + .is_err() + { return; // socket already dead; _child_reaper drops here } } @@ -258,7 +276,7 @@ async fn login_loop(mut socket: WebSocket, slug: String, state: AppState) { Ok(None) | Ok(Some(Err(_))) => return, // client gone / socket dead Ok(Some(Ok(Message::Close(_)))) => return, // client closed Ok(Some(Ok(_))) => {} // other client frames: ignore, keep polling - Err(_) => {} // poll interval elapsed: check status again + Err(_) => {} // poll interval elapsed: check status again } } } diff --git a/crates/omega-gateway/src/routes_agents.rs b/crates/omega-gateway/src/routes_agents.rs index f9bd6cd3..e655de10 100644 --- a/crates/omega-gateway/src/routes_agents.rs +++ b/crates/omega-gateway/src/routes_agents.rs @@ -149,7 +149,10 @@ async fn forward_lines( loop { match lines.next_line().await { Ok(Some(text)) => { - let frame = AgentInstallStreamMsg::Line { stream: stream_name.to_string(), text }; + let frame = AgentInstallStreamMsg::Line { + stream: stream_name.to_string(), + text, + }; if tx.send(frame).await.is_err() { return; } @@ -180,7 +183,10 @@ async fn forward_lines( /// uses for any blocking subprocess call. async fn kill_process_group(pid: u32) { let _ = tokio::task::spawn_blocking(move || { - std::process::Command::new("kill").arg("--").arg(format!("-{pid}")).status() + std::process::Command::new("kill") + .arg("--") + .arg(format!("-{pid}")) + .status() }) .await; } @@ -265,7 +271,9 @@ async fn install_stream_loop(mut socket: WebSocket, agent: Agent) { Err(e) => { let _ = send_install_frame( &mut socket, - &AgentInstallStreamMsg::Error { message: format!("failed to spawn omega: {e}") }, + &AgentInstallStreamMsg::Error { + message: format!("failed to spawn omega: {e}"), + }, ) .await; let _ = socket.send(Message::Close(None)).await; @@ -374,8 +382,13 @@ async fn install_stream_loop(mut socket: WebSocket, agent: Agent) { let _ = stderr_task.await; let exit_frame = match child.wait().await { - Ok(status) => AgentInstallStreamMsg::Exit { success: status.success(), code: status.code() }, - Err(e) => AgentInstallStreamMsg::Error { message: format!("failed to wait on child: {e}") }, + Ok(status) => AgentInstallStreamMsg::Exit { + success: status.success(), + code: status.code(), + }, + Err(e) => AgentInstallStreamMsg::Error { + message: format!("failed to wait on child: {e}"), + }, }; let _ = send_install_frame(&mut socket, &exit_frame).await; let _ = socket.send(Message::Close(None)).await; @@ -410,6 +423,10 @@ mod resolve_installable_agent_tests { fn rejects_shell_with_a_clear_message() { let err = resolve_installable_agent("shell").unwrap_err(); assert_eq!(err.0, axum::http::StatusCode::BAD_REQUEST); - assert!(err.1.contains("shell"), "message should name the agent: {}", err.1); + assert!( + err.1.contains("shell"), + "message should name the agent: {}", + err.1 + ); } } diff --git a/crates/omega-gateway/src/routes_audit.rs b/crates/omega-gateway/src/routes_audit.rs index 2c7a0c75..8990fe2f 100644 --- a/crates/omega-gateway/src/routes_audit.rs +++ b/crates/omega-gateway/src/routes_audit.rs @@ -27,7 +27,9 @@ //! `routes_dispatch.rs`/`routes_files.rs` use — a client never supplies a raw //! filesystem path, only a project NAME resolved server-side to its real root. -use crate::protocol::{AuditCheckResponse, AuditEntry, AuditRequest, AuditStreamMsg, AuditsResponse}; +use crate::protocol::{ + AuditCheckResponse, AuditEntry, AuditRequest, AuditStreamMsg, AuditsResponse, +}; use axum::{ extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, @@ -73,8 +75,12 @@ pub async fn list() -> Json { /// parse of an embedded TOML, no filesystem walk), so this needs no /// `spawn_blocking` of its own. fn resolve_audit_kind(kind: &str) -> Result { - omega_core::audit::find_audit(kind) - .ok_or_else(|| (StatusCode::BAD_REQUEST, format!("unknown audit kind: {kind}"))) + omega_core::audit::find_audit(kind).ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + format!("unknown audit kind: {kind}"), + ) + }) } /// Resolves `project` against the discovered-project allowlist — the exact @@ -87,11 +93,19 @@ async fn resolve_project_path(project: &str) -> Result) -> Result>) -> Response { +pub async fn stream( + ws: WebSocketUpgrade, + Query(query): Query>, +) -> Response { let project = query.get("project").cloned().unwrap_or_default(); let kind = query.get("kind").cloned().unwrap_or_default(); match resolve_audit_request(&project, &kind).await { @@ -150,7 +167,10 @@ pub async fn stream(ws: WebSocketUpgrade, Query(query): Query Result<(), axum::Error> { +async fn send_audit_frame( + socket: &mut WebSocket, + frame: &AuditStreamMsg, +) -> Result<(), axum::Error> { let text = serde_json::to_string(frame).expect("serialize AuditStreamMsg"); socket.send(Message::Text(text.into())).await } @@ -170,7 +190,10 @@ async fn forward_lines( loop { match lines.next_line().await { Ok(Some(text)) => { - let frame = AuditStreamMsg::Line { stream: stream_name.to_string(), text }; + let frame = AuditStreamMsg::Line { + stream: stream_name.to_string(), + text, + }; if tx.send(frame).await.is_err() { return; } @@ -188,7 +211,10 @@ async fn forward_lines( /// subprocess work). async fn kill_process_group(pid: u32) { let _ = tokio::task::spawn_blocking(move || { - std::process::Command::new("kill").arg("--").arg(format!("-{pid}")).status() + std::process::Command::new("kill") + .arg("--") + .arg(format!("-{pid}")) + .status() }) .await; } @@ -242,7 +268,11 @@ async fn kill_and_drain( /// the full history: a send-failure-only check misses exactly this case). async fn audit_stream_loop(mut socket: WebSocket, audit: AuditSkill, project_path: PathBuf) { let mut cmd = Command::new(crate::omega_cli::omega_bin()); - cmd.arg("audit").arg("run").arg(audit.id).arg("--dir").arg(&project_path); + cmd.arg("audit") + .arg("run") + .arg(audit.id) + .arg("--dir") + .arg(&project_path); cmd.stdin(Stdio::null()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); @@ -254,7 +284,9 @@ async fn audit_stream_loop(mut socket: WebSocket, audit: AuditSkill, project_pat Err(e) => { let _ = send_audit_frame( &mut socket, - &AuditStreamMsg::Error { message: format!("failed to spawn omega: {e}") }, + &AuditStreamMsg::Error { + message: format!("failed to spawn omega: {e}"), + }, ) .await; let _ = socket.send(Message::Close(None)).await; @@ -327,8 +359,13 @@ async fn audit_stream_loop(mut socket: WebSocket, audit: AuditSkill, project_pat let _ = stderr_task.await; let exit_frame = match child.wait().await { - Ok(status) => AuditStreamMsg::Exit { success: status.success(), code: status.code() }, - Err(e) => AuditStreamMsg::Error { message: format!("failed to wait on child: {e}") }, + Ok(status) => AuditStreamMsg::Exit { + success: status.success(), + code: status.code(), + }, + Err(e) => AuditStreamMsg::Error { + message: format!("failed to wait on child: {e}"), + }, }; let _ = send_audit_frame(&mut socket, &exit_frame).await; let _ = socket.send(Message::Close(None)).await; @@ -348,7 +385,11 @@ mod resolve_audit_kind_tests { fn rejects_unknown_audit_kind() { let err = resolve_audit_kind("not-a-real-audit").unwrap_err(); assert_eq!(err.0, axum::http::StatusCode::BAD_REQUEST); - assert!(err.1.contains("not-a-real-audit"), "message should name the kind: {}", err.1); + assert!( + err.1.contains("not-a-real-audit"), + "message should name the kind: {}", + err.1 + ); } #[test] diff --git a/crates/omega-gateway/src/routes_box.rs b/crates/omega-gateway/src/routes_box.rs index 3b37fbc6..f5be16a2 100644 --- a/crates/omega-gateway/src/routes_box.rs +++ b/crates/omega-gateway/src/routes_box.rs @@ -11,8 +11,7 @@ use crate::fsperm::{harden_dir, harden_file}; use crate::protocol::{ - BackupResponse, BoxIdResponse, BoxInfoResponse, DoctorCheckEntry, DoctorResponse, - UsageResponse, + BackupResponse, BoxIdResponse, BoxInfoResponse, DoctorCheckEntry, DoctorResponse, UsageResponse, }; use crate::server::AppState; use axum::extract::State; @@ -21,11 +20,17 @@ use axum::Json; use std::path::{Path, PathBuf}; fn internal_err(msg: String) -> (StatusCode, Json) { - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": msg }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": msg })), + ) } fn bad_gateway(msg: String) -> (StatusCode, Json) { - (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": msg }))) + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": msg })), + ) } // ── GET /v1/doctor ─────────────────────────────────────────────────────── @@ -65,7 +70,9 @@ fn parse_doctor_output(stdout: &str) -> Option { // this stays defensive) fails this test at the THIRD char (a space, // not '['), and the zero-indent trailing summary line fails it // immediately. Both are correctly skipped. - let Some(after_two_spaces) = line.strip_prefix(" ") else { continue }; + let Some(after_two_spaces) = line.strip_prefix(" ") else { + continue; + }; if after_two_spaces.len() < 3 || !after_two_spaces.starts_with('[') { continue; } @@ -77,7 +84,9 @@ fn parse_doctor_output(stdout: &str) -> Option { // index 3 is not a char boundary") -- `.get` returns `None` on a // non-boundary range instead, so the line is just skipped like any // other unrecognized glyph. - let Some(glyph) = after_two_spaces.get(0..3) else { continue }; + let Some(glyph) = after_two_spaces.get(0..3) else { + continue; + }; let health = match glyph { "[+]" => "ok", "[!]" => "warn", @@ -85,7 +94,10 @@ fn parse_doctor_output(stdout: &str) -> Option { _ => continue, }; let text = after_two_spaces[3..].trim().to_string(); - checks.push(DoctorCheckEntry { health: health.to_string(), text }); + checks.push(DoctorCheckEntry { + health: health.to_string(), + text, + }); } let overall = if checks.iter().any(|c| c.health == "fail") { @@ -96,7 +108,10 @@ fn parse_doctor_output(stdout: &str) -> Option { "ok" }; - Some(DoctorResponse { overall: overall.to_string(), checks }) + Some(DoctorResponse { + overall: overall.to_string(), + checks, + }) } /// `GET /v1/doctor` — runs BARE `omega doctor` (never `--fix`, which @@ -209,7 +224,9 @@ fn box_id_path(dir: &Path) -> PathBuf { /// regenerated -- an endpoint response must never echo back malformed /// content just because *something* was on disk. fn is_valid_box_id(s: &str) -> bool { - s.len() == 32 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + s.len() == 32 + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } /// REVIEW-FIX: the first pass raced on TWO different axes -- concurrent @@ -247,7 +264,9 @@ pub fn read_or_create_box_id(dir: &Path) -> std::io::Result { harden_dir(dir); let path = box_id_path(dir); - let _guard = BOX_ID_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let _guard = BOX_ID_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); if let Ok(existing) = std::fs::read_to_string(&path) { let trimmed = existing.trim(); @@ -268,7 +287,9 @@ pub fn read_or_create_box_id(dir: &Path) -> std::io::Result { /// and persisting it on first call if absent (anywhere-access plan §5.2). /// Device-token-guarded like every other route below (registered above /// `require_device`'s `route_layer` in `server.rs`). -pub async fn box_id(State(state): State) -> Result, (StatusCode, Json)> { +pub async fn box_id( + State(state): State, +) -> Result, (StatusCode, Json)> { let dir = state.dir.clone(); let id = tokio::task::spawn_blocking(move || read_or_create_box_id(&dir)) .await @@ -316,7 +337,10 @@ fn parse_backup_output(stdout: &str, fallback_path: &str) -> BackupResponse { size = Some(rest.trim().to_string()); } } - BackupResponse { path: path.unwrap_or_else(|| fallback_path.to_string()), size } + BackupResponse { + path: path.unwrap_or_else(|| fallback_path.to_string()), + size, + } } /// `POST /v1/backup` — runs `omega backup --out `. The @@ -337,15 +361,17 @@ pub async fn backup() -> Result, (StatusCode, Json bool { - id.len() == 16 && id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + id.len() == 16 + && id + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) } pub async fn list(State(state): State) -> Json { @@ -80,14 +83,22 @@ pub async fn create( // canonicalized ancestor, see its own doc comment); that is what gets // stored, so the persisted `cwd` can never diverge from what was // actually checked here. - let cwd = crate::routes_sessions::dir_under_home(&req.cwd)?.to_string_lossy().into_owned(); + let cwd = crate::routes_sessions::dir_under_home(&req.cwd)? + .to_string_lossy() + .into_owned(); if let Some(slug) = &req.account_slug { if !accounts::valid_slug(slug) { - return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": "invalid account_slug" })))); + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "invalid account_slug" })), + )); } let Some(account) = state.accounts.get(slug) else { - return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": "account not found" })))); + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "account not found" })), + )); }; if account.kind != account_kind_for(req.agent) { return Err(( @@ -96,7 +107,9 @@ pub async fn create( )); } } - let meta = state.chats.create(req.agent, cwd, req.title, req.account_slug); + let meta = state + .chats + .create(req.agent, cwd, req.title, req.account_slug); Ok((StatusCode::CREATED, Json(meta))) } @@ -121,7 +134,11 @@ pub async fn get( // existing HTTP tests) is chronological, oldest-first, so reverse it. let (mut messages, next_cursor) = state.chats.tail_page(&id, None, DETAIL_WINDOW); messages.reverse(); - Ok(Json(ChatDetailResponse { meta, messages, next_cursor })) + Ok(Json(ChatDetailResponse { + meta, + messages, + next_cursor, + })) } #[derive(Deserialize)] @@ -144,7 +161,10 @@ pub async fn messages( state.chats.get(&id).ok_or(StatusCode::NOT_FOUND)?; let limit = query.limit.unwrap_or(DEFAULT_MESSAGES_LIMIT); let (messages, next_cursor) = state.chats.tail_page(&id, query.before, limit); - Ok(Json(ChatMessagesPage { messages, next_cursor })) + Ok(Json(ChatMessagesPage { + messages, + next_cursor, + })) } pub async fn stream( @@ -167,11 +187,16 @@ fn resolve_account_dir(accounts: &AccountStore, meta: &ChatMeta) -> Option Result<(), axum::Error> { +async fn send_frame( + socket: &mut WebSocket, + frame: &ChatStreamServerMsg, +) -> Result<(), axum::Error> { let text = serde_json::to_string(frame).expect("serialize ChatStreamServerMsg"); socket.send(Message::Text(text.into())).await } @@ -179,11 +204,21 @@ async fn send_frame(socket: &mut WebSocket, frame: &ChatStreamServerMsg) -> Resu /// Sends `Error{message}` then `TurnDone`, the "can't start a turn" pair used /// by both the unknown-chat and busy-semaphore short-circuits. Returns /// `Err(())` if the socket died mid-send, so the caller can stop the loop. -async fn send_error_turn_done(socket: &mut WebSocket, message: impl Into) -> Result<(), ()> { - send_frame(socket, &ChatStreamServerMsg::Error { message: message.into() }) +async fn send_error_turn_done( + socket: &mut WebSocket, + message: impl Into, +) -> Result<(), ()> { + send_frame( + socket, + &ChatStreamServerMsg::Error { + message: message.into(), + }, + ) + .await + .map_err(|_| ())?; + send_frame(socket, &ChatStreamServerMsg::TurnDone) .await - .map_err(|_| ())?; - send_frame(socket, &ChatStreamServerMsg::TurnDone).await.map_err(|_| ()) + .map_err(|_| ()) } /// I-5 RAII guard: marks chat `id`'s turn as ended (`ChatStore::end_turn`) @@ -206,8 +241,13 @@ impl Drop for TurnGuard { async fn stream_loop(mut socket: WebSocket, id: String, state: AppState) { if !valid_chat_id(&id) { - let _ = send_frame(&mut socket, &ChatStreamServerMsg::Error { message: "invalid chat id".to_string() }) - .await; + let _ = send_frame( + &mut socket, + &ChatStreamServerMsg::Error { + message: "invalid chat id".to_string(), + }, + ) + .await; let _ = socket.send(Message::Close(None)).await; return; } @@ -217,14 +257,17 @@ async fn stream_loop(mut socket: WebSocket, id: String, state: AppState) { let text = match socket.recv().await { Some(Ok(Message::Text(text))) => text.to_string(), Some(Ok(Message::Close(_))) | None => return, // client closed or gone - Some(Ok(_)) => continue, // ping/pong/binary: not a turn - Some(Err(_)) => return, // socket error: dead + Some(Ok(_)) => continue, // ping/pong/binary: not a turn + Some(Err(_)) => return, // socket error: dead }; let client_msg: ChatStreamClientMsg = match serde_json::from_str(&text) { Ok(m) => m, Err(e) => { - if send_error_turn_done(&mut socket, format!("bad client message: {e}")).await.is_err() { + if send_error_turn_done(&mut socket, format!("bad client message: {e}")) + .await + .is_err() + { return; } continue; @@ -233,7 +276,10 @@ async fn stream_loop(mut socket: WebSocket, id: String, state: AppState) { let ChatStreamClientMsg::UserMessage { text: user_text } = client_msg; let Some(meta) = state.chats.get(&id) else { - if send_error_turn_done(&mut socket, "chat not found").await.is_err() { + if send_error_turn_done(&mut socket, "chat not found") + .await + .is_err() + { return; } continue; @@ -241,11 +287,18 @@ async fn stream_loop(mut socket: WebSocket, id: String, state: AppState) { state.chats.append_message( &id, - &ChatMessage { role: "user".to_string(), text: user_text.clone(), ts: now() }, + &ChatMessage { + role: "user".to_string(), + text: user_text.clone(), + ts: now(), + }, ); let Ok(permit) = state.chat_permits.clone().try_acquire_owned() else { - if send_error_turn_done(&mut socket, "busy, too many active chats").await.is_err() { + if send_error_turn_done(&mut socket, "busy, too many active chats") + .await + .is_err() + { return; } continue; @@ -258,12 +311,18 @@ async fn stream_loop(mut socket: WebSocket, id: String, state: AppState) { // second concurrent turn on one chat outright rather than letting // both run. if !state.chats.try_start_turn(&id) { - if send_error_turn_done(&mut socket, "a turn is already active on this chat").await.is_err() { + if send_error_turn_done(&mut socket, "a turn is already active on this chat") + .await + .is_err() + { return; } continue; } - let _turn_guard = TurnGuard { chats: state.chats.clone(), id: id.clone() }; + let _turn_guard = TurnGuard { + chats: state.chats.clone(), + id: id.clone(), + }; let (tx, mut rx) = tokio::sync::mpsc::channel::(64); let timeout = std::time::Duration::from_millis(state.cfg.chat_turn_timeout_ms); @@ -271,7 +330,15 @@ async fn stream_loop(mut socket: WebSocket, id: String, state: AppState) { let turn_meta = meta.clone(); let turn_handle = tokio::spawn(async move { let _permit = permit; // held for the whole turn, released on drop - run_turn(&turn_meta, &user_text, None, account_dir.as_deref(), timeout, tx).await + run_turn( + &turn_meta, + &user_text, + None, + account_dir.as_deref(), + timeout, + tx, + ) + .await }); let mut assistant_text = String::new(); @@ -286,7 +353,8 @@ async fn stream_loop(mut socket: WebSocket, id: String, state: AppState) { continue; } match &frame { - ChatStreamServerMsg::Delta { text } | ChatStreamServerMsg::AssistantMessage { text } => { + ChatStreamServerMsg::Delta { text } + | ChatStreamServerMsg::AssistantMessage { text } => { assistant_text.push_str(text); } ChatStreamServerMsg::TurnDone => turn_done_seen = true, @@ -302,7 +370,11 @@ async fn stream_loop(mut socket: WebSocket, id: String, state: AppState) { if !assistant_text.is_empty() { state.chats.append_message( &id, - &ChatMessage { role: "assistant".to_string(), text: assistant_text, ts: now() }, + &ChatMessage { + role: "assistant".to_string(), + text: assistant_text, + ts: now(), + }, ); } if let Some(sid) = provider_session_id { diff --git a/crates/omega-gateway/src/routes_config.rs b/crates/omega-gateway/src/routes_config.rs index a1d1a89f..733b9e98 100644 --- a/crates/omega-gateway/src/routes_config.rs +++ b/crates/omega-gateway/src/routes_config.rs @@ -31,10 +31,8 @@ //! writable field here, and that WAS faithful — `set_config_value` had no //! `("openrouter", _)` arm either. It since grew one (model / api_key / //! base_url), which left the twin genuinely one-sided: the CLI could -//! configure the provider and this API could not. The three arms below -//! close that gap. `kimi.*` is the remaining asymmetry — the CLI writes -//! four kimi fields, `ConfigResponse` has no kimi entry at all, so that one -//! needs a protocol change rather than a match arm. +//! configure the provider and this API could not. The provider response and +//! write allowlist now stay symmetric, including Kimi and Antigravity. //! //! REVIEW-FIX ROUND (an independent adversarial reviewer found these before //! the branch shipped — see `.superpowers/sdd/progress.md`'s Task C+D+E @@ -54,8 +52,9 @@ //! errors as 500, matching `routes_telegram.rs::toggle`'s existing split. use crate::protocol::{ - ClaudeConfigEntry, CodexConfigEntry, ConfigResponse, ConfigSetRequest, GeminiConfigEntry, - GlmConfigEntry, HermesConfigEntry, OpenRouterConfigEntry, PiConfigEntry, + AntigravityConfigEntry, ClaudeConfigEntry, CodexConfigEntry, ConfigResponse, ConfigSetRequest, + GeminiConfigEntry, GlmConfigEntry, HermesConfigEntry, KimiConfigEntry, OpenRouterConfigEntry, + PiConfigEntry, }; use axum::http::StatusCode; use axum::Json; @@ -64,11 +63,17 @@ use omega_core::providers::ProvidersConfig; type ApiError = (StatusCode, Json); fn bad_request(msg: impl Into) -> ApiError { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn internal(msg: impl std::fmt::Display) -> ApiError { - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": msg.to_string() }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": msg.to_string() })), + ) } /// Cap on `PUT /v1/config`'s `value` — generous enough for any real model @@ -89,11 +94,17 @@ fn to_response(cfg: &ProvidersConfig) -> ConfigResponse { model: cfg.codex.model.clone(), api_key_set: !cfg.codex.api_key.is_empty(), base_url: cfg.codex.base_url.clone(), + bypass_hook_trust: cfg.codex.bypass_hook_trust, }, gemini: GeminiConfigEntry { model: cfg.gemini.model.clone(), api_key_set: !cfg.gemini.api_key.is_empty(), }, + antigravity: AntigravityConfigEntry { + model: cfg.antigravity.model.clone(), + effort: cfg.antigravity.effort.clone(), + dangerously_skip_permissions: cfg.antigravity.dangerously_skip_permissions, + }, glm: GlmConfigEntry { model: cfg.glm.model.clone(), api_key_set: !cfg.glm.api_key.is_empty(), @@ -109,9 +120,16 @@ fn to_response(cfg: &ProvidersConfig) -> ConfigResponse { api_key_set: !cfg.pi.api_key.is_empty(), }, hermes: HermesConfigEntry { + provider: cfg.hermes.provider.clone(), model: cfg.hermes.model.clone(), api_key_set: !cfg.hermes.api_key.is_empty(), }, + kimi: KimiConfigEntry { + model: cfg.kimi.model.clone(), + api_key_set: !cfg.kimi.api_key.is_empty(), + base_url: cfg.kimi.base_url.clone(), + provider_type: cfg.kimi.provider_type.clone(), + }, } } @@ -132,33 +150,13 @@ pub async fn get() -> Result, ApiError> { Ok(Json(to_response(&cfg))) } -/// Loads `providers.toml`, refusing (rather than silently defaulting, the -/// way `ProvidersConfig::load()` itself does) when the file EXISTS but -/// fails to parse — see this module's review-fix doc comment for the exact -/// data-loss scenario this closes on the write side, and [`get`]'s doc -/// comment for why the read side uses it too. A missing file is still a -/// normal, expected "nothing configured yet" case (`Ok(default)`), matching -/// `ProvidersConfig::load()`'s own posture for that case. -/// -/// Re-derives `ProvidersConfig::path()` (`crate::config::omega_dir().join( -/// "providers.toml")` in `omega-core/src/providers.rs`) rather than calling -/// it: that method is private to its own module, not `pub`, so this is -/// necessarily a duplicated join of two already-`pub` primitives -/// (`omega_core::config::omega_dir()` + the literal filename), not a -/// reimplementation of any real logic. +/// Strictly loads `providers.toml` through the same authority path as the CLI. +/// `try_load` rejects corrupt content and captures the current private-file +/// revision, so a second gateway PUT can save without being mistaken for a +/// stale writer. The old hand-written TOML parse lost that revision and every +/// PUT after the first returned 500. fn load_config_or_refuse() -> Result { - let path = omega_core::config::omega_dir().join("providers.toml"); - if !path.exists() { - return Ok(ProvidersConfig::default()); - } - let content = std::fs::read_to_string(&path) - .map_err(|e| format!("providers.toml exists but could not be read: {e}"))?; - toml::from_str(&content).map_err(|e| { - format!( - "providers.toml exists but failed to parse ({e}) -- refusing to write and silently \ - drop its other fields; fix or remove the file first" - ) - }) + ProvidersConfig::try_load().map_err(|error| format!("{error:#}")) } /// The exact `(provider, field)` allowlist `omega-cli::set_config_value` @@ -177,8 +175,14 @@ fn load_config_or_refuse() -> Result { /// not a functional deviation for any value that already parses. fn apply_config_value(cfg: &mut ProvidersConfig, key: &str, value: &str) -> Result<(), String> { let mut parts = key.splitn(2, '.'); - let provider = parts.next().filter(|s| !s.is_empty()).ok_or("missing provider")?; - let field = parts.next().filter(|s| !s.is_empty()).ok_or("missing field (use provider.field)")?; + let provider = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or("missing provider")?; + let field = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or("missing field (use provider.field)")?; match (provider, field) { ("claude", "model") => cfg.claude.model = value.to_string(), ("claude", "effort") => cfg.claude.effort = value.to_string(), @@ -192,15 +196,27 @@ fn apply_config_value(cfg: &mut ProvidersConfig, key: &str, value: &str) -> Resu // module's security reasoning otherwise discusses only `api_key` // READ blast radius, not this field's WRITE blast radius. ("claude", "dangerously_skip_permissions") => { - cfg.claude.dangerously_skip_permissions = value - .parse() - .map_err(|_| "dangerously_skip_permissions must be 'true' or 'false'".to_string())?; + cfg.claude.dangerously_skip_permissions = value.parse().map_err(|_| { + "dangerously_skip_permissions must be 'true' or 'false'".to_string() + })?; } ("codex", "model") => cfg.codex.model = value.to_string(), ("codex", "api_key") => cfg.codex.api_key = value.to_string(), ("codex", "base_url") => cfg.codex.base_url = value.to_string(), + ("codex", "bypass_hook_trust") => { + cfg.codex.bypass_hook_trust = value + .parse() + .map_err(|_| "bypass_hook_trust must be 'true' or 'false'".to_string())?; + } ("gemini", "model") => cfg.gemini.model = value.to_string(), ("gemini", "api_key") => cfg.gemini.api_key = value.to_string(), + ("antigravity", "model") => cfg.antigravity.model = value.to_string(), + ("antigravity", "effort") => cfg.antigravity.effort = value.to_string(), + ("antigravity", "dangerously_skip_permissions") => { + cfg.antigravity.dangerously_skip_permissions = value.parse().map_err(|_| { + "dangerously_skip_permissions must be 'true' or 'false'".to_string() + })?; + } ("openrouter", "model") => cfg.openrouter.model = value.to_string(), ("openrouter", "api_key") => cfg.openrouter.api_key = value.to_string(), ("openrouter", "base_url") => cfg.openrouter.base_url = value.to_string(), @@ -209,8 +225,13 @@ fn apply_config_value(cfg: &mut ProvidersConfig, key: &str, value: &str) -> Resu ("pi", "api_key") => cfg.pi.api_key = value.to_string(), ("glm", "model") => cfg.glm.model = value.to_string(), ("glm", "api_key") => cfg.glm.api_key = value.to_string(), + ("hermes", "provider") => cfg.hermes.provider = value.to_string(), ("hermes", "model") => cfg.hermes.model = value.to_string(), ("hermes", "api_key") => cfg.hermes.api_key = value.to_string(), + ("kimi", "model") => cfg.kimi.model = value.to_string(), + ("kimi", "api_key") => cfg.kimi.api_key = value.to_string(), + ("kimi", "base_url") => cfg.kimi.base_url = value.to_string(), + ("kimi", "provider_type") => cfg.kimi.provider_type = value.to_string(), _ => return Err(format!("unknown key: {key}")), } Ok(()) @@ -229,7 +250,9 @@ pub async fn set(Json(req): Json) -> Result MAX_CONFIG_VALUE_LEN { - return Err(bad_request(format!("value too long (max {MAX_CONFIG_VALUE_LEN} bytes)"))); + return Err(bad_request(format!( + "value too long (max {MAX_CONFIG_VALUE_LEN} bytes)" + ))); } let key = req.key.clone(); @@ -283,8 +306,12 @@ mod tests { fn apply_config_value_writes_the_openrouter_fields_the_cli_writes() { let mut cfg = ProvidersConfig::default(); apply_config_value(&mut cfg, "openrouter.model", "stealth/ox-alpha").unwrap(); - apply_config_value(&mut cfg, "openrouter.base_url", "https://openrouter.ai/api/v1") - .unwrap(); + apply_config_value( + &mut cfg, + "openrouter.base_url", + "https://openrouter.ai/api/v1", + ) + .unwrap(); apply_config_value(&mut cfg, "openrouter.api_key", "sk-or-v1-test").unwrap(); assert_eq!(cfg.openrouter.model, "stealth/ox-alpha"); assert_eq!(cfg.openrouter.base_url, "https://openrouter.ai/api/v1"); diff --git a/crates/omega-gateway/src/routes_deposit.rs b/crates/omega-gateway/src/routes_deposit.rs index 35974d1c..81996604 100644 --- a/crates/omega-gateway/src/routes_deposit.rs +++ b/crates/omega-gateway/src/routes_deposit.rs @@ -16,7 +16,10 @@ use serde_json::json; type ApiError = (StatusCode, Json); fn bad_request(msg: impl Into) -> ApiError { - (StatusCode::BAD_REQUEST, Json(json!({ "error": msg.into() }))) + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": msg.into() })), + ) } /// Hard cap on a single deposit upload. Checked AFTER the `file` field is diff --git a/crates/omega-gateway/src/routes_dispatch.rs b/crates/omega-gateway/src/routes_dispatch.rs index b58999b9..9f1201ca 100644 --- a/crates/omega-gateway/src/routes_dispatch.rs +++ b/crates/omega-gateway/src/routes_dispatch.rs @@ -60,15 +60,24 @@ type ApiError = (StatusCode, Json); const MAX_MISSION_LEN: usize = 8000; fn bad_request(msg: impl Into) -> ApiError { - (StatusCode::BAD_REQUEST, Json(json!({ "error": msg.into() }))) + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": msg.into() })), + ) } fn too_many_requests(msg: impl Into) -> ApiError { - (StatusCode::TOO_MANY_REQUESTS, Json(json!({ "error": msg.into() }))) + ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({ "error": msg.into() })), + ) } fn gateway_timeout(msg: impl Into) -> ApiError { - (StatusCode::GATEWAY_TIMEOUT, Json(json!({ "error": msg.into() }))) + ( + StatusCode::GATEWAY_TIMEOUT, + Json(json!({ "error": msg.into() })), + ) } /// Parses `omega dispatch`'s stdout into a `DispatchResponse`, per the @@ -117,7 +126,9 @@ pub async fn create( // just a local variable here: it releases automatically when `create` // returns. let Ok(_permit) = state.dispatch_permits.clone().try_acquire_owned() else { - return Err(too_many_requests("too many concurrent dispatches, try again shortly")); + return Err(too_many_requests( + "too many concurrent dispatches, try again shortly", + )); }; // Step 1: reject empty/whitespace-only project/mission BEFORE any other @@ -146,7 +157,9 @@ pub async fn create( // project name that isn't a real, short, on-disk directory name is // rejected there regardless of its length). if req.mission.len() > MAX_MISSION_LEN { - return Err(bad_request(format!("mission too long (max {MAX_MISSION_LEN} bytes)"))); + return Err(bad_request(format!( + "mission too long (max {MAX_MISSION_LEN} bytes)" + ))); } // Step 2: validate `agent`, when given, against the real roster BEFORE @@ -154,12 +167,17 @@ pub async fn create( // from the CLI's own rejection of a garbage `--agent` value. if let Some(name) = req.agent.clone() { let is_known = tokio::task::spawn_blocking(move || { - omega_core::agents::Agent::all().iter().any(|a| a.name() == name) + omega_core::agents::Agent::all() + .iter() + .any(|a| a.name() == name) }) .await .unwrap_or(false); if !is_known { - return Err(bad_request(format!("unknown agent: {}", req.agent.unwrap()))); + return Err(bad_request(format!( + "unknown agent: {}", + req.agent.unwrap() + ))); } } @@ -169,7 +187,9 @@ pub async fn create( let project = req.project.clone(); let known = tokio::task::spawn_blocking(move || { let home = crate::config::home_dir(); - omega_core::projects::discover(&home).into_iter().any(|p| p.name == project) + omega_core::projects::discover(&home) + .into_iter() + .any(|p| p.name == project) }) .await .unwrap_or(false); @@ -249,7 +269,9 @@ pub async fn create( ); return Err(( StatusCode::BAD_GATEWAY, - Json(json!({ "error": "omega dispatch produced unparseable output (see gateway logs)" })), + Json( + json!({ "error": "omega dispatch produced unparseable output (see gateway logs)" }), + ), )); }; diff --git a/crates/omega-gateway/src/routes_duo.rs b/crates/omega-gateway/src/routes_duo.rs index f575c46d..d929bf20 100644 --- a/crates/omega-gateway/src/routes_duo.rs +++ b/crates/omega-gateway/src/routes_duo.rs @@ -118,27 +118,45 @@ use crate::server::AppState; type ApiError = (StatusCode, Json); fn bad_request(msg: impl Into) -> ApiError { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn conflict(msg: impl Into) -> ApiError { - (StatusCode::CONFLICT, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::CONFLICT, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn too_many_requests(msg: impl Into) -> ApiError { - (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn bad_gateway(msg: impl Into) -> ApiError { - (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn gateway_timeout(msg: impl Into) -> ApiError { - (StatusCode::GATEWAY_TIMEOUT, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::GATEWAY_TIMEOUT, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn internal(msg: impl std::fmt::Display) -> ApiError { - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": msg.to_string() }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": msg.to_string() })), + ) } /// Byte-length cap on `prompt`, mirroring `routes_dispatch.rs:: @@ -178,7 +196,11 @@ fn duo_root_dir() -> PathBuf { if let Ok(dir) = std::env::var("OMEGA_DUO_DIR") { return PathBuf::from(dir); } - dirs::home_dir().expect("no home dir").join(".omega").join("state").join("gateway-duo") + dirs::home_dir() + .expect("no home dir") + .join(".omega") + .join("state") + .join("gateway-duo") } /// Where this endpoint writes the caller's `prompt` before invoking @@ -200,7 +222,9 @@ pub(crate) fn duo_bin() -> PathBuf { if let Ok(bin) = std::env::var("OMEGA_DUO_BIN") { return PathBuf::from(bin); } - dirs::home_dir().expect("no home dir").join(".local/bin/omega-duo") + dirs::home_dir() + .expect("no home dir") + .join(".local/bin/omega-duo") } /// Maps this endpoint's `profile` (the `/duo` skill's own three real @@ -224,7 +248,10 @@ async fn resolve_project_path(project: &str) -> Result { let name = project.to_string(); let found = tokio::task::spawn_blocking(move || { let home = crate::config::home_dir(); - omega_core::projects::discover(&home).into_iter().find(|p| p.name == name).map(|p| p.path) + omega_core::projects::discover(&home) + .into_iter() + .find(|p| p.name == name) + .map(|p| p.path) }) .await .unwrap_or(None); @@ -256,13 +283,21 @@ impl Drop for CwdLockGuard { /// drop. `Err(409)` — without inserting anything — when `path` is already /// present, i.e. another `POST /v1/duo` request against the SAME resolved /// cwd is still in flight. -fn acquire_cwd_lock(set: &Arc>>, path: PathBuf) -> Result { +fn acquire_cwd_lock( + set: &Arc>>, + path: PathBuf, +) -> Result { let mut guard = set.lock().unwrap_or_else(|e| e.into_inner()); if !guard.insert(path.clone()) { - return Err(conflict("a duo run is already in flight against this directory")); + return Err(conflict( + "a duo run is already in flight against this directory", + )); } drop(guard); - Ok(CwdLockGuard { set: Arc::clone(set), path }) + Ok(CwdLockGuard { + set: Arc::clone(set), + path, + }) } /// Best-effort canonicalization of `path`: when `path` itself resolves @@ -344,7 +379,10 @@ fn repo_root_for_lock(target: &std::path::Path) -> PathBuf { /// reaches it too, not just the `omega-duo` process itself. async fn kill_process_group(pid: u32) { let _ = tokio::task::spawn_blocking(move || { - std::process::Command::new("kill").arg("--").arg(format!("-{pid}")).status() + std::process::Command::new("kill") + .arg("--") + .arg(format!("-{pid}")) + .status() }) .await; } @@ -433,7 +471,9 @@ async fn run_omega_duo(args: &[&str]) -> Result { cmd.kill_on_drop(true); cmd.process_group(0); - let child = cmd.spawn().map_err(|e| bad_gateway(format!("failed to spawn omega-duo: {e}")))?; + let child = cmd + .spawn() + .map_err(|e| bad_gateway(format!("failed to spawn omega-duo: {e}")))?; // Captured now, before the child is ever consumed by `wait_with_output` // (which takes `self` by value) — `Child::id()` returns `None` once the // child has been polled to completion. @@ -503,7 +543,9 @@ pub async fn create( // does — the cheapest possible short-circuit, mirroring // `routes_pdf::create`'s / `routes_dispatch::create`'s own Step 0. let Ok(_permit) = state.duo_permits.clone().try_acquire_owned() else { - return Err(too_many_requests("too many concurrent duo runs, try again shortly")); + return Err(too_many_requests( + "too many concurrent duo runs, try again shortly", + )); }; // Step 1: `profile` -> `--mode`, validated against the literal closed @@ -523,7 +565,9 @@ pub async fn create( return Err(bad_request("prompt must not contain a NUL byte")); } if req.prompt.len() > MAX_DUO_PROMPT_LEN { - return Err(bad_request(format!("prompt too long (max {MAX_DUO_PROMPT_LEN} bytes)"))); + return Err(bad_request(format!( + "prompt too long (max {MAX_DUO_PROMPT_LEN} bytes)" + ))); } // Step 3: exactly one of `project`/`dir`, then resolve it to a real, @@ -531,7 +575,9 @@ pub async fn create( // ambiguous, missing, unknown, or out-of-bounds target. let target = match (&req.project, &req.dir) { (Some(_), Some(_)) => { - return Err(bad_request("ambiguous target: give exactly one of project or dir")) + return Err(bad_request( + "ambiguous target: give exactly one of project or dir", + )) } (None, None) => return Err(bad_request("no target: give project or dir")), (Some(p), None) => resolve_project_path(p).await?, @@ -555,7 +601,9 @@ pub async fn create( // discovered project, or a `dir_under_home`-validated request) is // already absolute, so nothing legitimate is ever rejected here. if !target.is_absolute() { - return Err(bad_request("resolved target directory must be an absolute path")); + return Err(bad_request( + "resolved target directory must be an absolute path", + )); } // Step 5: per-cwd in-process lock, BEFORE writing the scratch file or @@ -578,7 +626,8 @@ pub async fn create( move || -> Result<(), ApiError> { std::fs::create_dir_all(&tasks_dir) .map_err(|e| internal(format!("mkdir {}: {e}", tasks_dir.display())))?; - std::fs::write(&task_path, prompt).map_err(|e| internal(format!("write task file: {e}")))?; + std::fs::write(&task_path, prompt) + .map_err(|e| internal(format!("write task file: {e}")))?; Ok(()) } }) @@ -590,8 +639,16 @@ pub async fn create( // `=`-joined flags (see this module's doc comment for why both would be // wrong for THIS binary's own arg parser). let cwd_str = target.to_string_lossy().to_string(); - let output = - run_omega_duo(&["run", "--task", &task_path_str, "--cwd", &cwd_str, "--mode", mode]).await?; + let output = run_omega_duo(&[ + "run", + "--task", + &task_path_str, + "--cwd", + &cwd_str, + "--mode", + mode, + ]) + .await?; // Step 8: parse EXACTLY one JSON line from stdout — the LAST non-empty // line, not the whole buffer (Finding 4, adversarial review round): the @@ -614,7 +671,12 @@ pub async fn create( // still goes to the gateway's own tracing log; the client only ever // sees the parse error (a generic "expected value at line X" shape, // never the offending text itself) with no raw dump attached. - let last_line = output.stdout.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or(""); + let last_line = output + .stdout + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .unwrap_or(""); match serde_json::from_str::(last_line.trim()) { Ok(resp) => Ok(Json(resp)), Err(e) => { @@ -655,7 +717,11 @@ mod tests { let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); std::env::remove_var("OMEGA_DUO_BIN"); let bin = duo_bin(); - assert!(bin.ends_with(".local/bin/omega-duo"), "expected ~/.local/bin/omega-duo, got {}", bin.display()); + assert!( + bin.ends_with(".local/bin/omega-duo"), + "expected ~/.local/bin/omega-duo, got {}", + bin.display() + ); } #[test] diff --git a/crates/omega-gateway/src/routes_files.rs b/crates/omega-gateway/src/routes_files.rs index 84699983..1d9cfe5e 100644 --- a/crates/omega-gateway/src/routes_files.rs +++ b/crates/omega-gateway/src/routes_files.rs @@ -12,8 +12,8 @@ //! scoped root, before any filesystem access beyond the read-only `discover` //! walk itself. -use crate::server::AppState; use crate::protocol::{FileEntry, FileReadResponse, FilesResponse}; +use crate::server::AppState; use axum::{ extract::{Query, State}, http::StatusCode, @@ -33,7 +33,10 @@ type ApiError = (StatusCode, Json); pub const MAX_FILE_READ_BYTES: u64 = 512 * 1024; fn bad_request(msg: impl Into) -> ApiError { - (StatusCode::BAD_REQUEST, Json(json!({ "error": msg.into() }))) + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": msg.into() })), + ) } fn not_found(msg: impl Into) -> ApiError { @@ -45,7 +48,10 @@ fn forbidden(msg: impl Into) -> ApiError { } fn internal(msg: impl std::fmt::Display) -> ApiError { - (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": msg.to_string() }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": msg.to_string() })), + ) } /// The three ways [`resolve_scoped_path`] can refuse to hand back a path. @@ -121,7 +127,9 @@ fn path_error_to_api(err: PathError) -> ApiError { /// `foo..bar`). pub fn resolve_scoped_path(root: &Path, rel: &str) -> Result { if rel.contains('\0') { - return Err(PathError::Invalid("path must not contain a NUL byte".into())); + return Err(PathError::Invalid( + "path must not contain a NUL byte".into(), + )); } let rel_path = Path::new(rel); if rel_path.is_absolute() { @@ -153,7 +161,9 @@ pub fn resolve_scoped_path(root: &Path, rel: &str) -> Result // reason OTHER than absence (e.g. EACCES on a search bit) is // skipped too, which keeps the fallthrough on the safe side. for ancestor in joined.ancestors().skip(1) { - let Ok(canon_ancestor) = std::fs::canonicalize(ancestor) else { continue }; + let Ok(canon_ancestor) = std::fs::canonicalize(ancestor) else { + continue; + }; return if canon_ancestor.starts_with(&canon_root) { Err(PathError::NotFound) } else { @@ -186,9 +196,12 @@ fn read_capped_text(path: &Path) -> Result { } let bytes = std::fs::read(path).map_err(|e| internal(format!("read failed: {e}")))?; if bytes.contains(&0u8) { - return Err(bad_request("file contains a NUL byte, refusing as non-text")); + return Err(bad_request( + "file contains a NUL byte, refusing as non-text", + )); } - String::from_utf8(bytes).map_err(|_| bad_request("file is not valid UTF-8, refusing as non-text")) + String::from_utf8(bytes) + .map_err(|_| bad_request("file is not valid UTF-8, refusing as non-text")) } /// Validates `project_name` against the discovered-project allowlist and @@ -229,18 +242,30 @@ pub async fn list( let entries = tokio::task::spawn_blocking(move || -> Result, ApiError> { let resolved = resolve_scoped_path(&root, &rel).map_err(path_error_to_api)?; - let meta = std::fs::metadata(&resolved).map_err(|e| internal(format!("stat failed: {e}")))?; + let meta = + std::fs::metadata(&resolved).map_err(|e| internal(format!("stat failed: {e}")))?; if !meta.is_dir() { return Err(bad_request("path is not a directory")); } - let read_dir = std::fs::read_dir(&resolved).map_err(|e| internal(format!("read_dir failed: {e}")))?; + let read_dir = + std::fs::read_dir(&resolved).map_err(|e| internal(format!("read_dir failed: {e}")))?; let mut out = Vec::new(); for entry in read_dir { let entry = entry.map_err(|e| internal(format!("read_dir entry failed: {e}")))?; - let file_type = entry.file_type().map_err(|e| internal(format!("file_type failed: {e}")))?; + let file_type = entry + .file_type() + .map_err(|e| internal(format!("file_type failed: {e}")))?; let is_dir = file_type.is_dir(); - let size = if is_dir { None } else { entry.metadata().ok().map(|m| m.len()) }; - out.push(FileEntry { name: entry.file_name().to_string_lossy().to_string(), is_dir, size }); + let size = if is_dir { + None + } else { + entry.metadata().ok().map(|m| m.len()) + }; + out.push(FileEntry { + name: entry.file_name().to_string_lossy().to_string(), + is_dir, + size, + }); } // Directories first, then alphabetical (byte order) within each // group — arbitrary but documented; not the focus of this endpoint. @@ -273,7 +298,8 @@ pub async fn read( let content = tokio::task::spawn_blocking(move || -> Result { let resolved = resolve_scoped_path(&root, &rel).map_err(path_error_to_api)?; - let meta = std::fs::metadata(&resolved).map_err(|e| internal(format!("stat failed: {e}")))?; + let meta = + std::fs::metadata(&resolved).map_err(|e| internal(format!("stat failed: {e}")))?; if meta.is_dir() { return Err(bad_request("path is a directory, not a file")); } @@ -359,9 +385,12 @@ mod tests { let outside = base.path().join("outside"); std::fs::create_dir_all(&root).unwrap(); std::fs::create_dir_all(&outside).unwrap(); // the outside DIR is real... - // ...but this specific leaf file does not exist. + // ...but this specific leaf file does not exist. let err = resolve_scoped_path(&root, "../outside/does-not-exist.txt").unwrap_err(); - assert!(matches!(err, PathError::Escaped), "expected Escaped, got {err:?}"); + assert!( + matches!(err, PathError::Escaped), + "expected Escaped, got {err:?}" + ); } #[test] @@ -386,7 +415,8 @@ mod tests { std::fs::create_dir_all(base.path().join("real-dir")).unwrap(); let outside_dir_exists = resolve_scoped_path(&root, "../real-dir/leaf.txt").unwrap_err(); - let outside_dir_missing = resolve_scoped_path(&root, "../no-such-dir/leaf.txt").unwrap_err(); + let outside_dir_missing = + resolve_scoped_path(&root, "../no-such-dir/leaf.txt").unwrap_err(); assert!( matches!(outside_dir_exists, PathError::Escaped), "expected Escaped, got {outside_dir_exists:?}" @@ -410,8 +440,14 @@ mod tests { let deep_exists = resolve_scoped_path(&root, "../real-dir/a/b/c.txt").unwrap_err(); let deep_missing = resolve_scoped_path(&root, "../no-such-dir/a/b/c.txt").unwrap_err(); - assert!(matches!(deep_exists, PathError::Escaped), "got {deep_exists:?}"); - assert!(matches!(deep_missing, PathError::Escaped), "got {deep_missing:?}"); + assert!( + matches!(deep_exists, PathError::Escaped), + "got {deep_exists:?}" + ); + assert!( + matches!(deep_missing, PathError::Escaped), + "got {deep_missing:?}" + ); } #[test] @@ -425,7 +461,10 @@ mod tests { std::fs::create_dir_all(&root).unwrap(); let err = resolve_scoped_path(&root, "no-such-subdir/nope.txt").unwrap_err(); - assert!(matches!(err, PathError::NotFound), "expected NotFound, got {err:?}"); + assert!( + matches!(err, PathError::NotFound), + "expected NotFound, got {err:?}" + ); } #[test] @@ -436,7 +475,10 @@ mod tests { std::fs::write(root.join("file.txt"), "hi").unwrap(); let resolved = resolve_scoped_path(&root, "sub/../file.txt").unwrap(); - assert_eq!(resolved, std::fs::canonicalize(root.join("file.txt")).unwrap()); + assert_eq!( + resolved, + std::fs::canonicalize(root.join("file.txt")).unwrap() + ); } #[test] diff --git a/crates/omega-gateway/src/routes_marketing.rs b/crates/omega-gateway/src/routes_marketing.rs index 3ebf6654..19ada082 100644 --- a/crates/omega-gateway/src/routes_marketing.rs +++ b/crates/omega-gateway/src/routes_marketing.rs @@ -103,23 +103,24 @@ pub async fn list() -> Result, (StatusCode, Json PathBuf { - dirs::home_dir() - .unwrap_or_else(|| std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("."))) + dirs::home_dir().unwrap_or_else(|| { + std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")) + }) } fn aisb_conversation_log_path() -> PathBuf { @@ -87,13 +90,19 @@ fn aisb_inbox_path() -> PathBuf { /// instead of burning 90 real seconds; production (no env var set) matches /// the CLI exactly. fn poll_attempts() -> u32 { - std::env::var("OMEGA_AISB_POLL_ATTEMPTS").ok().and_then(|v| v.parse().ok()).unwrap_or(180) + std::env::var("OMEGA_AISB_POLL_ATTEMPTS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(180) } /// Poll interval — CLI default 500ms. Overridable via /// `OMEGA_AISB_POLL_INTERVAL_MS` for the same reason as [`poll_attempts`]. fn poll_interval_ms() -> u64 { - std::env::var("OMEGA_AISB_POLL_INTERVAL_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(500) + std::env::var("OMEGA_AISB_POLL_INTERVAL_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500) } /// Acquires ONE permit for the WHOLE connection lifetime BEFORE ever @@ -126,7 +135,9 @@ async fn send_frame(socket: &mut WebSocket, frame: &MasterChatMsg) -> Result<(), /// documents for its own liveness probe. async fn master_is_live() -> bool { match tokio::task::spawn_blocking(crate::rmux::list_sessions).await { - Ok(Ok(names)) => names.iter().any(|n| n == omega_core::aisb::MASTER_SESSION_NAME), + Ok(Ok(names)) => names + .iter() + .any(|n| n == omega_core::aisb::MASTER_SESSION_NAME), _ => false, } } @@ -143,7 +154,10 @@ fn append_to_inbox(inbox: &std::path::Path, text: &str) -> std::io::Result<()> { "text": text, "ts": chrono::Utc::now().to_rfc3339(), }); - let mut f = std::fs::OpenOptions::new().create(true).append(true).open(inbox)?; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(inbox)?; writeln!(f, "{entry}") } @@ -195,7 +209,9 @@ async fn run_round_trip(text: String) -> Option { let log = aisb_conversation_log_path(); let log_for_len = log.clone(); - let before_len = tokio::task::spawn_blocking(move || log_len(&log_for_len)).await.unwrap_or(0); + let before_len = tokio::task::spawn_blocking(move || log_len(&log_for_len)) + .await + .unwrap_or(0); let inbox_for_write = inbox.clone(); let text_for_write = text.clone(); @@ -211,8 +227,10 @@ async fn run_round_trip(text: String) -> Option { for _ in 0..attempts { tokio::time::sleep(interval).await; let log_for_read = log.clone(); - let growth = - tokio::task::spawn_blocking(move || read_growth(&log_for_read, before_len)).await.ok().flatten(); + let growth = tokio::task::spawn_blocking(move || read_growth(&log_for_read, before_len)) + .await + .ok() + .flatten(); if let Some(delta) = growth { return Some(delta); } @@ -237,8 +255,8 @@ async fn master_chat_loop(mut socket: WebSocket, _permit: OwnedSemaphorePermit) let text = match socket.recv().await { Some(Ok(Message::Text(text))) => text.to_string(), Some(Ok(Message::Close(_))) | None => return, // client closed or gone - Some(Ok(_)) => continue, // ping/pong/binary: not a line - Some(Err(_)) => return, // socket error: dead + Some(Ok(_)) => continue, // ping/pong/binary: not a line + Some(Err(_)) => return, // socket error: dead }; if text.is_empty() { continue; @@ -258,7 +276,10 @@ async fn master_chat_loop(mut socket: WebSocket, _permit: OwnedSemaphorePermit) } if !master_is_live().await { - if send_frame(&mut socket, &MasterChatMsg::NotRunning).await.is_err() { + if send_frame(&mut socket, &MasterChatMsg::NotRunning) + .await + .is_err() + { return; } continue; // don't touch the inbox; wait for more client messages diff --git a/crates/omega-gateway/src/routes_missions.rs b/crates/omega-gateway/src/routes_missions.rs index 28ba7561..da91a38e 100644 --- a/crates/omega-gateway/src/routes_missions.rs +++ b/crates/omega-gateway/src/routes_missions.rs @@ -7,6 +7,8 @@ use serde_json::json; pub async fn list() -> Json { // missions::list() does blocking file I/O; keep it off the async runtime // thread, same as routes_sessions::list. - let missions = tokio::task::spawn_blocking(crate::missions::list).await.unwrap_or_default(); + let missions = tokio::task::spawn_blocking(crate::missions::list) + .await + .unwrap_or_default(); Json(json!({ "missions": missions })) } diff --git a/crates/omega-gateway/src/routes_new_project.rs b/crates/omega-gateway/src/routes_new_project.rs index 75ed19fa..44810cb8 100644 --- a/crates/omega-gateway/src/routes_new_project.rs +++ b/crates/omega-gateway/src/routes_new_project.rs @@ -117,7 +117,8 @@ const MAX_SLUG_LEN: usize = 64; fn is_slug(s: &str) -> bool { !s.is_empty() && !s.starts_with('-') - && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && s.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } /// Validates `name`/`category`/`group` BEFORE any upgrade or spawn, and @@ -129,10 +130,16 @@ fn resolve_new_project_request( group: &str, ) -> Result<(String, String, Option), (StatusCode, String)> { if name.is_empty() { - return Err((StatusCode::BAD_REQUEST, "name must not be empty".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "name must not be empty".to_string(), + )); } if name.len() > MAX_SLUG_LEN { - return Err((StatusCode::BAD_REQUEST, format!("name too long (max {MAX_SLUG_LEN} bytes)"))); + return Err(( + StatusCode::BAD_REQUEST, + format!("name too long (max {MAX_SLUG_LEN} bytes)"), + )); } if !is_slug(name) { return Err(( @@ -141,11 +148,18 @@ fn resolve_new_project_request( )); } - let category = if category.is_empty() { DEFAULT_CATEGORY.to_string() } else { category.to_string() }; + let category = if category.is_empty() { + DEFAULT_CATEGORY.to_string() + } else { + category.to_string() + }; if !ALLOWED_CATEGORIES.contains(&category.as_str()) { return Err(( StatusCode::BAD_REQUEST, - format!("unknown category: {category} (must be one of: {})", ALLOWED_CATEGORIES.join(", ")), + format!( + "unknown category: {category} (must be one of: {})", + ALLOWED_CATEGORIES.join(", ") + ), )); } @@ -153,12 +167,16 @@ fn resolve_new_project_request( None } else { if group.len() > MAX_SLUG_LEN { - return Err((StatusCode::BAD_REQUEST, format!("group too long (max {MAX_SLUG_LEN} bytes)"))); + return Err(( + StatusCode::BAD_REQUEST, + format!("group too long (max {MAX_SLUG_LEN} bytes)"), + )); } if !is_slug(group) { return Err(( StatusCode::BAD_REQUEST, - "group must match ^[a-z0-9-]+$ (lowercase letters, digits, hyphens only)".to_string(), + "group must match ^[a-z0-9-]+$ (lowercase letters, digits, hyphens only)" + .to_string(), )); } Some(group.to_string()) @@ -208,7 +226,9 @@ pub async fn stream( let Ok(permit) = state.new_project_permits.clone().try_acquire_owned() else { return StatusCode::TOO_MANY_REQUESTS.into_response(); }; - ws.on_upgrade(move |socket| new_project_stream_loop(socket, name, category, group, permit)) + ws.on_upgrade(move |socket| { + new_project_stream_loop(socket, name, category, group, permit) + }) } Err((code, _msg)) => code.into_response(), } @@ -239,7 +259,10 @@ async fn forward_lines( loop { match lines.next_line().await { Ok(Some(text)) => { - let frame = NewProjectStreamMsg::Line { stream: stream_name.to_string(), text }; + let frame = NewProjectStreamMsg::Line { + stream: stream_name.to_string(), + text, + }; if tx.send(frame).await.is_err() { return; } @@ -261,7 +284,10 @@ async fn forward_lines( /// see [`new_project_stream_loop`]'s doc comment. async fn kill_process_group(pid: u32) { let _ = tokio::task::spawn_blocking(move || { - std::process::Command::new("kill").arg("--").arg(format!("-{pid}")).status() + std::process::Command::new("kill") + .arg("--") + .arg(format!("-{pid}")) + .status() }) .await; } @@ -362,7 +388,9 @@ async fn new_project_stream_loop( Err(e) => { let _ = send_new_project_frame( &mut socket, - &NewProjectStreamMsg::Error { message: format!("failed to spawn omega: {e}") }, + &NewProjectStreamMsg::Error { + message: format!("failed to spawn omega: {e}"), + }, ) .await; let _ = socket.send(Message::Close(None)).await; @@ -442,8 +470,13 @@ async fn new_project_stream_loop( let _ = stderr_task.await; let exit_frame = match child.wait().await { - Ok(status) => NewProjectStreamMsg::Exit { success: status.success(), code: status.code() }, - Err(e) => NewProjectStreamMsg::Error { message: format!("failed to wait on child: {e}") }, + Ok(status) => NewProjectStreamMsg::Exit { + success: status.success(), + code: status.code(), + }, + Err(e) => NewProjectStreamMsg::Error { + message: format!("failed to wait on child: {e}"), + }, }; let _ = send_new_project_frame(&mut socket, &exit_frame).await; let _ = socket.send(Message::Close(None)).await; @@ -483,7 +516,8 @@ mod resolve_new_project_request_tests { #[test] fn accepts_valid_slug_name() { - let (name, category, group) = resolve_new_project_request("my-cool-project-1", "", "").unwrap(); + let (name, category, group) = + resolve_new_project_request("my-cool-project-1", "", "").unwrap(); assert_eq!(name, "my-cool-project-1"); assert_eq!(category, "works"); assert_eq!(group, None); @@ -545,7 +579,11 @@ mod resolve_new_project_request_tests { fn rejects_name_starting_with_dash() { for bad in ["--build", "--dry-run", "-x"] { let err = resolve_new_project_request(bad, "", "").unwrap_err(); - assert_eq!(err.0, StatusCode::BAD_REQUEST, "expected rejection for {bad:?}"); + assert_eq!( + err.0, + StatusCode::BAD_REQUEST, + "expected rejection for {bad:?}" + ); } } diff --git a/crates/omega-gateway/src/routes_oracles.rs b/crates/omega-gateway/src/routes_oracles.rs index 0f9d0f81..478bc00a 100644 --- a/crates/omega-gateway/src/routes_oracles.rs +++ b/crates/omega-gateway/src/routes_oracles.rs @@ -49,7 +49,9 @@ use serde_json::json; type ApiError = (StatusCode, Json); pub async fn list() -> Json { - let missions = tokio::task::spawn_blocking(crate::missions::list).await.unwrap_or_default(); + let missions = tokio::task::spawn_blocking(crate::missions::list) + .await + .unwrap_or_default(); // Never 500 on a degraded rmux read (same posture as routes_sessions::list): // a failed liveness probe just means every entry reports live: false. let live_sessions = match tokio::task::spawn_blocking(crate::rmux::list_sessions).await { @@ -62,7 +64,12 @@ pub async fn list() -> Json { .map(|m| { let session = m.key.clone(); let live = live_sessions.contains(&session); - OracleEntry { key: m.key.clone(), session, live, mission: Some(m) } + OracleEntry { + key: m.key.clone(), + session, + live, + mission: Some(m), + } }) .collect(); Json(OraclesResponse { oracles }) @@ -73,15 +80,24 @@ fn not_found(msg: impl Into) -> ApiError { } fn bad_gateway(msg: impl Into) -> ApiError { - (StatusCode::BAD_GATEWAY, Json(json!({ "error": msg.into() }))) + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": msg.into() })), + ) } fn bad_request(msg: impl Into) -> ApiError { - (StatusCode::BAD_REQUEST, Json(json!({ "error": msg.into() }))) + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": msg.into() })), + ) } fn gateway_timeout(msg: impl Into) -> ApiError { - (StatusCode::GATEWAY_TIMEOUT, Json(json!({ "error": msg.into() }))) + ( + StatusCode::GATEWAY_TIMEOUT, + Json(json!({ "error": msg.into() })), + ) } fn timeline_to_response(tl: omega_core::timeline::OracleTimeline) -> TimelineResponse { @@ -93,7 +109,11 @@ fn timeline_to_response(tl: omega_core::timeline::OracleTimeline) -> TimelineRes events: tl .events .into_iter() - .map(|e| TimelineEventEntry { at: e.at.to_rfc3339(), marker: e.marker.to_string(), text: e.text }) + .map(|e| TimelineEventEntry { + at: e.at.to_rfc3339(), + marker: e.marker.to_string(), + text: e.text, + }) .collect(), } } @@ -113,7 +133,9 @@ pub async fn timeline(Path(session): Path) -> Result Ok(Json(timeline_to_response(tl))), - None => Err(not_found(format!("no timeline for oracle '{session}' (no OracleState on disk)"))), + None => Err(not_found(format!( + "no timeline for oracle '{session}' (no OracleState on disk)" + ))), } } @@ -216,7 +238,8 @@ pub async fn gate(Path(session): Path) -> Result) -> Result) -> Result, Api // I-2 (Codex cross-model review, 2026-08-11): see // routes_sessions.rs::create's identical comment. let output = tokio::task::spawn_blocking(move || { - crate::omega_cli::run_with_timeout(&["reap", "--", target.as_str()], crate::omega_cli::cli_timeout()) + crate::omega_cli::run_with_timeout( + &["reap", "--", target.as_str()], + crate::omega_cli::cli_timeout(), + ) }) .await .map_err(|e| bad_gateway(format!("reap task panicked: {e}")))? @@ -310,7 +338,10 @@ pub async fn reap(Path(session): Path) -> Result, Api Json(json!({ "error": "omega reap failed (see gateway logs)" })), )); } - Ok(Json(ReapResponse { reaped: true, output: output.stdout })) + Ok(Json(ReapResponse { + reaped: true, + output: output.stdout, + })) } /// `POST /v1/oracles/{session}/resurrect` — runs `omega resurrect -- @@ -355,5 +386,8 @@ pub async fn resurrect(Path(session): Path) -> Result Result Result { if project.trim().is_empty() { - return Err((StatusCode::BAD_REQUEST, "project must not be empty".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "project must not be empty".to_string(), + )); } if mission.trim().is_empty() { - return Err((StatusCode::BAD_REQUEST, "mission must not be empty".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "mission must not be empty".to_string(), + )); } if project.contains('\0') || mission.contains('\0') { - return Err((StatusCode::BAD_REQUEST, "project/mission must not contain a NUL byte".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "project/mission must not contain a NUL byte".to_string(), + )); } if mission.len() > MAX_MISSION_LEN { - return Err((StatusCode::BAD_REQUEST, format!("mission too long (max {MAX_MISSION_LEN} bytes)"))); + return Err(( + StatusCode::BAD_REQUEST, + format!("mission too long (max {MAX_MISSION_LEN} bytes)"), + )); } if !agent.is_empty() { let name = agent.to_string(); let is_known = tokio::task::spawn_blocking(move || { - omega_core::agents::Agent::all().iter().any(|a| a.name() == name) + omega_core::agents::Agent::all() + .iter() + .any(|a| a.name() == name) }) .await .unwrap_or(false); @@ -153,7 +175,9 @@ pub async fn stream( let Ok(permit) = state.orchestrate_permits.clone().try_acquire_owned() else { return StatusCode::TOO_MANY_REQUESTS.into_response(); }; - ws.on_upgrade(move |socket| orchestrate_stream_loop(socket, project, mission, project_path, permit)) + ws.on_upgrade(move |socket| { + orchestrate_stream_loop(socket, project, mission, project_path, permit) + }) } Err((code, _msg)) => code.into_response(), } @@ -184,7 +208,10 @@ async fn forward_lines( loop { match lines.next_line().await { Ok(Some(text)) => { - let frame = OrchestrateStreamMsg::Line { stream: stream_name.to_string(), text }; + let frame = OrchestrateStreamMsg::Line { + stream: stream_name.to_string(), + text, + }; if tx.send(frame).await.is_err() { return; } @@ -202,7 +229,10 @@ async fn forward_lines( /// subprocess work). async fn kill_process_group(pid: u32) { let _ = tokio::task::spawn_blocking(move || { - std::process::Command::new("kill").arg("--").arg(format!("-{pid}")).status() + std::process::Command::new("kill") + .arg("--") + .arg(format!("-{pid}")) + .status() }) .await; } @@ -262,7 +292,12 @@ async fn orchestrate_stream_loop( // — `project`/`mission` could themselves start with `-`, and everything // after `--` is treated as a positional value by clap even then (same // clap-safe argv construction `routes_dispatch.rs::create` documents). - cmd.arg("orchestrate").arg("--dir").arg(&project_path).arg("--").arg(&project).arg(&mission); + cmd.arg("orchestrate") + .arg("--dir") + .arg(&project_path) + .arg("--") + .arg(&project) + .arg(&mission); cmd.stdin(Stdio::null()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); @@ -274,7 +309,9 @@ async fn orchestrate_stream_loop( Err(e) => { let _ = send_orchestrate_frame( &mut socket, - &OrchestrateStreamMsg::Error { message: format!("failed to spawn omega: {e}") }, + &OrchestrateStreamMsg::Error { + message: format!("failed to spawn omega: {e}"), + }, ) .await; let _ = socket.send(Message::Close(None)).await; @@ -358,8 +395,13 @@ async fn orchestrate_stream_loop( let _ = stderr_task.await; let exit_frame = match child.wait().await { - Ok(status) => OrchestrateStreamMsg::Exit { success: status.success(), code: status.code() }, - Err(e) => OrchestrateStreamMsg::Error { message: format!("failed to wait on child: {e}") }, + Ok(status) => OrchestrateStreamMsg::Exit { + success: status.success(), + code: status.code(), + }, + Err(e) => OrchestrateStreamMsg::Error { + message: format!("failed to wait on child: {e}"), + }, }; let _ = send_orchestrate_frame(&mut socket, &exit_frame).await; let _ = socket.send(Message::Close(None)).await; @@ -372,21 +414,27 @@ mod resolve_orchestrate_request_tests { #[tokio::test] async fn rejects_empty_project() { - let err = resolve_orchestrate_request("", "mission", "").await.unwrap_err(); + let err = resolve_orchestrate_request("", "mission", "") + .await + .unwrap_err(); assert_eq!(err.0, StatusCode::BAD_REQUEST); assert!(err.1.contains("project")); } #[tokio::test] async fn rejects_empty_mission() { - let err = resolve_orchestrate_request("SomeProj", "", "").await.unwrap_err(); + let err = resolve_orchestrate_request("SomeProj", "", "") + .await + .unwrap_err(); assert_eq!(err.0, StatusCode::BAD_REQUEST); assert!(err.1.contains("mission")); } #[tokio::test] async fn rejects_nul_byte() { - let err = resolve_orchestrate_request("Some\u{0}Proj", "mission", "").await.unwrap_err(); + let err = resolve_orchestrate_request("Some\u{0}Proj", "mission", "") + .await + .unwrap_err(); assert_eq!(err.0, StatusCode::BAD_REQUEST); assert!(err.1.contains("NUL")); } @@ -394,7 +442,9 @@ mod resolve_orchestrate_request_tests { #[tokio::test] async fn rejects_mission_over_length_cap() { let too_long = "x".repeat(8001); - let err = resolve_orchestrate_request("SomeProj", &too_long, "").await.unwrap_err(); + let err = resolve_orchestrate_request("SomeProj", &too_long, "") + .await + .unwrap_err(); assert_eq!(err.0, StatusCode::BAD_REQUEST); assert!(err.1.contains("too long")); } diff --git a/crates/omega-gateway/src/routes_pair.rs b/crates/omega-gateway/src/routes_pair.rs index e3b29ad1..7c034ca3 100644 --- a/crates/omega-gateway/src/routes_pair.rs +++ b/crates/omega-gateway/src/routes_pair.rs @@ -9,9 +9,15 @@ pub async fn pair( Json(req): Json, ) -> Result, (StatusCode, Json)> { if !PairingCode::consume(&state.dir, &req.code) { - return Err((StatusCode::FORBIDDEN, Json(json!({ "error": "invalid or expired code" })))); + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ "error": "invalid or expired code" })), + )); } let mut store = DeviceStore::open(&state.dir); let (device, token) = store.issue(&req.device_name); - Ok(Json(PairResponse { device_id: device.id, token })) + Ok(Json(PairResponse { + device_id: device.id, + token, + })) } diff --git a/crates/omega-gateway/src/routes_pdf.rs b/crates/omega-gateway/src/routes_pdf.rs index 78601151..6e13a654 100644 --- a/crates/omega-gateway/src/routes_pdf.rs +++ b/crates/omega-gateway/src/routes_pdf.rs @@ -81,27 +81,45 @@ use crate::server::AppState; type ApiError = (StatusCode, Json); fn bad_request(msg: impl Into) -> ApiError { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn not_found(msg: impl Into) -> ApiError { - (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn forbidden(msg: impl Into) -> ApiError { - (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn bad_gateway(msg: impl Into) -> ApiError { - (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn too_many_requests(msg: impl Into) -> ApiError { - (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn internal(msg: impl std::fmt::Display) -> ApiError { - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": msg.to_string() }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": msg.to_string() })), + ) } /// The literal `--template` values `omega pdf` accepts (`crates/omega-cli/ @@ -155,7 +173,11 @@ fn pdf_root_dir() -> PathBuf { if let Ok(dir) = std::env::var("OMEGA_PDF_DIR") { return PathBuf::from(dir); } - dirs::home_dir().expect("no home dir").join(".omega").join("state").join("gateway-pdf") + dirs::home_dir() + .expect("no home dir") + .join(".omega") + .join("state") + .join("gateway-pdf") } /// Where generated PDFs land — the ONLY directory `GET /v1/pdf/download` @@ -193,7 +215,10 @@ fn is_server_generated_pdf_name(name: &str) -> bool { /// child on POSIX). async fn kill_process_group(pid: u32) { let _ = tokio::task::spawn_blocking(move || { - std::process::Command::new("kill").arg("--").arg(format!("-{pid}")).status() + std::process::Command::new("kill") + .arg("--") + .arg(format!("-{pid}")) + .status() }) .await; } @@ -272,7 +297,9 @@ pub async fn create( } let Ok(_permit) = state.pdf_permits.clone().try_acquire_owned() else { - return Err(too_many_requests("too many concurrent PDF generations, try again shortly")); + return Err(too_many_requests( + "too many concurrent PDF generations, try again shortly", + )); }; let ts = chrono::Local::now().format("%Y%m%d-%H%M%S%.f").to_string(); @@ -303,9 +330,10 @@ pub async fn create( .map_err(|e| internal(format!("mkdir {}: {e}", data_dir.display())))?; std::fs::create_dir_all(&out_dir) .map_err(|e| internal(format!("mkdir {}: {e}", out_dir.display())))?; - let json = - serde_json::to_string_pretty(&data).map_err(|e| internal(format!("serialize data: {e}")))?; - std::fs::write(&data_path, json).map_err(|e| internal(format!("write data file: {e}")))?; + let json = serde_json::to_string_pretty(&data) + .map_err(|e| internal(format!("serialize data: {e}")))?; + std::fs::write(&data_path, json) + .map_err(|e| internal(format!("write data file: {e}")))?; Ok(()) } }) @@ -342,9 +370,16 @@ pub async fn create( let size_bytes = tokio::fs::metadata(&out_path) .await .map(|m| m.len()) - .map_err(|e| bad_gateway(format!("omega pdf reported success but no file at {out_path_str}: {e}")))?; + .map_err(|e| { + bad_gateway(format!( + "omega pdf reported success but no file at {out_path_str}: {e}" + )) + })?; - Ok(Json(crate::protocol::PdfResponse { path: out_path_str, size_bytes })) + Ok(Json(crate::protocol::PdfResponse { + path: out_path_str, + size_bytes, + })) } /// `GET /v1/pdf/download?path=` — see this module's doc comment. Streams @@ -357,7 +392,10 @@ pub async fn download(Query(query): Query>) -> Result Json { RulesResponse { laws, rules } }) .await - .unwrap_or(RulesResponse { laws: vec![], rules: vec![] }); + .unwrap_or(RulesResponse { + laws: vec![], + rules: vec![], + }); Json(response) } diff --git a/crates/omega-gateway/src/routes_session_org.rs b/crates/omega-gateway/src/routes_session_org.rs index ddfa18b5..0c15fe06 100644 --- a/crates/omega-gateway/src/routes_session_org.rs +++ b/crates/omega-gateway/src/routes_session_org.rs @@ -17,7 +17,10 @@ use axum::{ type ApiError = (StatusCode, Json); fn bad_request(msg: impl Into) -> ApiError { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": msg.into() })), + ) } /// Byte-length cap on `label`/`folder`. This is app-generated freeform @@ -34,7 +37,9 @@ const MAX_FOLDER_LEN: usize = 200; /// `GET /v1/session-org` — the whole overlay map. Empty when no session has /// ever been tagged. pub async fn get_all(State(state): State) -> Json { - Json(SessionOrgResponse { entries: state.session_org.get_all() }) + Json(SessionOrgResponse { + entries: state.session_org.get_all(), + }) } /// `PUT /v1/session-org/{name}` — FULL REPLACE of that session's overlay @@ -55,14 +60,25 @@ pub async fn set( return Err(bad_request("invalid session name")); } if req.label.as_ref().is_some_and(|s| s.len() > MAX_LABEL_LEN) { - return Err(bad_request(format!("label too long (max {MAX_LABEL_LEN} bytes)"))); + return Err(bad_request(format!( + "label too long (max {MAX_LABEL_LEN} bytes)" + ))); } - if req.folder.as_ref().is_some_and(|s| s.len() > MAX_FOLDER_LEN) { - return Err(bad_request(format!("folder too long (max {MAX_FOLDER_LEN} bytes)"))); + if req + .folder + .as_ref() + .is_some_and(|s| s.len() > MAX_FOLDER_LEN) + { + return Err(bad_request(format!( + "folder too long (max {MAX_FOLDER_LEN} bytes)" + ))); } - let entry = - SessionOrgEntry { label: req.label, folder: req.folder, pinned: req.pinned.unwrap_or(false) }; + let entry = SessionOrgEntry { + label: req.label, + folder: req.folder, + pinned: req.pinned.unwrap_or(false), + }; state.session_org.set(&name, entry.clone()); Ok(Json(entry)) } diff --git a/crates/omega-gateway/src/routes_sessions.rs b/crates/omega-gateway/src/routes_sessions.rs index 5d74ae0b..a38338e5 100644 --- a/crates/omega-gateway/src/routes_sessions.rs +++ b/crates/omega-gateway/src/routes_sessions.rs @@ -8,11 +8,20 @@ use axum::Json; pub async fn list() -> Json { match tokio::task::spawn_blocking(crate::rmux::list_sessions).await { Ok(Ok(names)) => Json(SessionsResponse { - sessions: names.into_iter().map(|name| SessionEntry { name }).collect(), + sessions: names + .into_iter() + .map(|name| SessionEntry { name }) + .collect(), error: None, }), - Ok(Err(e)) => Json(SessionsResponse { sessions: vec![], error: Some(e.to_string()) }), - Err(e) => Json(SessionsResponse { sessions: vec![], error: Some(e.to_string()) }), + Ok(Err(e)) => Json(SessionsResponse { + sessions: vec![], + error: Some(e.to_string()), + }), + Err(e) => Json(SessionsResponse { + sessions: vec![], + error: Some(e.to_string()), + }), } } @@ -68,8 +77,12 @@ async fn stream_loop(mut socket: WebSocket, name: String, state: AppState, color Some(StreamFrame::Frame { text }) } } - Ok(Err(e)) => Some(StreamFrame::Error { message: e.to_string() }), - Err(e) => Some(StreamFrame::Error { message: e.to_string() }), + Ok(Err(e)) => Some(StreamFrame::Error { + message: e.to_string(), + }), + Err(e) => Some(StreamFrame::Error { + message: e.to_string(), + }), }; if let Some(frame) = frame { let text = serde_json::to_string(&frame).expect("serialize frame"); @@ -111,7 +124,8 @@ pub(crate) fn valid_session_name(name: &str) -> bool { if name.contains('/') || name.contains("..") || name.contains('\0') { return false; } - name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.') + name.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.') } pub async fn send_keys( @@ -145,7 +159,10 @@ pub async fn send_keys( ) })? .map_err(|e| { - (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": e.to_string() }))) + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": e.to_string() })), + ) })?; if req.enter { @@ -167,7 +184,10 @@ pub async fn send_keys( ) })? .map_err(|e| { - (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": e.to_string() }))) + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": e.to_string() })), + ) })?; } @@ -177,15 +197,24 @@ pub async fn send_keys( type ApiError = (StatusCode, Json); fn bad_request(msg: impl Into) -> ApiError { - (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn too_many_requests(msg: impl Into) -> ApiError { - (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn gateway_timeout(msg: impl Into) -> ApiError { - (StatusCode::GATEWAY_TIMEOUT, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::GATEWAY_TIMEOUT, + Json(serde_json::json!({ "error": msg.into() })), + ) } /// Parses `cmd_kill`'s alias-resolution line (`"[i] {name} resolved to the @@ -254,18 +283,18 @@ pub async fn close( let output = tokio::task::spawn_blocking(move || crate::omega_cli::run(&["kill", "--", &session])) .await - .map_err(|e| { - ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({ "error": format!("kill task panicked: {e}") })), - ) - })? - .map_err(|e| { - ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({ "error": format!("failed to spawn omega: {e}") })), - ) - })?; + .map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": format!("kill task panicked: {e}") })), + ) + })? + .map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": format!("failed to spawn omega: {e}") })), + ) + })?; // Classify off the resolved name when `cmd_kill` reported one, else the // raw caller-supplied name — never parsed off the CLI otherwise. @@ -273,9 +302,14 @@ pub async fn close( let is_oracle = omega_core::session::OmegaSession::classify(classify_name).role == omega_core::session::SessionRole::Oracle; - let cascaded_count = - output.stdout.lines().filter(|l| l.starts_with(" cascaded worker")).count() as u32; - let already_closed = output.stdout.contains("is already closed — nothing live to kill."); + let cascaded_count = output + .stdout + .lines() + .filter(|l| l.starts_with(" cascaded worker")) + .count() as u32; + let already_closed = output + .stdout + .contains("is already closed — nothing live to kill."); // M-1 (Codex cross-model review, 2026-08-11): the SUCCESS-path `message` // (stdout alone) is a documented contract other code/tests depend on // (the success/already-closed/cascaded-worker informational text) and @@ -333,7 +367,8 @@ pub(crate) fn valid_new_session_name(name: &str) -> bool { if name.starts_with('-') || name.starts_with('.') { return false; } - name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + name.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') } /// `POST /v1/sessions/{name}/rename` — runs @@ -365,7 +400,10 @@ pub async fn rename( ) })? .map_err(|e| { - (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": e.to_string() }))) + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": e.to_string() })), + ) })?; Ok(Json(RenameSessionResponse { name: req.new_name })) @@ -435,7 +473,9 @@ pub(crate) fn dir_under_home(path: &str) -> Result use std::path::Component; for component in requested.components() { if matches!(component, Component::ParentDir) { - return Err(bad_request("dir must not contain a parent-directory (`..`) component")); + return Err(bad_request( + "dir must not contain a parent-directory (`..`) component", + )); } } @@ -529,14 +569,18 @@ pub async fn create( // `AppState::session_spawn_permits`'s doc comment). Mirrors // `routes_dispatch.rs::create`'s own Step 0. let Ok(_permit) = state.session_spawn_permits.clone().try_acquire_owned() else { - return Err(too_many_requests("too many concurrent session spawns, try again shortly")); + return Err(too_many_requests( + "too many concurrent session spawns, try again shortly", + )); }; // Step 1: `agent` must be a real, known agent — validated before // anything else touches the filesystem or a subprocess. let agent_check = req.agent.clone(); let is_known = tokio::task::spawn_blocking(move || { - omega_core::agents::Agent::all().iter().any(|a| a.name() == agent_check) + omega_core::agents::Agent::all() + .iter() + .any(|a| a.name() == agent_check) }) .await .unwrap_or(false); @@ -589,7 +633,9 @@ pub async fn create( return Err(bad_request("prompt must not contain a NUL byte")); } if p.len() > MAX_PROMPT_LEN { - return Err(bad_request(format!("prompt too long (max {MAX_PROMPT_LEN} bytes)"))); + return Err(bad_request(format!( + "prompt too long (max {MAX_PROMPT_LEN} bytes)" + ))); } } @@ -667,7 +713,11 @@ pub async fn create( )); } - Ok(Json(CreateSessionResponse { name, agent: req.agent, output: output.stdout })) + Ok(Json(CreateSessionResponse { + name, + agent: req.agent, + output: output.stdout, + })) } #[cfg(test)] @@ -929,7 +979,12 @@ mod dir_under_home_tests { let _g = LOCK.lock().unwrap(); let home = tempfile::tempdir().unwrap(); std::env::set_var("HOME", home.path()); - let escaping = home.path().join("Station").join("..").join("..").join("etc"); + let escaping = home + .path() + .join("Station") + .join("..") + .join("..") + .join("etc"); let err = dir_under_home(&escaping.display().to_string()).unwrap_err(); assert_eq!(err.0, axum::http::StatusCode::BAD_REQUEST); std::env::remove_var("HOME"); diff --git a/crates/omega-gateway/src/routes_skills.rs b/crates/omega-gateway/src/routes_skills.rs index 462da9f6..289eeb03 100644 --- a/crates/omega-gateway/src/routes_skills.rs +++ b/crates/omega-gateway/src/routes_skills.rs @@ -1,8 +1,8 @@ //! Authenticated skill catalog, detail, safe edit, and rmux delegation. use crate::protocol::{ - CreateSessionRequest, SkillAgentRequest, SkillAgentResponse, SkillDetail, SkillDetailResponse, - SkillDeleteRequest, SkillEntry, SkillRenameRequest, SkillUpdateRequest, SkillsResponse, + CreateSessionRequest, SkillAgentRequest, SkillAgentResponse, SkillDeleteRequest, SkillDetail, + SkillDetailResponse, SkillEntry, SkillRenameRequest, SkillUpdateRequest, SkillsResponse, }; use crate::server::AppState; use axum::extract::{Path, Query, State}; @@ -267,18 +267,11 @@ fn delete_at_root(root: &FsPath, name: &str, confirm_name: &str) -> anyhow::Resu Ok(()) } -pub async fn list(Query(params): Query>) -> Json { - let response = tokio::task::spawn_blocking(move || { - let registry = match SkillRegistry::discover_default() { - Ok(registry) => registry, - Err(error) => { - tracing::warn!("skill discovery failed: {error}"); - return SkillsResponse { - skills: vec![], - total: 0, - }; - } - }; +pub async fn list( + Query(params): Query>, +) -> Result, ApiError> { + let response = tokio::task::spawn_blocking(move || -> anyhow::Result { + let registry = SkillRegistry::discover_default()?; let all = registry.list(); let total = all.len(); @@ -319,14 +312,23 @@ pub async fn list(Query(params): Query>) -> Json) -> Result, ApiError> { @@ -347,6 +349,14 @@ pub async fn get(Path(name): Path) -> Result, .map_err(|error| { let status = if error.to_string() == "skill not found" { StatusCode::NOT_FOUND + } else if error + .to_string() + .contains("skills directory does not exist") + || error + .to_string() + .contains("skills root must be a real directory") + { + StatusCode::SERVICE_UNAVAILABLE } else { StatusCode::BAD_REQUEST }; @@ -554,7 +564,10 @@ mod tests { let registry = SkillRegistry::discover(root.path()).unwrap(); assert!(registry.get("after").is_some(), "the new name must resolve"); - assert!(registry.get("before").is_none(), "the old name must be gone"); + assert!( + registry.get("before").is_none(), + "the old name must be gone" + ); let content = fs::read_to_string(root.path().join("after").join("SKILL.md")).unwrap(); assert!(content.contains("name: after")); } @@ -564,7 +577,10 @@ mod tests { let content = "---\nname: old\ndescription: D\n---\n# Body\nname: not-frontmatter\n"; let rewritten = rewrite_frontmatter_name(content, "new").unwrap(); assert!(rewritten.starts_with("---\nname: new\ndescription: D\n---\n")); - assert!(rewritten.contains("name: not-frontmatter"), "body prose survives"); + assert!( + rewritten.contains("name: not-frontmatter"), + "body prose survives" + ); assert!(rewritten.ends_with('\n'), "the trailing newline survives"); assert!(rewrite_frontmatter_name("# no frontmatter\n", "new").is_err()); @@ -689,4 +705,3 @@ mod tests { } } } - diff --git a/crates/omega-gateway/src/routes_team.rs b/crates/omega-gateway/src/routes_team.rs index b252c0c4..8bedf6c5 100644 --- a/crates/omega-gateway/src/routes_team.rs +++ b/crates/omega-gateway/src/routes_team.rs @@ -40,15 +40,24 @@ use serde_json::json; type ApiError = (StatusCode, Json); fn bad_request(msg: impl Into) -> ApiError { - (StatusCode::BAD_REQUEST, Json(json!({ "error": msg.into() }))) + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": msg.into() })), + ) } fn too_many_requests(msg: impl Into) -> ApiError { - (StatusCode::TOO_MANY_REQUESTS, Json(json!({ "error": msg.into() }))) + ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({ "error": msg.into() })), + ) } fn gateway_timeout(msg: impl Into) -> ApiError { - (StatusCode::GATEWAY_TIMEOUT, Json(json!({ "error": msg.into() }))) + ( + StatusCode::GATEWAY_TIMEOUT, + Json(json!({ "error": msg.into() })), + ) } /// Per-member string cap — generous for a `"name:prompt"` spec (the CLI @@ -83,7 +92,9 @@ pub async fn create( // `AppState::session_spawn_permits`'s doc comment): a team spawn is at // least as heavy (up to `MAX_COUNT` sub-panes per call). let Ok(_permit) = state.session_spawn_permits.clone().try_acquire_owned() else { - return Err(too_many_requests("too many concurrent session spawns, try again shortly")); + return Err(too_many_requests( + "too many concurrent session spawns, try again shortly", + )); }; // Step 1: `project` becomes a session-name COMPONENT (see this file's @@ -118,7 +129,9 @@ pub async fn create( // CLI has no hard cap of its own. if let Some(count) = req.count { if !count_in_bounds(count) { - return Err(bad_request(format!("count must be between {MIN_COUNT} and {MAX_COUNT}"))); + return Err(bad_request(format!( + "count must be between {MIN_COUNT} and {MAX_COUNT}" + ))); } } @@ -148,7 +161,9 @@ pub async fn create( return Err(bad_request("member must not contain a NUL byte")); } if m.len() > MAX_MEMBER_LEN { - return Err(bad_request(format!("member too long (max {MAX_MEMBER_LEN} bytes)"))); + return Err(bad_request(format!( + "member too long (max {MAX_MEMBER_LEN} bytes)" + ))); } } } @@ -195,13 +210,19 @@ pub async fn create( }) .await .map_err(|e| { - (StatusCode::BAD_GATEWAY, Json(json!({ "error": format!("team task panicked: {e}") }))) + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": format!("team task panicked: {e}") })), + ) })? .map_err(|e| { if crate::omega_cli::is_timeout(&e) { gateway_timeout(e.to_string()) } else { - (StatusCode::BAD_GATEWAY, Json(json!({ "error": format!("failed to spawn omega: {e}") }))) + ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": format!("failed to spawn omega: {e}") })), + ) } })?; @@ -225,7 +246,10 @@ pub async fn create( )); } - Ok(Json(TeamResponse { session, output: output.stdout })) + Ok(Json(TeamResponse { + session, + output: output.stdout, + })) } #[cfg(test)] diff --git a/crates/omega-gateway/src/routes_telegram.rs b/crates/omega-gateway/src/routes_telegram.rs index 256e9461..3d9f03da 100644 --- a/crates/omega-gateway/src/routes_telegram.rs +++ b/crates/omega-gateway/src/routes_telegram.rs @@ -40,11 +40,17 @@ use omega_core::monitor::OmegaTelegramConfig; type ApiError = (StatusCode, Json); fn not_found(msg: impl Into) -> ApiError { - (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": msg.into() }))) + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": msg.into() })), + ) } fn internal(msg: impl std::fmt::Display) -> ApiError { - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": msg.to_string() }))) + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": msg.to_string() })), + ) } fn to_status_response(cfg: Option) -> TelegramStatusResponse { @@ -75,7 +81,9 @@ fn to_status_response(cfg: Option) -> TelegramStatusRespons /// is a NORMAL response, never an error — mirrors `TelegramAction::Status`'s /// own CLI arm, which renders "Not configured." rather than failing. pub async fn status() -> Json { - let cfg = tokio::task::spawn_blocking(OmegaTelegramConfig::read).await.unwrap_or(None); + let cfg = tokio::task::spawn_blocking(OmegaTelegramConfig::read) + .await + .unwrap_or(None); Json(to_status_response(cfg)) } @@ -86,7 +94,9 @@ pub async fn status() -> Json { /// setup …") rather than fabricating a config that was never set up. async fn toggle(target: bool) -> Result, ApiError> { let result = tokio::task::spawn_blocking(move || -> Result, String> { - let Some(mut cfg) = OmegaTelegramConfig::read() else { return Ok(None) }; + let Some(mut cfg) = OmegaTelegramConfig::read() else { + return Ok(None); + }; cfg.enabled = target; cfg.write().map_err(|e| e.to_string())?; Ok(Some(cfg.enabled)) @@ -96,7 +106,9 @@ async fn toggle(target: bool) -> Result, ApiError> match result { Ok(Some(enabled)) => Ok(Json(TelegramToggleResponse { enabled })), - Ok(None) => Err(not_found("telegram bridge is not configured (run: omega telegram setup …)")), + Ok(None) => Err(not_found( + "telegram bridge is not configured (run: omega telegram setup …)", + )), Err(msg) => Err(internal(msg)), } } diff --git a/crates/omega-gateway/src/server.rs b/crates/omega-gateway/src/server.rs index cfc114e8..77c9c74d 100644 --- a/crates/omega-gateway/src/server.rs +++ b/crates/omega-gateway/src/server.rs @@ -241,7 +241,9 @@ pub async fn require_device( // Query tokens exist for WebSocket clients that cannot set headers. // Request logging must therefore never log full request URIs. let token = header_token.or_else(|| query.get("token").cloned()); - let Some(token) = token else { return Err(StatusCode::UNAUTHORIZED) }; + let Some(token) = token else { + return Err(StatusCode::UNAUTHORIZED); + }; let Some(device) = DeviceStore::open(&state.dir).verify(&token) else { return Err(StatusCode::UNAUTHORIZED); }; @@ -250,7 +252,10 @@ pub async fn require_device( } async fn whoami(Extension(device): Extension) -> Json { - Json(WhoamiResponse { device_id: device.id, name: device.name }) + Json(WhoamiResponse { + device_id: device.id, + name: device.name, + }) } pub fn build_router(state: AppState) -> Router { @@ -260,7 +265,10 @@ pub fn build_router(state: AppState) -> Router { "/v1/sessions", get(crate::routes_sessions::list).post(crate::routes_sessions::create), ) - .route("/v1/sessions/{name}/stream", get(crate::routes_sessions::stream)) + .route( + "/v1/sessions/{name}/stream", + get(crate::routes_sessions::stream), + ) .route( "/v1/sessions/{name}/keys", axum::routing::post(crate::routes_sessions::send_keys), @@ -317,7 +325,10 @@ pub fn build_router(state: AppState) -> Router { .route("/v1/audits", get(crate::routes_audit::list)) .route("/v1/audit", axum::routing::post(crate::routes_audit::check)) .route("/v1/audit/stream", get(crate::routes_audit::stream)) - .route("/v1/dispatch", axum::routing::post(crate::routes_dispatch::create)) + .route( + "/v1/dispatch", + axum::routing::post(crate::routes_dispatch::create), + ) // B1 fix: axum 0.8's `Multipart` extractor falls back to its own // internal 2 MiB default body limit when no `DefaultBodyLimit` layer // is set, silently overriding `MAX_DEPOSIT_BYTES` (the crate's own @@ -331,7 +342,9 @@ pub fn build_router(state: AppState) -> Router { .route( "/v1/deposit", axum::routing::post(crate::routes_deposit::create).layer( - axum::extract::DefaultBodyLimit::max(crate::routes_deposit::MAX_DEPOSIT_BYTES + 8192), + axum::extract::DefaultBodyLimit::max( + crate::routes_deposit::MAX_DEPOSIT_BYTES + 8192, + ), ), ) .route("/v1/events", get(crate::routes_events::events)) @@ -347,29 +360,44 @@ pub fn build_router(state: AppState) -> Router { "/v1/accounts/{slug}/default", axum::routing::post(crate::routes_accounts::set_default), ) - .route("/v1/accounts/{slug}/login", get(crate::routes_accounts::login)) .route( - "/v1/accounts/{slug}/apikey", - axum::routing::post(crate::routes_accounts::apikey), + "/v1/accounts/{slug}/login", + get(crate::routes_accounts::login), ) .route( - "/v1/session-org", - get(crate::routes_session_org::get_all), + "/v1/accounts/{slug}/apikey", + axum::routing::post(crate::routes_accounts::apikey), ) + .route("/v1/session-org", get(crate::routes_session_org::get_all)) .route( "/v1/session-org/{name}", axum::routing::put(crate::routes_session_org::set), ) .route("/v1/master/chat", get(crate::routes_master::chat)) - .route("/v1/oracles/{session}/timeline", get(crate::routes_oracles::timeline)) - .route("/v1/oracles/{session}/gate", get(crate::routes_oracles::gate)) - .route("/v1/oracles/{session}/reap", axum::routing::post(crate::routes_oracles::reap)) + .route( + "/v1/oracles/{session}/timeline", + get(crate::routes_oracles::timeline), + ) + .route( + "/v1/oracles/{session}/gate", + get(crate::routes_oracles::gate), + ) + .route( + "/v1/oracles/{session}/reap", + axum::routing::post(crate::routes_oracles::reap), + ) .route( "/v1/oracles/{session}/resurrect", axum::routing::post(crate::routes_oracles::resurrect), ) - .route("/v1/orchestrate/stream", get(crate::routes_orchestrate::stream)) - .route("/v1/new-project/stream", get(crate::routes_new_project::stream)) + .route( + "/v1/orchestrate/stream", + get(crate::routes_orchestrate::stream), + ) + .route( + "/v1/new-project/stream", + get(crate::routes_new_project::stream), + ) .route("/v1/doctor", get(crate::routes_box::doctor)) .route("/v1/usage", get(crate::routes_box::usage)) .route("/v1/box-info", get(crate::routes_box::box_info)) @@ -399,7 +427,10 @@ pub fn build_router(state: AppState) -> Router { // IMPORTANT: route_layer only wraps routes registered BEFORE it is // called. Add every new protected .route(...) ABOVE this line, or it // ships unauthenticated. - .route_layer(middleware::from_fn_with_state(state.clone(), require_device)); + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_device, + )); Router::new() .route("/v1/health", get(health)) .route("/v1/pair", axum::routing::post(crate::routes_pair::pair)) diff --git a/crates/omega-gateway/src/session_org.rs b/crates/omega-gateway/src/session_org.rs index 3dbb7400..3a26e172 100644 --- a/crates/omega-gateway/src/session_org.rs +++ b/crates/omega-gateway/src/session_org.rs @@ -43,7 +43,10 @@ impl SessionOrgStore { std::fs::create_dir_all(gateway_dir).ok(); harden_dir(gateway_dir); let path = gateway_dir.join("session_org.json"); - Self { path, lock: Mutex::new(()) } + Self { + path, + lock: Mutex::new(()), + } } fn read_all(&self) -> HashMap { @@ -149,7 +152,11 @@ mod tests { store.set("oracle-Bar-2", entry(Some("second"), None, true)); let all = store.get_all(); - assert_eq!(all.len(), 2, "a read-modify-write over the whole map, not an overwrite of it"); + assert_eq!( + all.len(), + 2, + "a read-modify-write over the whole map, not an overwrite of it" + ); assert_eq!(all["oracle-Foo-1"].label.as_deref(), Some("first")); assert_eq!(all["oracle-Bar-2"].label.as_deref(), Some("second")); assert!(all["oracle-Bar-2"].pinned); @@ -166,7 +173,10 @@ mod tests { assert_eq!(all.len(), 1); let got = &all["oracle-Foo-1"]; assert_eq!(got.label.as_deref(), Some("new")); - assert_eq!(got.folder, None, "full replace: an omitted field must not survive from the old entry"); + assert_eq!( + got.folder, None, + "full replace: an omitted field must not survive from the old entry" + ); assert!(got.pinned); } diff --git a/crates/omega-gateway/tests/accounts_routes_test.rs b/crates/omega-gateway/tests/accounts_routes_test.rs index 201491cc..4f748af9 100644 --- a/crates/omega-gateway/tests/accounts_routes_test.rs +++ b/crates/omega-gateway/tests/accounts_routes_test.rs @@ -85,7 +85,10 @@ async fn create_list_and_default_flow() { std::env::set_var("OMEGA_CLAUDE_BIN", &bin); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let client = reqwest::Client::new(); @@ -100,7 +103,10 @@ async fn create_list_and_default_flow() { assert_eq!(create_res.status(), 201); let account: serde_json::Value = create_res.json().await.unwrap(); assert_eq!(account["slug"], "work-1"); - assert_eq!(account["is_default"], true, "first account of a kind is its default"); + assert_eq!( + account["is_default"], true, + "first account of a kind is its default" + ); // A second account of the same kind, so set_default has something to do. let create_res2 = client @@ -113,8 +119,15 @@ async fn create_list_and_default_flow() { assert_eq!(create_res2.status(), 201); // GET /v1/accounts lists both with a merged live status (fake claude -> logged_out). - let list_res: serde_json::Value = - client.get(format!("{base}/v1/accounts")).bearer_auth(&token).send().await.unwrap().json().await.unwrap(); + let list_res: serde_json::Value = client + .get(format!("{base}/v1/accounts")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); let accounts = list_res["accounts"].as_array().unwrap(); assert_eq!(accounts.len(), 2); let work1 = accounts.iter().find(|a| a["slug"] == "work-1").unwrap(); @@ -130,8 +143,15 @@ async fn create_list_and_default_flow() { .unwrap(); assert_eq!(default_res.status(), 200); - let list_res2: serde_json::Value = - client.get(format!("{base}/v1/accounts")).bearer_auth(&token).send().await.unwrap().json().await.unwrap(); + let list_res2: serde_json::Value = client + .get(format!("{base}/v1/accounts")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); let accounts2 = list_res2["accounts"].as_array().unwrap(); let work1_after = accounts2.iter().find(|a| a["slug"] == "work-1").unwrap(); let work2_after = accounts2.iter().find(|a| a["slug"] == "work-2").unwrap(); @@ -139,13 +159,29 @@ async fn create_list_and_default_flow() { assert_eq!(work2_after["is_default"], true); // DELETE /v1/accounts/work-1 -> 204, and it's gone from the list. - let delete_res = - client.delete(format!("{base}/v1/accounts/work-1")).bearer_auth(&token).send().await.unwrap(); + let delete_res = client + .delete(format!("{base}/v1/accounts/work-1")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(delete_res.status(), 204); - let list_res3: serde_json::Value = - client.get(format!("{base}/v1/accounts")).bearer_auth(&token).send().await.unwrap().json().await.unwrap(); - let slugs: Vec<&str> = list_res3["accounts"].as_array().unwrap().iter().map(|a| a["slug"].as_str().unwrap()).collect(); + let list_res3: serde_json::Value = client + .get(format!("{base}/v1/accounts")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let slugs: Vec<&str> = list_res3["accounts"] + .as_array() + .unwrap() + .iter() + .map(|a| a["slug"].as_str().unwrap()) + .collect(); assert!(!slugs.contains(&"work-1")); assert!(slugs.contains(&"work-2")); @@ -156,7 +192,10 @@ async fn create_list_and_default_flow() { async fn create_rejects_traversal_slug() { let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -169,8 +208,15 @@ async fn create_rejects_traversal_slug() { assert_eq!(res.status(), 400); // Nothing was created on disk for the traversal attempt. - let list_res: serde_json::Value = - reqwest::Client::new().get(format!("{base}/v1/accounts")).bearer_auth(&token).send().await.unwrap().json().await.unwrap(); + let list_res: serde_json::Value = reqwest::Client::new() + .get(format!("{base}/v1/accounts")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); assert!(list_res["accounts"].as_array().unwrap().is_empty()); } @@ -178,16 +224,28 @@ async fn create_rejects_traversal_slug() { async fn slug_path_param_routes_reject_invalid_slug_before_fs() { let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let client = reqwest::Client::new(); // "UP" fails valid_slug (uppercase) without touching the store/fs. - let delete_res = client.delete(format!("{base}/v1/accounts/UP")).bearer_auth(&token).send().await.unwrap(); + let delete_res = client + .delete(format!("{base}/v1/accounts/UP")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(delete_res.status(), 400); - let default_res = - client.post(format!("{base}/v1/accounts/UP/default")).bearer_auth(&token).send().await.unwrap(); + let default_res = client + .post(format!("{base}/v1/accounts/UP/default")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(default_res.status(), 400); let apikey_res = client @@ -253,9 +311,11 @@ printf '%s\n' '{{"type":"result","is_error":false,"stop_reason":"end_turn","resu // Run a turn and check the fake chat bin saw CLAUDE_CONFIG_DIR = account A's slot dir. let url = ws_url(&base, &format!("/v1/chats/{chat_id}/stream"), &token); let (mut ws, _) = connect_async(url).await.unwrap(); - ws.send(Message::Text(serde_json::json!({ "type": "user_message", "text": "hi" }).to_string())) - .await - .unwrap(); + ws.send(Message::Text( + serde_json::json!({ "type": "user_message", "text": "hi" }).to_string(), + )) + .await + .unwrap(); loop { let frame = recv_json(&mut ws).await; if frame["type"] == "turn_done" { @@ -304,7 +364,10 @@ printf '%s\n' '{{"type":"result","is_error":false,"stop_reason":"end_turn","resu .await .unwrap(); assert_eq!(create_res.status(), 201); - assert_eq!(create_res.json::().await.unwrap()["is_default"], true); + assert_eq!( + create_res.json::().await.unwrap()["is_default"], + true + ); let expected_slot_dir = state.accounts.slot_dir("the-default"); // Create a chat with NO account_slug. @@ -322,9 +385,11 @@ printf '%s\n' '{{"type":"result","is_error":false,"stop_reason":"end_turn","resu let url = ws_url(&base, &format!("/v1/chats/{chat_id}/stream"), &token); let (mut ws, _) = connect_async(url).await.unwrap(); - ws.send(Message::Text(serde_json::json!({ "type": "user_message", "text": "hi" }).to_string())) - .await - .unwrap(); + ws.send(Message::Text( + serde_json::json!({ "type": "user_message", "text": "hi" }).to_string(), + )) + .await + .unwrap(); loop { let frame = recv_json(&mut ws).await; if frame["type"] == "turn_done" { @@ -345,7 +410,10 @@ async fn chat_create_rejects_traversal_account_slug() { let _home = HomeRestore::set(dir.path()); let cwd = project_cwd(dir.path()); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -373,7 +441,9 @@ async fn chat_create_rejects_nonexistent_account_slug() { let res = reqwest::Client::new() .post(format!("{base}/v1/chats")) .bearer_auth(&token) - .json(&serde_json::json!({ "agent": "claude", "cwd": cwd, "account_slug": "does-not-exist" })) + .json( + &serde_json::json!({ "agent": "claude", "cwd": cwd, "account_slug": "does-not-exist" }), + ) .send() .await .unwrap(); @@ -381,7 +451,10 @@ async fn chat_create_rejects_nonexistent_account_slug() { let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["error"], "account not found"); - assert!(state.chats.list().is_empty(), "no chat should be persisted when the account pin is invalid"); + assert!( + state.chats.list().is_empty(), + "no chat should be persisted when the account pin is invalid" + ); } #[tokio::test] @@ -420,7 +493,10 @@ async fn chat_create_rejects_account_kind_mismatch() { let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["error"], "account kind does not match agent"); - assert!(state.chats.list().is_empty(), "no chat should be persisted on a kind mismatch"); + assert!( + state.chats.list().is_empty(), + "no chat should be persisted on a kind mismatch" + ); } #[tokio::test] @@ -466,7 +542,10 @@ async fn apikey_route_pipes_key_to_fake_codex_without_leaking_it_in_response() { std::env::set_var("CAPTURE_FILE", &capture); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let client = reqwest::Client::new(); @@ -488,7 +567,10 @@ async fn apikey_route_pipes_key_to_fake_codex_without_leaking_it_in_response() { .unwrap(); assert_eq!(apikey_res.status(), 200); let body = apikey_res.text().await.unwrap(); - assert!(!body.contains("sk-super-secret-123"), "the api key must never appear in the response body"); + assert!( + !body.contains("sk-super-secret-123"), + "the api key must never appear in the response body" + ); let captured = std::fs::read_to_string(&capture).unwrap(); assert_eq!(captured.trim(), "sk-super-secret-123"); @@ -502,7 +584,10 @@ async fn apikey_route_rejects_a_claude_account() { let _g = LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let client = reqwest::Client::new(); @@ -534,7 +619,10 @@ async fn login_ws_needs_box_when_no_url_emitted() { std::env::set_var("OMEGA_CLAUDE_BIN", &bin); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let client = reqwest::Client::new(); @@ -582,7 +670,10 @@ fi std::env::set_var("OMEGA_CLAUDE_BIN", &bin); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let client = reqwest::Client::new(); @@ -601,7 +692,11 @@ fi // The pidfile is written by the script BEFORE it emits the URL line we // just received, so it is guaranteed present by now. - let pid: i32 = std::fs::read_to_string(&pidfile).unwrap().trim().parse().unwrap(); + let pid: i32 = std::fs::read_to_string(&pidfile) + .unwrap() + .trim() + .parse() + .unwrap(); assert!( std::path::Path::new(&format!("/proc/{pid}")).exists(), "the fake login process should be alive right after login_url" @@ -620,7 +715,10 @@ fi } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } - assert!(reaped, "the OAuth child (pid {pid}) must be reaped after the client disconnects"); + assert!( + reaped, + "the OAuth child (pid {pid}) must be reaped after the client disconnects" + ); std::env::remove_var("OMEGA_CLAUDE_BIN"); } diff --git a/crates/omega-gateway/tests/agents_install_adversarial_test.rs b/crates/omega-gateway/tests/agents_install_adversarial_test.rs index e6e01e0e..330f2e84 100644 --- a/crates/omega-gateway/tests/agents_install_adversarial_test.rs +++ b/crates/omega-gateway/tests/agents_install_adversarial_test.rs @@ -85,7 +85,10 @@ exit 1 "#, ); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/agents/codex/install/stream", &token); @@ -118,7 +121,10 @@ exit 1 }) .await; - assert!(drain.is_ok(), "dual-stream drain did not complete within 30s — deadlock"); + assert!( + drain.is_ok(), + "dual-stream drain did not complete within 30s — deadlock" + ); let exit_frame = exit_frame.expect("exit frame must have been received"); assert_eq!(exit_frame["success"], true); assert_eq!(exit_frame["code"], 0); @@ -128,8 +134,14 @@ exit 1 stdout_lines.sort_unstable(); stderr_lines.sort_unstable(); let expected: Vec = (1..=3000).collect(); - assert_eq!(stdout_lines, expected, "stdout sequence has a gap or a duplicate"); - assert_eq!(stderr_lines, expected, "stderr sequence has a gap or a duplicate"); + assert_eq!( + stdout_lines, expected, + "stdout sequence has a gap or a duplicate" + ); + assert_eq!( + stderr_lines, expected, + "stderr sequence has a gap or a duplicate" + ); std::env::remove_var("OMEGA_BIN"); } @@ -188,7 +200,10 @@ fi ), ); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/agents/codex/install/stream", &token); @@ -203,7 +218,10 @@ fi ws.close(None).await.unwrap(); drop(ws); - assert!(!marker.exists(), "marker must not exist yet — installer hasn't reached it"); + assert!( + !marker.exists(), + "marker must not exist yet — installer hasn't reached it" + ); // Give the server up to 8s — comfortably past the nested installer's 5s // silent sleep — to (a) notice the clean close via the socket-read @@ -262,7 +280,10 @@ fi ); std::env::set_var("OMEGA_STREAM_TIMEOUT_SECS", "1"); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/agents/codex/install/stream", &token); @@ -367,7 +388,10 @@ fi ), ); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/agents/codex/install/stream", &token); @@ -381,7 +405,10 @@ fi assert_eq!(v["text"], "starting"); drop(ws); // client-side disconnect - assert!(!marker.exists(), "marker must not exist yet — installer hasn't reached it"); + assert!( + !marker.exists(), + "marker must not exist yet — installer hasn't reached it" + ); // Give the server up to 8s to (a) notice the disconnect on its next // send attempt (triggered by "installer progress 1" at ~1s) and (b) diff --git a/crates/omega-gateway/tests/agents_install_test.rs b/crates/omega-gateway/tests/agents_install_test.rs index 2b558db2..e812cd11 100644 --- a/crates/omega-gateway/tests/agents_install_test.rs +++ b/crates/omega-gateway/tests/agents_install_test.rs @@ -49,7 +49,11 @@ fn install_fake_omega(dir: &std::path::Path, script_body: &str) { fn install_fake_omega_that_must_not_run(dir: &std::path::Path) { use std::os::unix::fs::PermissionsExt; let path = dir.join("omega"); - std::fs::write(&path, "#!/usr/bin/env bash\necho 'SHOULD NEVER RUN' >&2\nexit 1\n").unwrap(); + std::fs::write( + &path, + "#!/usr/bin/env bash\necho 'SHOULD NEVER RUN' >&2\nexit 1\n", + ) + .unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); std::env::set_var("OMEGA_BIN", &path); } @@ -62,7 +66,10 @@ async fn install_check_rejects_unknown_agent_name() { let dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(dir.path()); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -82,7 +89,10 @@ async fn install_check_rejects_shell() { let dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(dir.path()); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -94,7 +104,10 @@ async fn install_check_rejects_shell() { assert_eq!(res.status(), 400); let body: serde_json::Value = res.json().await.unwrap(); let msg = body["error"].as_str().unwrap(); - assert!(msg.to_lowercase().contains("shell"), "message should name shell: {msg}"); + assert!( + msg.to_lowercase().contains("shell"), + "message should name shell: {msg}" + ); std::env::remove_var("OMEGA_BIN"); } @@ -107,7 +120,10 @@ async fn install_check_accepts_a_real_installable_agent() { // pointed at the must-not-run script too, to prove it. install_fake_omega_that_must_not_run(dir.path()); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -133,7 +149,10 @@ async fn install_check_is_case_insensitive() { let dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(dir.path()); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -152,7 +171,10 @@ async fn install_check_is_case_insensitive() { #[tokio::test] async fn install_check_requires_auth() { let dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -171,7 +193,10 @@ async fn install_stream_rejects_unknown_agent_before_any_spawn() { let dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(dir.path()); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; // A REAL WS handshake attempt (valid auth token, real Upgrade headers @@ -195,7 +220,10 @@ async fn install_stream_rejects_a_non_installable_agent_before_any_spawn() { let dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(dir.path()); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/agents/shell/install/stream", &token); @@ -223,7 +251,10 @@ exit 1 "#, ); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/agents/codex/install/stream", &token); @@ -239,7 +270,11 @@ exit 1 lines.push(v); }; - assert_eq!(lines.len(), 4, "3 stdout + 1 stderr line expected, got {lines:?}"); + assert_eq!( + lines.len(), + 4, + "3 stdout + 1 stderr line expected, got {lines:?}" + ); let texts: Vec<&str> = lines.iter().map(|l| l["text"].as_str().unwrap()).collect(); assert!(texts.contains(&"line one")); assert!(texts.contains(&"line two")); @@ -270,7 +305,10 @@ exit 7 "#, ); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/agents/codex/install/stream", &token); @@ -300,7 +338,10 @@ async fn install_stream_passes_agent_name_as_argv() { &format!("printf '%s\\n' \"$@\" > '{}'\nexit 0", capture.display()), ); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/agents/codex/install/stream", &token); diff --git a/crates/omega-gateway/tests/agents_test.rs b/crates/omega-gateway/tests/agents_test.rs index be449189..13451ebf 100644 --- a/crates/omega-gateway/tests/agents_test.rs +++ b/crates/omega-gateway/tests/agents_test.rs @@ -10,10 +10,13 @@ async fn spawn(app: axum::Router) -> String { } #[tokio::test] -async fn get_agents_returns_the_fixed_eight_agent_roster() { +async fn get_agents_returns_the_canonical_agent_roster() { let gateway_dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -26,19 +29,38 @@ async fn get_agents_returns_the_fixed_eight_agent_roster() { let body: serde_json::Value = res.json().await.unwrap(); let agents = body["agents"].as_array().unwrap(); - assert_eq!(agents.len(), 8, "Agent::all() is a fixed 8-element slice"); + assert_eq!( + agents.len(), + omega_core::agents::Agent::all().len(), + "gateway roster must mirror Agent::all()" + ); - let claude = agents.iter().find(|a| a["name"] == "claude").expect("claude must be present"); + let claude = agents + .iter() + .find(|a| a["name"] == "claude") + .expect("claude must be present"); assert!(claude["display_name"].is_string()); - assert!(claude["available"].is_boolean(), "available must be a boolean (value is PATH-dependent, not asserted)"); + assert!( + claude["available"].is_boolean(), + "available must be a boolean (value is PATH-dependent, not asserted)" + ); + assert!(agents.iter().any(|agent| agent["name"] == "antigravity")); + assert!(agents.iter().any(|agent| agent["name"] == "openrouter")); } #[tokio::test] async fn get_agents_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/agents")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/agents")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } diff --git a/crates/omega-gateway/tests/audit_test.rs b/crates/omega-gateway/tests/audit_test.rs index 780a169a..a424ec77 100644 --- a/crates/omega-gateway/tests/audit_test.rs +++ b/crates/omega-gateway/tests/audit_test.rs @@ -59,7 +59,11 @@ fn install_fake_omega(dir: &std::path::Path, script_body: &str) { fn install_fake_omega_that_must_not_run(dir: &std::path::Path) { use std::os::unix::fs::PermissionsExt; let path = dir.join("omega"); - std::fs::write(&path, "#!/usr/bin/env bash\necho 'SHOULD NEVER RUN' >&2\nexit 1\n").unwrap(); + std::fs::write( + &path, + "#!/usr/bin/env bash\necho 'SHOULD NEVER RUN' >&2\nexit 1\n", + ) + .unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); std::env::set_var("OMEGA_BIN", &path); } @@ -76,17 +80,30 @@ async fn list_returns_the_full_catalog_including_codeaudit() { let _g = LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/audits")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/audits")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); let audits = body["audits"].as_array().unwrap(); - assert_eq!(audits.len(), 23, "expected the full 23-audit Quality Arsenal catalog"); + assert_eq!( + audits.len(), + 23, + "expected the full 23-audit Quality Arsenal catalog" + ); assert!( - audits.iter().any(|a| a["id"] == "codeaudit" && a["domain"] == "Code"), + audits + .iter() + .any(|a| a["id"] == "codeaudit" && a["domain"] == "Code"), "codeaudit entry missing or malformed: {audits:?}" ); } @@ -94,10 +111,17 @@ async fn list_returns_the_full_catalog_including_codeaudit() { #[tokio::test] async fn list_requires_auth() { let dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/audits")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/audits")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } @@ -112,7 +136,10 @@ async fn check_rejects_unknown_kind_and_never_spawns() { install_fake_home(home_dir.path(), "TestProj"); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -140,7 +167,10 @@ async fn check_rejects_unknown_project_and_never_spawns() { install_fake_home(home_dir.path(), "TestProj"); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -152,7 +182,10 @@ async fn check_rejects_unknown_project_and_never_spawns() { .unwrap(); assert_eq!(res.status(), 400); let body: serde_json::Value = res.json().await.unwrap(); - assert!(body["error"].as_str().unwrap().contains("definitely-not-a-real-project")); + assert!(body["error"] + .as_str() + .unwrap() + .contains("definitely-not-a-real-project")); clear_env(); } @@ -168,7 +201,10 @@ async fn check_accepts_a_real_audit_and_project_and_spawns_nothing() { // at the must-not-run script too, to prove it. install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -199,7 +235,10 @@ async fn stream_rejects_unknown_kind_before_any_spawn() { install_fake_home(home_dir.path(), "TestProj"); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; // A REAL WS handshake attempt (valid auth token, real Upgrade headers via @@ -222,7 +261,10 @@ async fn stream_rejects_unknown_project_before_any_spawn() { install_fake_home(home_dir.path(), "TestProj"); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/audit/stream", &token) + "&project=nope-not-real&kind=codeaudit"; @@ -257,7 +299,10 @@ exit 1 ), ); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/audit/stream", &token) + "&project=TestProj&kind=codeaudit"; @@ -273,9 +318,15 @@ exit 1 lines.push(v); }; - assert_eq!(lines.len(), 3, "2 stdout + 1 stderr line expected, got {lines:?}"); + assert_eq!( + lines.len(), + 3, + "2 stdout + 1 stderr line expected, got {lines:?}" + ); let texts: Vec<&str> = lines.iter().map(|l| l["text"].as_str().unwrap()).collect(); - assert!(texts.iter().any(|t| t.contains("Audit: Code Architecture Audit"))); + assert!(texts + .iter() + .any(|t| t.contains("Audit: Code Architecture Audit"))); assert!(texts.iter().any(|t| t.contains("Phases: 23"))); assert!(texts.iter().any(|t| t.contains("Skill:"))); let stderr_count = lines.iter().filter(|l| l["stream"] == "stderr").count(); @@ -307,7 +358,10 @@ async fn stream_nonzero_exit_reports_failure() { install_fake_home(home_dir.path(), "TestProj"); install_fake_omega(bin_dir.path(), "echo 'trying...'; echo 'boom' >&2; exit 7"); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/audit/stream", &token) + "&project=TestProj&kind=codeaudit"; @@ -362,7 +416,10 @@ fi ), ); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/audit/stream", &token) + "&project=TestProj&kind=codeaudit"; @@ -376,7 +433,10 @@ fi ws.close(None).await.unwrap(); drop(ws); - assert!(!marker.exists(), "marker must not exist yet — the nested child hasn't reached it"); + assert!( + !marker.exists(), + "marker must not exist yet — the nested child hasn't reached it" + ); // Give the server up to 8s — comfortably past the nested child's 5s // silent sleep — to notice the clean close via the socket-read branch diff --git a/crates/omega-gateway/tests/auth_middleware_test.rs b/crates/omega-gateway/tests/auth_middleware_test.rs index f6c52d78..ec288b29 100644 --- a/crates/omega-gateway/tests/auth_middleware_test.rs +++ b/crates/omega-gateway/tests/auth_middleware_test.rs @@ -13,23 +13,63 @@ async fn spawn(app: axum::Router) -> String { async fn whoami_requires_valid_token() { let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("iphone"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let client = reqwest::Client::new(); // no token → 401 - assert_eq!(client.get(format!("{base}/v1/whoami")).send().await.unwrap().status(), 401); + assert_eq!( + client + .get(format!("{base}/v1/whoami")) + .send() + .await + .unwrap() + .status(), + 401 + ); // bad token → 401 - assert_eq!(client.get(format!("{base}/v1/whoami")) - .bearer_auth("bad").send().await.unwrap().status(), 401); + assert_eq!( + client + .get(format!("{base}/v1/whoami")) + .bearer_auth("bad") + .send() + .await + .unwrap() + .status(), + 401 + ); // good token via header → 200 with device name - let res = client.get(format!("{base}/v1/whoami")).bearer_auth(&token).send().await.unwrap(); + let res = client + .get(format!("{base}/v1/whoami")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["name"], "iphone"); assert!(!body["device_id"].as_str().unwrap().is_empty()); // good token via query param → 200 - assert_eq!(client.get(format!("{base}/v1/whoami?token={token}")).send().await.unwrap().status(), 200); + assert_eq!( + client + .get(format!("{base}/v1/whoami?token={token}")) + .send() + .await + .unwrap() + .status(), + 200 + ); // health stays public - assert_eq!(client.get(format!("{base}/v1/health")).send().await.unwrap().status(), 200); + assert_eq!( + client + .get(format!("{base}/v1/health")) + .send() + .await + .unwrap() + .status(), + 200 + ); } diff --git a/crates/omega-gateway/tests/box_test.rs b/crates/omega-gateway/tests/box_test.rs index 78c55834..1dc0ea3f 100644 --- a/crates/omega-gateway/tests/box_test.rs +++ b/crates/omega-gateway/tests/box_test.rs @@ -55,19 +55,32 @@ async fn doctor_parses_mixed_checks_without_fixed_width_assumption() { let _g = LOCK.lock().await; let gateway_dir = tempfile::tempdir().unwrap(); let bin_dir = tempfile::tempdir().unwrap(); - install_fake_omega(bin_dir.path(), &format!("cat <<'EOF'\n{DOCTOR_STDOUT_MIXED}EOF\nexit 1")); + install_fake_omega( + bin_dir.path(), + &format!("cat <<'EOF'\n{DOCTOR_STDOUT_MIXED}EOF\nexit 1"), + ); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/doctor")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/doctor")) + .bearer_auth(&token) + .send() + .await + .unwrap(); // omega doctor exits 1 on overall Fail -- that is NOT treated as a spawn // error, so the endpoint still answers 200 with the parsed body. assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); - assert_eq!(body["overall"], "fail", "aggregated from the parsed checks, not the trailing summary line"); + assert_eq!( + body["overall"], "fail", + "aggregated from the parsed checks, not the trailing summary line" + ); let checks = body["checks"].as_array().unwrap(); assert_eq!(checks.len(), 4); @@ -78,7 +91,10 @@ async fn doctor_parses_mixed_checks_without_fixed_width_assumption() { // space between them since the name already exceeds the {:16} minimum) // must survive intact -- a fixed-width split would have cut this wrong. assert_eq!(checks[1]["health"], "ok"); - assert_eq!(checks[1]["text"], "binary provenance built from HEAD (abc1234)"); + assert_eq!( + checks[1]["text"], + "binary provenance built from HEAD (abc1234)" + ); assert_eq!(checks[2]["health"], "warn"); assert_eq!(checks[2]["text"], "telegram poller stale (12m)"); @@ -99,11 +115,18 @@ async fn doctor_all_ok_reports_overall_ok() { "cat <<'EOF'\nOmegaOS doctor\n\n [+] daemon running\n\n[+] all systems healthy\nEOF\nexit 0", ); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/doctor")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/doctor")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["overall"], "ok"); @@ -122,11 +145,18 @@ async fn doctor_warn_only_reports_overall_warn() { "cat <<'EOF'\nOmegaOS doctor\n\n [+] daemon running\n [!] telegram stale\n\n[!] healthy, with warnings above\nEOF\nexit 0", ); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/doctor")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/doctor")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["overall"], "warn"); @@ -146,12 +176,23 @@ async fn doctor_exit_code_1_on_fail_is_not_treated_as_an_error() { "cat <<'EOF'\nOmegaOS doctor\n\n [x] disk full\n\n[x] problems detected — see [x] lines above\nEOF\nexit 1", ); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/doctor")).bearer_auth(&token).send().await.unwrap(); - assert_eq!(res.status(), 200, "a non-zero exit code alone must never become a 502"); + let res = reqwest::Client::new() + .get(format!("{base}/v1/doctor")) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + 200, + "a non-zero exit code alone must never become a 502" + ); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["overall"], "fail"); @@ -161,10 +202,17 @@ async fn doctor_exit_code_1_on_fail_is_not_treated_as_an_error() { #[tokio::test] async fn doctor_requires_auth() { let dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/doctor")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/doctor")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } @@ -194,11 +242,18 @@ async fn usage_reports_available_with_a_real_snapshot() { std::env::set_var("HOME", fake_home.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/usage")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/usage")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["available"], true); @@ -217,12 +272,23 @@ async fn usage_reports_unavailable_when_cache_file_absent() { std::env::set_var("HOME", fake_home.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/usage")).bearer_auth(&token).send().await.unwrap(); - assert_eq!(res.status(), 200, "no cache yet is a normal state, never an error"); + let res = reqwest::Client::new() + .get(format!("{base}/v1/usage")) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + 200, + "no cache yet is a normal state, never an error" + ); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["available"], false); assert!(body.get("session_pct").is_none() || body["session_pct"].is_null()); @@ -233,10 +299,17 @@ async fn usage_reports_unavailable_when_cache_file_absent() { #[tokio::test] async fn usage_requires_auth() { let dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/usage")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/usage")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } @@ -249,7 +322,10 @@ async fn box_info_reports_hostname_version_and_small_nonnegative_uptime() { let bin_dir = tempfile::tempdir().unwrap(); install_fake_omega(bin_dir.path(), "echo 'omega 0.1.9'"); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -266,7 +342,10 @@ async fn box_info_reports_hostname_version_and_small_nonnegative_uptime() { // The request runs shortly after AppState::new() -- uptime must be a // small, non-negative number of seconds. let uptime = body["uptime_secs"].as_u64().unwrap(); - assert!(uptime < 30, "uptime {uptime}s is implausibly large for a fresh test process"); + assert!( + uptime < 30, + "uptime {uptime}s is implausibly large for a fresh test process" + ); clear_env(); } @@ -274,10 +353,17 @@ async fn box_info_reports_hostname_version_and_small_nonnegative_uptime() { #[tokio::test] async fn box_info_requires_auth() { let dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/box-info")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/box-info")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } @@ -309,7 +395,10 @@ exit 0 std::env::set_var("OMEGA_BACKUP_DIR", backup_scratch.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -346,7 +435,10 @@ async fn backup_nonzero_exit_is_a_502() { std::env::set_var("OMEGA_BACKUP_DIR", backup_scratch.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -355,7 +447,11 @@ async fn backup_nonzero_exit_is_a_502() { .send() .await .unwrap(); - assert_eq!(res.status(), 502, "a failed backup is a real error, unlike doctor's expected non-zero exit"); + assert_eq!( + res.status(), + 502, + "a failed backup is a real error, unlike doctor's expected non-zero exit" + ); clear_env(); } @@ -363,10 +459,17 @@ async fn backup_nonzero_exit_is_a_502() { #[tokio::test] async fn backup_requires_auth() { let dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().post(format!("{base}/v1/backup")).send().await.unwrap(); + let res = reqwest::Client::new() + .post(format!("{base}/v1/backup")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } @@ -375,10 +478,17 @@ async fn backup_requires_auth() { #[tokio::test] async fn box_id_requires_auth() { let dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/box-id")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/box-id")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } @@ -386,21 +496,36 @@ async fn box_id_requires_auth() { async fn box_id_is_32_hex_chars_and_stable_across_repeated_calls() { let gateway_dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res1 = - reqwest::Client::new().get(format!("{base}/v1/box-id")).bearer_auth(&token).send().await.unwrap(); + let res1 = reqwest::Client::new() + .get(format!("{base}/v1/box-id")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res1.status(), 200); let body1: serde_json::Value = res1.json().await.unwrap(); let id1 = body1["box_id"].as_str().unwrap().to_string(); assert_eq!(id1.len(), 32, "box_id must be 32 hex chars"); assert!(id1.chars().all(|c| c.is_ascii_hexdigit())); - let res2 = - reqwest::Client::new().get(format!("{base}/v1/box-id")).bearer_auth(&token).send().await.unwrap(); + let res2 = reqwest::Client::new() + .get(format!("{base}/v1/box-id")) + .bearer_auth(&token) + .send() + .await + .unwrap(); let body2: serde_json::Value = res2.json().await.unwrap(); - assert_eq!(body2["box_id"].as_str().unwrap(), id1, "repeated calls must return the SAME id"); + assert_eq!( + body2["box_id"].as_str().unwrap(), + id1, + "repeated calls must return the SAME id" + ); } #[tokio::test] @@ -411,19 +536,37 @@ async fn box_id_persists_across_a_fresh_appstate_pointed_at_the_same_dir() { let gateway_dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app1 = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app1 = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base1 = spawn(app1).await; - let res1 = - reqwest::Client::new().get(format!("{base1}/v1/box-id")).bearer_auth(&token).send().await.unwrap(); + let res1 = reqwest::Client::new() + .get(format!("{base1}/v1/box-id")) + .bearer_auth(&token) + .send() + .await + .unwrap(); let body1: serde_json::Value = res1.json().await.unwrap(); let id1 = body1["box_id"].as_str().unwrap().to_string(); - let app2 = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app2 = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base2 = spawn(app2).await; - let res2 = - reqwest::Client::new().get(format!("{base2}/v1/box-id")).bearer_auth(&token).send().await.unwrap(); + let res2 = reqwest::Client::new() + .get(format!("{base2}/v1/box-id")) + .bearer_auth(&token) + .send() + .await + .unwrap(); let body2: serde_json::Value = res2.json().await.unwrap(); - assert_eq!(body2["box_id"].as_str().unwrap(), id1, "id must survive a restart against the same dir"); + assert_eq!( + body2["box_id"].as_str().unwrap(), + id1, + "id must survive a restart against the same dir" + ); } /// Adversarial: many requests fire at a gateway whose `box_id.txt` does not @@ -434,7 +577,10 @@ async fn box_id_persists_across_a_fresh_appstate_pointed_at_the_same_dir() { async fn box_id_concurrent_first_calls_converge_on_one_id() { let gateway_dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let mut handles = Vec::new(); @@ -456,7 +602,11 @@ async fn box_id_concurrent_first_calls_converge_on_one_id() { for h in handles { ids.insert(h.await.unwrap()); } - assert_eq!(ids.len(), 1, "every concurrent first-call must converge on exactly one id, got {ids:?}"); + assert_eq!( + ids.len(), + 1, + "every concurrent first-call must converge on exactly one id, got {ids:?}" + ); } #[cfg(unix)] @@ -465,11 +615,18 @@ async fn box_id_file_is_0600() { use std::os::unix::fs::PermissionsExt; let gateway_dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/box-id")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/box-id")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let mode = std::fs::metadata(gateway_dir.path().join("box_id.txt")) @@ -487,11 +644,18 @@ async fn box_id_regenerates_when_file_is_empty() { let gateway_dir = tempfile::tempdir().unwrap(); std::fs::write(gateway_dir.path().join("box_id.txt"), "").unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/box-id")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/box-id")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["box_id"].as_str().unwrap().len(), 32); @@ -507,17 +671,27 @@ async fn box_id_regenerates_when_file_is_malformed_but_nonempty() { // Too short, and contains a non-hex char ('z'). std::fs::write(gateway_dir.path().join("box_id.txt"), "not-a-real-boxid-z").unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/box-id")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/box-id")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); let id = body["box_id"].as_str().unwrap(); assert_eq!(id.len(), 32); assert!(id.chars().all(|c| c.is_ascii_hexdigit())); - assert_ne!(id, "not-a-real-boxid-z", "the malformed content must never be echoed back as-is"); + assert_ne!( + id, "not-a-real-boxid-z", + "the malformed content must never be echoed back as-is" + ); } /// Adversarial (review-fix regression test): the SAME 16-way concurrent @@ -532,7 +706,10 @@ async fn box_id_concurrent_calls_against_a_preexisting_empty_file_converge_on_on let gateway_dir = tempfile::tempdir().unwrap(); std::fs::write(gateway_dir.path().join("box_id.txt"), "").unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let mut handles = Vec::new(); @@ -568,19 +745,33 @@ async fn box_id_concurrent_calls_against_a_preexisting_empty_file_converge_on_on async fn box_id_creation_leaves_no_stray_tmp_files() { let gateway_dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/box-id")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/box-id")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let leftovers: Vec<_> = std::fs::read_dir(gateway_dir.path()) .unwrap() .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().starts_with("box_id.txt.tmp-")) + .filter(|e| { + e.file_name() + .to_string_lossy() + .starts_with("box_id.txt.tmp-") + }) .collect(); - assert!(leftovers.is_empty(), "no temp file should survive a successful creation: {leftovers:?}"); + assert!( + leftovers.is_empty(), + "no temp file should survive a successful creation: {leftovers:?}" + ); } /// Adversarial: `omega doctor` stdout with a check line whose glyph slot @@ -602,18 +793,37 @@ async fn doctor_survives_multibyte_glyph_in_check_line() { "cat <<'EOF2'\nOmegaOS doctor\n\n [\u{20ac}] weird glyph line\n\n[+] ok\nEOF2\nexit 0", ); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/doctor")).bearer_auth(&token).send().await.unwrap(); - assert_eq!(res.status(), 200, "a malformed check line must never crash the connection"); + let res = reqwest::Client::new() + .get(format!("{base}/v1/doctor")) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + 200, + "a malformed check line must never crash the connection" + ); let body: serde_json::Value = res.json().await.unwrap(); - assert_eq!(body["checks"].as_array().unwrap().len(), 0, "the unrecognized-glyph line is skipped"); + assert_eq!( + body["checks"].as_array().unwrap().len(), + 0, + "the unrecognized-glyph line is skipped" + ); // The server process itself must still be alive for a second request. - let res2 = - reqwest::Client::new().get(format!("{base}/v1/doctor")).bearer_auth(&token).send().await.unwrap(); + let res2 = reqwest::Client::new() + .get(format!("{base}/v1/doctor")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res2.status(), 200); clear_env(); diff --git a/crates/omega-gateway/tests/chat_concurrent_turns_test.rs b/crates/omega-gateway/tests/chat_concurrent_turns_test.rs index a2c2122a..d22aabb2 100644 --- a/crates/omega-gateway/tests/chat_concurrent_turns_test.rs +++ b/crates/omega-gateway/tests/chat_concurrent_turns_test.rs @@ -102,7 +102,10 @@ async fn second_ws_turn_on_same_chat_is_rejected_while_first_is_active() { // second connection against it. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); while !started_file.exists() { - assert!(std::time::Instant::now() < deadline, "first turn's agent never started"); + assert!( + std::time::Instant::now() < deadline, + "first turn's agent never started" + ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; } @@ -116,7 +119,10 @@ async fn second_ws_turn_on_same_chat_is_rejected_while_first_is_active() { match frame["type"].as_str().unwrap() { "error" => { assert!( - frame["message"].as_str().unwrap().contains("already active"), + frame["message"] + .as_str() + .unwrap() + .contains("already active"), "unexpected error message on ws2: {frame}" ); saw_busy_error = true; @@ -125,7 +131,10 @@ async fn second_ws_turn_on_same_chat_is_rejected_while_first_is_active() { other => panic!("unexpected frame type on ws2: {other}"), } } - assert!(saw_busy_error, "a second concurrent turn on the same chat must be rejected"); + assert!( + saw_busy_error, + "a second concurrent turn on the same chat must be rejected" + ); // The rejected attempt must never have spawned a real turn: no // "assistant" role message can exist yet (the only agent process that @@ -160,7 +169,10 @@ async fn second_ws_turn_on_same_chat_is_rejected_while_first_is_active() { _ => {} } } - assert!(!saw_error_on_third, "the per-chat guard must release once the prior turn has ended"); + assert!( + !saw_error_on_third, + "the per-chat guard must release once the prior turn has ended" + ); std::env::remove_var("OMEGA_CHAT_BIN"); } diff --git a/crates/omega-gateway/tests/chat_cwd_validation_test.rs b/crates/omega-gateway/tests/chat_cwd_validation_test.rs index 48c76737..55a38827 100644 --- a/crates/omega-gateway/tests/chat_cwd_validation_test.rs +++ b/crates/omega-gateway/tests/chat_cwd_validation_test.rs @@ -50,7 +50,10 @@ async fn create_with_cwd_outside_home_is_rejected_with_400() { let _home = HomeRestore::set(fake_home.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; // /etc is a real, existing absolute path outside the fake home — the @@ -62,7 +65,11 @@ async fn create_with_cwd_outside_home_is_rejected_with_400() { .send() .await .unwrap(); - assert_eq!(res.status(), 400, "cwd outside home must be rejected before the chat is ever created"); + assert_eq!( + res.status(), + 400, + "cwd outside home must be rejected before the chat is ever created" + ); let body: serde_json::Value = res.json().await.unwrap(); assert!( body["error"].as_str().unwrap().contains("home"), @@ -71,7 +78,10 @@ async fn create_with_cwd_outside_home_is_rejected_with_400() { // Confirm no chat was actually persisted for the rejected request. let state = AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default()); - assert!(state.chats.list().is_empty(), "a rejected create must not persist any chat"); + assert!( + state.chats.list().is_empty(), + "a rejected create must not persist any chat" + ); } #[tokio::test] @@ -82,7 +92,10 @@ async fn create_with_cwd_containing_dotdot_is_rejected_with_400() { let _home = HomeRestore::set(fake_home.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let escaping = format!("{}/../../../etc", fake_home.path().display()); @@ -106,7 +119,10 @@ async fn create_with_cwd_under_home_succeeds_and_stores_the_validated_path() { let _home = HomeRestore::set(fake_home.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let cwd_str = proj.display().to_string(); @@ -117,7 +133,14 @@ async fn create_with_cwd_under_home_succeeds_and_stores_the_validated_path() { .send() .await .unwrap(); - assert_eq!(res.status(), 201, "a real subdirectory of $HOME must be accepted"); + assert_eq!( + res.status(), + 201, + "a real subdirectory of $HOME must be accepted" + ); let meta: serde_json::Value = res.json().await.unwrap(); - assert_eq!(meta["cwd"], cwd_str, "the stored cwd must be dir_under_home's validated (original) path"); + assert_eq!( + meta["cwd"], cwd_str, + "the stored cwd must be dir_under_home's validated (original) path" + ); } diff --git a/crates/omega-gateway/tests/chat_driver_nested_kill_test.rs b/crates/omega-gateway/tests/chat_driver_nested_kill_test.rs index 1a620312..4b8bb62c 100644 --- a/crates/omega-gateway/tests/chat_driver_nested_kill_test.rs +++ b/crates/omega-gateway/tests/chat_driver_nested_kill_test.rs @@ -83,7 +83,10 @@ async fn timeout_kills_the_nested_grandchild_not_just_the_direct_child() { nested_pid = Some(p); } } - assert!(std::time::Instant::now() < deadline, "nested grandchild never started"); + assert!( + std::time::Instant::now() < deadline, + "nested grandchild never started" + ); tokio::time::sleep(Duration::from_millis(10)).await; } let nested_pid = nested_pid.expect("nested grandchild never started"); @@ -96,7 +99,10 @@ async fn timeout_kills_the_nested_grandchild_not_just_the_direct_child() { let before = std::fs::read_to_string(&marker_file).unwrap_or_default(); tokio::time::sleep(Duration::from_millis(150)).await; let grew = std::fs::read_to_string(&marker_file).unwrap_or_default(); - assert!(grew.len() > before.len(), "nested grandchild marker was not growing before the timeout"); + assert!( + grew.len() > before.len(), + "nested grandchild marker was not growing before the timeout" + ); // Let run_turn finish (its own 300ms timeout fires well before the // outer script's 120s sleep). diff --git a/crates/omega-gateway/tests/chat_driver_test.rs b/crates/omega-gateway/tests/chat_driver_test.rs index 4cc58115..7ba1f48c 100644 --- a/crates/omega-gateway/tests/chat_driver_test.rs +++ b/crates/omega-gateway/tests/chat_driver_test.rs @@ -56,7 +56,11 @@ printf '%s\n' '{"type":"result","is_error":false,"stop_reason":"end_turn","resul let session_id = driver.await.unwrap(); assert_eq!(session_id.as_deref(), Some("fake-session-1")); - assert_eq!(frames.len(), 2, "expected an AssistantMessage then a TurnDone"); + assert_eq!( + frames.len(), + 2, + "expected an AssistantMessage then a TurnDone" + ); assert!(matches!( &frames[0], ChatStreamServerMsg::AssistantMessage { text } if text == "PONG" @@ -130,7 +134,10 @@ async fn run_turn_kills_child_and_reports_timeout() { break pid; } } - assert!(std::time::Instant::now() < deadline, "fake agent never wrote its pidfile"); + assert!( + std::time::Instant::now() < deadline, + "fake agent never wrote its pidfile" + ); tokio::time::sleep(Duration::from_millis(10)).await; } }; @@ -178,18 +185,26 @@ async fn run_turn_kills_child_and_reports_timeout() { } #[tokio::test] -async fn run_turn_intercepts_codex_without_spawning() { +async fn run_turn_streams_codex_json_and_writes_prompt_to_stdin() { let _g = LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); - // Point OMEGA_CHAT_BIN at a script that would fail loudly (nonzero exit) - // if it were ever actually spawned, proving Codex never reaches it. - install_fake_agent(dir.path(), "echo 'should never run' >&2; exit 1"); + install_fake_agent( + dir.path(), + r#" +IFS= read -r prompt || true +test "$prompt" = "hi" || { echo "wrong stdin prompt" >&2; exit 2; } +printf '%s\n' '{"type":"thread.started","thread_id":"codex-thread-1"}' +printf '%s\n' '{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"CODEX PONG"}}' +printf '%s\n' '{"type":"turn.completed","usage":{}}' +"#, + ); let mut meta = test_meta(dir.path()); meta.agent = ChatAgent::Codex; let (tx, mut rx) = tokio::sync::mpsc::channel(16); - let driver = - tokio::spawn(async move { chat_driver::run_turn(&meta, "hi", None, None, Duration::from_secs(5), tx).await }); + let driver = tokio::spawn(async move { + chat_driver::run_turn(&meta, "hi", None, None, Duration::from_secs(5), tx).await + }); let mut frames = Vec::new(); while let Some(frame) = rx.recv().await { @@ -197,11 +212,11 @@ async fn run_turn_intercepts_codex_without_spawning() { } let session_id = driver.await.unwrap(); - assert!(session_id.is_none()); + assert_eq!(session_id.as_deref(), Some("codex-thread-1")); assert_eq!(frames.len(), 2); assert!(matches!( &frames[0], - ChatStreamServerMsg::Error { message } if message == "codex chat not yet supported" + ChatStreamServerMsg::AssistantMessage { text } if text == "CODEX PONG" )); assert!(matches!(&frames[1], ChatStreamServerMsg::TurnDone)); } diff --git a/crates/omega-gateway/tests/chat_messages_pagination_test.rs b/crates/omega-gateway/tests/chat_messages_pagination_test.rs index 6035feb9..13dc85bf 100644 --- a/crates/omega-gateway/tests/chat_messages_pagination_test.rs +++ b/crates/omega-gateway/tests/chat_messages_pagination_test.rs @@ -19,7 +19,11 @@ fn seed(state: &AppState, id: &str, count: usize) { for i in 0..count { state.chats.append_message( id, - &ChatMessage { role: "user".to_string(), text: format!("m{i}"), ts: format!("t{i}") }, + &ChatMessage { + role: "user".to_string(), + text: format!("m{i}"), + ts: format!("t{i}"), + }, ); } } @@ -29,7 +33,9 @@ async fn no_before_returns_the_newest_page() { let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); let state = AppState::new(dir.path().to_path_buf(), GatewayConfig::default()); - let meta = state.chats.create(ChatAgent::Claude, "/tmp".to_string(), None, None); + let meta = state + .chats + .create(ChatAgent::Claude, "/tmp".to_string(), None, None); seed(&state, &meta.id, 5); let base = spawn(build_router(state)).await; @@ -48,7 +54,10 @@ async fn no_before_returns_the_newest_page() { assert_eq!(messages[0]["text"], "m4", "newest first"); assert_eq!(messages[1]["text"], "m3"); assert_eq!(messages[2]["text"], "m2"); - assert!(res["next_cursor"].is_u64(), "2 older messages remain -> a cursor must be present"); + assert!( + res["next_cursor"].is_u64(), + "2 older messages remain -> a cursor must be present" + ); } #[tokio::test] @@ -56,7 +65,9 @@ async fn next_cursor_returns_the_next_older_page() { let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); let state = AppState::new(dir.path().to_path_buf(), GatewayConfig::default()); - let meta = state.chats.create(ChatAgent::Claude, "/tmp".to_string(), None, None); + let meta = state + .chats + .create(ChatAgent::Claude, "/tmp".to_string(), None, None); seed(&state, &meta.id, 5); let base = spawn(build_router(state)).await; let client = reqwest::Client::new(); @@ -73,7 +84,10 @@ async fn next_cursor_returns_the_next_older_page() { let cursor = page1["next_cursor"].as_u64().unwrap(); let page2: serde_json::Value = client - .get(format!("{base}/v1/chats/{}/messages?before={cursor}&limit=3", meta.id)) + .get(format!( + "{base}/v1/chats/{}/messages?before={cursor}&limit=3", + meta.id + )) .bearer_auth(&token) .send() .await @@ -83,7 +97,11 @@ async fn next_cursor_returns_the_next_older_page() { .unwrap(); let messages = page2["messages"].as_array().unwrap(); - assert_eq!(messages.len(), 2, "exactly the 2 remaining older messages, no gap, no dupe"); + assert_eq!( + messages.len(), + 2, + "exactly the 2 remaining older messages, no gap, no dupe" + ); assert_eq!(messages[0]["text"], "m1"); assert_eq!(messages[1]["text"], "m0"); assert!(page2["next_cursor"].is_null()); @@ -94,7 +112,9 @@ async fn fewer_messages_than_limit_returns_everything_with_null_cursor() { let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); let state = AppState::new(dir.path().to_path_buf(), GatewayConfig::default()); - let meta = state.chats.create(ChatAgent::Claude, "/tmp".to_string(), None, None); + let meta = state + .chats + .create(ChatAgent::Claude, "/tmp".to_string(), None, None); seed(&state, &meta.id, 1); let base = spawn(build_router(state)).await; @@ -119,7 +139,9 @@ async fn missing_limit_defaults_to_a_sane_page_size() { let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); let state = AppState::new(dir.path().to_path_buf(), GatewayConfig::default()); - let meta = state.chats.create(ChatAgent::Claude, "/tmp".to_string(), None, None); + let meta = state + .chats + .create(ChatAgent::Claude, "/tmp".to_string(), None, None); seed(&state, &meta.id, 3); let base = spawn(build_router(state)).await; @@ -134,7 +156,11 @@ async fn missing_limit_defaults_to_a_sane_page_size() { .unwrap(); let messages = res["messages"].as_array().unwrap(); - assert_eq!(messages.len(), 3, "3 messages all fit under the default limit"); + assert_eq!( + messages.len(), + 3, + "3 messages all fit under the default limit" + ); assert!(res["next_cursor"].is_null()); } @@ -142,7 +168,11 @@ async fn missing_limit_defaults_to_a_sane_page_size() { async fn unknown_chat_id_is_404() { let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let base = spawn(build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default()))).await; + let base = spawn(build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + ))) + .await; let res = reqwest::Client::new() .get(format!("{base}/v1/chats/0123456789abcdef/messages")) @@ -157,7 +187,11 @@ async fn unknown_chat_id_is_404() { async fn invalid_chat_id_shape_is_404_not_500() { let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let base = spawn(build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default()))).await; + let base = spawn(build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + ))) + .await; // "%2e%2e" decodes to "..", a path-traversal-shaped single path segment // that must never reach ChatStore's filesystem joins. @@ -173,7 +207,11 @@ async fn invalid_chat_id_shape_is_404_not_500() { #[tokio::test] async fn no_auth_token_is_401() { let dir = tempfile::tempdir().unwrap(); - let base = spawn(build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default()))).await; + let base = spawn(build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + ))) + .await; let res = reqwest::Client::new() .get(format!("{base}/v1/chats/0123456789abcdef/messages")) diff --git a/crates/omega-gateway/tests/chat_routes_test.rs b/crates/omega-gateway/tests/chat_routes_test.rs index b8bf27f4..cae4d25f 100644 --- a/crates/omega-gateway/tests/chat_routes_test.rs +++ b/crates/omega-gateway/tests/chat_routes_test.rs @@ -57,7 +57,10 @@ fn install_fake_agent(bin_dir: &std::path::Path, argv_file: &std::path::Path, sc let path = bin_dir.join("fake-agent"); std::fs::write( &path, - format!("#!/usr/bin/env bash\necho \"$@\" >> {}\n{script_body}\n", argv_file.display()), + format!( + "#!/usr/bin/env bash\necho \"$@\" >> {}\n{script_body}\n", + argv_file.display() + ), ) .unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); @@ -97,7 +100,10 @@ printf '%s\n' '{"type":"result","is_error":false,"stop_reason":"end_turn","resul ); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let client = reqwest::Client::new(); @@ -115,10 +121,21 @@ printf '%s\n' '{"type":"result","is_error":false,"stop_reason":"end_turn","resul assert_eq!(meta["agent"], "claude"); // GET /v1/chats lists it - let list_res: serde_json::Value = - client.get(format!("{base}/v1/chats")).bearer_auth(&token).send().await.unwrap().json().await.unwrap(); - let ids: Vec<&str> = - list_res["chats"].as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap()).collect(); + let list_res: serde_json::Value = client + .get(format!("{base}/v1/chats")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let ids: Vec<&str> = list_res["chats"] + .as_array() + .unwrap() + .iter() + .map(|c| c["id"].as_str().unwrap()) + .collect(); assert!(ids.contains(&chat_id.as_str())); // Open the chat WS and run the first turn. @@ -142,12 +159,22 @@ printf '%s\n' '{"type":"result","is_error":false,"stop_reason":"end_turn","resul other => panic!("unexpected frame type: {other}"), } } - assert!(saw_assistant_text, "expected an assistant_message frame before turn_done"); + assert!( + saw_assistant_text, + "expected an assistant_message frame before turn_done" + ); assert_eq!(turn_done_count, 1, "exactly one turn_done per turn"); // GET /v1/chats/{id} shows the persisted user + assistant messages. - let get_res: serde_json::Value = - client.get(format!("{base}/v1/chats/{chat_id}")).bearer_auth(&token).send().await.unwrap().json().await.unwrap(); + let get_res: serde_json::Value = client + .get(format!("{base}/v1/chats/{chat_id}")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); let messages = get_res["messages"].as_array().unwrap(); assert_eq!(messages.len(), 2); assert_eq!(messages[0]["role"], "user"); @@ -170,9 +197,19 @@ printf '%s\n' '{"type":"result","is_error":false,"stop_reason":"end_turn","resul let argv_log = std::fs::read_to_string(&argv_file).unwrap(); let lines: Vec<&str> = argv_log.lines().collect(); - assert_eq!(lines.len(), 2, "fake agent should have been invoked exactly twice"); - assert!(!lines[0].contains("--resume"), "the first turn must not pass --resume"); - assert!(lines[1].contains("--resume sess-A"), "the second turn must resume the first turn's session"); + assert_eq!( + lines.len(), + 2, + "fake agent should have been invoked exactly twice" + ); + assert!( + !lines[0].contains("--resume"), + "the first turn must not pass --resume" + ); + assert!( + lines[1].contains("--resume sess-A"), + "the second turn must resume the first turn's session" + ); std::env::remove_var("OMEGA_CHAT_BIN"); } @@ -203,7 +240,10 @@ printf '%s\n' '{"type":"result","is_error":false,"stop_reason":"end_turn","resul ); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let client = reqwest::Client::new(); @@ -226,7 +266,9 @@ printf '%s\n' '{"type":"result","is_error":false,"stop_reason":"end_turn","resul loop { let frame = recv_json(&mut ws).await; match frame["type"].as_str().unwrap() { - "assistant_message" => assistant_texts.push(frame["text"].as_str().unwrap().to_string()), + "assistant_message" => { + assistant_texts.push(frame["text"].as_str().unwrap().to_string()) + } "turn_done" => { turn_done_count += 1; break; @@ -235,12 +277,27 @@ printf '%s\n' '{"type":"result","is_error":false,"stop_reason":"end_turn","resul } } assert_eq!(turn_done_count, 1, "client must see exactly one turn_done"); - assert_eq!(assistant_texts, vec!["FIRST"], "text after the first turn_done must never be forwarded"); + assert_eq!( + assistant_texts, + vec!["FIRST"], + "text after the first turn_done must never be forwarded" + ); - let get_res: serde_json::Value = - client.get(format!("{base}/v1/chats/{chat_id}")).bearer_auth(&token).send().await.unwrap().json().await.unwrap(); + let get_res: serde_json::Value = client + .get(format!("{base}/v1/chats/{chat_id}")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); let messages = get_res["messages"].as_array().unwrap(); - assert_eq!(messages.len(), 2, "exactly one user + one assistant message, no duplicate persist"); + assert_eq!( + messages.len(), + 2, + "exactly one user + one assistant message, no duplicate persist" + ); assert_eq!(messages[1]["text"], "FIRST"); std::env::remove_var("OMEGA_CHAT_BIN"); @@ -250,7 +307,10 @@ printf '%s\n' '{"type":"result","is_error":false,"stop_reason":"end_turn","resul async fn create_with_invalid_agent_is_rejected() { let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -270,7 +330,10 @@ async fn create_with_invalid_agent_is_rejected() { async fn get_unknown_chat_is_404() { let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -287,7 +350,9 @@ async fn busy_semaphore_reports_error_and_turn_done_without_persisting_assistant let dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(dir.path()).issue("t"); let state = AppState::new(dir.path().to_path_buf(), GatewayConfig::default()); - let meta = state.chats.create(ChatAgent::Claude, "/tmp".to_string(), None, None); + let meta = state + .chats + .create(ChatAgent::Claude, "/tmp".to_string(), None, None); let chat_id = meta.id.clone(); // Exhaust every permit and hold it for the whole test: no OMEGA_CHAT_BIN @@ -297,7 +362,10 @@ async fn busy_semaphore_reports_error_and_turn_done_without_persisting_assistant while let Ok(p) = state.chat_permits.clone().try_acquire_owned() { held.push(p); } - assert!(!held.is_empty(), "the semaphore must have had at least one permit to exhaust"); + assert!( + !held.is_empty(), + "the semaphore must have had at least one permit to exhaust" + ); let app = build_router(state.clone()); let base = spawn(app).await; @@ -321,7 +389,11 @@ async fn busy_semaphore_reports_error_and_turn_done_without_persisting_assistant assert!(saw_busy_error); let transcript = state.chats.transcript(&chat_id); - assert_eq!(transcript.len(), 1, "only the user message should be persisted, no assistant turn ran"); + assert_eq!( + transcript.len(), + 1, + "only the user message should be persisted, no assistant turn ran" + ); assert_eq!(transcript[0].role, "user"); drop(held); diff --git a/crates/omega-gateway/tests/chats_cli_test.rs b/crates/omega-gateway/tests/chats_cli_test.rs index b6e47c53..26d36a41 100644 --- a/crates/omega-gateway/tests/chats_cli_test.rs +++ b/crates/omega-gateway/tests/chats_cli_test.rs @@ -10,7 +10,10 @@ fn run_chats(gateway_dir: &std::path::Path) -> String { .env("OMEGA_GATEWAY_DIR", gateway_dir) .output() .expect("failed to run omega-gatewayd chats"); - assert!(output.status.success(), "chats command exited non-zero: {output:?}"); + assert!( + output.status.success(), + "chats command exited non-zero: {output:?}" + ); String::from_utf8(output.stdout).expect("stdout should be utf8") } @@ -18,18 +21,35 @@ fn run_chats(gateway_dir: &std::path::Path) -> String { fn chats_command_reports_none_when_empty() { let dir = tempfile::tempdir().unwrap(); let stdout = run_chats(dir.path()); - assert!(stdout.contains("no chats"), "expected 'no chats', got: {stdout}"); + assert!( + stdout.contains("no chats"), + "expected 'no chats', got: {stdout}" + ); } #[test] fn chats_command_lists_created_chat() { let dir = tempfile::tempdir().unwrap(); let store = ChatStore::open(dir.path()); - let meta = - store.create(ChatAgent::Claude, "/tmp/proj".to_string(), Some("hello world".to_string()), None); + let meta = store.create( + ChatAgent::Claude, + "/tmp/proj".to_string(), + Some("hello world".to_string()), + None, + ); let stdout = run_chats(dir.path()); - assert!(stdout.contains(&meta.id), "expected chat id {} in output: {stdout}", meta.id); - assert!(stdout.contains("hello world"), "expected title in output: {stdout}"); - assert!(stdout.contains("claude"), "expected agent in output: {stdout}"); + assert!( + stdout.contains(&meta.id), + "expected chat id {} in output: {stdout}", + meta.id + ); + assert!( + stdout.contains("hello world"), + "expected title in output: {stdout}" + ); + assert!( + stdout.contains("claude"), + "expected agent in output: {stdout}" + ); } diff --git a/crates/omega-gateway/tests/config_test.rs b/crates/omega-gateway/tests/config_test.rs index 51d27c2e..55b46e92 100644 --- a/crates/omega-gateway/tests/config_test.rs +++ b/crates/omega-gateway/tests/config_test.rs @@ -23,7 +23,10 @@ async fn spawn(app: axum::Router) -> String { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -40,17 +43,67 @@ async fn get_config_on_a_fresh_box_returns_defaults_with_no_keys_set() { let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/config")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/config")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["claude"]["api_key_set"], false); assert_eq!(body["codex"]["api_key_set"], false); + assert_eq!(body["antigravity"]["dangerously_skip_permissions"], true); + assert_eq!(body["kimi"]["api_key_set"], false); // Never a raw `api_key` field on the wire at all. assert!(body["claude"].get("api_key").is_none()); + assert!(body["kimi"].get("api_key").is_none()); clear_env(); } +#[tokio::test] +async fn put_config_keeps_kimi_antigravity_and_hermes_in_cli_parity() { + let _g = LOCK.lock().await; + let gateway_dir = tempfile::tempdir().unwrap(); + let omega_dir = tempfile::tempdir().unwrap(); + std::env::set_var("OMEGA_DIR", omega_dir.path()); + let (app, token) = app_and_token(gateway_dir.path()).await; + let base = spawn(app).await; + let client = reqwest::Client::new(); + + for (key, value) in [ + ("kimi.provider_type", "anthropic"), + ("kimi.model", "k3"), + ("antigravity.effort", "high"), + ("hermes.provider", "openrouter"), + ] { + let response = client + .put(format!("{base}/v1/config")) + .bearer_auth(&token) + .json(&serde_json::json!({ "key": key, "value": value })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200, "{key}"); + } + + let body: serde_json::Value = client + .get(format!("{base}/v1/config")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(body["kimi"]["provider_type"], "anthropic"); + assert_eq!(body["kimi"]["model"], "k3"); + assert_eq!(body["antigravity"]["effort"], "high"); + assert_eq!(body["hermes"]["provider"], "openrouter"); + clear_env(); +} + #[tokio::test] async fn put_config_sets_a_known_key_and_never_echoes_the_secret_back() { let _g = LOCK.lock().await; @@ -69,12 +122,20 @@ async fn put_config_sets_a_known_key_and_never_echoes_the_secret_back() { .unwrap(); assert_eq!(res.status(), 200); let body_text = res.text().await.unwrap(); - assert!(!body_text.contains("sk-super-secret-value"), "the secret must never round-trip on the wire"); + assert!( + !body_text.contains("sk-super-secret-value"), + "the secret must never round-trip on the wire" + ); let body: serde_json::Value = serde_json::from_str(&body_text).unwrap(); assert_eq!(body["claude"]["api_key_set"], true); // Persisted -- a follow-up GET reflects it too. - let res2 = reqwest::Client::new().get(format!("{base}/v1/config")).bearer_auth(&token).send().await.unwrap(); + let res2 = reqwest::Client::new() + .get(format!("{base}/v1/config")) + .bearer_auth(&token) + .send() + .await + .unwrap(); let body2: serde_json::Value = res2.json().await.unwrap(); assert_eq!(body2["claude"]["api_key_set"], true); @@ -160,10 +221,17 @@ async fn get_config_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); let omega_dir = tempfile::tempdir().unwrap(); std::env::set_var("OMEGA_DIR", omega_dir.path()); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/config")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/config")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); clear_env(); @@ -219,7 +287,12 @@ async fn get_config_on_a_corrupt_providers_toml_is_500_not_a_silent_empty_view() let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/config")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/config")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 500); clear_env(); @@ -262,7 +335,10 @@ async fn put_config_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); let omega_dir = tempfile::tempdir().unwrap(); std::env::set_var("OMEGA_DIR", omega_dir.path()); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() diff --git a/crates/omega-gateway/tests/deposit_test.rs b/crates/omega-gateway/tests/deposit_test.rs index 8d43a81d..2bd79286 100644 --- a/crates/omega-gateway/tests/deposit_test.rs +++ b/crates/omega-gateway/tests/deposit_test.rs @@ -22,7 +22,10 @@ async fn spawn(app: axum::Router) -> String { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -56,7 +59,12 @@ async fn happy_path_fans_out_to_all_four_boxes() { let file_name = body["file"].as_str().unwrap(); assert!(deposit_dir.path().join("inbox").join(file_name).exists()); for b in ["Home", "AltReality", "Omega", "Box"] { - assert!(deposit_dir.path().join("deposit").join(b).join(file_name).exists()); + assert!(deposit_dir + .path() + .join("deposit") + .join(b) + .join(file_name) + .exists()); } std::env::remove_var("OMEGA_DEPOSIT_DIR"); @@ -206,7 +214,10 @@ async fn post_deposit_requires_auth() { let deposit_dir = tempfile::tempdir().unwrap(); std::env::set_var("OMEGA_DEPOSIT_DIR", deposit_dir.path()); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let part = reqwest::multipart::Part::bytes(b"hello".to_vec()).file_name("note.txt"); diff --git a/crates/omega-gateway/tests/dispatch_test.rs b/crates/omega-gateway/tests/dispatch_test.rs index b8f8f407..4f569c39 100644 --- a/crates/omega-gateway/tests/dispatch_test.rs +++ b/crates/omega-gateway/tests/dispatch_test.rs @@ -26,15 +26,17 @@ async fn spawn(app: axum::Router) -> String { /// The script also appends its full argv (one per line, `--`-separated) to /// a capture file under `capture_dir`, so a test can prove exactly what was /// passed to the subprocess. -fn install_fake_omega(bin_dir: &std::path::Path, capture_file: &std::path::Path, script_body: &str) { +fn install_fake_omega( + bin_dir: &std::path::Path, + capture_file: &std::path::Path, + script_body: &str, +) { use std::os::unix::fs::PermissionsExt; let path = bin_dir.join("omega"); let capture = capture_file.display(); std::fs::write( &path, - format!( - "#!/usr/bin/env bash\nprintf '%s\\n' \"$@\" > '{capture}'\n{script_body}\n" - ), + format!("#!/usr/bin/env bash\nprintf '%s\\n' \"$@\" > '{capture}'\n{script_body}\n"), ) .unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); @@ -52,7 +54,10 @@ fn install_fake_home(home_dir: &std::path::Path, project_name: &str) { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -113,7 +118,11 @@ async fn unknown_project_rejects_before_any_subprocess_spawn() { install_fake_home(home_dir.path(), "TestProj"); // Install a fake omega that would fail loudly if invoked at all, proving // a spawn never happens rather than merely happening to succeed. - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -133,7 +142,10 @@ async fn unknown_project_rejects_before_any_subprocess_spawn() { // The single most important assertion in this plan: the subprocess was // NEVER spawned, so the capture file the fake omega script writes on // every invocation must not exist. - assert!(!capture_file.exists(), "omega subprocess was spawned for an unknown project"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for an unknown project" + ); std::env::remove_var("OMEGA_HOME"); std::env::remove_var("OMEGA_BIN"); @@ -172,13 +184,25 @@ async fn subprocess_failure_surfaces_stderr_as_502() { // sanitized, generic error. The full raw text still goes to the // gateway's own tracing log (not asserted here, out of this test's // reach), never the HTTP response. - assert!(body.get("stderr").is_none(), "must not echo raw stderr: {body}"); - assert!(body.get("stdout").is_none(), "must not echo raw stdout: {body}"); assert!( - !body["error"].as_str().unwrap().contains("oracle registry lock held"), + body.get("stderr").is_none(), + "must not echo raw stderr: {body}" + ); + assert!( + body.get("stdout").is_none(), + "must not echo raw stdout: {body}" + ); + assert!( + !body["error"] + .as_str() + .unwrap() + .contains("oracle registry lock held"), "error message must not contain the raw subprocess text: {body}" ); - assert!(body.get("oracle").is_none(), "must never fabricate an oracle name on failure"); + assert!( + body.get("oracle").is_none(), + "must never fabricate an oracle name on failure" + ); std::env::remove_var("OMEGA_HOME"); std::env::remove_var("OMEGA_BIN"); @@ -258,7 +282,10 @@ async fn empty_project_and_mission_reject_before_discovery() { #[tokio::test] async fn post_dispatch_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -304,7 +331,10 @@ async fn dash_prefixed_values_land_as_positionals_after_separator() { let recorded = std::fs::read_to_string(&capture_file).unwrap(); let argv: Vec<&str> = recorded.lines().collect(); - assert_eq!(argv, vec!["dispatch", "--", "-weird-project", "-rf everything"]); + assert_eq!( + argv, + vec!["dispatch", "--", "-weird-project", "-rf everything"] + ); std::env::remove_var("OMEGA_HOME"); std::env::remove_var("OMEGA_BIN"); @@ -320,7 +350,11 @@ async fn unknown_agent_rejects_with_400_before_any_subprocess_spawn() { let capture_file = capture_dir.path().join("argv.txt"); install_fake_home(home_dir.path(), "TestProj"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -335,7 +369,10 @@ async fn unknown_agent_rejects_with_400_before_any_subprocess_spawn() { assert_eq!(res.status(), 400); let body: serde_json::Value = res.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("not-a-real-agent")); - assert!(!capture_file.exists(), "omega subprocess was spawned for an unknown agent"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for an unknown agent" + ); std::env::remove_var("OMEGA_HOME"); std::env::remove_var("OMEGA_BIN"); @@ -379,7 +416,17 @@ async fn known_agent_dispatches_normally() { let recorded = std::fs::read_to_string(&capture_file).unwrap(); let argv: Vec<&str> = recorded.lines().collect(); - assert_eq!(argv, vec!["dispatch", "--agent", agent_name, "--", "TestProj", "do the thing"]); + assert_eq!( + argv, + vec![ + "dispatch", + "--agent", + agent_name, + "--", + "TestProj", + "do the thing" + ] + ); std::env::remove_var("OMEGA_HOME"); std::env::remove_var("OMEGA_BIN"); @@ -395,7 +442,11 @@ async fn mission_with_nul_byte_rejects_with_400_no_spawn() { let capture_file = capture_dir.path().join("argv.txt"); install_fake_home(home_dir.path(), "TestProj"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -410,7 +461,10 @@ async fn mission_with_nul_byte_rejects_with_400_no_spawn() { assert_eq!(res.status(), 400); let body: serde_json::Value = res.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("NUL")); - assert!(!capture_file.exists(), "omega subprocess was spawned for a NUL-containing mission"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for a NUL-containing mission" + ); std::env::remove_var("OMEGA_HOME"); std::env::remove_var("OMEGA_BIN"); @@ -426,7 +480,11 @@ async fn mission_over_length_cap_rejects_with_400_no_spawn() { let capture_file = capture_dir.path().join("argv.txt"); install_fake_home(home_dir.path(), "TestProj"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -443,7 +501,10 @@ async fn mission_over_length_cap_rejects_with_400_no_spawn() { assert_eq!(res.status(), 400); let body: serde_json::Value = res.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("too long")); - assert!(!capture_file.exists(), "omega subprocess was spawned for an over-length mission"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for an over-length mission" + ); std::env::remove_var("OMEGA_HOME"); std::env::remove_var("OMEGA_BIN"); @@ -484,7 +545,9 @@ async fn concurrency_cap_returns_429_when_dispatch_permits_exhausted() { client .post(format!("{base}/v1/dispatch")) .bearer_auth(&token) - .json(&serde_json::json!({"project": "TestProj", "mission": format!("mission {i}")})) + .json( + &serde_json::json!({"project": "TestProj", "mission": format!("mission {i}")}), + ) .send() .await .unwrap() @@ -507,7 +570,10 @@ async fn concurrency_cap_returns_429_when_dispatch_permits_exhausted() { .unwrap(); assert_eq!(busy_res.status(), 429); let body: serde_json::Value = busy_res.json().await.unwrap(); - assert!(body["error"].as_str().unwrap().contains("too many concurrent dispatches")); + assert!(body["error"] + .as_str() + .unwrap() + .contains("too many concurrent dispatches")); for task in in_flight { let status = task.await.unwrap(); @@ -532,6 +598,9 @@ async fn concurrency_cap_returns_429_when_dispatch_permits_exhausted() { /// test run that it provably won't collide with any real project name. fn uuid_like() -> String { use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); format!("{nanos:x}") } diff --git a/crates/omega-gateway/tests/duo_test.rs b/crates/omega-gateway/tests/duo_test.rs index 50b33482..823dcd87 100644 --- a/crates/omega-gateway/tests/duo_test.rs +++ b/crates/omega-gateway/tests/duo_test.rs @@ -27,7 +27,10 @@ async fn spawn(app: axum::Router) -> String { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -55,7 +58,10 @@ fn install_fake_duo( exit_code: i32, ) { use std::os::unix::fs::PermissionsExt; - assert!(!stdout_body.contains('\''), "test stdout body must not contain a single quote"); + assert!( + !stdout_body.contains('\''), + "test stdout body must not contain a single quote" + ); let path = bin_dir.join("omega-duo"); let capture = capture_file.display(); let body = format!( @@ -75,7 +81,11 @@ fn clear_env() { } fn argv_lines(capture_file: &std::path::Path) -> Vec { - std::fs::read_to_string(capture_file).unwrap_or_default().lines().map(str::to_string).collect() + std::fs::read_to_string(capture_file) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() } /// Finding 2 (adversarial review round): writes an executable fake @@ -123,8 +133,14 @@ fn install_fake_duo_with_banner_line( exit_code: i32, ) { use std::os::unix::fs::PermissionsExt; - assert!(!stdout_body.contains('\''), "test stdout body must not contain a single quote"); - assert!(!banner.contains('\''), "test banner must not contain a single quote"); + assert!( + !stdout_body.contains('\''), + "test stdout body must not contain a single quote" + ); + assert!( + !banner.contains('\''), + "test banner must not contain a single quote" + ); let path = bin_dir.join("omega-duo"); let capture = capture_file.display(); let body = format!( @@ -210,13 +226,19 @@ async fn happy_path_with_project_builds_exact_argv_and_maps_every_field() { assert_eq!(argv[0], "run"); let task_idx = argv.iter().position(|l| l == "--task").unwrap(); let task_path = &argv[task_idx + 1]; - assert!(task_path.starts_with(duo_scratch.path().join("tasks").to_str().unwrap()), "argv: {argv:?}"); + assert!( + task_path.starts_with(duo_scratch.path().join("tasks").to_str().unwrap()), + "argv: {argv:?}" + ); let cwd_idx = argv.iter().position(|l| l == "--cwd").unwrap(); assert_eq!(argv[cwd_idx + 1], project_path.to_str().unwrap()); let mode_idx = argv.iter().position(|l| l == "--mode").unwrap(); assert_eq!(argv[mode_idx + 1], "code"); // --agent / --verify are NEVER passed by this endpoint. - assert!(!argv.iter().any(|l| l == "--agent" || l == "--verify"), "argv: {argv:?}"); + assert!( + !argv.iter().any(|l| l == "--agent" || l == "--verify"), + "argv: {argv:?}" + ); // No `--` separator (omega-duo's own parser has no positionals to // protect) and no `=`-joined flags (its parser does not understand // them -- see routes_duo.rs's doc comment). @@ -247,7 +269,12 @@ async fn happy_path_with_dir_uses_the_dir_under_home_resolved_path() { let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; - let dir_str = fake_home.path().join("Station").join("Proj").display().to_string(); + let dir_str = fake_home + .path() + .join("Station") + .join("Proj") + .display() + .to_string(); let res = reqwest::Client::new() .post(format!("{base}/v1/duo")) .bearer_auth(&token) @@ -276,7 +303,13 @@ async fn profile_review_maps_to_mode_review() { let capture_file = bin_dir.path().join("capture.txt"); install_fake_home(home_dir.path(), "TestProj"); - install_fake_duo(bin_dir.path(), &capture_file, "0", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -306,7 +339,13 @@ async fn both_project_and_dir_given_rejects_with_400_no_spawn() { let bin_dir = tempfile::tempdir().unwrap(); let duo_scratch = tempfile::tempdir().unwrap(); let capture_file = bin_dir.path().join("capture.txt"); - install_fake_duo(bin_dir.path(), &capture_file, "0", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -334,7 +373,13 @@ async fn neither_project_nor_dir_given_rejects_with_400_no_spawn() { let bin_dir = tempfile::tempdir().unwrap(); let duo_scratch = tempfile::tempdir().unwrap(); let capture_file = bin_dir.path().join("capture.txt"); - install_fake_duo(bin_dir.path(), &capture_file, "0", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -365,7 +410,13 @@ async fn unknown_project_rejects_with_400_no_spawn() { let capture_file = bin_dir.path().join("capture.txt"); install_fake_home(home_dir.path(), "RealProj"); - install_fake_duo(bin_dir.path(), &capture_file, "0", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -379,7 +430,10 @@ async fn unknown_project_rejects_with_400_no_spawn() { .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "no subprocess was ever spawned for an unknown project"); + assert!( + !capture_file.exists(), + "no subprocess was ever spawned for an unknown project" + ); clear_env(); } @@ -394,7 +448,13 @@ async fn dir_outside_home_rejects_with_400_no_spawn() { let capture_file = bin_dir.path().join("capture.txt"); std::env::set_var("HOME", fake_home.path()); - install_fake_duo(bin_dir.path(), &capture_file, "0", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -424,13 +484,24 @@ async fn dir_with_parent_dir_component_rejects_with_400_no_spawn() { let capture_file = bin_dir.path().join("capture.txt"); std::env::set_var("HOME", fake_home.path()); - install_fake_duo(bin_dir.path(), &capture_file, "0", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; - let escaping = fake_home.path().join("does-not-exist-yet").join("..").join("..").join("etc"); + let escaping = fake_home + .path() + .join("does-not-exist-yet") + .join("..") + .join("..") + .join("etc"); let res = reqwest::Client::new() .post(format!("{base}/v1/duo")) .bearer_auth(&token) @@ -454,7 +525,13 @@ async fn empty_prompt_rejects_with_400_no_spawn() { let capture_file = bin_dir.path().join("capture.txt"); install_fake_home(home_dir.path(), "TestProj"); - install_fake_duo(bin_dir.path(), &capture_file, "0", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -483,7 +560,13 @@ async fn oversized_prompt_rejects_with_400_no_spawn() { let capture_file = bin_dir.path().join("capture.txt"); install_fake_home(home_dir.path(), "TestProj"); - install_fake_duo(bin_dir.path(), &capture_file, "0", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -513,7 +596,13 @@ async fn nul_byte_prompt_rejects_with_400_no_spawn() { let capture_file = bin_dir.path().join("capture.txt"); install_fake_home(home_dir.path(), "TestProj"); - install_fake_duo(bin_dir.path(), &capture_file, "0", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -542,7 +631,13 @@ async fn unknown_profile_rejects_with_400_no_spawn() { let capture_file = bin_dir.path().join("capture.txt"); install_fake_home(home_dir.path(), "TestProj"); - install_fake_duo(bin_dir.path(), &capture_file, "0", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -575,7 +670,13 @@ async fn malformed_stdout_is_502_with_a_sanitized_error_never_the_raw_output() { let capture_file = bin_dir.path().join("capture.txt"); install_fake_home(home_dir.path(), "TestProj"); - install_fake_duo(bin_dir.path(), &capture_file, "0", "this is not json at all", 1); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + "this is not json at all", + 1, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -594,8 +695,14 @@ async fn malformed_stdout_is_502_with_a_sanitized_error_never_the_raw_output() { // longer echoed into the response body -- only the parse error itself // (a generic "expected value at..." shape). The full raw text still // goes to the gateway's own tracing log, never the HTTP response. - assert!(body.get("stdout").is_none(), "must not echo raw stdout: {body}"); - assert!(body.get("stderr").is_none(), "must not echo raw stderr: {body}"); + assert!( + body.get("stdout").is_none(), + "must not echo raw stdout: {body}" + ); + assert!( + body.get("stderr").is_none(), + "must not echo raw stderr: {body}" + ); assert!( !body["error"].as_str().unwrap().contains("not json"), "error message must not contain the raw subprocess text: {body}" @@ -617,7 +724,13 @@ async fn malformed_stdout_never_leaks_a_secret_shaped_string_into_the_response() let capture_file = bin_dir.path().join("capture.txt"); install_fake_home(home_dir.path(), "TestProj"); - install_fake_duo(bin_dir.path(), &capture_file, "0", "sk-ProjSECRETVALUE1234567890", 1); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + "sk-ProjSECRETVALUE1234567890", + 1, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -695,7 +808,13 @@ async fn subprocess_past_a_short_overridden_timeout_returns_bounded_with_a_clear install_fake_home(home_dir.path(), "TestProj"); // Sleeps 3s; the endpoint's own timeout is overridden to 1s, so this // must return well under the full sleep, never the full 1800s default. - install_fake_duo(bin_dir.path(), &capture_file, "3", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "3", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); std::env::set_var("DUO_TIMEOUT_SECS", "1"); @@ -712,7 +831,10 @@ async fn subprocess_past_a_short_overridden_timeout_returns_bounded_with_a_clear .unwrap(); let elapsed = started.elapsed(); assert_eq!(res.status(), 504); - assert!(elapsed < std::time::Duration::from_secs(3), "took {elapsed:?}, expected well under the 3s sleep"); + assert!( + elapsed < std::time::Duration::from_secs(3), + "took {elapsed:?}, expected well under the 3s sleep" + ); let body: serde_json::Value = res.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("timed out")); @@ -736,7 +858,13 @@ async fn concurrency_cap_returns_429_when_duo_permits_exhausted() { std::fs::create_dir_all(home_dir.path().join("ProjB").join(".git")).unwrap(); std::fs::create_dir_all(home_dir.path().join("ProjC").join(".git")).unwrap(); std::env::set_var("OMEGA_HOME", home_dir.path()); - install_fake_duo(bin_dir.path(), &capture_file, "0.15", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0.15", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -777,7 +905,10 @@ async fn concurrency_cap_returns_429_when_duo_permits_exhausted() { .unwrap(); assert_eq!(busy_res.status(), 429); let body: serde_json::Value = busy_res.json().await.unwrap(); - assert!(body["error"].as_str().unwrap().contains("too many concurrent")); + assert!(body["error"] + .as_str() + .unwrap() + .contains("too many concurrent")); for task in in_flight { assert_eq!(task.await.unwrap(), 200); @@ -796,7 +927,13 @@ async fn per_cwd_lock_rejects_a_second_concurrent_run_then_releases_after_the_fi let capture_file = bin_dir.path().join("capture.txt"); install_fake_home(home_dir.path(), "TestProj"); - install_fake_duo(bin_dir.path(), &capture_file, "0.25", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0.25", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -834,7 +971,10 @@ async fn per_cwd_lock_rejects_a_second_concurrent_run_then_releases_after_the_fi .unwrap(); assert_eq!(second_res.status(), 409); let body: serde_json::Value = second_res.json().await.unwrap(); - assert!(body["error"].as_str().unwrap().contains("already in flight")); + assert!(body["error"] + .as_str() + .unwrap() + .contains("already in flight")); assert_eq!(first.await.unwrap(), 200); @@ -880,7 +1020,13 @@ async fn per_cwd_lock_keys_on_repo_root_so_two_spellings_of_the_same_repo_collid std::fs::create_dir_all(&nested).unwrap(); std::fs::create_dir_all(repo_root.join(".git")).unwrap(); - install_fake_duo(bin_dir.path(), &capture_file, "0.25", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0.25", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -923,7 +1069,10 @@ async fn per_cwd_lock_keys_on_repo_root_so_two_spellings_of_the_same_repo_collid "two textually different paths into the same repo must collide on the per-cwd lock" ); let body: serde_json::Value = second_res.json().await.unwrap(); - assert!(body["error"].as_str().unwrap().contains("already in flight")); + assert!(body["error"] + .as_str() + .unwrap() + .contains("already in flight")); assert_eq!(first.await.unwrap(), 200); @@ -1000,7 +1149,10 @@ async fn client_disconnect_kills_the_nested_agent_and_releases_the_cwd_lock() { let before = std::fs::read_to_string(&marker_file).unwrap_or_default(); tokio::time::sleep(std::time::Duration::from_millis(150)).await; let grew = std::fs::read_to_string(&marker_file).unwrap_or_default(); - assert!(grew.len() > before.len(), "nested grandchild marker was not growing before the disconnect"); + assert!( + grew.len() > before.len(), + "nested grandchild marker was not growing before the disconnect" + ); // CLIENT-side disconnect: abort the task holding the connection, never // a server-side timeout. @@ -1016,13 +1168,22 @@ async fn client_disconnect_kills_the_nested_agent_and_releases_the_cwd_lock() { } tokio::time::sleep(std::time::Duration::from_millis(50)).await; } - assert!(died, "the nested grandchild (pid {nested_pid}) survived the client disconnect"); + assert!( + died, + "the nested grandchild (pid {nested_pid}) survived the client disconnect" + ); // The per-cwd lock must have been released too, ONLY once the kill // happened -- not leaked, and not letting a real orphan race a second // run. Point OMEGA_DUO_BIN at a fast, ordinary fake bin so this third // request does not also have to sit out a long sleep. - install_fake_duo(bin_dir.path(), &capture_file, "0", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + &fake_bridge_result_json().to_string(), + 0, + ); let third_res = client .post(format!("{base}/v1/duo")) .bearer_auth(&token) @@ -1069,7 +1230,13 @@ async fn dash_prefixed_resolved_dir_is_rejected_before_any_spawn() { let _cwd_restore = CwdRestore(original_cwd); std::env::set_current_dir(fake_home.path()).unwrap(); - install_fake_duo(bin_dir.path(), &capture_file, "0", &fake_bridge_result_json().to_string(), 0); + install_fake_duo( + bin_dir.path(), + &capture_file, + "0", + &fake_bridge_result_json().to_string(), + 0, + ); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -1087,7 +1254,10 @@ async fn dash_prefixed_resolved_dir_is_rejected_before_any_spawn() { assert_eq!(res.status(), 400); let body: serde_json::Value = res.json().await.unwrap(); - assert!(body["error"].as_str().unwrap().contains("absolute"), "error: {body}"); + assert!( + body["error"].as_str().unwrap().contains("absolute"), + "error: {body}" + ); assert!(!capture_file.exists(), "no subprocess was ever spawned"); clear_env(); @@ -1130,7 +1300,11 @@ async fn stray_banner_line_before_the_json_is_still_parsed_from_the_last_line() .send() .await .unwrap(); - assert_eq!(res.status(), 200, "a stray banner line before the real JSON line must not 502"); + assert_eq!( + res.status(), + 200, + "a stray banner line before the real JSON line must not 502" + ); let resp: serde_json::Value = res.json().await.unwrap(); assert_eq!(resp, body); @@ -1145,7 +1319,10 @@ async fn create_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); let duo_scratch = tempfile::tempdir().unwrap(); std::env::set_var("OMEGA_DUO_DIR", duo_scratch.path()); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() diff --git a/crates/omega-gateway/tests/events_test.rs b/crates/omega-gateway/tests/events_test.rs index 3f5a5743..b0f71629 100644 --- a/crates/omega-gateway/tests/events_test.rs +++ b/crates/omega-gateway/tests/events_test.rs @@ -25,7 +25,10 @@ async fn authed_device_receives_emitted_alert_frame() { // Give the server task a beat to register the subscription before we // emit, so the broadcast isn't sent before anyone is listening. tokio::time::sleep(std::time::Duration::from_millis(50)).await; - hub.emit(GatewayEvent::Alert { message: "disk full".into(), ts: "2026-08-10T00:00:00Z".into() }); + hub.emit(GatewayEvent::Alert { + message: "disk full".into(), + ts: "2026-08-10T00:00:00Z".into(), + }); let msg = ws.next().await.unwrap().unwrap().into_text().unwrap(); let frame: serde_json::Value = serde_json::from_str(&msg).unwrap(); @@ -37,7 +40,10 @@ async fn authed_device_receives_emitted_alert_frame() { #[tokio::test] async fn events_requires_auth() { let dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); @@ -47,7 +53,10 @@ async fn events_requires_auth() { // No Authorization header and no ?token= query: the auth middleware // rejects the upgrade with 401 before the WS handshake completes. let msg = err.to_string(); - assert!(msg.contains("401") || msg.contains("Unauthorized"), "unexpected error: {msg}"); + assert!( + msg.contains("401") || msg.contains("Unauthorized"), + "unexpected error: {msg}" + ); } #[tokio::test] @@ -65,7 +74,10 @@ async fn mission_updated_and_heartbeat_frames_round_trip() { let (mut ws, _) = connect_async(url).await.unwrap(); tokio::time::sleep(std::time::Duration::from_millis(50)).await; - hub.emit(GatewayEvent::MissionUpdated { key: "oracle-x".into(), updated_at: "t1".into() }); + hub.emit(GatewayEvent::MissionUpdated { + key: "oracle-x".into(), + updated_at: "t1".into(), + }); let msg1 = ws.next().await.unwrap().unwrap().into_text().unwrap(); let f1: serde_json::Value = serde_json::from_str(&msg1).unwrap(); assert_eq!(f1["type"], "mission_updated"); diff --git a/crates/omega-gateway/tests/files_test.rs b/crates/omega-gateway/tests/files_test.rs index e4d155cd..b9793185 100644 --- a/crates/omega-gateway/tests/files_test.rs +++ b/crates/omega-gateway/tests/files_test.rs @@ -33,14 +33,20 @@ fn install_fake_home(home_dir: &std::path::Path, project_name: &str) -> std::pat async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } #[tokio::test] async fn list_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -54,7 +60,10 @@ async fn list_requires_auth() { #[tokio::test] async fn read_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -108,7 +117,10 @@ async fn list_returns_project_root_entries_dirs_first_then_alpha() { assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); let entries = body["entries"].as_array().unwrap(); - let names: Vec<&str> = entries.iter().map(|e| e["name"].as_str().unwrap()).collect(); + let names: Vec<&str> = entries + .iter() + .map(|e| e["name"].as_str().unwrap()) + .collect(); // .git is also present (created by install_fake_home) but ordering only // needs to hold: every dir before every file, alpha within each group. let zdir_idx = names.iter().position(|n| *n == "zdir").unwrap(); @@ -132,7 +144,9 @@ async fn list_rejects_traversal_via_http() { let base = spawn(app).await; let res = reqwest::Client::new() - .get(format!("{base}/v1/files?project=TestProj&path=..%2Fsecret.txt")) + .get(format!( + "{base}/v1/files?project=TestProj&path=..%2Fsecret.txt" + )) .bearer_auth(&token) .send() .await @@ -226,7 +240,9 @@ async fn read_returns_content_for_a_real_text_file() { let base = spawn(app).await; let res = reqwest::Client::new() - .get(format!("{base}/v1/files/read?project=TestProj&path=hello.txt")) + .get(format!( + "{base}/v1/files/read?project=TestProj&path=hello.txt" + )) .bearer_auth(&token) .send() .await @@ -273,7 +289,9 @@ async fn read_rejects_oversized_file() { let base = spawn(app).await; let res = reqwest::Client::new() - .get(format!("{base}/v1/files/read?project=TestProj&path=big.bin")) + .get(format!( + "{base}/v1/files/read?project=TestProj&path=big.bin" + )) .bearer_auth(&token) .send() .await @@ -295,7 +313,9 @@ async fn read_rejects_binary_file() { let base = spawn(app).await; let res = reqwest::Client::new() - .get(format!("{base}/v1/files/read?project=TestProj&path=binary.dat")) + .get(format!( + "{base}/v1/files/read?project=TestProj&path=binary.dat" + )) .bearer_auth(&token) .send() .await diff --git a/crates/omega-gateway/tests/health_test.rs b/crates/omega-gateway/tests/health_test.rs index 55ff5907..956a25a3 100644 --- a/crates/omega-gateway/tests/health_test.rs +++ b/crates/omega-gateway/tests/health_test.rs @@ -11,10 +11,17 @@ async fn spawn(app: axum::Router) -> String { #[tokio::test] async fn health_returns_ok_and_version() { let dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let body: serde_json::Value = reqwest::get(format!("{base}/v1/health")) - .await.unwrap().json().await.unwrap(); + .await + .unwrap() + .json() + .await + .unwrap(); assert_eq!(body["ok"], true); assert_eq!(body["version"], env!("CARGO_PKG_VERSION")); } diff --git a/crates/omega-gateway/tests/marketing_test.rs b/crates/omega-gateway/tests/marketing_test.rs index 6839a6d1..65ada6f8 100644 --- a/crates/omega-gateway/tests/marketing_test.rs +++ b/crates/omega-gateway/tests/marketing_test.rs @@ -28,7 +28,10 @@ async fn spawn(app: axum::Router) -> String { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -83,15 +86,31 @@ fn write_layer_file(dir: &std::path::Path, name: &str, content: &str) { fn write_full_marketing_project(station: &std::path::Path, dir_name: &str) { let root = station.join(dir_name); let marketing = root.join("marketing"); - write_layer_file(&marketing.join("00-context"), "product-marketing.md", "context"); - write_layer_file(&marketing.join("01-strategy"), "gtm-strategy.md", "strategy"); + write_layer_file( + &marketing.join("00-context"), + "product-marketing.md", + "context", + ); + write_layer_file( + &marketing.join("01-strategy"), + "gtm-strategy.md", + "strategy", + ); write_layer_file(&marketing.join("02-copy"), "copywriting.md", "copy"); write_layer_file(&marketing.join("03-visual-identity"), "DA.md", "visual"); - write_layer_file(&marketing.join("06-branding"), "SOCIAL-BRAND-BOOK.md", "branding"); + write_layer_file( + &marketing.join("06-branding"), + "SOCIAL-BRAND-BOOK.md", + "branding", + ); std::fs::create_dir_all(marketing.join("04-publishing").join("daily-engine")).unwrap(); let cal_dir = marketing.join("05-calendar"); std::fs::create_dir_all(&cal_dir).unwrap(); - std::fs::write(cal_dir.join("calendar-90d.json"), r#"{"posts": ["p1", "p2", "p3"]}"#).unwrap(); + std::fs::write( + cal_dir.join("calendar-90d.json"), + r#"{"posts": ["p1", "p2", "p3"]}"#, + ) + .unwrap(); } /// A bare marketing-enabled project: just the `marketing/` dir itself, no @@ -105,10 +124,17 @@ fn write_bare_marketing_project(station: &std::path::Path, dir_name: &str) { async fn get_marketing_requires_auth() { let _g = LOCK.lock().await; let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/marketing")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/marketing")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } @@ -125,8 +151,12 @@ async fn get_marketing_returns_empty_when_no_marketing_projects() { let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/marketing")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/marketing")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["projects"].as_array().unwrap().len(), 0); @@ -149,8 +179,12 @@ async fn get_marketing_returns_full_status_flags_and_never_populates_accounts() let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/marketing")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/marketing")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); let projects = body["projects"].as_array().unwrap(); @@ -162,7 +196,10 @@ async fn get_marketing_returns_full_status_flags_and_never_populates_accounts() assert_eq!(p["has_content"], true); assert_eq!(p["calendar_posts"], 3); assert_eq!(p["engine_on"], true); - assert!(p["accounts"].is_null(), "accounts must never be populated by the list endpoint"); + assert!( + p["accounts"].is_null(), + "accounts must never be populated by the list endpoint" + ); assert_eq!(p["accounts_tried"], false); assert_eq!(p["has_context"], true); assert_eq!(p["has_strategy"], true); @@ -171,7 +208,10 @@ async fn get_marketing_returns_full_status_flags_and_never_populates_accounts() assert_eq!(p["has_branding"], true); // `path` is deliberately never sent over the wire (server-internal, same // posture ProjectEntry already takes for /v1/projects). - assert!(p.get("path").is_none(), "path must not be exposed on the wire"); + assert!( + p.get("path").is_none(), + "path must not be exposed on the wire" + ); clear_env(); restore_path(&old_path); @@ -196,8 +236,12 @@ async fn get_marketing_returns_multiple_projects_name_sorted() { let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/marketing")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/marketing")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); let projects = body["projects"].as_array().unwrap(); @@ -237,8 +281,12 @@ async fn get_marketing_returns_504_when_crontab_hangs_past_the_timeout() { let base = spawn(app).await; let started = std::time::Instant::now(); - let res = - reqwest::Client::new().get(format!("{base}/v1/marketing")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/marketing")) + .bearer_auth(&token) + .send() + .await + .unwrap(); let elapsed = started.elapsed(); assert_eq!(res.status(), 504); @@ -248,7 +296,10 @@ async fn get_marketing_returns_504_when_crontab_hangs_past_the_timeout() { ); let body: serde_json::Value = res.json().await.unwrap(); assert!( - body["error"].as_str().unwrap_or_default().contains("timed out"), + body["error"] + .as_str() + .unwrap_or_default() + .contains("timed out"), "expected a 'timed out' error message, got: {body}" ); @@ -307,8 +358,12 @@ async fn get_marketing_dedupes_when_registry_name_differs_from_directory_name() let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/marketing")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/marketing")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); let projects = body["projects"].as_array().unwrap(); diff --git a/crates/omega-gateway/tests/master_chat_test.rs b/crates/omega-gateway/tests/master_chat_test.rs index 1f057c24..d271d549 100644 --- a/crates/omega-gateway/tests/master_chat_test.rs +++ b/crates/omega-gateway/tests/master_chat_test.rs @@ -74,12 +74,19 @@ async fn not_running_sends_frame_and_never_touches_inbox() { std::env::set_var("HOME", fake_home.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/master/chat", &token); let (mut ws, _) = connect_async(url).await.unwrap(); - ws.send(tokio_tungstenite::tungstenite::Message::Text("hello master".to_string())).await.unwrap(); + ws.send(tokio_tungstenite::tungstenite::Message::Text( + "hello master".to_string(), + )) + .await + .unwrap(); let msg = ws.next().await.unwrap().unwrap().into_text().unwrap(); let frame: serde_json::Value = serde_json::from_str(&msg).unwrap(); assert_eq!(frame["type"], "not_running"); @@ -116,17 +123,27 @@ async fn running_reply_round_trip_writes_inbox_and_returns_reply() { tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(150)).await; use std::io::Write; - let mut f = std::fs::OpenOptions::new().append(true).open(&log_for_growth).unwrap(); + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&log_for_growth) + .unwrap(); writeln!(f, "AISB: hello back").unwrap(); }); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/master/chat", &token); let (mut ws, _) = connect_async(url).await.unwrap(); - ws.send(tokio_tungstenite::tungstenite::Message::Text("hello master".to_string())).await.unwrap(); + ws.send(tokio_tungstenite::tungstenite::Message::Text( + "hello master".to_string(), + )) + .await + .unwrap(); let msg = ws.next().await.unwrap().unwrap().into_text().unwrap(); let frame: serde_json::Value = serde_json::from_str(&msg).unwrap(); assert_eq!(frame["type"], "reply"); @@ -156,12 +173,19 @@ async fn running_timeout_when_log_never_grows() { std::env::set_var("OMEGA_AISB_POLL_ATTEMPTS", "3"); // 15ms budget, never grows let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/master/chat", &token); let (mut ws, _) = connect_async(url).await.unwrap(); - ws.send(tokio_tungstenite::tungstenite::Message::Text("anyone there?".to_string())).await.unwrap(); + ws.send(tokio_tungstenite::tungstenite::Message::Text( + "anyone there?".to_string(), + )) + .await + .unwrap(); let msg = ws.next().await.unwrap().unwrap().into_text().unwrap(); let frame: serde_json::Value = serde_json::from_str(&msg).unwrap(); assert_eq!(frame["type"], "timeout"); @@ -181,14 +205,19 @@ async fn oversized_message_rejected_with_error_frame_and_inbox_untouched() { std::env::set_var("HOME", fake_home.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/master/chat", &token); let (mut ws, _) = connect_async(url).await.unwrap(); // One byte over routes_master.rs's MAX_MASTER_CHAT_MESSAGE_LEN (8000). let too_long = "x".repeat(8001); - ws.send(tokio_tungstenite::tungstenite::Message::Text(too_long)).await.unwrap(); + ws.send(tokio_tungstenite::tungstenite::Message::Text(too_long)) + .await + .unwrap(); let msg = ws.next().await.unwrap().unwrap().into_text().unwrap(); let frame: serde_json::Value = serde_json::from_str(&msg).unwrap(); assert_eq!(frame["type"], "error"); @@ -205,7 +234,11 @@ async fn oversized_message_rejected_with_error_frame_and_inbox_untouched() { // The loop keeps serving after a rejection rather than closing — a // normal-length follow-up message still gets a real (NotRunning) reply // on the SAME socket. - ws.send(tokio_tungstenite::tungstenite::Message::Text("hello master".to_string())).await.unwrap(); + ws.send(tokio_tungstenite::tungstenite::Message::Text( + "hello master".to_string(), + )) + .await + .unwrap(); let msg2 = ws.next().await.unwrap().unwrap().into_text().unwrap(); let frame2: serde_json::Value = serde_json::from_str(&msg2).unwrap(); assert_eq!(frame2["type"], "not_running"); @@ -228,7 +261,10 @@ async fn concurrency_cap_returns_429_when_master_chat_permits_exhausted() { std::env::set_var("OMEGA_AISB_POLL_ATTEMPTS", "1000"); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; // Must match server.rs's MAX_CONCURRENT_MASTER_CHATS. @@ -238,7 +274,11 @@ async fn concurrency_cap_returns_429_when_master_chat_permits_exhausted() { for i in 0..MAX_CONCURRENT_MASTER_CHATS { let url = ws_url(&base, "/v1/master/chat", &token); let (mut ws, _) = connect_async(url).await.unwrap(); - ws.send(tokio_tungstenite::tungstenite::Message::Text(format!("turn {i}"))).await.unwrap(); + ws.send(tokio_tungstenite::tungstenite::Message::Text(format!( + "turn {i}" + ))) + .await + .unwrap(); held.push(ws); } @@ -257,10 +297,16 @@ async fn concurrency_cap_returns_429_when_master_chat_permits_exhausted() { #[tokio::test] async fn requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = format!("{}/v1/master/chat", base.replacen("http", "ws", 1)); let err = connect_async(url).await.unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("401") || msg.contains("Unauthorized"), "unexpected error: {msg}"); + assert!( + msg.contains("401") || msg.contains("Unauthorized"), + "unexpected error: {msg}" + ); } diff --git a/crates/omega-gateway/tests/missions_test.rs b/crates/omega-gateway/tests/missions_test.rs index c2a3e32e..3c5fc495 100644 --- a/crates/omega-gateway/tests/missions_test.rs +++ b/crates/omega-gateway/tests/missions_test.rs @@ -43,7 +43,10 @@ async fn get_missions_returns_parsed_ledgers_and_excludes_workers() { .unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -55,14 +58,21 @@ async fn get_missions_returns_parsed_ledgers_and_excludes_workers() { assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); let missions = body["missions"].as_array().unwrap(); - assert_eq!(missions.len(), 1, "worker ledger must be excluded from the mirror"); + assert_eq!( + missions.len(), + 1, + "worker ledger must be excluded from the mirror" + ); assert_eq!(missions[0]["key"], "oracle-dentistrygpt"); assert_eq!(missions[0]["project"], "dentistrygpt"); assert_eq!(missions[0]["title"], "Audit code reset vs addition"); assert_eq!(missions[0]["done"], 6); assert_eq!(missions[0]["total"], 6); assert_eq!(missions[0]["tasks"][0]["status"], "done"); - assert_eq!(missions[0]["tasks"][0]["title"], "Audit code reset vs addition"); + assert_eq!( + missions[0]["tasks"][0]["title"], + "Audit code reset vs addition" + ); std::env::remove_var("OMEGA_STATE_DIR"); } @@ -70,9 +80,16 @@ async fn get_missions_returns_parsed_ledgers_and_excludes_workers() { #[tokio::test] async fn get_missions_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/missions")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/missions")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } diff --git a/crates/omega-gateway/tests/new_project_test.rs b/crates/omega-gateway/tests/new_project_test.rs index 4b003244..d92d403c 100644 --- a/crates/omega-gateway/tests/new_project_test.rs +++ b/crates/omega-gateway/tests/new_project_test.rs @@ -53,7 +53,10 @@ async fn stream_rejects_empty_name_before_any_spawn() { let bin_dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; // A REAL WS handshake attempt proves this hits our handler's own @@ -73,7 +76,10 @@ async fn stream_rejects_bad_name_charset_before_any_spawn() { let bin_dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/new-project/stream", &token) + "&name=My_Project&category=works"; @@ -97,7 +103,10 @@ async fn stream_rejects_name_starting_with_dash_build_before_any_spawn() { let bin_dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/new-project/stream", &token) + "&name=--build&category=works"; @@ -117,7 +126,10 @@ async fn stream_rejects_name_starting_with_dash_dry_run_before_any_spawn() { let bin_dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/new-project/stream", &token) + "&name=--dry-run&category=works"; @@ -137,10 +149,14 @@ async fn stream_rejects_group_starting_with_dash_before_any_spawn() { let bin_dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let url = ws_url(&base, "/v1/new-project/stream", &token) + "&name=cool-app&category=works&group=-x"; + let url = + ws_url(&base, "/v1/new-project/stream", &token) + "&name=cool-app&category=works&group=-x"; let err = connect_async(url).await.unwrap_err(); assert!(err.to_string().contains("400"), "unexpected error: {err}"); @@ -156,17 +172,22 @@ async fn stream_rejects_group_starting_with_dash_before_any_spawn() { /// reject before any spawn, mirroring `routes_team.rs`'s own /// `"Team-{project}"` round-trip check. #[tokio::test] -async fn stream_rejects_name_that_would_truncate_session_name_after_setup_suffix_before_any_spawn() { +async fn stream_rejects_name_that_would_truncate_session_name_after_setup_suffix_before_any_spawn() +{ let _g = LOCK.lock().await; let gateway_dir = tempfile::tempdir().unwrap(); let bin_dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let name = "a".repeat(49); - let url = ws_url(&base, "/v1/new-project/stream", &token) + &format!("&name={name}&category=works"); + let url = + ws_url(&base, "/v1/new-project/stream", &token) + &format!("&name={name}&category=works"); let err = connect_async(url).await.unwrap_err(); assert!(err.to_string().contains("400"), "unexpected error: {err}"); @@ -180,13 +201,17 @@ async fn stream_rejects_name_that_would_truncate_session_name_after_setup_suffix /// independently rejected before any spawn, never silently merged onto one /// real session. #[tokio::test] -async fn stream_rejects_both_of_two_names_that_would_collide_onto_the_same_session_before_any_spawn() { +async fn stream_rejects_both_of_two_names_that_would_collide_onto_the_same_session_before_any_spawn( +) { let _g = LOCK.lock().await; let gateway_dir = tempfile::tempdir().unwrap(); let bin_dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; // Sanity-check the collision premise itself before asserting on the @@ -196,15 +221,26 @@ async fn stream_rejects_both_of_two_names_that_would_collide_onto_the_same_sessi let name_b = "a".repeat(50); let session_a = omega_core::session::sanitize_session_name(&format!("{name_a}-setup")); let session_b = omega_core::session::sanitize_session_name(&format!("{name_b}-setup")); - assert_eq!(session_a, session_b, "test premise broken: these two names no longer collide"); + assert_eq!( + session_a, session_b, + "test premise broken: these two names no longer collide" + ); - let url_a = ws_url(&base, "/v1/new-project/stream", &token) + &format!("&name={name_a}&category=works"); + let url_a = + ws_url(&base, "/v1/new-project/stream", &token) + &format!("&name={name_a}&category=works"); let err_a = connect_async(url_a).await.unwrap_err(); - assert!(err_a.to_string().contains("400"), "unexpected error for name_a: {err_a}"); + assert!( + err_a.to_string().contains("400"), + "unexpected error for name_a: {err_a}" + ); - let url_b = ws_url(&base, "/v1/new-project/stream", &token) + &format!("&name={name_b}&category=works"); + let url_b = + ws_url(&base, "/v1/new-project/stream", &token) + &format!("&name={name_b}&category=works"); let err_b = connect_async(url_b).await.unwrap_err(); - assert!(err_b.to_string().contains("400"), "unexpected error for name_b: {err_b}"); + assert!( + err_b.to_string().contains("400"), + "unexpected error for name_b: {err_b}" + ); clear_env(); } @@ -216,10 +252,14 @@ async fn stream_rejects_unknown_category_before_any_spawn() { let bin_dir = tempfile::tempdir().unwrap(); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let url = ws_url(&base, "/v1/new-project/stream", &token) + "&name=cool-app&category=not-a-real-category"; + let url = ws_url(&base, "/v1/new-project/stream", &token) + + "&name=cool-app&category=not-a-real-category"; let err = connect_async(url).await.unwrap_err(); assert!(err.to_string().contains("400"), "unexpected error: {err}"); @@ -249,7 +289,10 @@ exit 1 ), ); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; // category omitted entirely: must default to "works" in the argv. @@ -266,7 +309,11 @@ exit 1 lines.push(v); }; - assert_eq!(lines.len(), 3, "2 stdout + 1 stderr line expected, got {lines:?}"); + assert_eq!( + lines.len(), + 3, + "2 stdout + 1 stderr line expected, got {lines:?}" + ); let texts: Vec<&str> = lines.iter().map(|l| l["text"].as_str().unwrap()).collect(); assert!(texts.iter().any(|t| t.contains("bootstrap running"))); assert!(texts.iter().any(|t| t.contains("session: cool-app-setup"))); @@ -283,9 +330,18 @@ exit 1 assert_eq!(argv[2], "cool-app"); assert_eq!(argv[3], "nextstack"); assert_eq!(argv[4], "works"); - assert!(!argv.contains(&"--group"), "group must not be forwarded when omitted: {argv:?}"); - assert!(!argv.contains(&"--build"), "build must never be forwarded: {argv:?}"); - assert!(!argv.contains(&"--dry-run"), "dry-run must never be forwarded: {argv:?}"); + assert!( + !argv.contains(&"--group"), + "group must not be forwarded when omitted: {argv:?}" + ); + assert!( + !argv.contains(&"--build"), + "build must never be forwarded: {argv:?}" + ); + assert!( + !argv.contains(&"--dry-run"), + "dry-run must never be forwarded: {argv:?}" + ); clear_env(); } @@ -308,7 +364,10 @@ exit 0 ), ); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/new-project/stream", &token) @@ -344,7 +403,10 @@ async fn stream_nonzero_exit_reports_failure() { let bin_dir = tempfile::tempdir().unwrap(); install_fake_omega(bin_dir.path(), "echo 'trying...'; echo 'boom' >&2; exit 1"); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/new-project/stream", &token) + "&name=cool-app&category=works"; @@ -399,7 +461,10 @@ fi ), ); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/new-project/stream", &token) + "&name=cool-app&category=works"; @@ -411,14 +476,23 @@ fi ws.close(None).await.unwrap(); drop(ws); - assert!(!marker.exists(), "marker must not exist yet — the nested child hasn't reached it"); + assert!( + !marker.exists(), + "marker must not exist yet — the nested child hasn't reached it" + ); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8); while tokio::time::Instant::now() < deadline { - assert!(!marker.exists(), "the SILENT nested child kept running after a clean disconnect"); + assert!( + !marker.exists(), + "the SILENT nested child kept running after a clean disconnect" + ); tokio::time::sleep(std::time::Duration::from_millis(200)).await; } - assert!(!marker.exists(), "the silent nested child survived the disconnect"); + assert!( + !marker.exists(), + "the silent nested child survived the disconnect" + ); clear_env(); } @@ -432,7 +506,10 @@ async fn concurrency_cap_returns_429_when_new_project_permits_exhausted() { // attempt fires. install_fake_omega(bin_dir.path(), "echo starting; sleep 5; exit 0"); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; // Must match server.rs's MAX_CONCURRENT_NEW_PROJECT_SPAWNS. @@ -440,8 +517,8 @@ async fn concurrency_cap_returns_429_when_new_project_permits_exhausted() { let mut held = Vec::new(); for i in 0..MAX_CONCURRENT_NEW_PROJECT_SPAWNS { - let url = - ws_url(&base, "/v1/new-project/stream", &token) + &format!("&name=cool-app-{i}&category=works"); + let url = ws_url(&base, "/v1/new-project/stream", &token) + + &format!("&name=cool-app-{i}&category=works"); let (mut ws, _) = connect_async(url).await.unwrap(); let first = ws.next().await.unwrap().unwrap(); let v: serde_json::Value = serde_json::from_str(&first.into_text().unwrap()).unwrap(); @@ -459,10 +536,19 @@ async fn concurrency_cap_returns_429_when_new_project_permits_exhausted() { #[tokio::test] async fn stream_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let url = format!("{}/v1/new-project/stream?name=x&category=works", base.replacen("http", "ws", 1)); + let url = format!( + "{}/v1/new-project/stream?name=x&category=works", + base.replacen("http", "ws", 1) + ); let err = connect_async(url).await.unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("401") || msg.contains("Unauthorized"), "unexpected error: {msg}"); + assert!( + msg.contains("401") || msg.contains("Unauthorized"), + "unexpected error: {msg}" + ); } diff --git a/crates/omega-gateway/tests/oracle_ops_test.rs b/crates/omega-gateway/tests/oracle_ops_test.rs index 7a3e9ebb..c4a06cd5 100644 --- a/crates/omega-gateway/tests/oracle_ops_test.rs +++ b/crates/omega-gateway/tests/oracle_ops_test.rs @@ -25,7 +25,10 @@ async fn spawn(app: axum::Router) -> String { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -38,7 +41,11 @@ fn clear_env() { /// full argv (one per line) to a capture file, so a test can prove exactly /// what was passed to the subprocess — same idiom `dispatch_test.rs:: /// install_fake_omega` uses. -fn install_fake_omega(bin_dir: &std::path::Path, capture_file: &std::path::Path, script_body: &str) { +fn install_fake_omega( + bin_dir: &std::path::Path, + capture_file: &std::path::Path, + script_body: &str, +) { use std::os::unix::fs::PermissionsExt; let path = bin_dir.join("omega"); let capture = capture_file.display(); @@ -62,7 +69,11 @@ async fn timeline_returns_the_merged_events_for_a_real_oracle_state() { let state_dir = omega_dir.path().join("state"); let t0 = chrono::DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); - let mission = omega_core::mission::Mission::new("Acme", "ship the feature", std::path::PathBuf::from("/tmp")); + let mission = omega_core::mission::Mission::new( + "Acme", + "ship the feature", + std::path::PathBuf::from("/tmp"), + ); let mut state = omega_core::oracle_lifecycle::OracleState::new("oracle-Acme-1", &mission); state.started_at = t0; state.register_worker(omega_core::oracle_lifecycle::WorkerEntry { @@ -91,10 +102,24 @@ async fn timeline_returns_the_merged_events_for_a_real_oracle_state() { assert_eq!(body["oracle_name"], "oracle-Acme-1"); assert_eq!(body["project"], "Acme"); let events = body["events"].as_array().unwrap(); - assert_eq!(events.len(), 2, "oracle-dispatched + worker-dispatch events, got {events:?}"); - assert!(events[0]["text"].as_str().unwrap().starts_with("oracle dispatched")); - assert!(events[1]["text"].as_str().unwrap().contains("dispatch worker 'auth'")); - assert!(events[0]["at"].as_str().unwrap().contains("2023"), "at must be RFC3339: {:?}", events[0]["at"]); + assert_eq!( + events.len(), + 2, + "oracle-dispatched + worker-dispatch events, got {events:?}" + ); + assert!(events[0]["text"] + .as_str() + .unwrap() + .starts_with("oracle dispatched")); + assert!(events[1]["text"] + .as_str() + .unwrap() + .contains("dispatch worker 'auth'")); + assert!( + events[0]["at"].as_str().unwrap().contains("2023"), + "at must be RFC3339: {:?}", + events[0]["at"] + ); clear_env(); } @@ -125,7 +150,10 @@ async fn timeline_404s_cleanly_for_an_unknown_oracle() { #[tokio::test] async fn timeline_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() .get(format!("{base}/v1/oracles/oracle-x/timeline")) @@ -261,10 +289,16 @@ async fn gate_404s_cleanly_when_neither_result_nor_rubric_exists() { #[tokio::test] async fn gate_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/oracles/oracle-x/gate")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/oracles/oracle-x/gate")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } @@ -322,7 +356,11 @@ async fn reap_rejects_a_dash_leading_session_before_any_spawn() { let bin_dir = tempfile::tempdir().unwrap(); let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -335,7 +373,10 @@ async fn reap_rejects_a_dash_leading_session_before_any_spawn() { .await .unwrap(); assert_eq!(res.status(), 400, "session={evil}"); - assert!(!capture_file.exists(), "omega subprocess was spawned for session={evil}"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for session={evil}" + ); } clear_env(); @@ -383,7 +424,11 @@ async fn reap_rejects_nul_byte_session_before_any_spawn() { let bin_dir = tempfile::tempdir().unwrap(); let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -399,7 +444,10 @@ async fn reap_rejects_nul_byte_session_before_any_spawn() { assert_eq!(res.status(), 400); let body: serde_json::Value = res.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("NUL")); - assert!(!capture_file.exists(), "omega subprocess was spawned for a NUL-containing session"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for a NUL-containing session" + ); clear_env(); } @@ -407,10 +455,16 @@ async fn reap_rejects_nul_byte_session_before_any_spawn() { #[tokio::test] async fn reap_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = - reqwest::Client::new().post(format!("{base}/v1/oracles/oracle-x/reap")).send().await.unwrap(); + let res = reqwest::Client::new() + .post(format!("{base}/v1/oracles/oracle-x/reap")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } @@ -423,7 +477,11 @@ async fn resurrect_runs_omega_resurrect_with_exactly_the_oracle_argv() { let bin_dir = tempfile::tempdir().unwrap(); let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "printf '\\xe2\\x97\\x86 resurrected oracle-Acme-1\\n'; exit 0"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "printf '\\xe2\\x97\\x86 resurrected oracle-Acme-1\\n'; exit 0", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -437,7 +495,10 @@ async fn resurrect_runs_omega_resurrect_with_exactly_the_oracle_argv() { assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["resurrected"], true); - assert!(body["output"].as_str().unwrap().contains("resurrected oracle-Acme-1")); + assert!(body["output"] + .as_str() + .unwrap() + .contains("resurrected oracle-Acme-1")); let recorded = std::fs::read_to_string(&capture_file).unwrap(); let argv: Vec<&str> = recorded.lines().collect(); @@ -454,7 +515,11 @@ async fn resurrect_nonzero_exit_surfaces_as_502() { let bin_dir = tempfile::tempdir().unwrap(); let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'session daemon unreachable' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'session daemon unreachable' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -484,7 +549,11 @@ async fn resurrect_rejects_empty_session_before_any_spawn() { let bin_dir = tempfile::tempdir().unwrap(); let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -498,7 +567,10 @@ async fn resurrect_rejects_empty_session_before_any_spawn() { .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "omega subprocess was spawned for a blank session"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for a blank session" + ); clear_env(); } @@ -515,7 +587,11 @@ async fn resurrect_rejects_a_dash_leading_session_before_any_spawn() { let bin_dir = tempfile::tempdir().unwrap(); let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -528,7 +604,10 @@ async fn resurrect_rejects_a_dash_leading_session_before_any_spawn() { .await .unwrap(); assert_eq!(res.status(), 400, "session={evil}"); - assert!(!capture_file.exists(), "omega subprocess was spawned for session={evil}"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for session={evil}" + ); } clear_env(); @@ -537,7 +616,10 @@ async fn resurrect_rejects_a_dash_leading_session_before_any_spawn() { #[tokio::test] async fn resurrect_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() .post(format!("{base}/v1/oracles/oracle-x/resurrect")) diff --git a/crates/omega-gateway/tests/oracles_test.rs b/crates/omega-gateway/tests/oracles_test.rs index b69a1c14..18dd0266 100644 --- a/crates/omega-gateway/tests/oracles_test.rs +++ b/crates/omega-gateway/tests/oracles_test.rs @@ -62,7 +62,10 @@ exit 1"#, .unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -98,9 +101,16 @@ exit 1"#, #[tokio::test] async fn get_oracles_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/oracles")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/oracles")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } diff --git a/crates/omega-gateway/tests/orchestrate_test.rs b/crates/omega-gateway/tests/orchestrate_test.rs index 3a1d0e69..3a2129d9 100644 --- a/crates/omega-gateway/tests/orchestrate_test.rs +++ b/crates/omega-gateway/tests/orchestrate_test.rs @@ -66,13 +66,17 @@ async fn stream_rejects_unknown_project_before_any_spawn() { install_fake_home(home_dir.path(), "TestProj"); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; // A REAL WS handshake attempt proves this hits our handler's own // validation (a plain HTTP 400), never an upgrade followed by an // in-loop error. - let url = ws_url(&base, "/v1/orchestrate/stream", &token) + "&project=nope-not-real&mission=do+it"; + let url = + ws_url(&base, "/v1/orchestrate/stream", &token) + "&project=nope-not-real&mission=do+it"; let err = connect_async(url).await.unwrap_err(); assert!(err.to_string().contains("400"), "unexpected error: {err}"); @@ -88,7 +92,10 @@ async fn stream_rejects_unknown_agent_before_any_spawn() { install_fake_home(home_dir.path(), "TestProj"); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/orchestrate/stream", &token) @@ -108,7 +115,10 @@ async fn stream_rejects_empty_mission_before_any_spawn() { install_fake_home(home_dir.path(), "TestProj"); install_fake_omega_that_must_not_run(bin_dir.path()); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/orchestrate/stream", &token) + "&project=TestProj&mission="; @@ -143,7 +153,10 @@ exit 1 ), ); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; // agent=known-agent-name is supplied but must never reach argv (see @@ -165,10 +178,16 @@ exit 1 lines.push(v); }; - assert_eq!(lines.len(), 3, "2 stdout + 1 stderr line expected, got {lines:?}"); + assert_eq!( + lines.len(), + 3, + "2 stdout + 1 stderr line expected, got {lines:?}" + ); let texts: Vec<&str> = lines.iter().map(|l| l["text"].as_str().unwrap()).collect(); assert!(texts.iter().any(|t| t.contains("Mission dispatched"))); - assert!(texts.iter().any(|t| t.contains("Mission completed successfully"))); + assert!(texts + .iter() + .any(|t| t.contains("Mission completed successfully"))); assert_eq!(exit["success"], true); assert_eq!(exit["code"], 0); @@ -183,8 +202,14 @@ exit 1 assert_eq!(argv[3], "--"); assert_eq!(argv[4], "TestProj"); assert_eq!(argv[5], "do the thing"); - assert!(!argv.contains(&"--agent"), "agent must never be forwarded: {argv:?}"); - assert!(!argv.contains(&"--timeout"), "timeout must never be forwarded: {argv:?}"); + assert!( + !argv.contains(&"--agent"), + "agent must never be forwarded: {argv:?}" + ); + assert!( + !argv.contains(&"--timeout"), + "timeout must never be forwarded: {argv:?}" + ); clear_env(); } @@ -198,7 +223,10 @@ async fn stream_nonzero_exit_reports_failure() { install_fake_home(home_dir.path(), "TestProj"); install_fake_omega(bin_dir.path(), "echo 'trying...'; echo 'boom' >&2; exit 1"); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/orchestrate/stream", &token) + "&project=TestProj&mission=do+it"; @@ -245,7 +273,10 @@ fi ), ); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let url = ws_url(&base, "/v1/orchestrate/stream", &token) + "&project=TestProj&mission=do+it"; @@ -257,14 +288,23 @@ fi ws.close(None).await.unwrap(); drop(ws); - assert!(!marker.exists(), "marker must not exist yet — the nested child hasn't reached it"); + assert!( + !marker.exists(), + "marker must not exist yet — the nested child hasn't reached it" + ); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8); while tokio::time::Instant::now() < deadline { - assert!(!marker.exists(), "the SILENT nested child kept running after a clean disconnect"); + assert!( + !marker.exists(), + "the SILENT nested child kept running after a clean disconnect" + ); tokio::time::sleep(std::time::Duration::from_millis(200)).await; } - assert!(!marker.exists(), "the silent nested child survived the disconnect"); + assert!( + !marker.exists(), + "the silent nested child survived the disconnect" + ); clear_env(); } @@ -280,7 +320,10 @@ async fn concurrency_cap_returns_429_when_orchestrate_permits_exhausted() { // attempt fires. install_fake_omega(bin_dir.path(), "echo starting; sleep 5; exit 0"); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; // Must match server.rs's MAX_CONCURRENT_ORCHESTRATIONS. @@ -288,7 +331,8 @@ async fn concurrency_cap_returns_429_when_orchestrate_permits_exhausted() { let mut held = Vec::new(); for _ in 0..MAX_CONCURRENT_ORCHESTRATIONS { - let url = ws_url(&base, "/v1/orchestrate/stream", &token) + "&project=TestProj&mission=do+it"; + let url = + ws_url(&base, "/v1/orchestrate/stream", &token) + "&project=TestProj&mission=do+it"; let (mut ws, _) = connect_async(url).await.unwrap(); let first = ws.next().await.unwrap().unwrap(); let v: serde_json::Value = serde_json::from_str(&first.into_text().unwrap()).unwrap(); @@ -296,7 +340,8 @@ async fn concurrency_cap_returns_429_when_orchestrate_permits_exhausted() { held.push(ws); } - let url = ws_url(&base, "/v1/orchestrate/stream", &token) + "&project=TestProj&mission=one+too+many"; + let url = + ws_url(&base, "/v1/orchestrate/stream", &token) + "&project=TestProj&mission=one+too+many"; let err = connect_async(url).await.unwrap_err(); assert!(err.to_string().contains("429"), "unexpected error: {err}"); @@ -306,10 +351,19 @@ async fn concurrency_cap_returns_429_when_orchestrate_permits_exhausted() { #[tokio::test] async fn stream_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let url = format!("{}/v1/orchestrate/stream?project=x&mission=y", base.replacen("http", "ws", 1)); + let url = format!( + "{}/v1/orchestrate/stream?project=x&mission=y", + base.replacen("http", "ws", 1) + ); let err = connect_async(url).await.unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("401") || msg.contains("Unauthorized"), "unexpected error: {msg}"); + assert!( + msg.contains("401") || msg.contains("Unauthorized"), + "unexpected error: {msg}" + ); } diff --git a/crates/omega-gateway/tests/pair_test.rs b/crates/omega-gateway/tests/pair_test.rs index 78392c4a..f4deb0a6 100644 --- a/crates/omega-gateway/tests/pair_test.rs +++ b/crates/omega-gateway/tests/pair_test.rs @@ -13,21 +13,30 @@ async fn spawn(app: axum::Router) -> String { async fn pair_with_valid_code_once_then_reject() { let dir = tempfile::tempdir().unwrap(); let pairing = PairingCode::create(dir.path(), 300).unwrap(); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let client = reqwest::Client::new(); - let res = client.post(format!("{base}/v1/pair")) + let res = client + .post(format!("{base}/v1/pair")) .json(&serde_json::json!({ "code": pairing.code, "device_name": "iphone" })) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["token"].as_str().unwrap().len(), 64); // second use of the same code: refused - let res2 = client.post(format!("{base}/v1/pair")) + let res2 = client + .post(format!("{base}/v1/pair")) .json(&serde_json::json!({ "code": pairing.code, "device_name": "mac" })) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res2.status(), 403); } @@ -35,10 +44,16 @@ async fn pair_with_valid_code_once_then_reject() { async fn expired_code_rejected() { let dir = tempfile::tempdir().unwrap(); let pairing = PairingCode::create(dir.path(), -1).unwrap(); // already expired - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().post(format!("{base}/v1/pair")) + let res = reqwest::Client::new() + .post(format!("{base}/v1/pair")) .json(&serde_json::json!({ "code": pairing.code, "device_name": "x" })) - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(res.status(), 403); } diff --git a/crates/omega-gateway/tests/pdf_test.rs b/crates/omega-gateway/tests/pdf_test.rs index fa44722c..ccc09877 100644 --- a/crates/omega-gateway/tests/pdf_test.rs +++ b/crates/omega-gateway/tests/pdf_test.rs @@ -22,7 +22,10 @@ async fn spawn(app: axum::Router) -> String { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -37,7 +40,11 @@ fn clear_env() { /// handler stats the out path for real after a successful exit, so a fake /// bin that only prints text (like `box_test.rs`'s backup fake) would make /// every happy-path test 502. -fn install_fake_omega_pdf(bin_dir: &std::path::Path, capture_file: &std::path::Path, exit_code: i32) { +fn install_fake_omega_pdf( + bin_dir: &std::path::Path, + capture_file: &std::path::Path, + exit_code: i32, +) { use std::os::unix::fs::PermissionsExt; let path = bin_dir.join("omega"); let capture = capture_file.display(); @@ -61,7 +68,11 @@ exit {exit_code} } fn argv_lines(capture_file: &std::path::Path) -> Vec { - std::fs::read_to_string(capture_file).unwrap_or_default().lines().map(str::to_string).collect() + std::fs::read_to_string(capture_file) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() } #[tokio::test] @@ -95,9 +106,17 @@ async fn create_happy_path_returns_path_and_size_and_writes_the_data_file() { assert!(argv.contains(&"--template".to_string())); assert!(argv.contains(&"whitepaper".to_string())); assert!(argv.iter().any(|l| l.contains("/data/")), "argv: {argv:?}"); - assert!(argv.iter().any(|l| l.contains("/output/")), "argv: {argv:?}"); + assert!( + argv.iter().any(|l| l.contains("/output/")), + "argv: {argv:?}" + ); // --send / --caption are NEVER passed. - assert!(!argv.iter().any(|l| l.contains("send") || l.contains("caption")), "argv: {argv:?}"); + assert!( + !argv + .iter() + .any(|l| l.contains("send") || l.contains("caption")), + "argv: {argv:?}" + ); // The data file genuinely holds the client's JSON. let data_idx = argv.iter().position(|l| l == "--data").unwrap(); @@ -129,7 +148,10 @@ async fn create_rejects_unknown_template_before_any_spawn() { .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "no subprocess was ever spawned for an unknown template"); + assert!( + !capture_file.exists(), + "no subprocess was ever spawned for an unknown template" + ); clear_env(); } @@ -197,12 +219,18 @@ async fn create_timeout_kills_the_whole_process_group_not_just_the_direct_child( .unwrap(); assert_eq!(res.status(), 502); let body: serde_json::Value = res.json().await.unwrap(); - assert!(body["error"].as_str().unwrap().contains("timed out"), "body: {body}"); + assert!( + body["error"].as_str().unwrap().contains("timed out"), + "body: {body}" + ); // Generous buffer past the nested sleep's 4s -- if the group kill // missed the nested child, the marker appears around the 4s mark. tokio::time::sleep(std::time::Duration::from_secs(5)).await; - assert!(!marker.exists(), "the nested child survived the timeout kill"); + assert!( + !marker.exists(), + "the nested child survived the timeout kill" + ); clear_env(); } @@ -213,7 +241,10 @@ async fn create_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); let pdf_scratch = tempfile::tempdir().unwrap(); std::env::set_var("OMEGA_PDF_DIR", pdf_scratch.path()); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -260,8 +291,14 @@ async fn download_round_trips_a_generated_pdf() { .await .unwrap(); assert_eq!(dl_res.status(), 200); - assert_eq!(dl_res.headers().get("content-type").unwrap(), "application/pdf"); - assert_eq!(dl_res.headers().get("content-disposition").unwrap(), "attachment"); + assert_eq!( + dl_res.headers().get("content-type").unwrap(), + "application/pdf" + ); + assert_eq!( + dl_res.headers().get("content-disposition").unwrap(), + "attachment" + ); let bytes = dl_res.bytes().await.unwrap(); assert_eq!(&bytes[..], b"%PDF-FAKE-CONTENT"); @@ -291,7 +328,10 @@ async fn download_rejects_traversal_even_when_the_client_echoes_an_absolute_outs // particular must NEVER be 200 with /etc/passwd's real content. assert_eq!(dl_res.status(), 404); let body_text = dl_res.text().await.unwrap(); - assert!(!body_text.contains("root:"), "must never leak real /etc/passwd content"); + assert!( + !body_text.contains("root:"), + "must never leak real /etc/passwd content" + ); // A relative traversal attempt is likewise reduced to a bare file name. let dl_res2 = reqwest::Client::new() @@ -372,7 +412,10 @@ async fn download_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); let pdf_scratch = tempfile::tempdir().unwrap(); std::env::set_var("OMEGA_PDF_DIR", pdf_scratch.path()); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -507,7 +550,10 @@ async fn concurrency_cap_returns_429_when_pdf_permits_exhausted() { .unwrap(); assert_eq!(busy_res.status(), 429); let body: serde_json::Value = busy_res.json().await.unwrap(); - assert!(body["error"].as_str().unwrap().contains("too many concurrent")); + assert!(body["error"] + .as_str() + .unwrap() + .contains("too many concurrent")); for task in in_flight { let status = task.await.unwrap(); diff --git a/crates/omega-gateway/tests/projects_test.rs b/crates/omega-gateway/tests/projects_test.rs index 8eee3ff3..3b847052 100644 --- a/crates/omega-gateway/tests/projects_test.rs +++ b/crates/omega-gateway/tests/projects_test.rs @@ -2,6 +2,8 @@ use omega_gateway::auth::DeviceStore; use omega_gateway::config::GatewayConfig; use omega_gateway::server::{build_router, AppState}; +static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + async fn spawn(app: axum::Router) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -9,11 +11,38 @@ async fn spawn(app: axum::Router) -> String { format!("http://{addr}") } +fn project_fixture() -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + let node = home.path().join("Station/Customer/alpha"); + let rust = home.path().join("work/beta"); + let bare = home.path().join("gamma"); + std::fs::create_dir_all(&node).unwrap(); + std::fs::create_dir_all(&rust).unwrap(); + std::fs::create_dir_all(bare.join(".git")).unwrap(); + std::fs::write(node.join("package.json"), "{}").unwrap(); + std::fs::write( + rust.join("Cargo.toml"), + "[package]\nname='beta'\nversion='0.1.0'\n", + ) + .unwrap(); + home +} + +fn clear_env() { + std::env::remove_var("OMEGA_HOME"); +} + #[tokio::test] async fn get_projects_returns_the_discovered_project_list() { + let _guard = LOCK.lock().await; + let home = project_fixture(); + std::env::set_var("OMEGA_HOME", home.path()); let gateway_dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -26,24 +55,49 @@ async fn get_projects_returns_the_discovered_project_list() { let body: serde_json::Value = res.json().await.unwrap(); let projects = body["projects"].as_array().unwrap(); - assert!( - projects.len() >= 10, - "expected at least 10 discovered projects on this box, got {}", - projects.len() + assert_eq!(projects.len(), 3); + let names = projects + .iter() + .filter_map(|project| project["name"].as_str()) + .collect::>(); + assert_eq!( + names, + std::collections::BTreeSet::from(["alpha", "beta", "gamma"]) ); let first = &projects[0]; - assert!(first["name"].as_str().is_some(), "first project missing name"); - assert!(first["container"].as_str().is_some(), "first project missing container"); - assert!(first["stack"].as_array().is_some(), "first project missing stack"); + assert!( + first["name"].as_str().is_some(), + "first project missing name" + ); + assert!( + first["container"].as_str().is_some(), + "first project missing container" + ); + assert!( + first["stack"].as_array().is_some(), + "first project missing stack" + ); + clear_env(); } #[tokio::test] async fn get_projects_requires_auth() { + let _guard = LOCK.lock().await; + let home = project_fixture(); + std::env::set_var("OMEGA_HOME", home.path()); let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/projects")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/projects")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); + clear_env(); } diff --git a/crates/omega-gateway/tests/rules_test.rs b/crates/omega-gateway/tests/rules_test.rs index 2563c76c..49d0efcc 100644 --- a/crates/omega-gateway/tests/rules_test.rs +++ b/crates/omega-gateway/tests/rules_test.rs @@ -13,7 +13,10 @@ async fn spawn(app: axum::Router) -> String { async fn get_rules_returns_laws_and_operational_rules() { let gateway_dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -30,16 +33,30 @@ async fn get_rules_returns_laws_and_operational_rules() { assert!(laws.iter().any(|l| l["id"] == "L0"), "L0 must be present"); let rules = body["rules"].as_array().unwrap(); - assert!(rules.len() >= 40, "expected at least 40 operational rules, got {}", rules.len()); - assert!(rules.iter().any(|r| r["id"] == "R-CLI"), "R-CLI must be present"); + assert!( + rules.len() >= 40, + "expected at least 40 operational rules, got {}", + rules.len() + ); + assert!( + rules.iter().any(|r| r["id"] == "R-CLI"), + "R-CLI must be present" + ); } #[tokio::test] async fn get_rules_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/rules")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/rules")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } diff --git a/crates/omega-gateway/tests/schema_test.rs b/crates/omega-gateway/tests/schema_test.rs index 6f2541a3..c7642472 100644 --- a/crates/omega-gateway/tests/schema_test.rs +++ b/crates/omega-gateway/tests/schema_test.rs @@ -2,7 +2,10 @@ fn schema_contains_all_wire_types() { let schema = omega_gateway::protocol::schema_json(); let v: serde_json::Value = serde_json::from_str(&schema).unwrap(); - let defs = v["definitions"].as_object().or_else(|| v["$defs"].as_object()).unwrap(); + let defs = v["definitions"] + .as_object() + .or_else(|| v["$defs"].as_object()) + .unwrap(); for ty in [ "PairRequest", "PairResponse", diff --git a/crates/omega-gateway/tests/session_close_test.rs b/crates/omega-gateway/tests/session_close_test.rs index 66086d02..94e60f90 100644 --- a/crates/omega-gateway/tests/session_close_test.rs +++ b/crates/omega-gateway/tests/session_close_test.rs @@ -22,7 +22,10 @@ async fn spawn(app: axum::Router) -> String { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -71,7 +74,10 @@ async fn happy_path_close_of_a_plain_session() { // Real cmd_kill success-path shape: only the final "Killed session: ..." // line, no cascaded-worker lines, exit 0. - install_fake_omega(bin_dir.path(), "printf 'Killed session: worker-Foo-1\\n'; exit 0"); + install_fake_omega( + bin_dir.path(), + "printf 'Killed session: worker-Foo-1\\n'; exit 0", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -159,13 +165,23 @@ async fn refused_kill_returns_200_with_killed_false_and_a_sanitized_message() { .send() .await .unwrap(); - assert_eq!(res.status(), 200, "a REFUSED kill is a normal outcome, never a gateway error"); + assert_eq!( + res.status(), + 200, + "a REFUSED kill is a normal outcome, never a gateway error" + ); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["killed"], false); assert_eq!(body["is_oracle"], true); let message = body["message"].as_str().unwrap(); - assert!(!message.contains("REFUSED"), "the failure-path message must be sanitized, not raw CLI text: {message:?}"); - assert!(!message.contains("worker(s)"), "the failure-path message must be sanitized, not raw CLI text: {message:?}"); + assert!( + !message.contains("REFUSED"), + "the failure-path message must be sanitized, not raw CLI text: {message:?}" + ); + assert!( + !message.contains("worker(s)"), + "the failure-path message must be sanitized, not raw CLI text: {message:?}" + ); std::env::remove_var("OMEGA_BIN"); } @@ -205,9 +221,15 @@ async fn refused_kill_still_classifies_is_oracle_off_the_resolved_alias() { assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["killed"], false); - assert_eq!(body["is_oracle"], true, "expected classification off the RESOLVED name even on the REFUSED path"); + assert_eq!( + body["is_oracle"], true, + "expected classification off the RESOLVED name even on the REFUSED path" + ); let message = body["message"].as_str().unwrap(); - assert!(!message.contains("REFUSED"), "the failure-path message must be sanitized, not raw CLI text: {message:?}"); + assert!( + !message.contains("REFUSED"), + "the failure-path message must be sanitized, not raw CLI text: {message:?}" + ); std::env::remove_var("OMEGA_BIN"); } @@ -278,7 +300,10 @@ async fn is_oracle_true_when_the_raw_path_param_is_an_unresolved_alias() { assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["killed"], true); - assert_eq!(body["is_oracle"], true, "expected classification off the RESOLVED name"); + assert_eq!( + body["is_oracle"], true, + "expected classification off the RESOLVED name" + ); assert_eq!(body["cascaded_count"], 1); std::env::remove_var("OMEGA_BIN"); @@ -330,7 +355,11 @@ async fn invalid_session_name_rejects_before_any_subprocess_spawn() { .send() .await .unwrap(); - assert_eq!(res.status(), 400, "expected 400 for session name {bad_name}"); + assert_eq!( + res.status(), + 400, + "expected 400 for session name {bad_name}" + ); } std::env::remove_var("OMEGA_BIN"); @@ -355,7 +384,11 @@ async fn close_uses_a_double_dash_separator_so_a_leading_dash_session_name_is_ne let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega_capturing(bin_dir.path(), &capture_file, "printf 'Killed session: -x\\n'; exit 0"); + install_fake_omega_capturing( + bin_dir.path(), + &capture_file, + "printf 'Killed session: -x\\n'; exit 0", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -385,7 +418,10 @@ async fn close_uses_a_double_dash_separator_so_a_leading_dash_session_name_is_ne async fn post_close_requires_auth() { let _g = LOCK.lock().await; let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() diff --git a/crates/omega-gateway/tests/session_keys_test.rs b/crates/omega-gateway/tests/session_keys_test.rs index 47131dbf..b38f19b4 100644 --- a/crates/omega-gateway/tests/session_keys_test.rs +++ b/crates/omega-gateway/tests/session_keys_test.rs @@ -36,7 +36,11 @@ fn install_fake_rmux(dir: &std::path::Path, capture_file: &std::path::Path) { fn install_fake_rmux_that_must_not_run(dir: &std::path::Path) { use std::os::unix::fs::PermissionsExt; let path = dir.join("rmux"); - std::fs::write(&path, "#!/usr/bin/env bash\necho 'SHOULD NEVER RUN' >&2\nexit 1\n").unwrap(); + std::fs::write( + &path, + "#!/usr/bin/env bash\necho 'SHOULD NEVER RUN' >&2\nexit 1\n", + ) + .unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); std::env::set_var("OMEGA_RMUX_BIN", &path); } @@ -50,7 +54,10 @@ async fn spawn(app: axum::Router) -> String { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -90,8 +97,15 @@ async fn happy_path_with_enter_sends_two_separate_calls() { assert_eq!(body["ok"], true); let calls = parse_calls(&capture_file); - assert_eq!(calls.len(), 2, "expected exactly two separate subprocess invocations"); - assert_eq!(calls[0], vec!["send-keys", "-t", "oracle-Foo-1", "-l", "--", "ls -la"]); + assert_eq!( + calls.len(), + 2, + "expected exactly two separate subprocess invocations" + ); + assert_eq!( + calls[0], + vec!["send-keys", "-t", "oracle-Foo-1", "-l", "--", "ls -la"] + ); assert_eq!(calls[1], vec!["send-keys", "-t", "oracle-Foo-1", "Enter"]); std::env::remove_var("OMEGA_RMUX_BIN"); @@ -120,8 +134,15 @@ async fn enter_false_sends_only_one_call() { assert_eq!(res.status(), 200); let calls = parse_calls(&capture_file); - assert_eq!(calls.len(), 1, "enter omitted/false must send exactly one call"); - assert_eq!(calls[0], vec!["send-keys", "-t", "oracle-Foo-1", "-l", "--", "echo hi"]); + assert_eq!( + calls.len(), + 1, + "enter omitted/false must send exactly one call" + ); + assert_eq!( + calls[0], + vec!["send-keys", "-t", "oracle-Foo-1", "-l", "--", "echo hi"] + ); std::env::remove_var("OMEGA_RMUX_BIN"); } @@ -155,7 +176,10 @@ async fn dash_prefixed_values_land_as_literal_after_separator() { let calls = parse_calls(&capture_file); assert_eq!(calls.len(), 1); - assert_eq!(calls[0], vec!["send-keys", "-t", "oracle-Foo-1", "-l", "--", "-N"]); + assert_eq!( + calls[0], + vec!["send-keys", "-t", "oracle-Foo-1", "-l", "--", "-N"] + ); std::env::remove_var("OMEGA_RMUX_BIN"); } @@ -178,7 +202,11 @@ async fn path_traversal_session_name_rejects_before_any_subprocess_spawn() { .send() .await .unwrap(); - assert_eq!(res.status(), 400, "expected 400 for session name {bad_name}"); + assert_eq!( + res.status(), + 400, + "expected 400 for session name {bad_name}" + ); } std::env::remove_var("OMEGA_RMUX_BIN"); @@ -211,7 +239,10 @@ async fn oversized_data_rejects_before_any_subprocess_spawn() { async fn post_keys_requires_auth() { let _g = LOCK.lock().await; let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() diff --git a/crates/omega-gateway/tests/session_org_adversarial_test.rs b/crates/omega-gateway/tests/session_org_adversarial_test.rs index 839571a9..686041ec 100644 --- a/crates/omega-gateway/tests/session_org_adversarial_test.rs +++ b/crates/omega-gateway/tests/session_org_adversarial_test.rs @@ -22,7 +22,10 @@ async fn spawn(app: axum::Router) -> String { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -56,7 +59,12 @@ async fn n_concurrent_puts_on_distinct_keys_never_lose_an_update() { } let client = reqwest::Client::new(); - let res = client.get(format!("{base}/v1/session-org")).bearer_auth(&token).send().await.unwrap(); + let res = client + .get(format!("{base}/v1/session-org")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); let entries = body["entries"].as_object().unwrap(); @@ -71,7 +79,8 @@ async fn n_concurrent_puts_on_distinct_keys_never_lose_an_update() { for i in 0..N { let name = format!("oracle-Concurrent-{i}"); assert_eq!( - entries[&name]["label"], format!("label-{i}"), + entries[&name]["label"], + format!("label-{i}"), "key {name} missing or wrong after concurrent PUTs -- lost update" ); assert_eq!(entries[&name]["pinned"], true); @@ -83,7 +92,11 @@ async fn corrupted_session_org_json_degrades_to_empty_map_no_panic() { let gateway_dir = tempfile::tempdir().unwrap(); // Pre-seed a garbage (non-JSON) file at the exact path the store reads, // simulating disk corruption / a torn write / manual tampering. - std::fs::write(gateway_dir.path().join("session_org.json"), b"{not valid json!!!").unwrap(); + std::fs::write( + gateway_dir.path().join("session_org.json"), + b"{not valid json!!!", + ) + .unwrap(); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -99,9 +112,17 @@ async fn corrupted_session_org_json_degrades_to_empty_map_no_panic() { // 500, a hung connection (panic unwinding the task), or a crashed // process -- the axum::serve task would abort silently on panic and // every OTHER route on this gateway would go down with it. - assert_eq!(res.status(), 200, "corrupted session_org.json must not surface as a 500 or crash"); + assert_eq!( + res.status(), + 200, + "corrupted session_org.json must not surface as a 500 or crash" + ); let body: serde_json::Value = res.json().await.unwrap(); - assert_eq!(body["entries"], serde_json::json!({}), "corrupted file must degrade to an empty overlay"); + assert_eq!( + body["entries"], + serde_json::json!({}), + "corrupted file must degrade to an empty overlay" + ); } #[tokio::test] @@ -121,7 +142,12 @@ async fn corrupted_session_org_json_does_not_block_a_subsequent_put() { .unwrap(); assert_eq!(res.status(), 200, "a PUT after a corrupted read must still succeed (treats corrupt as empty, then writes cleanly)"); - let res = reqwest::Client::new().get(format!("{base}/v1/session-org")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/session-org")) + .bearer_auth(&token) + .send() + .await + .unwrap(); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["entries"]["oracle-Foo-1"]["label"], "recovered"); } diff --git a/crates/omega-gateway/tests/session_org_routes_test.rs b/crates/omega-gateway/tests/session_org_routes_test.rs index bcd15c62..cfaedbf6 100644 --- a/crates/omega-gateway/tests/session_org_routes_test.rs +++ b/crates/omega-gateway/tests/session_org_routes_test.rs @@ -16,7 +16,10 @@ async fn spawn(app: axum::Router) -> String { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -40,7 +43,12 @@ async fn put_then_get_roundtrips_over_the_wire() { assert_eq!(echoed["folder"], "/work/foo"); assert_eq!(echoed["pinned"], true); - let res = client.get(format!("{base}/v1/session-org")).bearer_auth(&token).send().await.unwrap(); + let res = client + .get(format!("{base}/v1/session-org")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); let entry = &body["entries"]["oracle-Foo-1"]; @@ -92,8 +100,15 @@ async fn put_is_a_full_replace_not_a_merge() { assert_eq!(res.status(), 200); let echoed: serde_json::Value = res.json().await.unwrap(); assert_eq!(echoed["label"], "new"); - assert_eq!(echoed["folder"], serde_json::Value::Null, "full replace: folder must not survive from the first PUT"); - assert_eq!(echoed["pinned"], false, "full replace: pinned must reset to its default, not survive as true"); + assert_eq!( + echoed["folder"], + serde_json::Value::Null, + "full replace: folder must not survive from the first PUT" + ); + assert_eq!( + echoed["pinned"], false, + "full replace: pinned must reset to its default, not survive as true" + ); } #[tokio::test] @@ -118,7 +133,12 @@ async fn put_a_different_key_preserves_the_first_entry() { .await .unwrap(); - let res = client.get(format!("{base}/v1/session-org")).bearer_auth(&token).send().await.unwrap(); + let res = client + .get(format!("{base}/v1/session-org")) + .bearer_auth(&token) + .send() + .await + .unwrap(); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["entries"].as_object().unwrap().len(), 2); assert_eq!(body["entries"]["oracle-Foo-1"]["label"], "first"); @@ -140,7 +160,11 @@ async fn invalid_session_name_rejects_400_before_any_file_write() { .send() .await .unwrap(); - assert_eq!(res.status(), 400, "expected 400 for session name {bad_name}"); + assert_eq!( + res.status(), + 400, + "expected 400 for session name {bad_name}" + ); } assert!( @@ -188,17 +212,27 @@ async fn oversized_folder_rejects_400() { #[tokio::test] async fn get_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/session-org")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/session-org")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); } #[tokio::test] async fn put_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() diff --git a/crates/omega-gateway/tests/session_rename_test.rs b/crates/omega-gateway/tests/session_rename_test.rs index 333b9bab..3d6dd604 100644 --- a/crates/omega-gateway/tests/session_rename_test.rs +++ b/crates/omega-gateway/tests/session_rename_test.rs @@ -19,7 +19,10 @@ async fn spawn(app: axum::Router) -> String { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -46,7 +49,11 @@ fn install_fake_rmux(dir: &std::path::Path, capture_file: &std::path::Path) { fn install_fake_rmux_that_must_not_run(dir: &std::path::Path) { use std::os::unix::fs::PermissionsExt; let path = dir.join("rmux"); - std::fs::write(&path, "#!/usr/bin/env bash\necho 'SHOULD NEVER RUN' >&2\nexit 1\n").unwrap(); + std::fs::write( + &path, + "#!/usr/bin/env bash\necho 'SHOULD NEVER RUN' >&2\nexit 1\n", + ) + .unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); std::env::set_var("OMEGA_RMUX_BIN", &path); } @@ -86,7 +93,10 @@ async fn happy_path_rename_records_exact_argv() { let calls = parse_calls(&capture_file); assert_eq!(calls.len(), 1); - assert_eq!(calls[0], vec!["rename-session", "-t", "oracle-Foo-1", "oracle-Foo-renamed"]); + assert_eq!( + calls[0], + vec!["rename-session", "-t", "oracle-Foo-1", "oracle-Foo-renamed"] + ); std::env::remove_var("OMEGA_RMUX_BIN"); } @@ -109,7 +119,11 @@ async fn new_name_with_dot_rejects_before_any_subprocess_spawn() { .send() .await .unwrap(); - assert_eq!(res.status(), 400, "expected 400 for new_name {bad_new_name}"); + assert_eq!( + res.status(), + 400, + "expected 400 for new_name {bad_new_name}" + ); } std::env::remove_var("OMEGA_RMUX_BIN"); @@ -137,7 +151,11 @@ async fn leading_dash_new_name_rejects_before_any_subprocess_spawn() { .send() .await .unwrap(); - assert_eq!(res.status(), 400, "expected 400 for new_name {bad_new_name}"); + assert_eq!( + res.status(), + 400, + "expected 400 for new_name {bad_new_name}" + ); } std::env::remove_var("OMEGA_RMUX_BIN"); @@ -169,7 +187,10 @@ async fn invalid_path_session_name_rejects_before_any_subprocess_spawn() { async fn post_rename_requires_auth() { let _g = LOCK.lock().await; let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() diff --git a/crates/omega-gateway/tests/sessions_create_test.rs b/crates/omega-gateway/tests/sessions_create_test.rs index 9577bdce..eeafe5bb 100644 --- a/crates/omega-gateway/tests/sessions_create_test.rs +++ b/crates/omega-gateway/tests/sessions_create_test.rs @@ -25,7 +25,11 @@ async fn spawn(app: axum::Router) -> String { /// Writes an executable fake `omega` script that also appends its full argv /// (one per line) to `capture_file` — same idiom `dispatch_test.rs:: /// install_fake_omega` uses. -fn install_fake_omega(bin_dir: &std::path::Path, capture_file: &std::path::Path, script_body: &str) { +fn install_fake_omega( + bin_dir: &std::path::Path, + capture_file: &std::path::Path, + script_body: &str, +) { use std::os::unix::fs::PermissionsExt; let path = bin_dir.join("omega"); let capture = capture_file.display(); @@ -40,7 +44,10 @@ fn install_fake_omega(bin_dir: &std::path::Path, capture_file: &std::path::Path, async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -98,7 +105,17 @@ async fn happy_path_with_explicit_name_builds_exact_argv() { let argv: Vec<&str> = recorded.lines().collect(); // Finding 5 (adversarial review round): `--prompt` is now a single // `=`-joined argv element, never two separate elements. - assert_eq!(argv, vec!["new", "--agent", agent, "--prompt=do the thing", "--", "my-session"]); + assert_eq!( + argv, + vec![ + "new", + "--agent", + agent, + "--prompt=do the thing", + "--", + "my-session" + ] + ); clear_env(); } @@ -119,7 +136,12 @@ async fn happy_path_with_dir_builds_exact_argv_in_order() { let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; - let dir_str = fake_home.path().join("Station").join("Proj").display().to_string(); + let dir_str = fake_home + .path() + .join("Station") + .join("Proj") + .display() + .to_string(); let res = reqwest::Client::new() .post(format!("{base}/v1/sessions")) .bearer_auth(&token) @@ -141,7 +163,15 @@ async fn happy_path_with_dir_builds_exact_argv_in_order() { let dir_flag = format!("--dir={dir_str}"); assert_eq!( argv, - vec!["new", "--agent", agent, dir_flag.as_str(), "--prompt=hello", "--", "my-session"] + vec![ + "new", + "--agent", + agent, + dir_flag.as_str(), + "--prompt=hello", + "--", + "my-session" + ] ); clear_env(); @@ -175,7 +205,9 @@ async fn happy_path_with_no_name_generates_one_and_uses_it_as_the_positional() { assert!(name.starts_with("gw-"), "expected gw- prefix, got {name}"); let hex_part = &name["gw-".len()..]; assert_eq!(hex_part.len(), 12, "expected 12 hex chars, got {hex_part}"); - assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + assert!(hex_part + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); let recorded = std::fs::read_to_string(&capture_file).unwrap(); let argv: Vec<&str> = recorded.lines().collect(); @@ -192,7 +224,11 @@ async fn unknown_agent_rejects_with_400_no_spawn() { let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -207,7 +243,10 @@ async fn unknown_agent_rejects_with_400_no_spawn() { assert_eq!(res.status(), 400); let body: serde_json::Value = res.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("not-a-real-agent")); - assert!(!capture_file.exists(), "omega subprocess was spawned for an unknown agent"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for an unknown agent" + ); clear_env(); } @@ -221,7 +260,11 @@ async fn invalid_caller_name_leading_dash_rejects_with_400_no_spawn() { let capture_file = capture_dir.path().join("argv.txt"); let agent = real_agent_name(); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -234,7 +277,10 @@ async fn invalid_caller_name_leading_dash_rejects_with_400_no_spawn() { .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "omega subprocess was spawned for an invalid name"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for an invalid name" + ); clear_env(); } @@ -248,7 +294,11 @@ async fn invalid_caller_name_slash_rejects_with_400_no_spawn() { let capture_file = capture_dir.path().join("argv.txt"); let agent = real_agent_name(); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -261,7 +311,10 @@ async fn invalid_caller_name_slash_rejects_with_400_no_spawn() { .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "omega subprocess was spawned for an invalid name"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for an invalid name" + ); clear_env(); } @@ -275,7 +328,11 @@ async fn invalid_caller_name_nul_byte_rejects_with_400_no_spawn() { let capture_file = capture_dir.path().join("argv.txt"); let agent = real_agent_name(); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -288,7 +345,10 @@ async fn invalid_caller_name_nul_byte_rejects_with_400_no_spawn() { .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "omega subprocess was spawned for a NUL-containing name"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for a NUL-containing name" + ); clear_env(); } @@ -305,7 +365,11 @@ async fn dir_outside_home_rejects_with_400_no_spawn() { let agent = real_agent_name(); std::env::set_var("HOME", fake_home.path()); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -323,7 +387,10 @@ async fn dir_outside_home_rejects_with_400_no_spawn() { assert_eq!(res.status(), 400); let body: serde_json::Value = res.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("home directory")); - assert!(!capture_file.exists(), "omega subprocess was spawned for a dir outside home"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for a dir outside home" + ); clear_env(); } @@ -368,7 +435,10 @@ async fn prompt_starting_with_dash_reaches_argv_intact_as_a_single_element() { let recorded = std::fs::read_to_string(&capture_file).unwrap(); let argv: Vec<&str> = recorded.lines().collect(); - assert_eq!(argv, vec!["new", "--agent", agent, "--prompt=-x", "--", "my-session"]); + assert_eq!( + argv, + vec!["new", "--agent", agent, "--prompt=-x", "--", "my-session"] + ); clear_env(); } @@ -389,7 +459,11 @@ async fn caller_name_that_sanitize_would_truncate_rejects_with_400_no_spawn() { let capture_file = capture_dir.path().join("argv.txt"); let agent = real_agent_name(); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -405,7 +479,10 @@ async fn caller_name_that_sanitize_would_truncate_rejects_with_400_no_spawn() { .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "omega subprocess was spawned for a name sanitize would truncate"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for a name sanitize would truncate" + ); clear_env(); } @@ -423,7 +500,11 @@ async fn caller_name_with_trailing_dash_that_sanitize_would_trim_rejects_with_40 let capture_file = capture_dir.path().join("argv.txt"); let agent = real_agent_name(); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -436,7 +517,10 @@ async fn caller_name_with_trailing_dash_that_sanitize_would_trim_rejects_with_40 .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "omega subprocess was spawned for a name sanitize would trim"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for a name sanitize would trim" + ); clear_env(); } @@ -462,7 +546,11 @@ async fn dir_traversal_via_nonexistent_leading_component_rejects_with_400_no_spa let agent = real_agent_name(); std::env::set_var("HOME", fake_home.path()); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -487,7 +575,10 @@ async fn dir_traversal_via_nonexistent_leading_component_rejects_with_400_no_spa .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "omega subprocess was spawned for a traversal-shaped dir"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for a traversal-shaped dir" + ); clear_env(); } @@ -501,7 +592,11 @@ async fn prompt_over_length_cap_rejects_with_400_no_spawn() { let capture_file = capture_dir.path().join("argv.txt"); let agent = real_agent_name(); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -518,7 +613,10 @@ async fn prompt_over_length_cap_rejects_with_400_no_spawn() { assert_eq!(res.status(), 400); let body: serde_json::Value = res.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("too long")); - assert!(!capture_file.exists(), "omega subprocess was spawned for an over-length prompt"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for an over-length prompt" + ); clear_env(); } @@ -559,7 +657,10 @@ async fn nonzero_exit_surfaces_stdout_and_stderr_as_502() { assert!(!error_text.contains("session name already in use")); assert!(body.get("stdout").is_none()); assert!(body.get("stderr").is_none()); - assert!(body.get("name").is_none(), "must never fabricate a session on failure"); + assert!( + body.get("name").is_none(), + "must never fabricate a session on failure" + ); clear_env(); } @@ -618,7 +719,10 @@ async fn concurrency_cap_returns_429_when_session_spawn_permits_exhausted() { .unwrap(); assert_eq!(busy_res.status(), 429); let body: serde_json::Value = busy_res.json().await.unwrap(); - assert!(body["error"].as_str().unwrap().contains("too many concurrent")); + assert!(body["error"] + .as_str() + .unwrap() + .contains("too many concurrent")); for task in in_flight { let status = task.await.unwrap(); @@ -654,7 +758,10 @@ async fn create_times_out_with_504_and_kills_the_whole_process_group() { install_fake_omega( bin_dir.path(), &capture_file, - &format!("bash -c 'sleep 4; touch \"{}\"' &\nwait\n", marker.display()), + &format!( + "bash -c 'sleep 4; touch \"{}\"' &\nwait\n", + marker.display() + ), ); let (app, token) = app_and_token(gateway_dir.path()).await; @@ -669,12 +776,18 @@ async fn create_times_out_with_504_and_kills_the_whole_process_group() { .unwrap(); assert_eq!(res.status(), 504); let body: serde_json::Value = res.json().await.unwrap(); - assert!(body["error"].as_str().unwrap().contains("timed out"), "body: {body}"); + assert!( + body["error"].as_str().unwrap().contains("timed out"), + "body: {body}" + ); // Generous buffer past the nested sleep's 4s -- if the group kill // missed the nested child, the marker appears around the 4s mark. tokio::time::sleep(std::time::Duration::from_secs(5)).await; - assert!(!marker.exists(), "the nested child survived the gateway's timeout kill"); + assert!( + !marker.exists(), + "the nested child survived the gateway's timeout kill" + ); std::env::remove_var("OMEGA_CLI_TIMEOUT_SECS"); clear_env(); @@ -683,7 +796,10 @@ async fn create_times_out_with_504_and_kills_the_whole_process_group() { #[tokio::test] async fn post_sessions_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() diff --git a/crates/omega-gateway/tests/sessions_test.rs b/crates/omega-gateway/tests/sessions_test.rs index 83213a74..2f65eca9 100644 --- a/crates/omega-gateway/tests/sessions_test.rs +++ b/crates/omega-gateway/tests/sessions_test.rs @@ -29,17 +29,33 @@ fn install_fake_rmux(dir: &std::path::Path, script_body: &str) { async fn lists_sessions_from_rmux() { let _g = LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); - install_fake_rmux(dir.path(), r#" + install_fake_rmux( + dir.path(), + r#" if [ "$1" = "ls" ]; then printf 'oracle-Verba-1\nworker-a\n'; exit 0; fi -exit 1"#); +exit 1"#, + ); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let body: serde_json::Value = reqwest::Client::new() - .get(format!("{base}/v1/sessions")).bearer_auth(&token) - .send().await.unwrap().json().await.unwrap(); - let names: Vec<&str> = body["sessions"].as_array().unwrap() - .iter().map(|s| s["name"].as_str().unwrap()).collect(); + .get(format!("{base}/v1/sessions")) + .bearer_auth(&token) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let names: Vec<&str> = body["sessions"] + .as_array() + .unwrap() + .iter() + .map(|s| s["name"].as_str().unwrap()) + .collect(); assert_eq!(names, vec!["oracle-Verba-1", "worker-a"]); } @@ -49,12 +65,22 @@ async fn rmux_failure_yields_empty_list_with_error_not_500() { let dir = tempfile::tempdir().unwrap(); install_fake_rmux(dir.path(), "echo 'no server running' >&2; exit 1"); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let app = build_router(AppState::new(dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() - .get(format!("{base}/v1/sessions")).bearer_auth(&token).send().await.unwrap(); + .get(format!("{base}/v1/sessions")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["sessions"].as_array().unwrap().len(), 0); - assert!(body["error"].as_str().unwrap().contains("no server running")); + assert!(body["error"] + .as_str() + .unwrap() + .contains("no server running")); } diff --git a/crates/omega-gateway/tests/skills_test.rs b/crates/omega-gateway/tests/skills_test.rs index 1eeb906e..fc1e24ba 100644 --- a/crates/omega-gateway/tests/skills_test.rs +++ b/crates/omega-gateway/tests/skills_test.rs @@ -2,6 +2,8 @@ use omega_gateway::auth::DeviceStore; use omega_gateway::config::GatewayConfig; use omega_gateway::server::{build_router, AppState}; +static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + async fn spawn(app: axum::Router) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -9,11 +11,52 @@ async fn spawn(app: axum::Router) -> String { format!("http://{addr}") } +fn skill_fixture() -> tempfile::TempDir { + let omega = tempfile::tempdir().unwrap(); + let root = omega.path().join("skills"); + std::fs::create_dir_all(&root).unwrap(); + for index in 0..60 { + let name = if index == 0 { + "audit-fixture".to_string() + } else { + format!("fixture-{index:02}") + }; + let dir = root.join(&name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("SKILL.md"), + format!( + "---\nname: {name}\ndescription: {} test skill\n---\n\n# {name}\n", + if index == 0 { "Audit" } else { "Gateway" } + ), + ) + .unwrap(); + } + let monitor = root.join("monitor"); + std::fs::create_dir_all(&monitor).unwrap(); + std::fs::write( + monitor.join("SKILL.md"), + "---\nname: monitor\ndescription: Monitor sessions\n---\n\n# Monitor\n", + ) + .unwrap(); + omega +} + +fn clear_env() { + std::env::remove_var("OMEGA_DIR"); +} + #[tokio::test] async fn get_skills_returns_the_full_catalog_capped_at_the_default_limit() { + let _guard = LOCK.lock().await; + let omega = skill_fixture(); + std::env::set_var("OMEGA_DIR", omega.path()); let gateway_dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -26,17 +69,28 @@ async fn get_skills_returns_the_full_catalog_capped_at_the_default_limit() { let body: serde_json::Value = res.json().await.unwrap(); let total = body["total"].as_u64().unwrap(); - assert!(total >= 300, "expected at least 300 skills in the catalog, got {total}"); + assert_eq!(total, 61); let skills = body["skills"].as_array().unwrap(); - assert!(skills.len() <= 50, "default cap is 50, got {}", skills.len()); + assert!( + skills.len() <= 50, + "default cap is 50, got {}", + skills.len() + ); + clear_env(); } #[tokio::test] async fn get_skills_filters_by_q_and_caps_by_limit() { + let _guard = LOCK.lock().await; + let omega = skill_fixture(); + std::env::set_var("OMEGA_DIR", omega.path()); let gateway_dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() @@ -49,8 +103,15 @@ async fn get_skills_filters_by_q_and_caps_by_limit() { let body: serde_json::Value = res.json().await.unwrap(); let skills = body["skills"].as_array().unwrap(); - assert!(skills.len() <= 5, "limit=5 must cap the returned skills, got {}", skills.len()); - assert!(!skills.is_empty(), "expected at least one skill matching 'audit'"); + assert!( + skills.len() <= 5, + "limit=5 must cap the returned skills, got {}", + skills.len() + ); + assert!( + !skills.is_empty(), + "expected at least one skill matching 'audit'" + ); for skill in skills { let name = skill["name"].as_str().unwrap().to_lowercase(); @@ -60,23 +121,41 @@ async fn get_skills_filters_by_q_and_caps_by_limit() { "skill {name:?} / {description:?} does not match q=audit" ); } + clear_env(); } #[tokio::test] async fn get_skills_requires_auth() { + let _guard = LOCK.lock().await; + let omega = skill_fixture(); + std::env::set_var("OMEGA_DIR", omega.path()); let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/skills")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/skills")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); + clear_env(); } #[tokio::test] async fn get_skills_filters_by_server_category() { + let _guard = LOCK.lock().await; + let omega = skill_fixture(); + std::env::set_var("OMEGA_DIR", omega.path()); let gateway_dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let response = reqwest::Client::new() .get(format!("{base}/v1/skills?category=Audit&limit=200")) @@ -89,13 +168,20 @@ async fn get_skills_filters_by_server_category() { let skills = body["skills"].as_array().unwrap(); assert!(!skills.is_empty()); assert!(skills.iter().all(|skill| skill["category"] == "Audit")); + clear_env(); } #[tokio::test] async fn skill_detail_returns_content_but_never_a_host_path() { + let _guard = LOCK.lock().await; + let omega = skill_fixture(); + std::env::set_var("OMEGA_DIR", omega.path()); let gateway_dir = tempfile::tempdir().unwrap(); let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let response = reqwest::Client::new() .get(format!("{base}/v1/skills/monitor")) @@ -110,4 +196,27 @@ async fn skill_detail_returns_content_but_never_a_host_path() { .as_str() .is_some_and(|value| !value.is_empty())); assert!(body["skill"].get("path").is_none()); + clear_env(); +} + +#[tokio::test] +async fn missing_skill_install_returns_service_unavailable_not_empty_success() { + let _guard = LOCK.lock().await; + let omega = tempfile::tempdir().unwrap(); + std::env::set_var("OMEGA_DIR", omega.path()); + let gateway_dir = tempfile::tempdir().unwrap(); + let (_, token) = DeviceStore::open(gateway_dir.path()).issue("t"); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); + let base = spawn(app).await; + let response = reqwest::Client::new() + .get(format!("{base}/v1/skills")) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 503); + clear_env(); } diff --git a/crates/omega-gateway/tests/stream_test.rs b/crates/omega-gateway/tests/stream_test.rs index 41bac825..48ddfdf4 100644 --- a/crates/omega-gateway/tests/stream_test.rs +++ b/crates/omega-gateway/tests/stream_test.rs @@ -21,14 +21,22 @@ async fn stream_sends_frame_then_only_on_change() { let _g = LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); // fake rmux: capture-pane output changes based on a counter file - install_fake_rmux(dir.path(), &format!(r#" + install_fake_rmux( + dir.path(), + &format!( + r#" counter="{}/count" n=$(cat "$counter" 2>/dev/null || echo 0) echo $((n+1)) > "$counter" if [ $n -lt 2 ]; then echo "SCREEN-A"; else echo "SCREEN-B"; fi"#, - dir.path().display())); + dir.path().display() + ), + ); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let cfg = GatewayConfig { stream_interval_ms: 50, ..GatewayConfig::default() }; + let cfg = GatewayConfig { + stream_interval_ms: 50, + ..GatewayConfig::default() + }; let app = build_router(AppState::new(dir.path().to_path_buf(), cfg)); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -59,7 +67,10 @@ async fn color_query_param_passes_dash_e_to_capture_pane() { r#"if [[ " $* " == *" -e "* ]]; then echo "COLOR-MODE"; else echo "NO-COLOR"; fi"#, ); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let cfg = GatewayConfig { stream_interval_ms: 50, ..GatewayConfig::default() }; + let cfg = GatewayConfig { + stream_interval_ms: 50, + ..GatewayConfig::default() + }; let app = build_router(AppState::new(dir.path().to_path_buf(), cfg)); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -88,7 +99,10 @@ async fn no_color_param_does_not_pass_dash_e() { r#"if [[ " $* " == *" -e "* ]]; then echo "COLOR-MODE"; else echo "NO-COLOR"; fi"#, ); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let cfg = GatewayConfig { stream_interval_ms: 50, ..GatewayConfig::default() }; + let cfg = GatewayConfig { + stream_interval_ms: 50, + ..GatewayConfig::default() + }; let app = build_router(AppState::new(dir.path().to_path_buf(), cfg)); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -115,7 +129,10 @@ async fn capture_failure_becomes_error_frame_and_loop_survives() { let dir = tempfile::tempdir().unwrap(); install_fake_rmux(dir.path(), "echo 'session not found' >&2; exit 1"); let (_, token) = DeviceStore::open(dir.path()).issue("t"); - let cfg = GatewayConfig { stream_interval_ms: 50, ..GatewayConfig::default() }; + let cfg = GatewayConfig { + stream_interval_ms: 50, + ..GatewayConfig::default() + }; let app = build_router(AppState::new(dir.path().to_path_buf(), cfg)); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -126,7 +143,10 @@ async fn capture_failure_becomes_error_frame_and_loop_survives() { let msg = ws.next().await.unwrap().unwrap().into_text().unwrap(); let frame: serde_json::Value = serde_json::from_str(&msg).unwrap(); assert_eq!(frame["type"], "error"); - assert!(frame["message"].as_str().unwrap().contains("session not found")); + assert!(frame["message"] + .as_str() + .unwrap() + .contains("session not found")); // the connection is still alive: another error frame arrives instead of a close let msg2 = ws.next().await.unwrap().unwrap(); assert!(msg2.is_text()); diff --git a/crates/omega-gateway/tests/team_test.rs b/crates/omega-gateway/tests/team_test.rs index 06f52092..bd90edbd 100644 --- a/crates/omega-gateway/tests/team_test.rs +++ b/crates/omega-gateway/tests/team_test.rs @@ -19,7 +19,11 @@ async fn spawn(app: axum::Router) -> String { /// Writes an executable fake `omega` script that also appends its full argv /// (one per line) to `capture_file` — same idiom `dispatch_test.rs:: /// install_fake_omega` uses. -fn install_fake_omega(bin_dir: &std::path::Path, capture_file: &std::path::Path, script_body: &str) { +fn install_fake_omega( + bin_dir: &std::path::Path, + capture_file: &std::path::Path, + script_body: &str, +) { use std::os::unix::fs::PermissionsExt; let path = bin_dir.join("omega"); let capture = capture_file.display(); @@ -34,7 +38,10 @@ fn install_fake_omega(bin_dir: &std::path::Path, capture_file: &std::path::Path, async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -80,7 +87,15 @@ async fn happy_path_with_members_builds_exact_argv() { let argv: Vec<&str> = recorded.lines().collect(); assert_eq!( argv, - vec!["team", "--count", "2", "--", "Acme", "alice:build the API", "bob:build the UI"] + vec![ + "team", + "--count", + "2", + "--", + "Acme", + "alice:build the API", + "bob:build the UI" + ] ); clear_env(); @@ -160,7 +175,11 @@ async fn project_failing_slug_check_rejects_with_400_no_spawn() { let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -173,7 +192,10 @@ async fn project_failing_slug_check_rejects_with_400_no_spawn() { .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "omega subprocess was spawned for an invalid project name"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for an invalid project name" + ); clear_env(); } @@ -186,7 +208,11 @@ async fn count_zero_rejects_with_400_no_spawn() { let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -199,7 +225,10 @@ async fn count_zero_rejects_with_400_no_spawn() { .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "omega subprocess was spawned for count=0"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for count=0" + ); clear_env(); } @@ -212,7 +241,11 @@ async fn count_nine_rejects_with_400_no_spawn() { let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -225,7 +258,10 @@ async fn count_nine_rejects_with_400_no_spawn() { .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "omega subprocess was spawned for count=9"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for count=9" + ); clear_env(); } @@ -266,7 +302,11 @@ async fn oversized_member_rejects_with_400_no_spawn() { let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -282,7 +322,10 @@ async fn oversized_member_rejects_with_400_no_spawn() { assert_eq!(res.status(), 400); let body: serde_json::Value = res.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("too long")); - assert!(!capture_file.exists(), "omega subprocess was spawned for an oversized member"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for an oversized member" + ); clear_env(); } @@ -303,7 +346,11 @@ async fn project_whose_team_prefixed_name_sanitize_would_truncate_rejects_with_4 let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -319,7 +366,10 @@ async fn project_whose_team_prefixed_name_sanitize_would_truncate_rejects_with_4 .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "omega subprocess was spawned for a project sanitize would truncate"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for a project sanitize would truncate" + ); clear_env(); } @@ -338,7 +388,11 @@ async fn nine_members_rejects_with_400_no_spawn() { let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -354,7 +408,10 @@ async fn nine_members_rejects_with_400_no_spawn() { assert_eq!(res.status(), 400); let body: serde_json::Value = res.json().await.unwrap(); assert!(body["error"].as_str().unwrap().contains("too many members")); - assert!(!capture_file.exists(), "omega subprocess was spawned for 9 members"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for 9 members" + ); clear_env(); } @@ -397,7 +454,11 @@ async fn member_with_nul_byte_rejects_with_400_no_spawn() { let capture_dir = tempfile::tempdir().unwrap(); let capture_file = capture_dir.path().join("argv.txt"); - install_fake_omega(bin_dir.path(), &capture_file, "echo 'SHOULD NEVER RUN' >&2; exit 1"); + install_fake_omega( + bin_dir.path(), + &capture_file, + "echo 'SHOULD NEVER RUN' >&2; exit 1", + ); let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; @@ -410,7 +471,10 @@ async fn member_with_nul_byte_rejects_with_400_no_spawn() { .await .unwrap(); assert_eq!(res.status(), 400); - assert!(!capture_file.exists(), "omega subprocess was spawned for a NUL-containing member"); + assert!( + !capture_file.exists(), + "omega subprocess was spawned for a NUL-containing member" + ); clear_env(); } @@ -445,13 +509,25 @@ async fn nonzero_exit_surfaces_stdout_and_stderr_as_502() { // stdout/stderr is no longer echoed into the response body -- only a // sanitized, generic error. The full raw text still goes to the // gateway's own tracing log, never the HTTP response. - assert!(body.get("stdout").is_none(), "must not echo raw stdout: {body}"); - assert!(body.get("stderr").is_none(), "must not echo raw stderr: {body}"); assert!( - !body["error"].as_str().unwrap().contains("rmux daemon unreachable"), + body.get("stdout").is_none(), + "must not echo raw stdout: {body}" + ); + assert!( + body.get("stderr").is_none(), + "must not echo raw stderr: {body}" + ); + assert!( + !body["error"] + .as_str() + .unwrap() + .contains("rmux daemon unreachable"), "error message must not contain the raw subprocess text: {body}" ); - assert!(body.get("session").is_none(), "must never fabricate a session on failure"); + assert!( + body.get("session").is_none(), + "must never fabricate a session on failure" + ); clear_env(); } @@ -549,7 +625,10 @@ async fn concurrency_cap_returns_429_when_session_spawn_permits_exhausted() { .unwrap(); assert_eq!(busy_res.status(), 429); let body: serde_json::Value = busy_res.json().await.unwrap(); - assert!(body["error"].as_str().unwrap().contains("too many concurrent")); + assert!(body["error"] + .as_str() + .unwrap() + .contains("too many concurrent")); for task in in_flight { let status = task.await.unwrap(); @@ -562,7 +641,10 @@ async fn concurrency_cap_returns_429_when_session_spawn_permits_exhausted() { #[tokio::test] async fn post_team_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; let res = reqwest::Client::new() diff --git a/crates/omega-gateway/tests/telegram_test.rs b/crates/omega-gateway/tests/telegram_test.rs index fa7f5681..bcfc90db 100644 --- a/crates/omega-gateway/tests/telegram_test.rs +++ b/crates/omega-gateway/tests/telegram_test.rs @@ -25,7 +25,10 @@ async fn spawn(app: axum::Router) -> String { async fn app_and_token(gateway_dir: &std::path::Path) -> (axum::Router, String) { let (_, token) = DeviceStore::open(gateway_dir).issue("t"); - let app = build_router(AppState::new(gateway_dir.to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.to_path_buf(), + GatewayConfig::default(), + )); (app, token) } @@ -51,9 +54,17 @@ async fn status_reports_unconfigured_when_no_toml_exists() { let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/telegram/status")).bearer_auth(&token).send().await.unwrap(); - assert_eq!(res.status(), 200, "unconfigured is a normal response, never an error"); + let res = reqwest::Client::new() + .get(format!("{base}/v1/telegram/status")) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + 200, + "unconfigured is a normal response, never an error" + ); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["configured"], false); assert!(body["enabled"].is_null()); @@ -71,11 +82,18 @@ async fn status_redacts_the_bot_token() { let (app, token) = app_and_token(gateway_dir.path()).await; let base = spawn(app).await; - let res = - reqwest::Client::new().get(format!("{base}/v1/telegram/status")).bearer_auth(&token).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/telegram/status")) + .bearer_auth(&token) + .send() + .await + .unwrap(); assert_eq!(res.status(), 200); let body_text = res.text().await.unwrap(); - assert!(!body_text.contains("FAKE-super-secret-token"), "the bot token must never round-trip"); + assert!( + !body_text.contains("FAKE-super-secret-token"), + "the bot token must never round-trip" + ); let body: serde_json::Value = serde_json::from_str(&body_text).unwrap(); assert_eq!(body["configured"], true); assert_eq!(body["enabled"], true); @@ -158,7 +176,10 @@ async fn enable_on_an_unconfigured_bridge_is_404_not_a_fabricated_config() { .await .unwrap(); assert_eq!(res.status(), 404); - assert!(!home.path().join(".omega/telegram.toml").exists(), "never fabricates a config on enable"); + assert!( + !home.path().join(".omega/telegram.toml").exists(), + "never fabricates a config on enable" + ); clear_env(); } @@ -189,10 +210,17 @@ async fn status_requires_auth() { let gateway_dir = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); std::env::set_var("HOME", home.path()); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res = reqwest::Client::new().get(format!("{base}/v1/telegram/status")).send().await.unwrap(); + let res = reqwest::Client::new() + .get(format!("{base}/v1/telegram/status")) + .send() + .await + .unwrap(); assert_eq!(res.status(), 401); clear_env(); @@ -205,12 +233,23 @@ async fn enable_and_disable_require_auth() { let home = tempfile::tempdir().unwrap(); std::env::set_var("HOME", home.path()); write_fake_telegram_toml(home.path(), false); - let app = build_router(AppState::new(gateway_dir.path().to_path_buf(), GatewayConfig::default())); + let app = build_router(AppState::new( + gateway_dir.path().to_path_buf(), + GatewayConfig::default(), + )); let base = spawn(app).await; - let res_en = reqwest::Client::new().post(format!("{base}/v1/telegram/enable")).send().await.unwrap(); + let res_en = reqwest::Client::new() + .post(format!("{base}/v1/telegram/enable")) + .send() + .await + .unwrap(); assert_eq!(res_en.status(), 401); - let res_dis = reqwest::Client::new().post(format!("{base}/v1/telegram/disable")).send().await.unwrap(); + let res_dis = reqwest::Client::new() + .post(format!("{base}/v1/telegram/disable")) + .send() + .await + .unwrap(); assert_eq!(res_dis.status(), 401); // Neither unauthenticated call touched the config. diff --git a/crates/omega-tui/src/app.rs b/crates/omega-tui/src/app.rs index f0d76969..5b305e86 100644 --- a/crates/omega-tui/src/app.rs +++ b/crates/omega-tui/src/app.rs @@ -328,9 +328,11 @@ pub enum MenuAction { NewClaude, NewCodex, NewGemini, + NewAntigravity, NewPi, NewHermes, NewGlm, + NewKimi, NewTerminal, NewProject, DispatchOracle, @@ -349,9 +351,11 @@ impl MenuAction { MenuAction::NewClaude, MenuAction::NewCodex, MenuAction::NewGemini, + MenuAction::NewAntigravity, MenuAction::NewPi, MenuAction::NewHermes, MenuAction::NewGlm, + MenuAction::NewKimi, MenuAction::NewTerminal, MenuAction::NewProject, MenuAction::DispatchOracle, @@ -370,9 +374,11 @@ impl MenuAction { MenuAction::NewClaude => "New Claude session", MenuAction::NewCodex => "New Codex session", MenuAction::NewGemini => "New Gemini session", + MenuAction::NewAntigravity => "New Antigravity session", MenuAction::NewPi => "New Pi session (earendil-works)", MenuAction::NewHermes => "New Hermes session (Nous Research)", MenuAction::NewGlm => "New GLM session", + MenuAction::NewKimi => "New Kimi session (Moonshot AI)", MenuAction::NewTerminal => "New Terminal (plain shell)", MenuAction::NewProject => { "New project → pick stack + auto-provision (Convex/Vercel/Clerk/Stripe)" @@ -395,9 +401,11 @@ impl MenuAction { MenuAction::NewClaude => "c", MenuAction::NewCodex => "o", MenuAction::NewGemini => "g", + MenuAction::NewAntigravity => "a", MenuAction::NewPi => "p", MenuAction::NewHermes => "h", MenuAction::NewGlm => "G", + MenuAction::NewKimi => "K", MenuAction::NewTerminal => "t", MenuAction::NewProject => "N", MenuAction::DispatchOracle => "d", @@ -416,9 +424,11 @@ impl MenuAction { MenuAction::NewClaude => Some(omega_core::agents::Agent::Claude), MenuAction::NewCodex => Some(omega_core::agents::Agent::Codex), MenuAction::NewGemini => Some(omega_core::agents::Agent::Gemini), + MenuAction::NewAntigravity => Some(omega_core::agents::Agent::Antigravity), MenuAction::NewPi => Some(omega_core::agents::Agent::Pi), MenuAction::NewHermes => Some(omega_core::agents::Agent::Hermes), MenuAction::NewGlm => Some(omega_core::agents::Agent::Glm), + MenuAction::NewKimi => Some(omega_core::agents::Agent::Kimi), MenuAction::NewTerminal => Some(omega_core::agents::Agent::Shell), _ => None, } @@ -580,8 +590,8 @@ fn effort_field(config_key: &str, current: &str) -> SettingsField { /// Build a model field for a provider. When the provider has a known model /// list (`providers::models_for`), this is an arrow-key Select (NO typing); -/// otherwise it falls back to a free-text field so providers without a curated -/// list (e.g. pi/hermes) still work. +/// otherwise it falls back to a free-text field for account-scoped catalogs +/// such as Antigravity. fn model_field(provider: &str, config_key: &str, current: &str) -> SettingsField { let opts: Vec = omega_core::providers::ProvidersConfig::models_for(provider) .iter() @@ -699,7 +709,12 @@ pub fn fields_for_section( SettingsSection::Claude => Some(Agent::Claude), SettingsSection::Codex => Some(Agent::Codex), SettingsSection::Gemini => Some(Agent::Gemini), + SettingsSection::Antigravity => Some(Agent::Antigravity), + SettingsSection::OpenRouter => Some(Agent::OpenRouter), + SettingsSection::Pi => Some(Agent::Pi), + SettingsSection::Hermes => Some(Agent::Hermes), SettingsSection::Glm => Some(Agent::Glm), + SettingsSection::Kimi => Some(Agent::Kimi), _ => None, } }; @@ -847,6 +862,11 @@ pub fn fields_for_section( current_value: c.base_url.clone(), masked: false, }); + out.push(SettingsField::Toggle { + label: "Bypass hook trust in Omega sessions".to_string(), + config_key: "codex.bypass_hook_trust".to_string(), + current: c.bypass_hook_trust, + }); out.extend(install_actions_for(Agent::Codex)); } SettingsSection::Gemini => { @@ -860,6 +880,32 @@ pub fn fields_for_section( }); out.extend(install_actions_for(Agent::Gemini)); } + SettingsSection::Antigravity => { + let c = &providers.antigravity; + out.push(model_field("antigravity", "antigravity.model", &c.model)); + let mut effort_options = + vec!["low".to_string(), "medium".to_string(), "high".to_string()]; + let effort_index = match effort_options.iter().position(|e| e == &c.effort) { + Some(index) => index, + None if c.effort.is_empty() => 1, + None => { + effort_options.insert(0, c.effort.clone()); + 0 + } + }; + out.push(SettingsField::Select { + label: "Effort".to_string(), + config_key: "antigravity.effort".to_string(), + options: effort_options, + current_index: effort_index, + }); + out.push(SettingsField::Toggle { + label: "Dangerously skip permissions".to_string(), + config_key: "antigravity.dangerously_skip_permissions".to_string(), + current: c.dangerously_skip_permissions, + }); + out.extend(install_actions_for(Agent::Antigravity)); + } SettingsSection::Glm => { let c = &providers.glm; out.push(model_field("glm", "glm.model", &c.model)); @@ -871,6 +917,23 @@ pub fn fields_for_section( }); out.extend(install_actions_for(Agent::Glm)); } + SettingsSection::OpenRouter => { + let c = &providers.openrouter; + out.push(model_field("openrouter", "openrouter.model", &c.model)); + out.push(SettingsField::EditText { + label: "OpenRouter API key".to_string(), + config_key: "openrouter.api_key".to_string(), + current_value: c.api_key.clone(), + masked: true, + }); + out.push(SettingsField::EditText { + label: "Base URL".to_string(), + config_key: "openrouter.base_url".to_string(), + current_value: c.base_url.clone(), + masked: false, + }); + out.extend(install_actions_for(Agent::OpenRouter)); + } SettingsSection::Pi => { let c = &providers.pi; out.push(SettingsField::EditText { @@ -890,6 +953,12 @@ pub fn fields_for_section( } SettingsSection::Hermes => { let c = &providers.hermes; + out.push(SettingsField::EditText { + label: "Provider (empty = native; key = openrouter)".to_string(), + config_key: "hermes.provider".to_string(), + current_value: c.provider.clone(), + masked: false, + }); out.push(model_field("hermes", "hermes.model", &c.model)); out.push(SettingsField::EditText { label: "Hermes API key".to_string(), @@ -899,6 +968,29 @@ pub fn fields_for_section( }); out.extend(install_actions_for(Agent::Hermes)); } + SettingsSection::Kimi => { + let c = &providers.kimi; + out.push(model_field("kimi", "kimi.model", &c.model)); + out.push(SettingsField::EditText { + label: "Kimi API key".to_string(), + config_key: "kimi.api_key".to_string(), + current_value: c.api_key.clone(), + masked: true, + }); + out.push(SettingsField::EditText { + label: "Base URL".to_string(), + config_key: "kimi.base_url".to_string(), + current_value: c.base_url.clone(), + masked: false, + }); + out.push(SettingsField::EditText { + label: "Provider type (kimi/anthropic/openai)".to_string(), + config_key: "kimi.provider_type".to_string(), + current_value: c.provider_type.clone(), + masked: false, + }); + out.extend(install_actions_for(Agent::Kimi)); + } SettingsSection::Aisb => { out.push(SettingsField::Info(format!( "Viewer session name: {}", @@ -1013,9 +1105,12 @@ pub enum SettingsSection { Claude, Codex, Gemini, + Antigravity, + OpenRouter, Pi, Hermes, Glm, + Kimi, Aisb, Telegram, } @@ -1029,9 +1124,12 @@ impl SettingsSection { SettingsSection::Claude, SettingsSection::Codex, SettingsSection::Gemini, + SettingsSection::Antigravity, + SettingsSection::OpenRouter, SettingsSection::Pi, SettingsSection::Hermes, SettingsSection::Glm, + SettingsSection::Kimi, SettingsSection::Aisb, SettingsSection::Telegram, ] @@ -1044,9 +1142,12 @@ impl SettingsSection { SettingsSection::Claude => "Claude (Anthropic)", SettingsSection::Codex => "Codex (OpenAI)", SettingsSection::Gemini => "Gemini (Google)", + SettingsSection::Antigravity => "Antigravity (Google)", + SettingsSection::OpenRouter => "OpenRouter (via Pi)", SettingsSection::Pi => "Pi (earendil-works)", SettingsSection::Hermes => "Hermes (Nous Research)", SettingsSection::Glm => "GLM (Z.AI)", + SettingsSection::Kimi => "Kimi (Moonshot AI)", SettingsSection::Aisb => "AISB viewer (legacy)", SettingsSection::Telegram => "Telegram", } @@ -2185,6 +2286,26 @@ impl App { } } + fn record_preview_failure( + &mut self, + name: &str, + capture_kind: &str, + error: &dyn std::fmt::Display, + ) { + self.preview_fail_streak = self.preview_fail_streak.saturating_add(1); + self.preview_revision = 0; + if self.preview_fail_streak >= 3 { + if self.preview_fail_streak == 3 { + omega_core::tuilog::log(format!( + "preview: {capture_kind} capture for '{name}' failed 3 consecutive ticks — showing placeholder; last error: {error}" + )); + } + self.preview_content = String::from("(session has no pane content)"); + self.preview_styled = None; + self.preview_cursor = None; + } + } + pub async fn refresh_preview(&mut self) -> anyhow::Result<()> { let name = match self.selected_session() { Some(e) => e.session.name.clone(), @@ -2225,8 +2346,6 @@ impl App { } } - // Cached connection — avoid a fresh rmux daemon socket per refresh. - let mgr = omega_core::session::SessionManager::connect_cached().await?; // Hot tail path stays on the cheap visible-only snapshot. Only when the // user is browsing history (follow_tail == false) do we pay for a full // scrollback capture, so there is real content above the screen to @@ -2238,6 +2357,16 @@ impl App { // renderer would paint stale history over the live tail. self.preview_history_for = None; self.preview_history_styled = None; + // A missing daemon is a preview capture failure, not a reason to + // fail the whole TUI refresh. This also keeps cache-only unit tests + // hermetic instead of requiring a real rmux binary. + let mgr = match omega_core::session::SessionManager::connect_cached().await { + Ok(manager) => manager, + Err(error) => { + self.record_preview_failure(&name, "styled", &error); + return Ok(()); + } + }; // Tail path: capture STYLED rows + text + REAL cursor together. // Styled rows carry the `/` selector highlight + Claude's // colored UI; plain text is kept as a fallback + for scroll math. @@ -2284,19 +2413,7 @@ impl App { // last good frame and force a full recapture next tick // (revision=0). Only a SUSTAINED failure (≥3 ticks) — i.e. a // genuinely dead pane — replaces the view with the message. - self.preview_fail_streak = self.preview_fail_streak.saturating_add(1); - self.preview_revision = 0; // force fresh recapture next tick - if self.preview_fail_streak >= 3 { - // Log only on the transition (not every later tick). - if self.preview_fail_streak == 3 { - omega_core::tuilog::log(format!( - "preview: styled capture for '{name}' failed 3 consecutive ticks — showing placeholder; last error: {e:#}" - )); - } - self.preview_content = String::from("(session has no pane content)"); - self.preview_styled = None; - self.preview_cursor = None; - } + self.record_preview_failure(&name, "styled", &e); // else: retain this target's last-good frame, or the cleared // frame prepared above when this tick switched sessions. } @@ -2313,6 +2430,15 @@ impl App { // so the next scroll-up gets a fresh deep capture. Depth matches the // rmux history-limit so the user can scroll to the very top. if self.preview_history_for.as_deref() != Some(name.as_str()) { + let mgr = match omega_core::session::SessionManager::connect_cached().await { + Ok(manager) => manager, + Err(error) => { + self.preview_history_for = None; + self.preview_history_styled = None; + self.record_preview_failure(&name, "history", &error); + return Ok(()); + } + }; match mgr.capture_pane_history(&name, 500_000).await { Ok(content) => { // The capture now carries its attributes (-e), so split @@ -2328,15 +2454,7 @@ impl App { Err(e) => { // Same sticky-last-good policy as the tail path: a // transient capture error must not clobber the view. - self.preview_fail_streak = self.preview_fail_streak.saturating_add(1); - if self.preview_fail_streak >= 3 { - if self.preview_fail_streak == 3 { - omega_core::tuilog::log(format!( - "preview: history capture for '{name}' failed 3 consecutive ticks — showing placeholder; last error: {e:#}" - )); - } - self.preview_content = String::from("(session has no pane content)"); - } + self.record_preview_failure(&name, "history", &e); self.preview_history_for = None; self.preview_history_styled = None; } diff --git a/crates/omega-tui/src/input.rs b/crates/omega-tui/src/input.rs index ab3428d4..f8f10708 100644 --- a/crates/omega-tui/src/input.rs +++ b/crates/omega-tui/src/input.rs @@ -2147,6 +2147,13 @@ Statut actuel: {status}.", Some("Session name for new Gemini (Enter, Esc to cancel)".to_string()); Action::None } + KeyCode::Char('a') => { + app.input_buffer = String::new(); + app.input_mode = InputMode::NewNamedSession("antigravity".to_string()); + app.status_message = + Some("Session name for new Antigravity (Enter, Esc to cancel)".to_string()); + Action::None + } // Projects tab: 'p' runs the planner for the selected project // (the global 'p' = new Pi session applies on every other tab). KeyCode::Char('p') if app.tab == Tab::Projects => match app.selected_project() { @@ -2172,6 +2179,13 @@ Statut actuel: {status}.", Some("Session name for new GLM (Enter, Esc to cancel)".to_string()); Action::None } + KeyCode::Char('K') => { + app.input_buffer = String::new(); + app.input_mode = InputMode::NewNamedSession("kimi".to_string()); + app.status_message = + Some("Session name for new Kimi (Enter, Esc to cancel)".to_string()); + Action::None + } KeyCode::Char('t') => { app.input_buffer = String::new(); app.input_mode = InputMode::NewNamedSession("shell".to_string()); diff --git a/crates/omega-tui/src/ui.rs b/crates/omega-tui/src/ui.rs index dd56c3e3..016c68d3 100644 --- a/crates/omega-tui/src/ui.rs +++ b/crates/omega-tui/src/ui.rs @@ -1957,9 +1957,11 @@ fn menu_group(action: &MenuAction) -> &'static str { MenuAction::NewClaude | MenuAction::NewCodex | MenuAction::NewGemini + | MenuAction::NewAntigravity | MenuAction::NewPi | MenuAction::NewHermes - | MenuAction::NewGlm => "New agent sessions", + | MenuAction::NewGlm + | MenuAction::NewKimi => "New agent sessions", MenuAction::NewTerminal => "Terminal", MenuAction::NewProject | MenuAction::DispatchOracle => "Orchestration", MenuAction::Refresh | MenuAction::ToggleProtection | MenuAction::KillSelected => { diff --git a/docs/ARCHITECTURE-V3.md b/docs/ARCHITECTURE-V3.md index c548dd1e..ec021db6 100644 --- a/docs/ARCHITECTURE-V3.md +++ b/docs/ARCHITECTURE-V3.md @@ -67,9 +67,10 @@ The built-in default provider is Codex. Default models at this revision are: | Provider | Default model | Authentication | |---|---|---| | Claude | `opus` | OAuth or API key | -| Codex | `gpt-5.5-codex` | ChatGPT device auth or API key | -| Gemini | `gemini-3.1-pro` | OAuth or API key | -| GLM | `glm-5.1` | API key | +| Codex | `gpt-5.6` | ChatGPT device auth or API key | +| Gemini | `auto` | Enterprise/API-key Gemini CLI | +| Antigravity | native account default | Google sign-in/keyring | +| GLM | `glm-5.3` | API key | | OpenRouter | `anthropic/claude-opus-5` | API key | | Pi | `anthropic/claude-opus-5` | OpenRouter configuration | | Hermes | `anthropic/claude-opus-5` | API key | @@ -87,19 +88,23 @@ static model list into automation. model = "opus" [codex] -model = "gpt-5.5-codex" +model = "gpt-5.6" [gemini] -model = "gemini-3.1-pro" +model = "auto" ``` -The active Telegram model selection is stored under -`~/.omega/state/telegram-active-model.json` and can be changed with `/model`. +The global default selected through Telegram `/model` or +`omega config activate [model]` is mirrored under +`~/.omega/state/active-model.json`. `config.toml:agent_command` remains the +launch authority. ## Safe migration and verification -The installer migrates Claude and Gemini credentials and delegates Codex -conflict handling to the typed reconciler. After install or provider login: +The installer reconciles Claude credentials with freshness-aware typed code and +delegates Codex conflicts to its flow-aware reconciler. Gemini 0.56+ and +Antigravity retain ownership of their native keyring/hybrid credential stores. +After install or provider login: ```bash omega sync diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 48f587bd..a68dad7c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,7 +95,7 @@ sessions through a unified configuration and orchestration layer. │ ├── sessions/ Active session metadata │ ├── locks/ Scope-claim file locks │ ├── done/ .done.json files -│ └── telegram-active-model.json Current provider+model per chat +│ └── active-model.json Global provider+model diagnostic mirror │ ├── logs/ Session logs └── audit/ Audit results @@ -125,14 +125,16 @@ One home, everything ordered. When an agent/LLM installs something, it lands her ### Principle -OmegaOS keeps its canonical credential copies under `~/.omega/credentials/`. -Claude and Gemini compatibility paths may be symlinked there. Codex is a -special two-copy topology: its native `auth.json` stays under `CODEX_HOME` +OmegaOS keeps canonical Claude and Codex credential copies under +`~/.omega/credentials/`. Gemini 0.56+ and Antigravity keep OAuth in their +native keyring/hybrid stores; Omega does not replace those stores with +symlinks. Codex is a special two-copy topology: its native `auth.json` stays under `CODEX_HOME` (default `~/.codex`) while `omega codex-reconcile` compares, validates, quarantines conflicts, and updates the canonical OmegaOS copy. This way: - LLM CLIs still find their creds at the expected paths -- Backups only need to cover `~/.omega/` +- Omega-managed credentials are covered by `~/.omega/`; native Gemini, + Antigravity, Hermes, and Kimi auth stores require provider-native backup/login - Account switching = updating one file - Migration is a one-time operation @@ -141,13 +143,14 @@ quarantines conflicts, and updates the canonical OmegaOS copy. This way: | Provider | Type | Credential file | Default model | |----------|------|-----------------|---------------| | Claude | OAuth | `credentials/claude.json` | opus | -| Codex | ChatGPT device auth or API key | native `CODEX_HOME/auth.json`, reconciled with `credentials/codex.json` | gpt-5.5-codex | -| Gemini | OAuth | `credentials/gemini.json` | gemini-3.1-pro | -| GLM | API key | `credentials/glm.json` | glm-5.1 | -| OpenRouter | API key | `credentials/openrouter.json` | anthropic/claude-opus-5 | -| Pi | OpenRouter config | `credentials/pi.json` | anthropic/claude-opus-5 | -| Hermes | API key | `credentials/hermes.json` | anthropic/claude-opus-5 | -| Kimi | OAuth or API key | `credentials/kimi.json` | kimi-for-coding | +| Codex | ChatGPT device auth or API key | native `CODEX_HOME/auth.json`, reconciled with `credentials/codex.json` | gpt-5.6 | +| Gemini | Enterprise OAuth or API key | Gemini native keyring/store; API key in `providers.toml` | auto | +| Antigravity | Google sign-in | native keyring | native account default | +| GLM | API key | `providers.toml` | glm-5.3 | +| OpenRouter | API key, launched through Pi | `providers.toml` | anthropic/claude-opus-5 | +| Pi | OpenRouter config | `providers.toml` | anthropic/claude-opus-5 | +| Hermes | native provider or OpenRouter key | Hermes native config + `providers.toml` | anthropic/claude-opus-5 | +| Kimi | OAuth or API key | Kimi native OAuth or `providers.toml` | kimi-for-coding | | Shell | local process | none | none | ### Account Switching @@ -163,17 +166,21 @@ no top-level `omega accounts` command. Behind the scenes: switching updates `~/.omega/credentials/claude.json` to be a copy/symlink of the saved profile. -### Telegram Model Selection (per chat) +### Global provider/model selection ``` /model Show current + list available /model codex Switch to Codex (OmegaOS default) /model claude opus Switch to Claude with opus -/model codex gpt-5 Switch to Codex with gpt-5 +/model codex gpt-5.6 Switch to Codex with GPT-5.6 /model openrouter Switch to OpenRouter (default model) ``` -Active selection persisted to `~/.omega/state/telegram-active-model.json`. +The same operation is available as +`omega config activate [model]`. It updates +`config.toml:agent_command`, the selected provider model, and the diagnostic +mirror at `~/.omega/state/active-model.json`. A mission-level `--agent` +override still wins for that mission. --- @@ -252,9 +259,10 @@ Each LLM CLI reads OmegaOS config: | LLM | Mechanism | |-----|-----------| | Claude Code | `~/.claude/rules/omega-*.md` → symlinks to `~/.omega/rules/` | -| Gemini CLI | `~/.gemini/GEMINI.md` includes `@import ~/.omega/OMEGA.md` | +| Gemini CLI | `~/.gemini/GEMINI.md` includes `@import ~/.omega/OMEGA.md` (Enterprise/API-key lane) | +| Antigravity | Agent Skills / project instruction discovery through `agy` | | Codex | `~/.codex/AGENTS.md` → symlink to `~/.omega/OMEGA.md` | -| Pi / Hermes / GLM | Launch with `--append-system-prompt-file ~/.omega/OMEGA.md` | +| Pi / OpenRouter / Hermes / GLM / Kimi | Role-scoped Laws and Rules are included in the dispatched mission prompt | ### User ↔ OmegaOS @@ -342,7 +350,7 @@ auto_naming = true # Sessions named claude-1, codex-2, ... model = "opus" [codex] -model = "gpt-5.5-codex" +model = "gpt-5.6" # The built-in provider default is Codex. `providers.toml` contains typed # per-provider settings; `omega config` manages the active selection. diff --git a/docs/CLAUDE-CODE-INTEGRATION.md b/docs/CLAUDE-CODE-INTEGRATION.md index 300c87b3..2be2a40d 100644 --- a/docs/CLAUDE-CODE-INTEGRATION.md +++ b/docs/CLAUDE-CODE-INTEGRATION.md @@ -1,8 +1,16 @@ # Claude Code CLI → OmegaOS Integration +> **Historical research snapshot.** Paths and feature status below describe +> the May 2026 design, not the current runtime. Today interactive launches live +> in `crates/omega-core/src/agents.rs`, headless chat streaming lives in +> `crates/omega-gateway/src/chat_driver.rs`, and Telegram orchestration lives in +> `telegram-bot/omega-tg-bot.ts`. See +> [PROVIDER-COMPATIBILITY.md](PROVIDER-COMPATIBILITY.md) for the supported +> contract. + **Research date:** 2026-05-28 **Claude Code reference version:** v2.1.144+ (as documented at https://code.claude.com/docs/en) -**OmegaOS surfaces touched:** `crates/omega-cli/src/claude_stream.rs`, `crates/omega-cli/src/telegram_bridge.rs`, `crates/omega-core/src/session.rs` +**Current OmegaOS surfaces:** `crates/omega-core/src/agents.rs`, `crates/omega-gateway/src/chat_driver.rs`, `telegram-bot/omega-tg-bot.ts` --- diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 88951ead..995d0345 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -15,13 +15,18 @@ personal pieces are left — they take ~5 minutes.** New OmegaOS and Oracle sessions use Codex by default. Link it once: ``` -codex login -codex login status +omega codex-login +omega codex-login-status ``` -On a headless VPS, use the displayed device flow. A successful status prints -the active login method. Claude Code remains available as an explicit provider: -run `claude`, enter `/login`, then select Claude per session or mission. +On a headless VPS, approve the displayed device flow. A successful status +prints the active login method. Claude Code remains available as an explicit +provider: run `claude auth login`, then select Claude per session or mission. + +For personal Google accounts, use Antigravity (`omega install antigravity`, +then `agy` once to authenticate). Google ended Gemini CLI service for free, +AI Pro, and Ultra individual accounts in June 2026; `gemini` remains supported +for Gemini Code Assist Standard/Enterprise and paid API-key users. ## Step 2 — Telegram remote control (recommended) @@ -144,7 +149,9 @@ local chat), and `omega attach -t ` (jump into any live agent). - **Mission Control dashboard** (web UI, one container per agent — needs Docker): `omega-mc-up`, then open `http://:8080`. -- **More CLI agents**: `omega install claude|gemini|pi|hermes|glm` (or +- **More CLI agents**: + `omega install claude|antigravity|gemini|openrouter|pi|hermes|glm|kimi` + (or Settings → Install agents in the TUI). All install user-space, no root. - **Global keybindings**: `omega install-bindings` (Ctrl+Space popup). - **Themes**: a gallery of TUI palettes with live preview (Settings → Theme) — diff --git a/docs/HERMES-INTEGRATION.md b/docs/HERMES-INTEGRATION.md index a88cbdcd..6d0d1c1b 100644 --- a/docs/HERMES-INTEGRATION.md +++ b/docs/HERMES-INTEGRATION.md @@ -1,7 +1,13 @@ # Hermes Agent → OmegaOS Integration Report +> **Historical research, not runtime documentation.** This May 2026 document +> proposes mechanisms that were not necessarily implemented. The current CLI +> adapter lives in `crates/omega-core/src/agents.rs`; supported versions and +> commands are documented in +> [PROVIDER-COMPATIBILITY.md](PROVIDER-COMPATIBILITY.md). + **Source**: https://github.com/nousresearch/hermes-agent -**Target**: OmegaOS at `/home/hacker/VibeCoding/work/OmegaOS` (Rust + Bun) +**Target**: OmegaOS (Rust + Bun) **Date**: 2026-05-28 **Author**: research worker diff --git a/docs/INSTALL-AND-CREDENTIALS.md b/docs/INSTALL-AND-CREDENTIALS.md index cb879b5d..e7153de3 100644 --- a/docs/INSTALL-AND-CREDENTIALS.md +++ b/docs/INSTALL-AND-CREDENTIALS.md @@ -71,14 +71,15 @@ The architecture (centralized config, symlinks, multi-provider) is enforced by install.sh Phase 5: ```bash -# Phase 5a: Credential Migration (migrate_creds function) -migrate_creds "claude" "$HOME/.claude/.credentials.json" -migrate_creds "gemini" "$HOME/.config/gemini/oauth_creds.json" -# → moves existing Claude/Gemini creds to ~/.omega/credentials/ and links back +# Phase 5a + end-of-install reconciliation +omega reconcile +# → freshness-aware Claude migration/symlink repair in omega-core # Codex is handled separately by the CODEX_HOME-aware omega-core reconciler. # It validates native and canonical copies under a lock and quarantines # conflicts instead of blindly replacing either file. +# +# Gemini 0.56+ and Antigravity retain their native hybrid/keyring stores. # Then: omega rules export # writes the 7 Laws + named Rules to ~/.omega/rules/ @@ -179,7 +180,7 @@ shows the real email instead of "unknown". | File | Purpose | |------|---------| | `~/.omega/state/pending-reauth.json` | Pending login record; expires after five minutes | -| `~/.omega/state/telegram-active-model.json` | Per-chat active provider+model | +| `~/.omega/state/active-model.json` | Global active provider+model mirror | | `~/.omega/credentials/accounts/*.json` | Saved account profiles | The pending record's five-minute lifetime is distinct from the in-process @@ -200,7 +201,7 @@ When detected, the bridge can auto-trigger the reauth flow. |---------|------|---------| | /help | List commands | — | | /account | Account card | [Login] [Logout] [Billing] [Switch] | -| /model | Switch provider/model | Provider buttons → model buttons | +| /model | Switch the global default provider/model | Provider buttons → model buttons | | /projects | Project list | per-project + [+ New] [Scan & add existing] | | /sessions | Active sessions | per-session (tap to target) | diff --git a/docs/MAP.md b/docs/MAP.md index 051a8255..a54b8637 100644 --- a/docs/MAP.md +++ b/docs/MAP.md @@ -123,8 +123,8 @@ OmegaOS/ ├── credentials/ OmegaOS-owned credential copies │ ├── claude.json ← ~/.claude/.credentials.json points here │ ├── codex.json ↔ CODEX_HOME/auth.json (reconciled) -│ ├── gemini.json ← ~/.gemini/oauth_creds.json points here │ └── accounts/ Saved account profiles +│ # Gemini/Antigravity OAuth stays in provider-native keyring storage │ ├── rules/ The typed doctrine (.md, synced from repo on install) ├── agents/ Shared and role-specific agent prompts @@ -165,9 +165,8 @@ $ ./install.sh Phase 4: Verify/install omega, falling back to a locked source build Phase 5: Setup ~/.omega/ - Create credentials/, state/, logs/, accounts/ - - migrate_creds claude → moves ~/.claude/.credentials.json - into ~/.omega/credentials/claude.json + symlink back - - migrate Claude and Gemini compatibility credentials + - reconcile Claude credentials with freshness-aware omega-core code + - leave Gemini/Antigravity native keyring credentials provider-owned - reconcile Codex through omega-core (CODEX_HOME-aware) - Copy OMEGA.md, agents/, rules/, skills/pdfgen/, skills/audits/ - Run `omega rules export` and `omega sync` @@ -184,7 +183,7 @@ $ ./install.sh | Launch TUI | `omega` or `om` | | Send work from Telegram | Send it in the Atlas or project topic; Atlas resolves and dispatches | | List sessions | `/sessions` on Telegram or `omega list` | -| Switch model | `/model claude opus` | +| Switch global provider/model | `/model` buttons or `omega config activate claude opus` | | Manage accounts | `/account` (button menu) | | New project | `/projects` → [+ New project] | | Generate PDF | `omega pdf --template=audit --send` | diff --git a/docs/PROVIDER-COMPATIBILITY.md b/docs/PROVIDER-COMPATIBILITY.md new file mode 100644 index 00000000..7d4a0bd3 --- /dev/null +++ b/docs/PROVIDER-COMPATIBILITY.md @@ -0,0 +1,75 @@ +# Provider compatibility + +OmegaOS validates installed agent CLIs with `omega doctor`. Reinstall or update +an existing provider with: + +```bash +omega install --force +``` + +## Supported contracts + +| Provider | Minimum tested CLI | Omega launch contract | +|---|---:|---| +| Claude Code | 2.1.219 | interactive TTY, `--permission-mode auto` | +| Codex | 0.147.0 | `--approve-for-me`, hook-trust bypass, no conflicting `--sandbox` | +| Gemini CLI | 0.31.0 | `--prompt-interactive`, Enterprise/API-key accounts | +| Antigravity (`agy`) | 1.1.8 | native Google auth, prompt-interactive | +| Pi / OpenRouter | 0.84.3 | explicit provider/model and `--` prompt delimiter | +| Hermes | 0.20.0 | `hermes chat`; `-q` for a dispatched one-shot | +| Kimi Code | 0.38.0 | `--prompt` without the incompatible `--auto` flag | +| GLM | Claude Code 2.1.219 | Claude adapter pointed at Z.AI Anthropic endpoint | + +Current catalog defaults are `gpt-5.6` for Codex, `auto` for Gemini CLI, +`glm-5.3` for direct GLM, and `anthropic/claude-opus-5` for OpenRouter-backed +Pi/Hermes sessions. Account-scoped products may resolve an alias to a different +eligible model; use the provider's own model-list command to verify the actual +selection. + +Omega-managed Codex sessions default `codex.bypass_hook_trust = true` so +installer-managed hooks cannot block a detached pane. This applies to every +enabled Codex hook; set it to `false` if you keep third-party hooks that must be +reviewed interactively. + +## Google migration + +Google stopped serving Gemini CLI requests for free, AI Pro, and AI Ultra +individual accounts on June 18, 2026. Those users should install Antigravity: + +```bash +omega install antigravity +agy # authenticate once +omega config activate antigravity +``` + +Gemini CLI remains supported for Gemini Code Assist Standard/Enterprise and +paid Gemini or Enterprise Agent Platform API keys. Gemini and Antigravity keep +OAuth credentials in their native hybrid/keyring stores; OmegaOS does not copy +or symlink those credentials. + +## Selecting a provider + +```bash +omega config activate codex gpt-5.6 +omega config activate claude opus +omega config activate antigravity # native account default +omega dispatch MyProject "mission" --agent hermes +``` + +The active global selection is mirrored in +`~/.omega/state/active-model.json`. A mission-level `--agent` override takes +precedence without changing the global default. + +## Upgrade checks + +After updating OmegaOS: + +```bash +omega reconcile +omega doctor +omega doctor --deep +``` + +`doctor` checks the executable version before a detached pane can fail on an +obsolete flag. `--deep` additionally performs provider authentication probes +where supported. diff --git a/docs/README.md b/docs/README.md index 7b75b9a2..1ea551a0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ Start with the repo-root [GUIDE.md](../GUIDE.md) (the operator manual) and [READ - [ARCHITECTURE-V3.md](ARCHITECTURE-V3.md) — the `~/.omega/` centralized runtime layout (credentials, providers, state). - [MAP.md](MAP.md) — where everything lives: source repo vs installed binary vs `~/.omega/` runtime. - [INSTALL-AND-CREDENTIALS.md](INSTALL-AND-CREDENTIALS.md) — install flow + the credentials/OAuth system. +- [PROVIDER-COMPATIBILITY.md](PROVIDER-COMPATIBILITY.md) — supported CLI versions, current launch contracts, model defaults, and Gemini→Antigravity migration. - [RELEASE.md](RELEASE.md) — CI-gated publishing, artifact provenance, and known-good-tag rollback. - [THEMES.md](THEMES.md) — the TUI palette gallery and contrast contract. - [RESET-RECOVERY.md](RESET-RECOVERY.md) — backing up and rebuilding a box (`omega backup` / restore). diff --git a/install.sh b/install.sh index 3be8ac54..a59eb6c4 100755 --- a/install.sh +++ b/install.sh @@ -93,6 +93,21 @@ install_binary() { mv -f "$dst.new" "$dst" } +# Mirror one installer-owned directory exactly. The fallback removes the old +# managed copy first; plain `cp -r` retained files deleted upstream forever. +mirror_owned_dir() { + local src="$1" dst="$2" + mkdir -p "$(dirname "$dst")" + if command -v rsync >/dev/null 2>&1; then + mkdir -p "$dst" + rsync -a --delete "$src/" "$dst/" + else + rm -rf "$dst" + mkdir -p "$dst" + cp -a "$src/." "$dst/" + fi +} + # Privileged command runner — the ONE way this script touches sudo that can # ever PROMPT (sole exception: the system-wide rmux config block in Phase 5 # uses raw `sudo` but self-gates on `sudo -n true`, so it never prompts). Tries @@ -277,14 +292,29 @@ ensure_build_toolchain() { fi ok "Build toolchain installed" fi - # (2) Rust (rustup) — only reached on the source-build path. - if ! command -v cargo >/dev/null 2>&1; then - info "Rust not found. Installing via rustup..." + # (2) Rust (rustup) — only reached on the source-build path. Cargo merely + # being present is not enough: serde-saphyr is edition 2024 and an old + # system toolchain fails before Omega can print a useful diagnostic. Keep + # this in lockstep with rust-toolchain.toml and CI. + local rust_required="1.97.1" rust_installed="" rust_major=0 rust_minor=0 + if command -v rustc >/dev/null 2>&1; then + rust_installed="$(rustc --version 2>/dev/null | awk '{print $2}')" + IFS=. read -r rust_major rust_minor _ <<< "$rust_installed" + rust_major="${rust_major:-0}" + rust_minor="${rust_minor:-0}" + fi + if ! command -v cargo >/dev/null 2>&1 \ + || (( rust_major < 1 || (rust_major == 1 && rust_minor < 97) )); then + info "Rust ${rust_installed:-missing} cannot build this checkout; installing $rust_required via rustup..." command -v curl >/dev/null 2>&1 || { err "curl is required to bootstrap Rust but is missing; install curl and re-run."; exit 1; } - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + if command -v rustup >/dev/null 2>&1; then + rustup toolchain install "$rust_required" --profile minimal + else + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain "$rust_required" + fi # shellcheck disable=SC1091 source "$HOME/.cargo/env" - ok "Rust installed: $(rustc --version)" + ok "Rust toolchain ready: $(rustc --version)" fi } @@ -546,7 +576,8 @@ maybe_install_prebuilt() { if ! tar xzf "$tmp/$tarball" -C "$tmp" 2>/dev/null; then info "Prebuilt extract failed — building from source"; rm -rf "$tmp"; return 0 fi - [[ -f "$tmp/omega" && -f "$tmp/rmux" && -f "$tmp/BUILD-INFO.json" ]] || { + [[ -f "$tmp/omega" && -f "$tmp/omega-gatewayd" && -f "$tmp/rmux" \ + && -f "$tmp/BUILD-INFO.json" ]] || { info "Prebuilt missing binaries or BUILD-INFO.json — building from source" rm -rf "$tmp" return 0 @@ -563,22 +594,26 @@ maybe_install_prebuilt() { mkdir -p "$INSTALL_DIR" install_binary "$tmp/omega" "$INSTALL_DIR/omega" || { rm -rf "$tmp"; return 0; } + install_binary "$tmp/omega-gatewayd" "$INSTALL_DIR/omega-gatewayd" \ + || { rm -rf "$tmp"; return 0; } install_binary "$tmp/rmux" "$INSTALL_DIR/rmux" || { rm -rf "$tmp"; return 0; } ln -sf "$INSTALL_DIR/omega" "$INSTALL_DIR/omg" # Sanity: the downloaded binaries actually run on THIS host (right libc/arch). # rmux is tmux-style — its version flag is `-V` (NOT --version, which exits 1). - if "$INSTALL_DIR/omega" --version >/dev/null 2>&1 && "$INSTALL_DIR/rmux" -V >/dev/null 2>&1; then + if "$INSTALL_DIR/omega" --version >/dev/null 2>&1 \ + && "$INSTALL_DIR/omega-gatewayd" --help >/dev/null 2>&1 \ + && "$INSTALL_DIR/rmux" -V >/dev/null 2>&1; then mkdir -p "$OMEGA_DIR/state" chmod 700 "$OMEGA_DIR/state" install -m 0644 "$tmp/BUILD-INFO.json" "$OMEGA_DIR/state/.installed-build-info.json.new" mv -f "$OMEGA_DIR/state/.installed-build-info.json.new" \ "$OMEGA_DIR/state/installed-build-info.json" PREBUILT_OK=1 - ok "Prebuilt omega + rmux installed ($tag, $triple) — skipped the source build" + ok "Prebuilt omega + gateway + rmux installed ($tag, $triple) — skipped the source build" else info "Prebuilt binaries did not run here — building from source" - rm -f "$INSTALL_DIR/omega" "$INSTALL_DIR/rmux" + rm -f "$INSTALL_DIR/omega" "$INSTALL_DIR/omega-gatewayd" "$INSTALL_DIR/rmux" fi rm -rf "$tmp" return 0 @@ -981,9 +1016,8 @@ record_install_provenance # --- omega-gateway (app API daemon; consumed by the omega-app mobile/desktop # clients — Plan 4). `cargo build --release` above builds the whole workspace # (crates/omega-gateway is a member), so the binary already exists here on the -# source-build path; the PREBUILT_OK path installs only omega/rmux from GitHub -# release assets and has no gateway artifact, so this degrades to a skip there -# (release-pipeline follow-up, not attempted in this task). +# source-build path; release archives carry the same gateway binary so both +# install lanes configure the identical runtime. # Every line below degrades non-fatally: this block sits BEFORE the # deliberately-early Telegram bot install (next section) and set -euo # pipefail must never let a gateway hiccup abort the install and skip the @@ -991,6 +1025,8 @@ record_install_provenance if [[ -x target/release/omega-gatewayd ]]; then install_binary target/release/omega-gatewayd "$INSTALL_DIR/omega-gatewayd" \ || warn "omega-gatewayd binary install failed (non-fatal)" +fi +if [[ -x "$INSTALL_DIR/omega-gatewayd" ]]; then mkdir -p "$HOME/.config/systemd/user" || true # Unit is GENERATED from $INSTALL_DIR, not copied from # config/omega-gateway.service (which hardcodes %h/.local/bin) — a custom @@ -1024,7 +1060,7 @@ EOF ok "omega-gateway binary installed to $INSTALL_DIR/omega-gatewayd (systemd unavailable — start manually: omega-gatewayd serve)" fi else - info "omega-gatewayd not built (target/release/omega-gatewayd missing) — skipping gateway install" + info "omega-gatewayd unavailable after build/install — skipping gateway service" fi # Install the Telegram command bot NOW (before the long, fragile Phase 5) so a @@ -1210,7 +1246,7 @@ fi STEPPER_SKILL_DST="$OMEGA_DIR/skills/stepper-os" if [[ -d "$OMEGA_SRC/skills/stepper-os" ]]; then mkdir -p "$STEPPER_SKILL_DST" "$HOME/.claude/commands" - cp -rf "$OMEGA_SRC/skills/stepper-os/." "$STEPPER_SKILL_DST/" + mirror_owned_dir "$OMEGA_SRC/skills/stepper-os" "$STEPPER_SKILL_DST" for cmd in stepper-os omg-stepper-os; do cat > "$HOME/.claude/commands/$cmd.md" < $canonical" - fi - fi - - # Ensure parent dir of legacy exists so the symlink can be created. - mkdir -p "$(dirname "$legacy")" - - # Create the symlink (target may not exist yet — that is fine; the LLM - # will write through it on first login). - if [ ! -e "$legacy" ] && [ ! -L "$legacy" ]; then - ln -s "$canonical" "$legacy" - ok "$provider creds: linked $legacy -> $canonical" - fi -} - -migrate_creds "claude" "$HOME/.claude/.credentials.json" -migrate_creds "gemini" "$HOME/.gemini/oauth_creds.json" +# Credential conflict resolution belongs to omega-core, where freshness and +# validity can be checked before either copy is moved. The old shell migration +# always preferred an existing canonical file and could discard a newly rotated +# Claude token. End-of-install `omega reconcile` now invokes the typed Claude +# reconciler. Gemini 0.56+ stores OAuth in its native keyring/hybrid store, so +# Omega deliberately leaves Gemini credentials under CLI ownership. # Codex may relocate its complete native home with CODEX_HOME. Preserve any # native/canonical split exactly as found; the installed Omega binary performs @@ -1345,18 +1344,30 @@ if [[ ! -f "$OMEGA_DIR/config.toml" ]]; then else ok "Config already exists: $OMEGA_DIR/config.toml" fi +# Always refresh a non-authoritative reference so upgrades expose new knobs +# without overwriting operator state. +cp config/default.toml "$OMEGA_DIR/config.toml.defaults" +if [[ ! -f "$OMEGA_DIR/providers.toml" ]]; then + cp config/providers.default.toml "$OMEGA_DIR/providers.toml" + chmod 600 "$OMEGA_DIR/providers.toml" + ok "Provider config created: $OMEGA_DIR/providers.toml" +else + ok "Provider config already exists: $OMEGA_DIR/providers.toml" +fi +cp config/providers.default.toml "$OMEGA_DIR/providers.toml.defaults" # Run the dedicated flow-aware reconciler through the newly installed binary. # Its JSON and exit status are authoritative. An active device login is -# reported and preserved; an actual reconciliation failure stays visible. +# reported and preserved; an actual reconciliation failure stays visible but +# does not leave the rest of the installation half-applied. if [[ -x "$INSTALL_DIR/omega" ]]; then CODEX_RECONCILE_OUTPUT="" if CODEX_RECONCILE_OUTPUT=$(OMEGA_DIR="$OMEGA_DIR" CODEX_HOME="$CODEX_NATIVE_DIR" \ "$INSTALL_DIR/omega" codex-reconcile --json 2>&1); then ok "Codex credential reconciliation: $CODEX_RECONCILE_OUTPUT" else - err "Codex credential reconciliation failed: $CODEX_RECONCILE_OUTPUT" - exit 1 + warn "Codex credential reconciliation failed: $CODEX_RECONCILE_OUTPUT" + warn "Continuing install; repair with: omega codex-reconcile --json" fi fi @@ -1424,7 +1435,7 @@ fi # Credentials are symlinked so OAuth still works. BRIDGE_CFG="$OMEGA_DIR/claude-bridge-config" mkdir -p "$BRIDGE_CFG" -echo '{}' > "$BRIDGE_CFG/settings.json" +[[ -f "$BRIDGE_CFG/settings.json" ]] || echo '{}' > "$BRIDGE_CFG/settings.json" # Ensure the symlink target exists so the bridge never reads through a dangling # link before `claude` login writes real creds (NEVER clobber an existing file). [[ -e "$OMEGA_DIR/credentials/claude.json" ]] || : > "$OMEGA_DIR/credentials/claude.json" @@ -1916,12 +1927,7 @@ if [[ -d "$DI_SRC" ]]; then [[ -f "$skill_md" ]] || continue di_dir="$(dirname "$skill_md")"; di_name="$(basename "$di_dir")" [[ -d "$OMEGA_SRC/skills/$di_name" ]] && continue # OmegaOS-vendored = canon, skip - mkdir -p "$OMEGA_DIR/skills/$di_name" - if command -v rsync >/dev/null 2>&1; then - rsync -a "$di_dir/" "$OMEGA_DIR/skills/$di_name/" 2>/dev/null || true - else - cp -r "$di_dir/." "$OMEGA_DIR/skills/$di_name/" 2>/dev/null || true - fi + mirror_owned_dir "$di_dir" "$OMEGA_DIR/skills/$di_name" 2>/dev/null || true find "$OMEGA_DIR/skills/$di_name" -name '*.sh' -exec chmod +x {} + 2>/dev/null || true : > "$OMEGA_DIR/skills/$di_name/.omega-managed" 2>/dev/null || true DI_N=$((DI_N + 1)) @@ -2024,8 +2030,7 @@ CBEOF [[ -f "$skill_md" ]] || continue cb_dir="$(dirname "$skill_md")"; cb_name="$(basename "$cb_dir")" [[ -d "$OMEGA_SRC/skills/$cb_name" ]] && continue - mkdir -p "$OMEGA_DIR/skills/$cb_name" - cp -rf "$cb_dir/." "$OMEGA_DIR/skills/$cb_name/" 2>/dev/null || true + mirror_owned_dir "$cb_dir" "$OMEGA_DIR/skills/$cb_name" 2>/dev/null || true CB_N=$((CB_N + 1)) done @@ -3235,6 +3240,11 @@ fi # omega install-bindings mkdir -p "$OMEGA_DIR" if [[ -f config/rmux.conf.omega ]]; then + if [[ -f "$OMEGA_DIR/rmux.conf.omega" ]] \ + && ! cmp -s config/rmux.conf.omega "$OMEGA_DIR/rmux.conf.omega"; then + cp -p "$OMEGA_DIR/rmux.conf.omega" "$OMEGA_DIR/rmux.conf.omega.pre-update" + info "Previous customized rmux config backed up to $OMEGA_DIR/rmux.conf.omega.pre-update" + fi cp config/rmux.conf.omega "$OMEGA_DIR/rmux.conf.omega" ok "rmux config available at $OMEGA_DIR/rmux.conf.omega (run 'omega install-bindings' to activate)" fi @@ -3421,14 +3431,19 @@ if [[ -d "$OMEGA_SRC/scripts/hooks" ]]; then mkdir -p "$HOME/.codex" [[ -f "$CODEX_HOOKS" ]] || echo '{}' > "$CODEX_HOOKS" TMP="$(mktemp)" - jq --arg verify "$HOOKS_DST/stop-verify-hook.sh" \ + jq --arg track "$HOOKS_DST/track-tool-use.sh" \ + --arg verify "$HOOKS_DST/stop-verify-hook.sh" \ + --arg guard "$HOOKS_DST/omega-audit-guard.sh" \ --arg contract "$HOOKS_DST/omega-session-contract.sh" \ - --arg scan "$HOOKS_DST/omega-prompt-scan.sh" ' + --arg scan "$HOOKS_DST/omega-prompt-scan.sh" \ + --arg mirror "$HOOKS_DST/omega-plan-mirror.sh" ' .hooks = (.hooks // {}) + | .hooks.PostToolUse = ((.hooks.PostToolUse // []) | map(select(((.hooks[0].command // "") | test("track-tool-use|omega-plan-mirror")) | not)) + [{"matcher":"*","hooks":[{"type":"command","command":$track}]},{"matcher":"TaskCreate|TaskUpdate|TodoWrite|update_plan","hooks":[{"type":"command","command":$mirror}]}]) | .hooks.Stop = ((.hooks.Stop // []) | map(select(((.hooks[0].command // "") | test("stop-verify")) | not)) + [{"hooks":[{"type":"command","command":$verify}]}]) + | .hooks.PreToolUse = ((.hooks.PreToolUse // []) | map(select(((.hooks[0].command // "") | test("omega-audit-guard")) | not)) + [{"matcher":"Bash","hooks":[{"type":"command","command":$guard}]}]) | .hooks.SessionStart = ((.hooks.SessionStart // []) | map(select(((.hooks[0].command // "") | test("omega-session-contract")) | not)) + [{"hooks":[{"type":"command","command":$contract}]}]) | .hooks.UserPromptSubmit = ((.hooks.UserPromptSubmit // []) | map(select(((.hooks[0].command // "") | test("omega-prompt-scan")) | not)) + [{"hooks":[{"type":"command","command":$scan}]}]) - ' "$CODEX_HOOKS" > "$TMP" 2>/dev/null && mv "$TMP" "$CODEX_HOOKS" && ok "Codex hooks registered (SessionStart contract + Stop finish-guard + prompt scan)" || { rm -f "$TMP"; info "Codex hook merge skipped (jq error)"; } + ' "$CODEX_HOOKS" > "$TMP" 2>/dev/null && mv "$TMP" "$CODEX_HOOKS" && ok "Codex hooks registered (contract + finish-guard + prompt scan + plan mirror + audit guard + tracker)" || { rm -f "$TMP"; info "Codex hook merge skipped (jq error)"; } else info "jq not found — hooks copied to $HOOKS_DST; install jq to auto-register them in settings.json" fi @@ -3446,9 +3461,17 @@ fi install_command_bot || true install_inbox_bot || true -# (d) Claude Code agent binary — omega needs it to spawn agents. +# (d) Agent binaries. Codex is OmegaOS's configured default, while Claude is +# still used by explicit Claude sessions and optional Telegram bridge lanes. +# A successful installer must never hand the operator a default command that +# does not exist. +if ! command -v codex >/dev/null 2>&1; then + info "Codex CLI absent — installing the OmegaOS default runtime..." + omega_timeout 240 "$INSTALL_DIR/omega" install codex 2>/dev/null \ + || info "Run 'omega install codex', then authenticate with 'omega codex-login'." +fi if ! command -v claude >/dev/null 2>&1; then - info "Claude Code CLI absent — omega needs it to spawn agents. Attempting install..." + info "Claude Code CLI absent — installing the supported Claude/Telegram runtime..." omega_timeout 180 "$INSTALL_DIR/omega" install claude 2>/dev/null || info "Run 'omega install claude' (or install Claude Code manually), then authenticate with 'claude'." fi @@ -3724,15 +3747,8 @@ if [[ -d "$SKILLS_REPO_DIR/.git" ]]; then SKMIRROR_REJECTED=$((SKMIRROR_REJECTED + 1)) continue fi - mkdir -p "$OMEGA_DIR/skills/$sk_name" - if command -v rsync >/dev/null 2>&1; then - # This directory is an SSOT mirror, not a merge surface. Remove - # files deleted upstream so stale nested protocols cannot survive - # indefinitely and later collide in the strict installed catalog. - rsync -a --delete "$sk_dir/" "$OMEGA_DIR/skills/$sk_name/" 2>/dev/null || true - else - cp -r "$sk_dir/." "$OMEGA_DIR/skills/$sk_name/" 2>/dev/null || true - fi + # This directory is an SSOT mirror, not a merge surface. + mirror_owned_dir "$sk_dir" "$OMEGA_DIR/skills/$sk_name" 2>/dev/null || true # Stamped AFTER the mirror, so the `--delete` pass cannot strip it. # See the stamping block further down for why this matters. : > "$OMEGA_DIR/skills/$sk_name/.omega-managed" 2>/dev/null || true @@ -3904,7 +3920,8 @@ fi echo -e " ${BOLD}Your 5-minute setup — in order:${NC}" echo "" echo -e " ${BOLD}0.${NC} Reload your shell: source $RC_FILE" -echo -e " ${BOLD}1.${NC} Connect Claude (required): claude → then type /login and follow the URL" +echo -e " ${BOLD}1.${NC} Connect Codex (default): omega codex-login → approve the device URL" +echo -e " ${BOLD}Optional Claude:${NC} claude auth login" echo -e " ${BOLD}2.${NC} Telegram remote (recommended):" echo " @BotFather → /newbot → copy the token; @userinfobot → your numeric id" echo " OMEGA_TG_TOKEN= omega telegram setup --user-id " diff --git a/installer/package.json b/installer/package.json index bb53f788..c39f04a2 100644 --- a/installer/package.json +++ b/installer/package.json @@ -1,6 +1,6 @@ { "name": "omega-os", - "version": "1.5.12", + "version": "1.5.13", "description": "One-command installer for OmegaOS \u2014 the agentic terminal OS (rmux + AI orchestration). Run: npx omega-os", "bin": { "omega-os": "bin/omega-os.js" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..3caff2a7 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +profile = "minimal" +components = ["clippy", "rustfmt"] diff --git a/scripts/migrate-to-omegaos-root.sh b/scripts/migrate-to-omegaos-root.sh index 3c73f62f..00e4c935 100755 --- a/scripts/migrate-to-omegaos-root.sh +++ b/scripts/migrate-to-omegaos-root.sh @@ -40,9 +40,9 @@ mkdir -p "$ROOT" # 2. Quiesce writers so nothing writes into ~/.omega mid-move. STOPPED_SERVICE="" -if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active --quiet omega-telegram.service 2>/dev/null; then - systemctl --user stop omega-telegram.service && STOPPED_SERVICE=1 - info "Stopped omega-telegram.service for the move" +if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active --quiet omega-tg-bot.service 2>/dev/null; then + systemctl --user stop omega-tg-bot.service && STOPPED_SERVICE=1 + info "Stopped omega-tg-bot.service for the move" fi CRON_BACKUP="" if command -v crontab >/dev/null 2>&1 && crontab -l >/dev/null 2>&1; then @@ -91,12 +91,13 @@ if [[ ! -e "$ROOT/Setup" ]]; then ok "Linked repo: $ROOT/Setup → $SRC" fi -# 6. Restart writers. -if command -v crontab >/dev/null 2>&1 && ! crontab -l 2>/dev/null | grep -q 'omega patrol'; then - ( crontab -l 2>/dev/null; echo "* * * * * $HOME/.local/bin/omega patrol --once >> $SYS/logs/omega-patrol.log 2>&1" ) | crontab - || true - info "Restored omega patrol cron" +# 6. Restart writers. Restore the exact marker-bearing crontab captured above; +# synthesizing one patrol line used to drop usage/update/self-heal jobs. +if [[ -n "$CRON_BACKUP" ]] && command -v crontab >/dev/null 2>&1; then + printf '%s\n' "$CRON_BACKUP" | crontab - || true + info "Restored omega crons" fi -[[ -n "$STOPPED_SERVICE" ]] && systemctl --user start omega-telegram.service 2>/dev/null || true +[[ -n "$STOPPED_SERVICE" ]] && systemctl --user start omega-tg-bot.service 2>/dev/null || true echo ok "Migration complete. Layout:" diff --git a/scripts/omega-skills b/scripts/omega-skills index 0b6e56c9..4f8de3c9 100755 --- a/scripts/omega-skills +++ b/scripts/omega-skills @@ -5,7 +5,8 @@ # omega-skills list all native OmegaOS skills + commands # omega-skills search native skills (name/desc/command) # omega-skills --powerups search the Power-Up library (907 paid skills) -# omega-skills --all search both +# omega-skills --cookbooks search Anthropic's pinned cookbook recipes +# omega-skills --all search native, Power-Ups, and cookbooks # omega-skills --rag "" SEMANTIC retrieval by meaning (native + library) # omega-skills --html print the served catalog URL # ═══════════════════════════════════════════════════════════════════════════ @@ -44,15 +45,19 @@ def valid_atlas(candidate): if (not isinstance(candidate, dict) or candidate.get("schema_version") != 2 or not isinstance(candidate.get("native"), list) or not isinstance(candidate.get("powerups"), list) or + not isinstance(candidate.get("cookbooks"), list) or candidate.get("native_count") != len(candidate["native"]) or candidate.get("powerup_count") != len(candidate["powerups"]) or - candidate.get("total") != len(candidate["native"]) + len(candidate["powerups"])): + candidate.get("cookbook_count") != len(candidate["cookbooks"]) or + candidate.get("total") != + len(candidate["native"]) + len(candidate["powerups"]) + len(candidate["cookbooks"])): return False payload = { "catalog_hash": candidate.get("catalog_hash"), "source_tree_digest": candidate.get("source_tree_digest"), "native": candidate["native"], "powerups": candidate["powerups"], + "cookbooks": candidate["cookbooks"], } actual = hashlib.sha256(json.dumps( payload, ensure_ascii=False, sort_keys=True, @@ -206,6 +211,7 @@ fi SCOPE="native"; TERM="" case "${1:-}" in --powerups) SCOPE="powerups"; TERM="${2:-}";; + --cookbooks) SCOPE="cookbooks"; TERM="${2:-}";; --all) SCOPE="all"; TERM="${2:-}";; "") SCOPE="native"; TERM="";; *) SCOPE="native"; TERM="$1";; @@ -221,6 +227,9 @@ if scope in ("native","all"): if scope in ("powerups","all"): for r in atlas["powerups"]: rows.append((r["name"],"","(library) "+r.get("description","") ,r["source"])) +if scope in ("cookbooks","all"): + for r in atlas.get("cookbooks", []): + rows.append((r["name"],"","(cookbook) "+r.get("description",""),"cookbook")) def hit(row): return not term or term in row[0].lower() or term in row[2].lower() or term in row[1].lower() rows=[r for r in rows if hit(r)] rows.sort(key=lambda r:(r[3]!="omegaos",r[0].lower())) diff --git a/scripts/tests/test_release_contract.py b/scripts/tests/test_release_contract.py index bad8bae9..b6a0acce 100644 --- a/scripts/tests/test_release_contract.py +++ b/scripts/tests/test_release_contract.py @@ -17,9 +17,6 @@ ROOT = Path(__file__).resolve().parents[2] WORKFLOW_DIR = ROOT / ".github" / "workflows" -NON_GATEWAY_PACKAGES = "-p omega-core -p omega-tui -p omega" - - class ReleaseContractTests(unittest.TestCase): @classmethod def setUpClass(cls): @@ -46,6 +43,7 @@ def heredocs(text, language): def test_prebuilt_requires_checksum_and_build_info(self): self.assertIn('if ! curl -fsSL "$base/$tarball.sha256"', self.install) self.assertIn('-f "$tmp/BUILD-INFO.json"', self.install) + self.assertIn('-f "$tmp/omega-gatewayd"', self.install) self.assertNotIn("if the .sha256 sidecar exists", self.install) def test_source_build_never_falls_back_to_unlocked_dependencies(self): @@ -85,17 +83,16 @@ def test_checkouts_never_persist_release_credentials(self): f"checkout credential persistence in {workflow}", ) - def test_non_gateway_quality_gate_is_blocking_locked_and_reusable(self): + def test_full_workspace_quality_gate_is_blocking_locked_and_reusable(self): for command in ( - f"cargo fmt {NON_GATEWAY_PACKAGES} -- --check", - f"cargo clippy --locked {NON_GATEWAY_PACKAGES} --all-targets -- -D warnings", - f"cargo build --release --locked {NON_GATEWAY_PACKAGES}", - f"cargo test --locked {NON_GATEWAY_PACKAGES}", + "cargo fmt --all -- --check", + "cargo clippy --locked --workspace --all-targets -- -D warnings", + "cargo build --release --locked --workspace", + "cargo test --locked --workspace", ): self.assertIn(command, self.ci) - self.assertNotIn("--workspace", self.ci) - self.assertNotIn("-p omega-gateway", self.ci) - self.assertIn("-not -path './crates/omega-gateway/*'", self.ci) + self.assertNotIn("non-gateway", self.ci) + self.assertNotIn("-not -path './crates/omega-gateway/*'", self.ci) self.assertNotIn("continue-on-error", self.ci) self.assertIn("workflow_call:", self.ci) self.assertIn("uses: ./.github/workflows/ci.yml", self.release) @@ -193,6 +190,7 @@ def test_archive_builder_is_deterministic_and_normalizes_metadata(self): dist = root / "dist" dist.mkdir() (dist / "omega").write_bytes(b"omega-binary") + (dist / "omega-gatewayd").write_bytes(b"gateway-binary") (dist / "rmux").write_bytes(b"rmux-binary") (dist / "BUILD-INFO.json").write_text("{}\n", encoding="utf-8") command = [sys.executable, "-", "x86_64-unknown-linux-gnu"] @@ -218,7 +216,10 @@ def test_archive_builder_is_deterministic_and_normalizes_metadata(self): self.assertEqual(second.returncode, 0, second.stderr) self.assertEqual(archive_path.read_bytes(), before) with tarfile.open(fileobj=io.BytesIO(before), mode="r:gz") as bundle: - self.assertEqual(bundle.getnames(), ["omega", "rmux", "BUILD-INFO.json"]) + self.assertEqual( + bundle.getnames(), + ["omega", "omega-gatewayd", "rmux", "BUILD-INFO.json"], + ) metadata = { item.name: (item.uid, item.gid, item.mtime, item.mode) for item in bundle.getmembers() @@ -227,6 +228,7 @@ def test_archive_builder_is_deterministic_and_normalizes_metadata(self): metadata, { "omega": (0, 0, 0, 0o755), + "omega-gatewayd": (0, 0, 0, 0o755), "rmux": (0, 0, 0, 0o755), "BUILD-INFO.json": (0, 0, 0, 0o644), }, @@ -277,6 +279,7 @@ def test_publisher_verifier_accepts_complete_assets_and_rejects_tampering(self): with tarfile.open(archive_path, "w:gz") as bundle: for name, data in ( ("omega", b"omega"), + ("omega-gatewayd", b"gateway"), ("rmux", b"rmux"), ("BUILD-INFO.json", build_info), ): @@ -332,6 +335,7 @@ def test_publisher_verifier_accepts_complete_assets_and_rejects_tampering(self): ).encode() for name, data in ( ("omega", b"omega"), + ("omega-gatewayd", b"gateway"), ("rmux", b"rmux"), ("BUILD-INFO.json", forged), ): diff --git a/scripts/verify-install.sh b/scripts/verify-install.sh index c0067732..7e73a4b6 100755 --- a/scripts/verify-install.sh +++ b/scripts/verify-install.sh @@ -13,6 +13,7 @@ set -u cd "$(dirname "$0")/.." || exit 2 fail=0 +VERIFY_SOURCE_ONLY="${VERIFY_SOURCE_ONLY:-0}" ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } bad() { printf ' \033[31m✗ %s\033[0m\n' "$1"; fail=1; } @@ -323,6 +324,8 @@ DUO_INVARIANTS else bad "omega-duo source self-test failed or incomplete" fi +elif [ "$VERIFY_SOURCE_ONLY" = "1" ] && ! command -v bun >/dev/null 2>&1; then + ok "Bun runtime self-test skipped in VERIFY_SOURCE_ONLY mode (installer wiring checked above)" else bad "Bun absent: omega-duo runtime gate cannot execute" fi @@ -419,6 +422,8 @@ LC_RULE_SRC=$(grep -cE "id:[[:space:]]*\"$LC_RULE_ID\"" crates/omega-core/src/ru LC_DOC_SRC=$(find docs -iname '*lifecycle*.md' 2>/dev/null | wc -l) if [ "$LC_RULE_SRC" -eq 0 ] && [ "$LC_DOC_SRC" -eq 0 ]; then ok "oracle lifecycle contract assets absent from this branch (0 $LC_RULE_ID registrations in rules.rs, 0 docs/*lifecycle*.md): landing check SKIPPED, nothing to verify yet" +elif [ "$VERIFY_SOURCE_ONLY" = "1" ]; then + ok "installed lifecycle landing check skipped in VERIFY_SOURCE_ONLY mode" elif [ ! -d "$LC_HOME" ]; then ok "$LC_HOME absent (OmegaOS never installed here): lifecycle landing check SKIPPED (sources present: $LC_RULE_SRC $LC_RULE_ID registration(s), $LC_DOC_SRC docs pages)" else @@ -701,7 +706,7 @@ fi # import error, so the enforcement vanished with nothing to see. Assert the whole # chain so that class of bug is caught here instead of in production. HOOK_PARITY_OK=1 -for f in stop-verify-hook.sh omega-session-contract.sh omega-prompt-scan.sh omega-plan-mirror.sh omega_plan_state.py; do +for f in stop-verify-hook.sh omega-session-contract.sh omega-prompt-scan.sh omega-plan-mirror.sh omega-audit-guard.sh track-tool-use.sh omega_plan_state.py; do [ -f "scripts/hooks/$f" ] || { bad "hook payload missing from repo: scripts/hooks/$f"; HOOK_PARITY_OK=0; } done grep -q 'scripts/hooks/"\*\.py' install.sh || { bad "install.sh does not copy scripts/hooks/*.py (shared parser would not ship)"; HOOK_PARITY_OK=0; } @@ -709,6 +714,13 @@ for marker in stop-verify-hook omega-session-contract omega-prompt-scan omega-pl grep -q "$marker" install.sh || { bad "install.sh never registers $marker"; HOOK_PARITY_OK=0; } done [ "$HOOK_PARITY_OK" = "1" ] && ok "anti-abandon hooks shipped, copied (*.sh + *.py) and registered by install.sh" +if [ "$(grep -c '\.hooks.PreToolUse' install.sh)" -ge 2 ] \ + && [ "$(grep -c '\.hooks.PostToolUse' install.sh)" -ge 2 ] \ + && grep -q -- '--dangerously-bypass-hook-trust' crates/omega-core/src/agents.rs; then + ok "Claude and Codex both receive audit/tracker hooks; detached Codex trust is explicit" +else + bad "Codex hook enforcement is not in parity with Claude" +fi # 10b. Post-update coherence: an install must reconcile itself, and the # staleness check must have something true to compare against. @@ -767,7 +779,8 @@ if grep -q 'skip_metadata' tools/agent-reach/install-agent-reach.sh \ else bad "Agent Reach can publish upstream-only metadata that breaks omega sync" fi -if grep -q 'rsync -a --delete "$sk_dir/"' install.sh \ +if grep -q 'mirror_owned_dir "$sk_dir"' install.sh \ + && grep -q 'rsync -a --delete "$src/" "$dst/"' install.sh \ && grep -q 'rsync -a --delete --exclude=node_modules' install.sh \ && grep -q 'skills validate --root "$sk_dir"' install.sh \ && grep -q 'SKMIRROR_REJECTED' install.sh; then @@ -776,14 +789,45 @@ else bad "skill mirrors can retain stale or schema-invalid protocols across reinstall" fi -# omega-gateway (app API daemon): unit shipped, a workspace member (so the -# source-build `cargo build --release` above produces omega-gatewayd for -# free), and installer wiring (binary install + systemd unit + enable). +# omega-gateway (app API daemon): unit shipped, source-build wiring, AND the +# deterministic prebuilt archive. Grepping install.sh alone used to report a +# false pass while every prebuilt install silently omitted the daemon. if [ -f config/omega-gateway.service ] && grep -q "crates/omega-gateway" Cargo.toml \ - && grep -q "omega-gatewayd" install.sh && grep -q "omega-gateway.service" install.sh; then - ok "omega-gateway daemon shipped + wired (unit + workspace build + install.sh)" + && grep -q "omega-gatewayd" install.sh && grep -q "omega-gateway.service" install.sh \ + && grep -q "release/omega-gatewayd" .github/workflows/release.yml \ + && grep -q "'omega-gatewayd'" .github/workflows/release.yml \ + && grep -q '\$tmp/omega-gatewayd' install.sh; then + ok "omega-gateway daemon shipped in source + prebuilt lanes" +else + bad "omega-gateway daemon missing from source/prebuilt/install wiring" +fi + +# The configured runtime default and fresh-install payload must agree. +if grep -q 'default_agent_command().*codex\|agent_command = "codex"' config/default.toml \ + && grep -q 'omega" install codex' install.sh; then + ok "fresh install provisions the default Codex runtime" +else + bad "Codex is the default but install.sh does not provision it" +fi + +# Provider template is non-secret, installed owner-only, and visible on a fresh +# box instead of materializing only after the first UI mutation. +if [ -f config/providers.default.toml ] \ + && grep -q 'config/providers.default.toml' install.sh \ + && grep -q 'chmod 600 "\$OMEGA_DIR/providers.toml"' install.sh; then + ok "providers.toml template shipped + installed owner-only" +else + bad "provider config template missing or not installed owner-only" +fi + +# Reproducible Rust version: CI, rust-toolchain.toml, and source installer must +# name the same compiler floor. +if grep -q 'channel = "1.97.1"' rust-toolchain.toml \ + && grep -q 'rust_required="1.97.1"' install.sh \ + && grep -q 'toolchain: 1.97.1' .github/workflows/ci.yml; then + ok "Rust toolchain pinned consistently (1.97.1)" else - bad "omega-gateway daemon not fully shipped/wired (unit file / workspace member / install.sh wiring)" + bad "Rust toolchain pin drift between CI, installer, and rust-toolchain.toml" fi # ── OS suite parity: every INTEGRATED OS (a payload beyond its scaffold) must @@ -873,6 +917,8 @@ fi if command -v bun >/dev/null 2>&1 \ && bun scripts/verify-os-suite.ts >/dev/null; then ok "OS documentation parity: 24 complete manifests and checksum inventories" +elif [ "$VERIFY_SOURCE_ONLY" = "1" ] && ! command -v bun >/dev/null 2>&1; then + ok "OS documentation runtime verifier skipped in VERIFY_SOURCE_ONLY mode (Bun unavailable)" else bad "OS documentation parity failed (README/README_FR/MANIFEST/integration/skill/checksums)" fi diff --git a/skills/audits/_shared/grep-loop.sh b/skills/audits/_shared/grep-loop.sh index 2b3e4dd4..b0502621 100755 --- a/skills/audits/_shared/grep-loop.sh +++ b/skills/audits/_shared/grep-loop.sh @@ -109,17 +109,16 @@ while [ "$ITER" -lt "$MAX_ITER" ]; do fi # ─── Liveness gate ─── - if [ -x "$HOME/.claude/lib/worker-alive-check.sh" ]; then - if ! "$HOME/.claude/lib/worker-alive-check.sh" "$SESSION" >/dev/null 2>&1; then - # Exit 0 = safe-to-kill = session idle/dead → liveness lost - write_halt "worker_died" "$ITER" - exit 4 - fi + # OmegaOS is rmux-native; the old optional provider-specific helper and + # tmux fallback made fresh installs silently skip this gate. + if ! command -v omega >/dev/null 2>&1 \ + || ! omega capture "$SESSION" >/dev/null 2>&1; then + write_halt "worker_died" "$ITER" + exit 4 fi # ─── Re-dispatch fix instruction ─── - # Use tmux send-keys directly; works whether or not omega-todo is set up. - if tmux has-session -t "$SESSION" 2>/dev/null; then + if omega capture "$SESSION" >/dev/null 2>&1; then local_diff_summary=$(git diff "$GIT_BASELINE"..HEAD --shortstat 2>/dev/null | head -1) FIX_MSG="Verify command failed: $VERIFY_CMD Current diff: $local_diff_summary @@ -128,9 +127,7 @@ Iteration: $ITER of $MAX_ITER Fix the failing assertions ONLY. Don't expand scope. Don't rename files. Only modify files already in your brief.files_owned scope. Re-run verify before calling worker-mark-done.sh." - tmux send-keys -t "$SESSION" "$FIX_MSG" 2>/dev/null - sleep 0.3 - tmux send-keys -t "$SESSION" Enter 2>/dev/null + omega send "$SESSION" "$FIX_MSG" >/dev/null 2>&1 else write_halt "session_missing" "$ITER" exit 4 diff --git a/telegram-bot/omega-tg-bot.ts b/telegram-bot/omega-tg-bot.ts index ba5a022b..23af8b74 100644 --- a/telegram-bot/omega-tg-bot.ts +++ b/telegram-bot/omega-tg-bot.ts @@ -1427,8 +1427,8 @@ async function runCodex(text: string, systemPrompt: string, addDir: string, who: const task = systemPrompt.trim() ? `${systemPrompt.trim()}\n\n---\n\n${text}` : text; return runAgentProc( codex, - ["exec", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox", task], - who, cwd || addDir, timeoutMs, "codex", + ["exec", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox", "--dangerously-bypass-hook-trust", "-"], + who, cwd || addDir, timeoutMs, "codex", task, ); } @@ -1436,11 +1436,15 @@ async function runCodex(text: string, systemPrompt: string, addDir: string, who: // implementation so Claude and Codex cannot drift apart on timeout handling, // kill escalation or empty-output diagnostics. `label` names the binary in the // operator-facing messages. -async function runAgentProc(bin: string, argv: string[], who: string, cwd: string | undefined, timeoutMs: number, label: string): Promise { +async function runAgentProc(bin: string, argv: string[], who: string, cwd: string | undefined, timeoutMs: number, label: string, stdinText?: string): Promise { try { const proc = Bun.spawn([bin, ...argv], { - cwd, env: { ...process.env, OMEGA_DIR }, stdin: "ignore", stdout: "pipe", stderr: "pipe", + cwd, env: { ...process.env, OMEGA_DIR }, stdin: stdinText === undefined ? "ignore" : "pipe", stdout: "pipe", stderr: "pipe", }); + if (stdinText !== undefined) { + proc.stdin.write(stdinText); + proc.stdin.end(); + } // Race the run against the watchdog instead of awaiting the streams after a // kill: a SIGTERM'd claude can leave a grandchild holding the stdout pipe, // which would block the drain (and the operator's reply) until IT exits. @@ -3081,18 +3085,28 @@ function statusCard(raw: string): string { // is the fallback for binaries predating that subcommand. Selecting writes // providers.toml (omega sessions) and, for claude, the omega-mc dashboard fallback // (defaults.model only — the per-agent opus/sonnet split is preserved). -const PROVIDER_FALLBACK = ["claude", "codex", "gemini", "glm", "openrouter"]; +const PROVIDER_FALLBACK = [ + "claude", "codex", "gemini", "antigravity", "glm", + "openrouter", "pi", "hermes", "kimi", +]; const MODEL_FALLBACK: Record = { claude: ["opus", "sonnet", "haiku"], - codex: ["gpt-5", "gpt-5-codex", "o3"], - gemini: ["gemini-2.5-pro", "gemini-2.5-flash"], - glm: ["glm-4.6", "glm-4.5"], - openrouter: ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5", "google/gemini-2.5-pro", "deepseek/deepseek-chat"], + codex: ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"], + gemini: ["auto", "pro", "flash", "gemini-3.1-pro-preview", "gemini-3.6-flash"], + antigravity: [], + glm: ["glm-5.3", "glm-5-turbo", "glm-4.7"], + openrouter: ["anthropic/claude-opus-5", "anthropic/claude-sonnet-5", "openai/gpt-5.5", "google/gemini-3.1-pro-preview", "z-ai/glm-5.3", "deepseek/deepseek-chat"], + pi: ["anthropic/claude-opus-5", "anthropic/claude-sonnet-5", "openai/gpt-5.5"], + hermes: ["anthropic/claude-opus-5", "anthropic/claude-sonnet-5", "openai/gpt-5.5"], + kimi: ["kimi-for-coding", "kimi-for-coding-highspeed", "k3", "k3-256k"], +}; +const PROVIDER_ICON: Record = { + claude: "🟣", codex: "🟢", gemini: "🔵", antigravity: "🚀", + glm: "🟡", openrouter: "🌐", pi: "π", hermes: "⚕", kimi: "🌙", }; -const PROVIDER_ICON: Record = { claude: "🟣", codex: "🟢", gemini: "🔵", glm: "🟡", openrouter: "🌐" }; // Claude alias → full model id the omega-mc yaml uses (mirror of dispatch.rs + the // dashboard's model convention). Anything not aliased is passed through verbatim. -const CLAUDE_FULL_ID: Record = { opus: "claude-opus-5", sonnet: "claude-sonnet-4-6", haiku: "claude-haiku-4-5" }; +const CLAUDE_FULL_ID: Record = { opus: "claude-opus-5", sonnet: "claude-sonnet-5", haiku: "claude-haiku-4-5" }; async function listProviders(): Promise { const out = await omega(["config", "models"]); const ps = out.split("\n").map(s => s.trim()).filter(s => /^[a-z]+$/.test(s)); @@ -3140,7 +3154,8 @@ async function modelProviderView(provider: string, banner = ""): Promise<{ text: ? ` Current: ${esc(cur || "default")}\n Tap a model to activate it.\n\n${keyLine}` : ` No catalogued models. Configure: omega config set ${esc(provider)}.model …\n\n${keyLine}`); const keyRow: Btn[][] = hasKey ? [[{ text: "🗑 Delete API key (x)", callback_data: `model:delkey:${provider}`.slice(0, 64) }]] : []; - return { text: card(`MODEL — ${provider.toUpperCase()}`, body), markup: kb([...rows, ...keyRow, [{ text: "« Providers", callback_data: "nav:model" }]]) }; + const activateRow: Btn[][] = [[{ text: "✓ Use provider native default", callback_data: `model:activate:${provider}`.slice(0, 64) }]]; + return { text: card(`MODEL — ${provider.toUpperCase()}`, body), markup: kb([...rows, ...activateRow, ...keyRow, [{ text: "« Providers", callback_data: "nav:model" }]]) }; } // ── Zernio views (built from the omega-zernio --json CLI) ───────────────────── @@ -3358,11 +3373,10 @@ async function view(name: string): Promise<{ text: string; markup: any }> { ]) }; case "model": { const provs = await listProviders(); - const active = await currentModel("claude"); const rows: Btn[][] = []; for (let i = 0; i < provs.length; i += 2) rows.push(provs.slice(i, i + 2).map(p => ({ text: `${PROVIDER_ICON[p] || "•"} ${p}`.slice(0, 28), callback_data: `model:prov:${p}`.slice(0, 64) }))); - const body = ` omega sessions run on:\n claude · ${esc(active || "default")}\n\n Pick a provider to view and change its model.`; + const body = ` Pick a provider, then a model. The selection becomes the global default for new sessions; --agent or “avec codex/claude” still overrides one mission.`; return { text: card("MODEL / PROVIDERS", body), markup: kb([...rows, [{ text: "🔄 Refresh", callback_data: "nav:model" }, back()]]) }; } case "zernio": return await zernioHome(); @@ -3446,18 +3460,27 @@ async function onCallback(data: string, chat: number, msgId: number, from: numbe const v = await modelProviderView(arg, ` ${ok ? "🗑 ✅" : "⚠️"} ${esc(arg)} API key ${ok ? "deleted — sessions now use OAuth/subscription (autonomous, no prompt)." : "delete failed: " + esc(res.slice(0, 80))}`); return edit(chat, msgId, v.text, v.markup); } + if (ns === "model" && action === "activate") { + const res = await omega(["config", "activate", arg]); + const ok = /^\[\+\] Active provider/m.test(res); + const v = await modelProviderView( + arg, + ` ${ok ? "✅" : "⚠️"} ${esc(arg)} native default ${ok ? "activated globally." : "activation failed: " + esc(res.slice(0, 100))}`, + ); + return edit(chat, msgId, v.text, v.markup); + } if (ns === "model" && action === "set") { // arg = "provider:model" — model may contain "/" (openrouter ids), never ":". const i = arg.indexOf(":"); const provider = arg.slice(0, i); const model = arg.slice(i + 1); - const res = await omega(["config", "set", `${provider}.model`, model]); - const okOmega = /^\[\+\] Set/m.test(res); + const res = await omega(["config", "activate", provider, model]); + const okOmega = /^\[\+\] Active provider/m.test(res); let dash = ""; if (provider === "claude") { const full = CLAUDE_FULL_ID[model] || model; const wrote = mcSetDefaultModel(full); dash = `\n 🖥 Dashboard defaults: ${wrote ? `${esc(full)}(hot-reload ~3s)` : "unchanged"}`; } - const banner = ` ${okOmega ? "✅" : "⚠️"} ${esc(provider)}${esc(model)}\n ⚙️ omega sessions: ${okOmega ? "✅" : "⚠️ " + esc(res.slice(0, 80))}${dash}`; + const banner = ` ${okOmega ? "✅" : "⚠️"} ${esc(provider)}${esc(model)}\n ⚙️ global default for new sessions: ${okOmega ? "✅" : "⚠️ " + esc(res.slice(0, 80))}${dash}`; const v = await modelProviderView(provider, banner); return edit(chat, msgId, v.text, v.markup); } diff --git a/tools/duo/bin/omega-duo b/tools/duo/bin/omega-duo index 95abe6ee..22ff7a3f 100755 --- a/tools/duo/bin/omega-duo +++ b/tools/duo/bin/omega-duo @@ -684,12 +684,15 @@ function codexArgv(mode: string, degraded = false, preflight = false): string[] if (mode === "code") { // Bypass Codex's bwrap workspace sandbox. On this operator-owned VPS the // network namespace is locked down, so bwrap's loopback setup fails - // ("RTM_NEWADDR: Operation not permitted") and --full-auto can't write a + // ("RTM_NEWADDR: Operation not permitted") and the sandbox can't write a // single file. Bypassing matches how Claude already runs here // (--dangerously-skip-permissions): same trust boundary, same box, the - // operator owns it. DUO_CODEX_FULL_AUTO=1 forces the sandboxed path back - // on a host where bwrap works. - if (process.env.DUO_CODEX_FULL_AUTO === "1") return [...base, "--full-auto", "-"]; + // operator owns it. The legacy env name is retained for compatibility; + // Codex >=0.147 removed --full-auto, and --approve-for-me now supplies the + // workspace-write + automatic-review preset by itself. + if (process.env.DUO_CODEX_FULL_AUTO === "1") { + return [...base, "--approve-for-me", "--dangerously-bypass-hook-trust", "-"]; + } return [...base, "--dangerously-bypass-approvals-and-sandbox", "-"]; } throw new Error("invalid duo mode");