diff --git a/.githooks/pre-push b/.githooks/pre-push deleted file mode 100755 index 0a69472f..00000000 --- a/.githooks/pre-push +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Committed pre-push hook. Runs the fast gate (`make fmt-check clippy`) before any push so formatting/clippy failures are caught locally -# Enable once per clone: make install-hooks (sets core.hooksPath=.githooks) -# Bypass in an emergency: git push --no-verify -set -euo pipefail - -# Resolve the repo root so the hook works regardless of the cwd at push time. -repo_root="$(git rev-parse --show-toplevel)" -cd "$repo_root" - -echo "pre-push: running lint checks (fmt-check + clippy)…" -if ! make fmt-check clippy; then - echo - echo "pre-push: lint checks failed - push aborted." >&2 - echo "Fix the issues above, or bypass with 'git push --no-verify' (not recommended)." >&2 - exit 1 -fi diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f95b23f6..6e50c67e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -92,14 +92,18 @@ jobs: bun run lint:agnt bun run lint:coder-module + - name: Webview unit tests + run: bun run test:webview + - name: Lint shell scripts - run: shellcheck -S warning scripts/*.sh scripts/ci/*.sh .githooks/* + run: shellcheck -S warning scripts/*.sh scripts/ci/*.sh - name: Build UI dist run: | cd ui bun install --frozen-lockfile bun run typecheck + bun test bun run build DIST_SIZE=$(du -sb dist/ | cut -f1) echo "UI dist size: ${DIST_SIZE}B ($(echo "scale=1; $DIST_SIZE/1048576" | bc)MB uncompressed)" @@ -449,19 +453,16 @@ jobs: sed 's/^version = ".*"/version = "'"$V"'"/' Cargo.toml > Cargo.toml.tmp && mv Cargo.toml.tmp Cargo.toml cargo update --workspace - # Build release with embedded UI. - # - # Add `OPERATOR_RELEASE: "1"` below once OPERATOR_LICENSE_PUBLIC_KEYS and - # OPERATOR_LICENSE_ISSUER exist as repository secrets. build.rs then - # refuses to produce an artifact without them, because a build with no - # verification keys rejects every licence - a silent, total Premium - # outage. Until then the keys are passed through when set and the build - # succeeds either way. + # Compile license material into the release binary. These are not + # runtime settings. build.rs refuses an empty root keyring when + # OPERATOR_RELEASE=1, so a missing repository variable fails this job + # instead of publishing a binary that rejects every license. - name: Build release run: cargo build --locked --release --features embed-ui --target ${{ matrix.target }} env: - OPERATOR_LICENSE_PUBLIC_KEYS: ${{ secrets.OPERATOR_LICENSE_PUBLIC_KEYS }} - OPERATOR_LICENSE_ISSUER: ${{ secrets.OPERATOR_LICENSE_ISSUER }} + OPERATOR_RELEASE: "1" + OPERATOR_LICENSE_ISSUER: operator-licensing + OPERATOR_LICENSE_ROOT_KEYS: ${{ vars.OPERATOR_LICENSE_ROOT_KEYS }} OPERATOR_PURCHASE_URL: ${{ vars.OPERATOR_PURCHASE_URL }} - name: Rename binary diff --git a/CLAUDE.md b/AGENTS.md similarity index 91% rename from CLAUDE.md rename to AGENTS.md index f6607762..f0765677 100644 --- a/CLAUDE.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ -# CLAUDE.md - operator +# AGENTS.md - operator ## Project Overview -`operator` is a Rust TUI application for orchestrating Claude Code agents across multi-project codebases. It manages ticket queues, launches agents, tracks progress, and provides notifications. +`operator` is a Rust TUI application for orchestrating Claude/Codex/Gemini/Grok CLI agents across multi-project codebases. It manages ticket queues, launches agents, tracks progress, and provides notifications. ## Tech Stack @@ -77,14 +77,6 @@ excluded by `.oxfmtrc.json` / `.oxlintrc.jsonc` and must never be reformatted. > deprecation that only surfaces under `--all-targets`), which is how a clippy > failure can pass locally yet break CI. Always use the full command above. -Install the pre-push hook once per clone so the fast gate (`fmt-check` + -root `clippy`, no tests) runs automatically before every push; the full `make check` remains -the expectation before opening a PR: - -```bash -make install-hooks # sets core.hooksPath=.githooks -``` - If any of these fail, fix the issues before proceeding. Do NOT use `#[allow(...)]` attributes to silence warnings unless there's a documented reason (e.g., code used only in tests). #### Strict Linting @@ -241,7 +233,7 @@ webhooks. ## Project Discovery -On startup, operator scans the configured projects directory for subdirectories containing an agent marker file (`CLAUDE.md`, `GEMINI.md`, `CODEX.md`). +On startup, operator scans the configured projects directory for subdirectories containing an agent marker file (`AGENTS.md`, plus vendor backups `CLAUDE.md`, `GEMINI.md`, `CODEX.md`, `GROK.md`). These are presented as available projects when creating tickets. ## Working a Ticket @@ -291,8 +283,7 @@ cargo run -- docs --only openapi cargo run -- docs --only config # `--only` accepts any key from docs_gen::all_generators(); an unknown key -# prints the full list. `llm-tools` is opt-in only: it is excluded from a full -# run because docs/llm-tools/index.md is currently maintained by hand. +# prints the full list. ``` ### Auto-Generated File Headers @@ -314,25 +305,18 @@ All generated files include a header warning: ## Design & UI Consistency -Operator presents one brand (terracotta + cornflower + cream over a green -scale) across **four rendering surfaces**. Keep them consistent by following the -rule that fits each surface - they are deliberately *not* all styled the same -way. Full details and swatches live in `docs/design-system/` (`/design-system/`). +Operator presents one brand (terracotta + cornflower + cream over a green scale) across **four rendering surfaces**. Keep them consistent by following the rule that fits each surface - they are deliberately *not* all styled the same way. Full details and swatches live in `docs/design-system/` (`/design-system/`). -**Brand source of truth:** `docs/assets/css/tokens.css` - the only place the -brand hex values + dark-mode overrides are declared. Both web surfaces consume -it; never re-declare a brand color elsewhere. +**Brand source of truth:** `docs/assets/css/tokens.css` - the only place the brand hex values + dark-mode overrides are declared. Both web surfaces consume it; never re-declare a brand color elsewhere. | Surface | Where | Rule | |---------|-------|------| | Docs site (Jekyll) | `docs/assets/css/main.css` | Links `tokens.css` (via `_includes/head.html`); style components with `var(--...)`, never raw hex. | | Embedded SPA (Vite/React) | `ui/src/index.css` + `*.module.css` | Imports `tokens.css`; layers app-only semantic tokens (`--surface`, `--border`, `--danger`, …) on top. Components reference semantic tokens, not raw hex. | -| Ratatui TUI | `src/ui/*.rs` | Terminal can't render hex - match a **semantic role to ANSI** (danger→Red, success→Green, warning→Yellow, focus→Cyan). Reuse `color_for_key`/`glyph_for_key` from `src/templates/mod.rs`; don't re-hardcode issuetype/priority colors. | +| Ratatui TUI | `src/ui/*.rs` | Terminal can't render hex - match a **semantic role to ANSI** (danger=Red, success=Green, warning=Yellow, focus=Cyan). Reuse `color_for_key`/`glyph_for_key` from `src/templates/mod.rs`; don't re-hardcode issuetype/priority colors. | | VS Code webview | `vscode-extension/webview-ui/` | **Defer to the VS Code host theme**: style with raw `var(--vscode-*)` custom properties (`styles/webview.css` + `components/primitives/`). Apply brand only as accents via the `--op-*` variables; never override the user's editor theme wholesale. No MUI/CSS-in-JS - enforced by `tests/ui_packaging.rs`. | -When adding or changing UI: change a brand color in `tokens.css` (web surfaces -follow automatically); reference semantic tokens in new web CSS; map a role to -ANSI in the TUI; and leave the webview deferring to the editor theme. +When adding or changing UI: change a brand color in `tokens.css` (web surfaces follow automatically); reference semantic tokens in new web CSS; map a role to ANSI in the TUI; and leave the webview deferring to the editor theme. **Icons.** Every SVG icon follows the Operator icon standard - a single monochrome `` on a 24×24 canvas with no `fill`/`stroke`/`width`/`height`, so it tints from `currentColor` and sizes to its container on all four surfaces. Governed directories: `icons/`, `docs/assets/icons/`, `ui/public/icons/`, and each collection's `icon.svg`. Enforced by `cargo test --test svg_icon_standard`; the rules and rationale are in `docs/design-system/`. diff --git a/Cargo.lock b/Cargo.lock index 107f24f2..87c0f69a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,6 +407,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -556,17 +562,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - [[package]] name = "chrono" version = "0.4.45" @@ -627,6 +622,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -1174,7 +1179,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1314,21 +1319,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1503,11 +1493,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", ] [[package]] @@ -1731,23 +1718,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", ] [[package]] @@ -1756,7 +1726,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2020,6 +1990,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -2048,7 +2067,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64", + "base64 0.22.1", "js-sys", "pem", "ring", @@ -2183,12 +2202,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -2293,23 +2306,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nix" version = "0.29.0" @@ -2561,49 +2557,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "operator" version = "0.2.13" @@ -2614,7 +2573,7 @@ dependencies = [ "async-trait", "axum", "backon", - "base64", + "base64 0.22.1", "chrono", "clap", "config", @@ -2642,6 +2601,7 @@ dependencies = [ "ring", "rusqlite", "rust-embed", + "rustls", "schemars 1.2.2", "serde", "serde_json", @@ -2800,7 +2760,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -3009,62 +2969,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.20", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.20", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.47" @@ -3105,17 +3009,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - [[package]] name = "rand_chacha" version = "0.9.0" @@ -3144,21 +3037,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "ratatui" version = "0.30.2" @@ -3355,11 +3233,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "encoding_rs", "futures-channel", @@ -3371,23 +3249,20 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", "mime", - "native-tls", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tower", "tower-http 0.6.11", @@ -3396,7 +3271,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] @@ -3511,7 +3385,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3528,16 +3402,54 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ - "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -3769,7 +3681,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", @@ -3888,6 +3800,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -4089,7 +4017,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4142,7 +4070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.13.1", "fancy-regex", "filedescriptor", @@ -4320,16 +4248,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4736,7 +4654,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" dependencies = [ "axum", - "base64", + "base64 0.22.1", "mime_guess", "regex", "rust-embed", @@ -4887,20 +4805,10 @@ dependencies = [ ] [[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" +name = "webpki-root-certs" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -5008,7 +4916,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4adac1e9..dce006cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,7 +75,12 @@ sysinfo = "0.39" sha2 = "0.11" # HTTP client for API calls -reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json", "blocking", "query", "charset", "http2", "system-proxy"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } + +# Native (in-daemon) LLM calls via Rig; isolated in src/llm/native/rig/. +# re-enable together with the `native-llm` feature below once further Rig capabilities +# rig-core = { version = "=0.42.0", default-features = false, optional = true } # Async traits async-trait = "0.1" @@ -114,14 +119,18 @@ utoipa-axum = "0.2" [features] default = ["embed-ui"] embed-ui = ["dep:rust-embed", "dep:mime_guess"] +# native-llm = ["dep:rig-core"] [dev-dependencies] operator-relay = { path = "crates/relay" } +tokio = { version = "1", features = ["test-util"] } tempfile = "3" flate2 = "1" [lints.rust] unsafe_code = "deny" +# `native-llm` is paused (see rig-core above); its cfg gates stay in the source. +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("native-llm"))'] } [lints.clippy] all = { level = "warn", priority = -2 } @@ -168,7 +177,7 @@ unnecessary_wraps = "allow" unused_async = "allow" # Doc links with quotes are fine doc_link_with_quotes = "allow" -# Allow cast_possible_wrap for u64→i64 in timestamps +# Allow cast_possible_wrap for u64->i64 in timestamps cast_possible_wrap = "allow" # Match arms kept separate for readability in TUI event handlers match_same_arms = "allow" diff --git a/Makefile b/Makefile index 3ff304e0..10a0d28d 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,7 @@ lint-ts: bun run lint:coder-module lint-shell: - shellcheck -S warning scripts/*.sh scripts/ci/*.sh .githooks/* + shellcheck -S warning scripts/*.sh scripts/ci/*.sh lint-helm: helm lint charts/operator @@ -98,6 +98,7 @@ test-ts: bun install --frozen-lockfile cd webcomponents && bun install --frozen-lockfile && bun run test cd coder-module && bun test + bun test agnt-plugin # Every gate for one module, for when only that module changed. relay: @@ -163,8 +164,3 @@ docs: webcomponents # The collection bundle is excluded from Jekyll (see docs/_config.yml) and # copied in verbatim, so the bytes operator fetches match their checksums. cp -R docs/collections docs/_site/ - -# One-time per clone: route git hooks at the committed .githooks/ directory. -install-hooks: - git config core.hooksPath .githooks - @echo "pre-push hook installed (runs 'make fmt-check clippy')" diff --git a/README.md b/README.md index 6c562e04..509c007d 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ * **Kanban Provider** [![Operator](https://img.shields.io/badge/Operator-C8613F)](https://operator.untra.io/getting-started/kanban/operator/) [![Jira](https://img.shields.io/badge/Jira-0052CC?logo=jira&logoColor=white)](https://operator.untra.io/getting-started/kanban/jira/) [![Linear](https://img.shields.io/badge/Linear-5E6AD2?logo=linear&logoColor=white)](https://operator.untra.io/getting-started/kanban/linear/) [![GitHub Projects](https://img.shields.io/badge/GitHub_Projects-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/kanban/github/) -* **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) +* **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) [![Grok](https://img.shields.io/badge/Grok-000000)](https://operator.untra.io/getting-started/agents/grok/) -* **Model Provider** [![Anthropic](https://img.shields.io/badge/Anthropic-D97757?logo=anthropic&logoColor=white)](https://operator.untra.io/getting-started/model-servers/anthropic/) [![OpenAI](https://img.shields.io/badge/OpenAI-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/model-servers/openai/) [![Google](https://img.shields.io/badge/Google-4285F4?logo=google&logoColor=white)](https://operator.untra.io/getting-started/model-servers/google/) [![OpenRouter](https://img.shields.io/badge/OpenRouter-94A3B8?logo=openrouter&logoColor=white)](https://operator.untra.io/getting-started/model-servers/openrouter/) [![Ollama](https://img.shields.io/badge/Ollama-000000?logo=ollama&logoColor=white)](https://operator.untra.io/getting-started/model-servers/ollama/) +* **Model Provider** [![Anthropic](https://img.shields.io/badge/Anthropic-D97757?logo=anthropic&logoColor=white)](https://operator.untra.io/getting-started/model-servers/anthropic/) [![OpenAI](https://img.shields.io/badge/OpenAI-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/model-servers/openai/) [![Google](https://img.shields.io/badge/Google-4285F4?logo=google&logoColor=white)](https://operator.untra.io/getting-started/model-servers/google/) [![xAI](https://img.shields.io/badge/xAI-000000)](https://operator.untra.io/getting-started/model-servers/xai/) [![OpenRouter](https://img.shields.io/badge/OpenRouter-94A3B8?logo=openrouter&logoColor=white)](https://operator.untra.io/getting-started/model-servers/openrouter/) [![Ollama](https://img.shields.io/badge/Ollama-000000?logo=ollama&logoColor=white)](https://operator.untra.io/getting-started/model-servers/ollama/) * **Git Version Control** [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/git/github/) [![GitLab](https://img.shields.io/badge/GitLab-FC6D26?logo=gitlab&logoColor=white)](https://operator.untra.io/getting-started/git/gitlab/) @@ -232,6 +232,7 @@ Operator launches LLM agents via CLI tools in terminal sessions. Each tool is co | `claude` | `claude --version` | opus, sonnet, haiku | `--session-id` | | `codex` | `codex --version` | gpt-4o, o1, o3 | `--resume` | | `gemini` | `gemini --version` | pro, flash, ultra | `--resume` | +| `grok` | `grok --version` | grok-4 | `--session-id` | ### How Operator Calls LLM Tools diff --git a/agnt-plugin/README.md b/agnt-plugin/README.md index cede4ff9..86913578 100644 --- a/agnt-plugin/README.md +++ b/agnt-plugin/README.md @@ -4,7 +4,9 @@ An [AGNT.gg](https://agnt.gg) plugin that exposes **Operator!**'s ticket orchest Drop these nodes into an AGNT workflow to create tickets, launch coding agents, poll the queue, export workflows, and raise investigations. -This is the **AGNT → Operator** direction. The companion direction (Operator → AGNT) is the `operator workflow export --format agnt` emitter built into Operator, which emits graphs composed of the `operator-launch-agent` nodes this plugin defines. +This is the **AGNT -> Operator** direction. The companion direction (Operator -> AGNT) is `operator workflow export --format agnt`, which emits one `operator-run-step` node per issuetype step. `operator-launch-agent` remains a separate node: it launches a whole ticket by `id`. + +Operator exports AGNT workflows as runnable visual scaffolds of a ticket's execution shape, not as lossless equivalents of Operator's internal workflow semantics. Interactive AGNT agents should use Operator's MCP server against the Operator that holds their tickets. Visual workflows use these nodes. See `docs/getting-started/integrations/agnt.md`. ## Nodes @@ -33,14 +35,27 @@ Start Operator's REST API with: operator api ``` +## Authentication + +`OPERATOR_BASE_URL` defaults to `http://localhost:7008`. + +| Deployment | Set this | What it is | +|------------|----------|------------| +| Loopback `operator api` | `OPERATOR_API_TOKEN` | Contents of `.tickets/operator/local-token`. Sent as a bearer. Replaced every server start. Absent when Operator is not bound to loopback. | +| Kubernetes or any other bind | `OPERATOR_ACCESS_KEY` | Service access key with `read`, `write`, and `execute`. Exchanged at `POST /api/v1/auth/token` (`grant_type` `operator:access-key`) for a 15-minute bearer. | + +A 401 on `/api/v1/queue/status` means neither credential was presented. A certificate error means the AGNT process does not trust the server CA. Set `NODE_EXTRA_CA_CERTS`. The plugin keeps TLS verification on. + +Node parameters `operatorApiToken` and `operatorAccessKey` override the env vars and are stored in the workflow file. Prefer the env vars. + ## Example workflow ``` webhook-trigger - → operator-create-ticket { template: "fix", project: "gamesvc", summary: "{{payload.title}}" } - → operator-launch-agent { id: "{{prev.result.id}}" } - → operator-queue-status - → slack-send +operator-create-ticket { template: "fix", project: "gamesvc", summary: "{{payload.title}}" } +operator-launch-agent { id: "{{prev.result.id}}" } +operator-queue-status +slack-send ``` `operator-create-ticket` returns `{ id, filename, path }`, so the next node can @@ -62,7 +77,7 @@ Packaging uses AGNT's bundled builder (it gzips the manifest + JS + any cp -r agnt-plugin /path/to/agnt/backend/plugins/dev/operator-plugin cd /path/to/agnt/backend/plugins node build-plugin.js operator-plugin -# → plugin-builds/operator-plugin.agnt +# plugin-builds/operator-plugin.agnt ``` Install the resulting `.agnt` via AGNT's Marketplace UI, or drop it into @@ -72,16 +87,12 @@ Install the resulting `.agnt` via AGNT's Marketplace UI, or drop it into curl -X POST http://localhost:3333/api/plugins/reload ``` -## Alternative: the MCP bridge (no plugin) +## MCP for interactive agents -Operator also ships a stdio MCP server exposing ~18 orchestration tools. AGNT -consumes stdio MCP servers natively - register Operator without this plugin via -AGNT's MCP settings: +`operator mcp` is a local subprocess. It opens the tickets on the machine where AGNT spawns it. It does not call a remote Operator URL. ```json { "name": "operator", "command": "operator", "args": ["mcp"] } ``` -The plugin's value over the raw MCP bridge is first-class canvas nodes with -typed parameters and marketplace discoverability. See -`docs/getting-started/integrations/agnt/` for the full comparison. +A cluster Operator serves the same tools at the descriptor's `transport_url` (`GET /api/v1/mcp/sse`), and that stream requires the same bearer as the REST API. Write and launch tools stay off until `[mcp].expose_ticket_write_tools = true`. If AGNT can only register stdio, use these plugin nodes against the cluster. The comparison is in `docs/getting-started/integrations/agnt.md`. diff --git a/agnt-plugin/lib/operator-client.js b/agnt-plugin/lib/operator-client.js index daa8744b..ee09638d 100644 --- a/agnt-plugin/lib/operator-client.js +++ b/agnt-plugin/lib/operator-client.js @@ -4,14 +4,102 @@ // AGNT contract `{ success, result, error }`. const DEFAULT_BASE_URL = "http://localhost:7008"; +const REFRESH_SKEW_MS = 30_000; +const tokenCache = new Map(); -/** - * Resolve the Operator REST base URL from params, env, or the default. - */ -export function resolveBaseUrl(params) { +export function resolveBaseUrl(params, env = process.env) { const fromParam = params?.operatorBaseUrl; - const fromEnv = typeof process === "undefined" ? undefined : process.env?.OPERATOR_BASE_URL; - return (fromParam || fromEnv || DEFAULT_BASE_URL).replace(/\/+$/, ""); + const fromEnv = env?.OPERATOR_BASE_URL; + return String(fromParam || fromEnv || DEFAULT_BASE_URL).replace(/\/+$/, ""); +} + +function nonempty(value) { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +// First hit wins: an explicit bearer, an env bearer, a param access key, an env access key. +export function resolveCredential(params, env = process.env) { + const bearer = nonempty(params?.operatorApiToken) || nonempty(env?.OPERATOR_API_TOKEN); + if (bearer) { + return { kind: "bearer", token: bearer }; + } + const accessKey = nonempty(params?.operatorAccessKey) || nonempty(env?.OPERATOR_ACCESS_KEY); + if (accessKey) { + return { kind: "access_key", token: accessKey }; + } + return null; +} + +export function tokenStillValid(entry, nowMs) { + return Boolean(entry) && entry.expiresAtMs - nowMs > REFRESH_SKEW_MS; +} + +export function clearTokenCache() { + tokenCache.clear(); +} + +function cacheKey(baseUrl, accessKey) { + return `${baseUrl}\n${accessKey}`; +} + +async function readJson(res) { + const text = await res.text(); + try { + return text ? JSON.parse(text) : null; + } catch { + return text; + } +} + +async function exchangeAccessKey(baseUrl, accessKey) { + const key = cacheKey(baseUrl, accessKey); + const cached = tokenCache.get(key); + if (tokenStillValid(cached, Date.now())) { + return cached.accessToken; + } + + const res = await fetch(`${baseUrl}/api/v1/auth/token`, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ grant_type: "operator:access-key", access_key: accessKey }), + }); + const parsed = await readJson(res); + if (!res.ok || !parsed?.access_token) { + tokenCache.delete(key); + const detail = + parsed && typeof parsed === "object" && parsed.error ? parsed.error : `HTTP ${res.status}`; + throw new Error(`access key exchange failed: ${detail}`); + } + const expiresInMs = Number(parsed.expires_in) * 1000; + tokenCache.set(key, { + accessToken: parsed.access_token, + expiresAtMs: Date.now() + (Number.isFinite(expiresInMs) ? expiresInMs : 0), + }); + return parsed.access_token; +} + +async function send(url, method, body, bearer) { + const init = { + method, + headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` }, + }; + if (body !== undefined) { + init.headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + const res = await fetch(url, init); + const parsed = await readJson(res); + return { ok: res.ok, status: res.status, parsed }; +} + +function failure(method, url, parsed, status) { + const detail = + parsed && typeof parsed === "object" && parsed.error ? parsed.error : `HTTP ${status}`; + return { success: false, result: parsed, error: `${method} ${url} failed: ${detail}` }; } /** @@ -19,34 +107,44 @@ export function resolveBaseUrl(params) { * `{ success, result, error }` contract. * * @param {object} opts - * @param {object} opts.params tool params (used to resolve the base URL) + * @param {object} opts.params tool params (base URL and optional credential overrides) * @param {string} opts.path request path, e.g. "/api/v1/queue/status" * @param {string} [opts.method=GET] * @param {object} [opts.body] JSON body for POST/PUT + * @param {object} [opts.env] environment used to resolve the credential */ -export async function callOperator({ params, path, method = "GET", body }) { - const baseUrl = resolveBaseUrl(params); +export async function callOperator({ params, path, method = "GET", body, env = process.env }) { + const baseUrl = resolveBaseUrl(params, env); + const credential = resolveCredential(params, env); + if (!credential) { + return { + success: false, + result: null, + error: + "Operator requires a credential. Set OPERATOR_ACCESS_KEY (service access key) or OPERATOR_API_TOKEN (bearer, including the loopback local token).", + }; + } + const url = `${baseUrl}${path}`; try { - const init = { method, headers: { Accept: "application/json" } }; - if (body !== undefined) { - init.headers["Content-Type"] = "application/json"; - init.body = JSON.stringify(body); - } - const res = await fetch(url, init); - const text = await res.text(); - let parsed; - try { - parsed = text ? JSON.parse(text) : null; - } catch { - parsed = text; + let bearer = + credential.kind === "bearer" + ? credential.token + : await exchangeAccessKey(baseUrl, credential.token); + let result = await send(url, method, body, bearer); + if (!result.ok && result.status === 401 && credential.kind === "access_key") { + tokenCache.delete(cacheKey(baseUrl, credential.token)); + bearer = await exchangeAccessKey(baseUrl, credential.token); + result = await send(url, method, body, bearer); } - if (!res.ok) { - const detail = parsed?.error ? parsed.error : `HTTP ${res.status}`; - return { success: false, result: parsed, error: `${method} ${url} failed: ${detail}` }; + if (!result.ok) { + return failure(method, url, result.parsed, result.status); } - return { success: true, result: parsed, error: null }; + return { success: true, result: result.parsed, error: null }; } catch (e) { - return { success: false, result: null, error: `${method} ${url} failed: ${e.message}` }; + const hint = /CERT|UNABLE_TO_VERIFY|self.signed/i.test(e.message) + ? " The certificate was not trusted. Point NODE_EXTRA_CA_CERTS at the CA file. TLS verification stays on." + : ""; + return { success: false, result: null, error: `${method} ${url} failed: ${e.message}${hint}` }; } } diff --git a/agnt-plugin/lib/operator-client.test.js b/agnt-plugin/lib/operator-client.test.js new file mode 100644 index 00000000..013e7092 --- /dev/null +++ b/agnt-plugin/lib/operator-client.test.js @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + callOperator, + clearTokenCache, + resolveCredential, + tokenStillValid, +} from "./operator-client.js"; + +const env = {}; + +function requestUrl(url) { + if (typeof url === "string") { + return url; + } + if (url instanceof URL) { + return url.href; + } + return url.url; +} + +describe("resolveCredential", () => { + test("prefers an explicit bearer over an access key", () => { + const cred = resolveCredential( + { operatorApiToken: "local-token", operatorAccessKey: "opk_should_not_be_sent" }, + { OPERATOR_API_TOKEN: "env-bearer", OPERATOR_ACCESS_KEY: "env-key" }, + ); + expect(cred).toEqual({ kind: "bearer", token: "local-token" }); + }); + + test("uses OPERATOR_API_TOKEN when no param bearer is set", () => { + const cred = resolveCredential( + {}, + { OPERATOR_API_TOKEN: "env-bearer", OPERATOR_ACCESS_KEY: "env-key" }, + ); + expect(cred).toEqual({ kind: "bearer", token: "env-bearer" }); + }); + + test("uses an access key only when no bearer is set", () => { + const cred = resolveCredential( + { operatorAccessKey: "param-key" }, + { OPERATOR_ACCESS_KEY: "env-key" }, + ); + expect(cred).toEqual({ kind: "access_key", token: "param-key" }); + }); + + test("falls back to OPERATOR_ACCESS_KEY", () => { + expect(resolveCredential({}, { OPERATOR_ACCESS_KEY: "env-key" })).toEqual({ + kind: "access_key", + token: "env-key", + }); + }); + + test("treats blank values as unset", () => { + expect(resolveCredential({ operatorApiToken: " " }, { OPERATOR_API_TOKEN: "" })).toBeNull(); + }); +}); + +test("refreshes inside the 30s skew and keeps a token outside it", () => { + const now = 1_000_000; + expect(tokenStillValid({ expiresAtMs: now + 20_000 }, now)).toBe(false); + expect(tokenStillValid({ expiresAtMs: now + 60_000 }, now)).toBe(true); +}); + +describe("callOperator", () => { + let calls; + + beforeEach(() => { + clearTokenCache(); + calls = []; + globalThis.fetch = (url, init) => { + const u = requestUrl(url); + calls.push({ url: u, init }); + if (u.endsWith("/api/v1/auth/token")) { + const body = JSON.parse(init.body); + if (body.access_key !== "good-key") { + return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); + } + return new Response( + JSON.stringify({ + access_token: "minted-access", + token_type: "Bearer", + expires_in: 1, + scopes: ["read", "write", "execute"], + }), + { status: 200 }, + ); + } + const auth = init.headers.Authorization; + if (auth !== "Bearer minted-access" && auth !== "Bearer local-token") { + return new Response(JSON.stringify({ error: "no valid credential was presented" }), { + status: 401, + }); + } + return new Response(JSON.stringify({ queued: 1 }), { status: 200 }); + }; + }); + + afterEach(() => { + delete globalThis.fetch; + clearTokenCache(); + }); + + test("sends a loopback bearer and does not call the token endpoint", async () => { + const out = await callOperator({ + params: { operatorBaseUrl: "http://127.0.0.1:7008", operatorApiToken: "local-token" }, + path: "/api/v1/queue/status", + env, + }); + expect(out.success).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("http://127.0.0.1:7008/api/v1/queue/status"); + expect(calls[0].init.headers.Authorization).toBe("Bearer local-token"); + }); + + test("exchanges an access key, then sends the access token", async () => { + const out = await callOperator({ + params: { operatorBaseUrl: "https://operator.example", operatorAccessKey: "good-key" }, + path: "/api/v1/queue/status", + env, + }); + expect(out.success).toBe(true); + expect(calls[0].url).toBe("https://operator.example/api/v1/auth/token"); + expect(JSON.parse(calls[0].init.body)).toEqual({ + grant_type: "operator:access-key", + access_key: "good-key", + }); + expect(calls[0].init.headers.Authorization).toBeUndefined(); + expect(calls[1].init.headers.Authorization).toBe("Bearer minted-access"); + }); + + test("exchanges again once the cached access token is inside the skew window", async () => { + await callOperator({ + params: { operatorBaseUrl: "https://operator.example", operatorAccessKey: "good-key" }, + path: "/api/v1/queue/status", + env, + }); + await new Promise((r) => setTimeout(r, 1100)); + await callOperator({ + params: { operatorBaseUrl: "https://operator.example", operatorAccessKey: "good-key" }, + path: "/api/v1/queue/status", + env, + }); + const tokenCalls = calls.filter((c) => c.url.endsWith("/api/v1/auth/token")); + expect(tokenCalls).toHaveLength(2); + }); + + test("does not send the access key as a bearer when exchange fails", async () => { + const out = await callOperator({ + params: { operatorBaseUrl: "https://operator.example", operatorAccessKey: "bad-key" }, + path: "/api/v1/queue/status", + env, + }); + expect(out.success).toBe(false); + expect(calls.some((c) => c.init.headers.Authorization)).toBe(false); + expect(out.error).toContain("access key"); + }); + + test("a missing credential names the env vars", async () => { + const out = await callOperator({ + params: { operatorBaseUrl: "https://operator.example" }, + path: "/api/v1/queue/status", + env, + }); + expect(out.success).toBe(false); + expect(calls).toHaveLength(0); + expect(out.error).toContain("OPERATOR_ACCESS_KEY"); + expect(out.error).toContain("OPERATOR_API_TOKEN"); + }); + + test("re-exchanges once after a 401 and does not loop", async () => { + let queueCalls = 0; + globalThis.fetch = (url, init) => { + const u = requestUrl(url); + calls.push({ url: u, init }); + if (u.endsWith("/api/v1/auth/token")) { + const n = calls.filter((c) => c.url.endsWith("/api/v1/auth/token")).length; + return new Response( + JSON.stringify({ + access_token: n === 1 ? "stale" : "fresh", + token_type: "Bearer", + expires_in: 900, + scopes: ["read"], + }), + { status: 200 }, + ); + } + queueCalls += 1; + if (queueCalls === 1 || init.headers.Authorization !== "Bearer fresh") { + return new Response(JSON.stringify({ error: "no valid credential was presented" }), { + status: 401, + }); + } + return new Response(JSON.stringify({ queued: 1 }), { status: 200 }); + }; + + const out = await callOperator({ + params: { operatorBaseUrl: "https://operator.example", operatorAccessKey: "good-key" }, + path: "/api/v1/queue/status", + env, + }); + expect(out.success).toBe(true); + expect(calls.filter((c) => c.url.endsWith("/api/v1/auth/token"))).toHaveLength(2); + expect(calls.filter((c) => c.url.endsWith("/queue/status"))).toHaveLength(2); + }); + + test("a second 401 is a failure", async () => { + globalThis.fetch = (url, init) => { + const u = requestUrl(url); + calls.push({ url: u, init }); + if (u.endsWith("/api/v1/auth/token")) { + return new Response( + JSON.stringify({ + access_token: "still-bad", + token_type: "Bearer", + expires_in: 900, + scopes: ["read"], + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({ error: "no valid credential was presented" }), { + status: 401, + }); + }; + + const out = await callOperator({ + params: { operatorBaseUrl: "https://operator.example", operatorAccessKey: "good-key" }, + path: "/api/v1/queue/status", + env, + }); + expect(out.success).toBe(false); + expect(calls.filter((c) => c.url.endsWith("/api/v1/auth/token"))).toHaveLength(2); + expect(calls.filter((c) => c.url.endsWith("/queue/status"))).toHaveLength(2); + }); + + test("a bearer 401 is not retried", async () => { + globalThis.fetch = (url, init) => { + calls.push({ url: requestUrl(url), init }); + return new Response(JSON.stringify({ error: "no valid credential was presented" }), { + status: 401, + }); + }; + const out = await callOperator({ + params: { operatorBaseUrl: "http://127.0.0.1:7008", operatorApiToken: "local-token" }, + path: "/api/v1/queue/status", + env, + }); + expect(out.success).toBe(false); + expect(calls).toHaveLength(1); + }); + + test("a certificate failure stays a failure and names NODE_EXTRA_CA_CERTS", async () => { + globalThis.fetch = () => { + throw new Error("fetch failed: self-signed certificate"); + }; + const out = await callOperator({ + params: { operatorBaseUrl: "https://operator.example", operatorApiToken: "local-token" }, + path: "/api/v1/queue/status", + env, + }); + expect(out.success).toBe(false); + expect(out.error).toContain("NODE_EXTRA_CA_CERTS"); + expect(out.error).toContain("self-signed certificate"); + }); +}); diff --git a/agnt-plugin/manifest.json b/agnt-plugin/manifest.json index a646ebe9..69cc3371 100644 --- a/agnt-plugin/manifest.json +++ b/agnt-plugin/manifest.json @@ -25,6 +25,16 @@ "placeholder": "http://localhost:7008", "default": "http://localhost:7008" }, + "operatorApiToken": { + "type": "string", + "inputType": "text", + "description": "Bearer token override. Prefer OPERATOR_API_TOKEN. AGNT stores node parameters in the workflow file." + }, + "operatorAccessKey": { + "type": "string", + "inputType": "text", + "description": "Service access key override. Prefer OPERATOR_ACCESS_KEY. Exchanged for a 15-minute bearer. AGNT stores node parameters in the workflow file." + }, "template": { "type": "string", "inputType": "select", @@ -87,6 +97,16 @@ "placeholder": "http://localhost:7008", "default": "http://localhost:7008" }, + "operatorApiToken": { + "type": "string", + "inputType": "text", + "description": "Bearer token override. Prefer OPERATOR_API_TOKEN. AGNT stores node parameters in the workflow file." + }, + "operatorAccessKey": { + "type": "string", + "inputType": "text", + "description": "Service access key override. Prefer OPERATOR_ACCESS_KEY. Exchanged for a 15-minute bearer. AGNT stores node parameters in the workflow file." + }, "id": { "type": "string", "inputType": "text", @@ -151,6 +171,16 @@ "placeholder": "http://localhost:7008", "default": "http://localhost:7008" }, + "operatorApiToken": { + "type": "string", + "inputType": "text", + "description": "Bearer token override. Prefer OPERATOR_API_TOKEN. AGNT stores node parameters in the workflow file." + }, + "operatorAccessKey": { + "type": "string", + "inputType": "text", + "description": "Service access key override. Prefer OPERATOR_ACCESS_KEY. Exchanged for a 15-minute bearer. AGNT stores node parameters in the workflow file." + }, "ticket": { "type": "string", "inputType": "text", @@ -203,6 +233,16 @@ "description": "Base URL of the Operator REST API", "placeholder": "http://localhost:7008", "default": "http://localhost:7008" + }, + "operatorApiToken": { + "type": "string", + "inputType": "text", + "description": "Bearer token override. Prefer OPERATOR_API_TOKEN. AGNT stores node parameters in the workflow file." + }, + "operatorAccessKey": { + "type": "string", + "inputType": "text", + "description": "Service access key override. Prefer OPERATOR_ACCESS_KEY. Exchanged for a 15-minute bearer. AGNT stores node parameters in the workflow file." } }, "outputs": { @@ -239,6 +279,16 @@ "placeholder": "http://localhost:7008", "default": "http://localhost:7008" }, + "operatorApiToken": { + "type": "string", + "inputType": "text", + "description": "Bearer token override. Prefer OPERATOR_API_TOKEN. AGNT stores node parameters in the workflow file." + }, + "operatorAccessKey": { + "type": "string", + "inputType": "text", + "description": "Service access key override. Prefer OPERATOR_ACCESS_KEY. Exchanged for a 15-minute bearer. AGNT stores node parameters in the workflow file." + }, "id": { "type": "string", "inputType": "text", @@ -291,6 +341,16 @@ "placeholder": "http://localhost:7008", "default": "http://localhost:7008" }, + "operatorApiToken": { + "type": "string", + "inputType": "text", + "description": "Bearer token override. Prefer OPERATOR_API_TOKEN. AGNT stores node parameters in the workflow file." + }, + "operatorAccessKey": { + "type": "string", + "inputType": "text", + "description": "Service access key override. Prefer OPERATOR_ACCESS_KEY. Exchanged for a 15-minute bearer. AGNT stores node parameters in the workflow file." + }, "source": { "type": "string", "inputType": "text", diff --git a/agnt-plugin/run-step.js b/agnt-plugin/run-step.js index 91d38b87..2966b97d 100644 --- a/agnt-plugin/run-step.js +++ b/agnt-plugin/run-step.js @@ -1,9 +1,5 @@ -// operator-run-step - the node type emitted by `operator workflow export --format agnt`. -// -// Each exported node represents one issuetype step and carries -// { ticket, step, prompt, ... } in its config. This tool reads `ticket` and asks Operator to run it via the launch endpoint. -// Operator sequences its own steps internally, so the per-step nodes are a faithful visualization of the ticket's shape; -// executing them drives the one underlying Operator ticket (the launch endpoint's relaunch path tolerates a ticket that is already in progress). +// operator-run-step - node type emitted by `operator workflow export --format agnt`. +// One node per issuetype step. Launch drives the one Operator ticket; Operator sequences the step. import { callOperator } from "./lib/operator-client.js"; class RunStepTool { diff --git a/bindings/Config.ts b/bindings/Config.ts index 118a5c30..9881497e 100644 --- a/bindings/Config.ts +++ b/bindings/Config.ts @@ -10,6 +10,7 @@ import type { LlmToolsConfig } from "./LlmToolsConfig"; import type { LoggingConfig } from "./LoggingConfig"; import type { McpConfig } from "./McpConfig"; import type { ModelServer } from "./ModelServer"; +import type { NativeLlmConfig } from "./NativeLlmConfig"; import type { NotificationsConfig } from "./NotificationsConfig"; import type { PathsConfig } from "./PathsConfig"; import type { ProfileIdentity } from "./ProfileIdentity"; @@ -70,4 +71,9 @@ mcp: McpConfig, /** * Agent Client Protocol (ACP) agent configuration */ -acp: AcpConfig, }; +acp: AcpConfig, +/** + * In-daemon LLM calls (judge). Accepted but inert until the `native-llm` + * build feature ships; a configured judge falls back to the deterministic rule. + */ +native_llm: NativeLlmConfig, }; diff --git a/bindings/Delegator.ts b/bindings/Delegator.ts index 52af7cfd..fbfc8e83 100644 --- a/bindings/Delegator.ts +++ b/bindings/Delegator.ts @@ -7,8 +7,7 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Agent delegator configuration for autonomous ticket launching * - * A delegator is a named {tool, model} pairing with optional launch configuration - * that can be used to launch agents for tickets. + * A delegator is a named {tool, model} pairing with optional launch configuration. */ export type Delegator = { /** diff --git a/bindings/IntegrationCatalogEntryDto.ts b/bindings/IntegrationCatalogEntryDto.ts index 7968b209..a63276aa 100644 --- a/bindings/IntegrationCatalogEntryDto.ts +++ b/bindings/IntegrationCatalogEntryDto.ts @@ -37,4 +37,8 @@ status: SupportStatus, premium: boolean, /** * Implemented session controllers for an IDE; absent for other categories. */ -session_wrappers: Array | null, }; +session_wrappers: Array | null, +/** + * Vertical-specific structural support (not the advertising `status` ramp). + */ +support: unknown, }; diff --git a/bindings/ItemSource.ts b/bindings/ItemSource.ts index 7bba7b5e..baa34381 100644 --- a/bindings/ItemSource.ts +++ b/bindings/ItemSource.ts @@ -2,8 +2,8 @@ /** * Where a pipeline's iterated items come from. The variant determines *when* - * the list resolves: export-time (a literal array → static fan-out width in - * the compiled graph) vs runtime (an identifier → symbolic width). + * the list resolves: export-time (a literal array -> static fan-out width in + * the compiled graph) vs runtime (an identifier -> symbolic width). */ export type ItemSource = { "type": "projects" } | { "type": "from_step", /** diff --git a/bindings/JudgeAttempt.ts b/bindings/JudgeAttempt.ts new file mode 100644 index 00000000..9da842d0 --- /dev/null +++ b/bindings/JudgeAttempt.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type JudgeAttempt = { attempt_id: string, started_at: string, +/** + * Judge timeout copied at start, so a config edit can't strand the attempt + */ +timeout_secs: bigint, }; diff --git a/bindings/JudgeConfig.ts b/bindings/JudgeConfig.ts new file mode 100644 index 00000000..468bb005 --- /dev/null +++ b/bindings/JudgeConfig.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type JudgeConfig = { +/** + * Name of a declared or implicit model server (e.g. "anthropic-api") + */ +model_server: string, +/** + * Full API model id (e.g. "claude-sonnet-5"), not a CLI alias like "sonnet" + */ +model: string, +/** + * Seconds before the judge is abandoned and the deterministic rule applies + */ +timeout_secs: bigint, }; diff --git a/bindings/ModelServer.ts b/bindings/ModelServer.ts index cc4c61f1..fc20379c 100644 --- a/bindings/ModelServer.ts +++ b/bindings/ModelServer.ts @@ -7,9 +7,7 @@ * (`llm_tool`, e.g. claude/codex/gemini) with a model-serving endpoint * (`model_server`, e.g. ollama-local, openai-api, a custom vllm host). * - * Implicit builtin servers (`anthropic-api`, `openai-api`, `google-api`) are - * returned by [`implicit_model_server_for_tool`] and do not need to be declared - * in config. + * Implicit builtin servers are returned by [`implicit_model_server_for_tool`] for shipped tools. */ export type ModelServer = { /** diff --git a/bindings/MultiAgentGroup.ts b/bindings/MultiAgentGroup.ts index efba6c86..0103b17b 100644 --- a/bindings/MultiAgentGroup.ts +++ b/bindings/MultiAgentGroup.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JudgeAttempt } from "./JudgeAttempt"; import type { MultiAgentPhase } from "./MultiAgentPhase"; import type { PendingSubAgent } from "./PendingSubAgent"; import type { JsonValue } from "./serde_json/JsonValue"; @@ -52,4 +53,9 @@ pending_launches: Array, /** * Maps launched `agent_id` to the `variant_key` used as the output key. */ -agent_variant_keys: { [key in string]: string }, }; +agent_variant_keys: { [key in string]: string }, +/** + * The in-flight LLM judge call (set when phase = Voting). Its verdict + * arrives as a side file keyed by `attempt_id`, never through `State`. + */ +judge_attempt: JudgeAttempt | null, }; diff --git a/bindings/MultiModelConfig.ts b/bindings/MultiModelConfig.ts index 22a680a7..5eff73af 100644 --- a/bindings/MultiModelConfig.ts +++ b/bindings/MultiModelConfig.ts @@ -19,7 +19,7 @@ voting_strategy: VotingStrategy, */ share_answers: boolean, /** - * Prompt for the voting round (Handlebars, receives {{ answers }} array) + * Instruction prompt for the judge that picks the winner (Handlebars, rendered with the ticket context) */ voting_prompt?: string | null, /** diff --git a/bindings/MultiPromptConfig.ts b/bindings/MultiPromptConfig.ts index 39a9cc1c..0e24e7d1 100644 --- a/bindings/MultiPromptConfig.ts +++ b/bindings/MultiPromptConfig.ts @@ -18,6 +18,7 @@ selection_strategy: SelectionStrategy, */ agent?: string | null, /** - * Prompt for the selection/review round + * Instruction for the judge that picks the best variation with + * `model_choice` (Handlebars, rendered with the ticket context) */ selection_prompt?: string | null, }; diff --git a/bindings/NativeLlmConfig.ts b/bindings/NativeLlmConfig.ts new file mode 100644 index 00000000..fc17c6a1 --- /dev/null +++ b/bindings/NativeLlmConfig.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JudgeConfig } from "./JudgeConfig"; + +/** + * In-daemon LLM calls (built with the `native-llm` feature). Distinct from + * delegators: these are single typed API calls, not agent CLI sessions. + */ +export type NativeLlmConfig = { +/** + * Model that picks the winner of `multi_model` (`voting_mode = single_judge`) + * and `multi_prompt` (`selection_strategy = model_choice`) steps. Unset keeps + * the deterministic first/longest rule. + */ +judge?: JudgeConfig | null, }; diff --git a/bindings/SetupStep.ts b/bindings/SetupStep.ts index be9b63f2..8d145610 100644 --- a/bindings/SetupStep.ts +++ b/bindings/SetupStep.ts @@ -3,4 +3,4 @@ /** * A step in the setup wizard. */ -export type SetupStep = "welcome" | "license" | "execution-mode" | "kanban-info" | "model-server" | "git-provider" | "collection-source" | "hosted-collections" | "task-field-config" | "session-wrapper-choice" | "execution-target" | "worktree-preference" | "admin-password" | "tmux-onboarding" | "vscode-setup" | "cmux-setup" | "zellij-setup" | "acceptance-criteria" | "startup-tickets" | "confirm"; +export type SetupStep = "welcome" | "license" | "execution-mode" | "kanban-info" | "model-server" | "git-provider" | "collection-source" | "task-field-config" | "session-wrapper-choice" | "worktree-preference" | "admin-password" | "tmux-onboarding" | "vscode-setup" | "cmux-setup" | "zellij-setup" | "acceptance-criteria" | "startup-tickets" | "hosted-collections" | "execution-target" | "confirm"; diff --git a/bindings/TargetProbeResponse.ts b/bindings/TargetProbeResponse.ts index 952b4e14..8bd7dba1 100644 --- a/bindings/TargetProbeResponse.ts +++ b/bindings/TargetProbeResponse.ts @@ -1,3 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TargetToolProbe } from "./TargetToolProbe"; -export type TargetProbeResponse = { reachable: boolean, message: string, }; +export type TargetProbeResponse = { reachable: boolean, message: string, +/** + * LLM CLIs reported by `opr8r tools --json` on the target. Empty if unreachable/unknown. + */ +tools: Array, tools_error?: string | null, }; diff --git a/bindings/TargetToolProbe.ts b/bindings/TargetToolProbe.ts new file mode 100644 index 00000000..f2fd1b94 --- /dev/null +++ b/bindings/TargetToolProbe.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TargetToolProbe = { name: string, version?: string | null, health_ok: boolean, }; diff --git a/build.rs b/build.rs index 70b68fd3..dd96512f 100644 --- a/build.rs +++ b/build.rs @@ -51,26 +51,26 @@ fn walk_dir_size(dir: &Path) -> u64 { total } -/// A release build must carry the Premium verification keys. +/// A release build must carry the Premium root keyring. /// -/// `src/licensing.rs` reads them with `option_env!`, so they are baked in at -/// compile time. Without them every licence is rejected as "unknown license -/// signing key" - the right default for a source build, and a silent, total -/// Premium outage if it ever reaches a release artifact. +/// `src/licensing.rs` reads it with `option_env!`, so it is baked in at +/// compile time. Without it every licence is rejected as an untrusted root - +/// the right default for a source build, and a silent, total Premium outage +/// if it ever reaches a release artifact. fn check_license_keys() { println!("cargo:rerun-if-env-changed=OPERATOR_RELEASE"); - println!("cargo:rerun-if-env-changed=OPERATOR_LICENSE_PUBLIC_KEYS"); + println!("cargo:rerun-if-env-changed=OPERATOR_LICENSE_ROOT_KEYS"); println!("cargo:rerun-if-env-changed=OPERATOR_LICENSE_ISSUER"); println!("cargo:rerun-if-env-changed=OPERATOR_PURCHASE_URL"); if std::env::var("OPERATOR_RELEASE").as_deref() != Ok("1") { return; } - let keys = std::env::var("OPERATOR_LICENSE_PUBLIC_KEYS").unwrap_or_default(); + let keys = std::env::var("OPERATOR_LICENSE_ROOT_KEYS").unwrap_or_default(); let keys = keys.trim(); assert!( !(keys.is_empty() || keys == "{}"), - "OPERATOR_RELEASE=1 but OPERATOR_LICENSE_PUBLIC_KEYS is unset or empty - \ + "OPERATOR_RELEASE=1 but OPERATOR_LICENSE_ROOT_KEYS is unset or empty - \ this build would reject every Premium licence" ); } diff --git a/clippy.toml b/clippy.toml index d0d0c5a2..8cbba9d2 100644 --- a/clippy.toml +++ b/clippy.toml @@ -3,3 +3,9 @@ too-many-arguments-threshold = 8 type-complexity-threshold = 300 allowed-idents-below-min-chars = ["x", "y", "r", "f", "e", "i", "n", "s", "k", "v"] doc-valid-idents = ["GitHub", "GitLab", "macOS", "OpenAPI", "OAuth", "TypeScript", "WebSocket", "VsCode", "DevOps", "SubPath", "TodoApp","TOML", "JSON", "YAML", "UUID", "URL", "API", "CLI", "TUI", "PR", "SSH", "HTTP", "HTTPS", "stdin", "stdout", "tmux", "stderr"] +disallowed-methods = [ + { path = "reqwest::Client::new", reason = "use crate::http_client::default_client() so the rustls provider is installed" }, + { path = "reqwest::Client::builder", reason = "use crate::http_client::client_builder() so the rustls provider is installed" }, + { path = "reqwest::blocking::Client::new", allow-invalid = true, reason = "use crate::http_client::blocking_client_builder() so the rustls provider is installed" }, + { path = "reqwest::blocking::Client::builder", allow-invalid = true, reason = "use crate::http_client::blocking_client_builder() so the rustls provider is installed" }, +] diff --git a/deny.toml b/deny.toml index 401dd573..0704bd12 100644 --- a/deny.toml +++ b/deny.toml @@ -5,9 +5,9 @@ db-urls = ["https://github.com/rustsec/advisory-db"] unmaintained = "all" yanked = "deny" ignore = [ - # instant: unmaintained, pulled in by notify-types → notify; no upgrade path available + # instant: unmaintained, pulled in by notify-types. No upgrade path available. { id = "RUSTSEC-2024-0384" }, - # paste: unmaintained, pulled in by utoipa-axum; no upgrade path available + # paste: unmaintained, pulled in by utoipa-axum; no upgrade path available. { id = "RUSTSEC-2024-0436" }, ] diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index 775e17e9..70155210 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -93,6 +93,9 @@ docs: - title: Gemini CLI url: /getting-started/agents/gemini-cli/ icon: gemini + - title: Grok + url: /getting-started/agents/grok/ + icon: xai - title: Supported Model Providers url: /getting-started/model-servers/ children: @@ -105,6 +108,9 @@ docs: - title: Google url: /getting-started/model-servers/google/ icon: google + - title: xAI + url: /getting-started/model-servers/xai/ + icon: xai - title: OpenRouter url: /getting-started/model-servers/openrouter/ icon: openrouter diff --git a/docs/assets/icons/xai.svg b/docs/assets/icons/xai.svg new file mode 100644 index 00000000..97cb7851 --- /dev/null +++ b/docs/assets/icons/xai.svg @@ -0,0 +1 @@ +xAI diff --git a/docs/cli/index.md b/docs/cli/index.md index 44b7d735..b9132dda 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -194,9 +194,6 @@ All configuration can be overridden via environment variables using the `OPERATO | `OPERATOR_LLM_TOOLS__DENIED` | Comma-separated list of denied LLM tools | | | `OPERATOR_LOGGING__LEVEL` | Log level (trace, debug, info, warn, error) | info | | `OPERATOR_LOGGING__TO_FILE` | Write logs to file in addition to stderr | true | -| `OPERATOR_LICENSE_PUBLIC_KEYS` | JSON map of key id to base64 Ed25519 public key used to verify Premium licences. Compile-time only | {} | -| `OPERATOR_LICENSE_ISSUER` | Expected `iss` claim on a Premium licence. Compile-time only | operator-licensing | -| `OPERATOR_PURCHASE_URL` | External destination shown by the Premium paywall. Compile-time only | - | ### Authentication @@ -280,11 +277,3 @@ All configuration can be overridden via environment variables using the `OPERATO | `OPERATOR_LOGGING__LEVEL` | Log level (trace, debug, info, warn, error) | info | | `OPERATOR_LOGGING__TO_FILE` | Write logs to file in addition to stderr | true | -### Licensing (build-time) - -| Variable | Description | Default | -| --- | --- | --- | -| `OPERATOR_LICENSE_PUBLIC_KEYS` | JSON map of key id to base64 Ed25519 public key used to verify Premium licences. Compile-time only | {} | -| `OPERATOR_LICENSE_ISSUER` | Expected `iss` claim on a Premium licence. Compile-time only | operator-licensing | -| `OPERATOR_PURCHASE_URL` | External destination shown by the Premium paywall. Compile-time only | - | - diff --git a/docs/configuration/index.md b/docs/configuration/index.md index aee53590..2fed45e7 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -46,9 +46,9 @@ macOS notification preferences | Field | Type | Default | Description | | --- | --- | --- | --- | | `enabled` * | `boolean` | true | Global enabled flag for all notifications | -| `os` | → `OsNotificationConfig` | - | OS notification configuration | +| `os` | `OsNotificationConfig` | - | OS notification configuration | | `webhook` | `any` | - | Single webhook configuration (for simple setups) | -| `webhooks` | `array`[→ `WebhookConfig`] | - | Multiple webhook configurations | +| `webhooks` | `array`[`WebhookConfig`] | - | Multiple webhook configurations | ## `[queue]` @@ -80,7 +80,7 @@ Terminal UI appearance and behavior | `refresh_rate_ms` * | `integer` | 250 | | | `completed_history_hours` * | `integer` | 24 | | | `summary_max_length` * | `integer` | 40 | | -| `panel_names` | → `PanelNamesConfig` | - | | +| `panel_names` | `PanelNamesConfig` | - | | ## `[launch]` @@ -92,8 +92,8 @@ Agent launch behavior and confirmations | `confirm_paired` * | `boolean` | true | | | `launch_delay_ms` * | `integer` | 2000 | | | `target` | `string` \| `null` | - | Default named execution target. Per-launch and per-delegator choices take precedence. | -| `docker` | → `DockerConfig` | - | Docker execution configuration | -| `yolo` | → `YoloConfig` | - | YOLO (auto-accept) mode configuration | +| `docker` | `DockerConfig` | - | Docker execution configuration | +| `yolo` | `YoloConfig` | - | YOLO (auto-accept) mode configuration | ## `[templates]` @@ -101,7 +101,7 @@ Issue type collections and presets | Field | Type | Default | Description | | --- | --- | --- | --- | -| `preset` | → `CollectionPreset` | - | Named preset for issue type collection Options: simple, `dev_kanban`, `devops_kanban`, custom | +| `preset` | `CollectionPreset` | - | Named preset for issue type collection Options: simple, `dev_kanban`, `devops_kanban`, custom | | `collection` | `array`[`string`] | - | Custom issuetype collection (only used when preset = custom) List of issue type keys: TASK, FEAT, FIX, SPIKE, INV | | `active_collection` | `string` \| `null` | - | Active collection name (overrides preset if set) Can be a builtin preset name or a user-defined collection | | `collections_fetch_enabled` | `boolean` | - | Enable fetching hosted issuetype collections during setup. When disabled, only the embedded (offline) collections are offered. | @@ -141,8 +141,8 @@ LLM CLI tool detection and providers | Field | Type | Default | Description | | --- | --- | --- | --- | -| `detected` | `array`[→ `DetectedTool`] | - | Detected CLI tools (populated on first startup) | -| `providers` | `array`[→ `LlmProvider`] | - | Available {tool, model} pairs for launching tickets Built from detected tools + their model aliases | +| `detected` | `array`[`DetectedTool`] | - | Detected CLI tools (populated on first startup) | +| `providers` | `array`[`LlmProvider`] | - | Available {tool, model} pairs for launching tickets Built from detected tools + their model aliases | | `detection_complete` | `boolean` | - | Whether detection has been completed | | `default_tool` | `string` \| `null` | - | User's preferred default LLM tool (e.g., "claude") | | `default_model` | `string` \| `null` | - | User's preferred default model alias (e.g., "opus") | @@ -330,6 +330,8 @@ external_servers = [] stdio_advertised = true max_concurrent_sessions = 8 +[native_llm] + ``` ## Configuration Files diff --git a/docs/getting-started/agents/claude.md b/docs/getting-started/agents/claude.md index 62b59c15..845d3ca4 100644 --- a/docs/getting-started/agents/claude.md +++ b/docs/getting-started/agents/claude.md @@ -1,62 +1,47 @@ --- title: "Claude" -description: "Configure Claude Code as your AI coding agent." +description: "Use Claude Code as an Operator LLM tool." layout: doc --- -[Claude Code](https://code.claude.com) is Anthropic's AI coding assistant agent, available as Claude Code for command-line development workflows. +[Claude Code](https://code.claude.com) is Anthropic's agentic CLI. In Operator it is an **LLM tool** (binary `claude`). Anthropic the API is a separate [model provider](/getting-started/model-servers/anthropic/). -## Installation +## Status -Install Claude Code via npm: +Generally available. Catalog slug `claude`. + +## Install ```bash npm install -g @anthropic-ai/claude-code ``` -Or download directly from [Anthropic](https://claude.ai/code). - -### Plans and Pricing - -View the [Claude pricing page](https://www.claude.com/pricing) - -## Configuration - -See the full [Claude agent configuration reference](/configuration/#agents-claude). - -Add Claude to your Operator configuration: - -```toml -# ~/.config/operator/config.toml - -[agents.claude] -enabled = true -path = "claude" # or full path to binary -``` - -## Authentication +Operator detects it with `which claude` and `claude --version` (minimum 2.1.0). `health_ok` means the binary is on **this host's** PATH. -Claude Code requires an API key or Claude Pro subscription. Set up authentication: +## Authenticate ```bash claude auth login ``` -## Multi-agent relay +Headless and remote launches need `ANTHROPIC_API_KEY` in the environment Operator can see. Browser login on a laptop does not travel to an SSH target. -Agents launched by Operator can participate in the relay hub when the hub is running. -As long as the delegator (or global config) has enabled relay MCP injection. +## Launch -When relay is enabled for a delegator, Operator: +Operator does not use `[agents.claude]` config. Pair the tool in a delegator: + +```toml +[[delegators]] +name = "claude-opus" +llm_tool = "claude" +model = "opus" +# model_server omitted → implicit anthropic-api +``` -1. Injects `RELAY_HUB_SOCKET` and `RELAY_AGENT_NAME` (the ticket ID, - e.g. `FEAT-042`) into the session environment. -2. Writes a per-session `relay-mcp.json` config and passes - `--mcp-config ` to Claude Code, so the `relay` MCP server starts alongside the agent. +Or pick Claude + a live model id from the Model Providers view after a successful `/models` probe. -To enable relay for a delegator, set `operator_relay = true` in its -`launch_config`. The global default is `false` (opt-in), so single-agent -workflows stay lean unless relay is explicitly requested. +Project discovery looks for `CLAUDE.md` at the repo root. -See [Relay](/relay/) for the full architecture. +## Relay +When a delegator's `launch_config.operator_relay` is true, Operator injects the relay MCP config for Claude Code. See [Relay](/relay/). diff --git a/docs/getting-started/agents/codex.md b/docs/getting-started/agents/codex.md index 24c90caa..4024015d 100644 --- a/docs/getting-started/agents/codex.md +++ b/docs/getting-started/agents/codex.md @@ -1,58 +1,60 @@ --- title: "Codex" -description: "Configure OpenAI Codex as your AI coding agent." +description: "Use OpenAI Codex as an Operator LLM tool." layout: doc --- -[Codex](https://developers.openai.com/codex/) is the [OpenAI](https://openai.com/) code-specialized CLI agent, available through the OpenAI API. +[Codex](https://developers.openai.com/codex/) is OpenAI's agentic CLI. In Operator it is an **LLM tool** (binary `codex`). OpenAI the API is a separate [model provider](/getting-started/model-servers/openai/). Codex speaks the OpenAI protocol, so it can also target [Ollama](/getting-started/model-servers/ollama/) or [OpenRouter](/getting-started/model-servers/openrouter/) when a delegator names that `model_server`. ## Status -Codex integration is currently **experimental**. Features may be limited compared to other agents. +Beta. Catalog slug `codex`. -## Installation - -Install the OpenAI CLI: +## Install ```bash npm i -g @openai/codex ``` -### Plans and Pricing +Operator detects it with `which codex` and `codex --version`. `health_ok` means the binary is on **this host's** PATH. + +## Authenticate -View [OpenAI Codex pricing page](https://developers.openai.com/codex/pricing/) +```bash +export OPENAI_API_KEY="sk-..." +``` -## Configuration +Remote launches need that key in the environment Operator can inject. A local Codex login does not travel to an SSH target. -See the full [Codex agent configuration reference](/configuration/#agents-codex). +## Launch -Add Codex to your Operator configuration: +Operator does not use `[agents.codex]` config. Pair the tool in a delegator: ```toml -# ~/.config/operator/config.toml - -[agents.codex] -enabled = true -api_key_env = "OPENAI_API_KEY" -model = "gpt-4" +[[delegators]] +name = "codex-gpt" +llm_tool = "codex" +model = "gpt-4o" +# model_server omitted → implicit openai-api ``` -## Authentication - -Set your OpenAI API key: +To run Codex against a local Ollama host: -```bash -export OPENAI_API_KEY="your-api-key" +```toml +[[model_servers]] +name = "ollama-local" +kind = "ollama" +base_url = "http://localhost:11434" + +[[delegators]] +name = "codex-local-qwen" +llm_tool = "codex" +model = "qwen2.5-coder" +model_server = "ollama-local" ``` -Or add it to your shell profile for persistence. - -## Multi-agent relay - -Operator injects relay env vars (`RELAY_HUB_SOCKET`, `RELAY_AGENT_NAME`) into Codex sessions at launch so agents can discover each other by ticket ID. Full MCP tool support for Codex relay is planned for a future release. - -See [Relay](/relay/) for details. +Project discovery looks for `CODEX.md` at the repo root. -## API Usage +## Relay -Codex uses the OpenAI API which has usage-based pricing. Monitor your usage at [platform.openai.com](https://platform.openai.com/). +Operator injects relay env vars into Codex sessions. Full MCP tool support for Codex relay is still limited. See [Relay](/relay/). diff --git a/docs/getting-started/agents/gemini-cli.md b/docs/getting-started/agents/gemini-cli.md index 3cdda9de..7746000a 100644 --- a/docs/getting-started/agents/gemini-cli.md +++ b/docs/getting-started/agents/gemini-cli.md @@ -1,74 +1,47 @@ --- title: "Gemini CLI" -description: "Configure Google Gemini as your AI coding agent." +description: "Use Google Gemini CLI as an Operator LLM tool." layout: doc --- -[Gemini](https://geminicli.com/) is [Google](https://google.com)'s multimodal agent CLI with strong coding capabilities. +[Gemini CLI](https://geminicli.com/) is Google's agentic CLI. In Operator it is an **LLM tool** (binary `gemini`). The catalog slug is `gemini-cli` so it is not confused with the Gemini model family. Google the API is a separate [model provider](/getting-started/model-servers/google/). ## Status -Gemini integration is currently **experimental**. Features may be limited compared to other agents. +Alpha. Catalog slug `gemini-cli`, binary `gemini`. -## Installation +## Install -Install the Google AI SDK: +Install the Gemini CLI (not the `google-generativeai` Python SDK): ```bash -pip install google-generativeai +npm install -g @google/gemini-cli ``` -### Plans and Pricing +Operator detects it with `which gemini` and `gemini --version`. `health_ok` means the binary is on **this host's** PATH. - - -## Configuration - -See the full [Gemini agent configuration reference](/configuration/#agents-gemini). - -Add Gemini to your Operator configuration: - -```toml -# ~/.config/operator/config.toml - -[agents.gemini] -enabled = true -api_key_env = "GOOGLE_AI_API_KEY" -model = "gemini-pro" -``` - -## Authentication - -Set your Google AI API key: +## Authenticate ```bash -export GOOGLE_AI_API_KEY="your-api-key" +export GEMINI_API_KEY="..." ``` -Get an API key from [Google AI Studio](https://makersuite.google.com/). - -## Features - -Gemini provides: +Remote launches need that key in the environment Operator can inject. -- Code generation and completion -- Multi-language support -- Code explanation -- Documentation generation +## Launch -## Limitations +Operator does not use `[agents.gemini]` config. Pair the tool in a delegator: -Current experimental limitations: - -- Limited context window compared to Claude -- May require more specific prompting -- Some Operator features may not be fully supported +```toml +[[delegators]] +name = "gemini-pro" +llm_tool = "gemini" +model = "pro" +# model_server omitted → implicit google-api +``` -## Operator Integration +`llm_tool` is the binary name (`gemini`), not the catalog slug (`gemini-cli`). -When Operator assigns a ticket to Gemini: +Project discovery looks for `GEMINI.md` at the repo root. -1. Gemini receives the ticket context -2. Generates code implementations -3. Applies changes to the codebase -4. Reports completion status +Gemini speaks Google's protocol. Pointing it at Ollama or OpenRouter requires a protocol bridge, the same as Claude. diff --git a/docs/getting-started/agents/grok.md b/docs/getting-started/agents/grok.md new file mode 100644 index 00000000..2fbcfdec --- /dev/null +++ b/docs/getting-started/agents/grok.md @@ -0,0 +1,56 @@ +--- +title: "Grok" +description: "Use Grok (xAI) as an Operator LLM tool." +layout: doc +--- + +[Grok](https://x.ai/cli) is xAI's agentic CLI. In Operator it is an **LLM tool** (binary `grok`). xAI the API is a separate [model provider](/getting-started/model-servers/xai/). + +## Status + +Alpha. Catalog slug `grok` (same as the binary). + +## Install + +```bash +curl -fsSL https://x.ai/cli/install.sh | bash +``` + +Operator detects it with `which grok` and `grok --version`. `health_ok` means the binary is on **this host's** PATH. + +## Authenticate + +Locally: + +```bash +grok login +``` + +Headless and remote launches need the API key in the environment Operator can see: + +```bash +export XAI_API_KEY="xai-..." +``` + +Browser login on a laptop does not travel to an SSH target. Operator injects `XAI_API_KEY` into the remote session when that variable is set on the control plane. + +## Launch + +Pair the tool in a delegator. `model_server` omitted resolves to implicit `xai-api`: + +```toml +[[delegators]] +name = "grok-default" +llm_tool = "grok" +model = "grok-4" +``` + +For a remote tmux pane, set the delegator's `target` (or `host`) to an SSH target that has `grok` on PATH. The payload is interactive (`grok --session-id … "$(cat prompt)"`) so you can attach. + +Project discovery: `AGENTS.md` (shared default) or `GROK.md`. + +## Gaps (Alpha) + +- Session resume uses `--session-id` on each launch; `--resume` is not wired yet. +- Relay MCP and permission translators are not implemented for Grok. +- Custom models in `~/.grok/config.toml` are the CLI's concern, not Operator's. diff --git a/docs/getting-started/agents/index.md b/docs/getting-started/agents/index.md index 97a3b9ed..cac0df7e 100644 --- a/docs/getting-started/agents/index.md +++ b/docs/getting-started/agents/index.md @@ -13,6 +13,7 @@ Operator orchestrates AI coding agents to work on tickets from your kanban board | [Claude](/getting-started/agents/claude/) | Recommended | Full feature support | | [Codex](/getting-started/agents/codex/) | Supported | OpenAI's coding model | | [Gemini CLI](/getting-started/agents/gemini-cli/) | Experimental | Google's AI assistant | +| [Grok](/getting-started/agents/grok/) | Alpha | xAI CLI; remote SSH + tmux | ## Agent Capabilities diff --git a/docs/getting-started/ides/cursor.md b/docs/getting-started/ides/cursor.md index 26c92ab2..708ff6b8 100644 --- a/docs/getting-started/ides/cursor.md +++ b/docs/getting-started/ides/cursor.md @@ -57,6 +57,8 @@ The extension shares the same configuration as the VS Code session manager. Sett Cursor's `~/.cursor/mcp.json` uses the `mcpServers` shape with `command`, `args`, and `cwd` - stdio only. SSE-style URL entries are not honored by Cursor's MCP UI. +Stock VS Code 1.140 also writes workspace-root `.mcp.json` and can write Copilot Global (`$COPILOT_HOME/mcp-config.json` or `~/.copilot/mcp-config.json`). Inside Cursor, Connect MCP still writes only `~/.cursor/mcp.json`. + ### Requirements - Operator must be running with `[mcp].stdio_advertised = true` in its config (this is the default). Restart the operator API after toggling. diff --git a/docs/getting-started/ides/vscode.md b/docs/getting-started/ides/vscode.md index c917d804..20e97ebe 100644 --- a/docs/getting-started/ides/vscode.md +++ b/docs/getting-started/ides/vscode.md @@ -62,6 +62,25 @@ Access via Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`): | `Operator: Launch Ticket` | Launch a ticket in a new terminal | | `Operator: Launch Ticket (with options)` | Launch with agent/mode selection | | `Operator: Download Operator` | Download the Operator CLI | +| `Operator: Connect MCP Server` | Register Operator as an MCP server for this editor, workspace `.mcp.json`, and optionally Copilot Global | + +## MCP Integration + +The sidebar launches and watches tickets. MCP is the other direction: an agent in the Copilot harness or Copilot CLI asks Operator about that same queue. + +`Operator: Connect MCP Server` writes: + +1. Workspace `mcp.servers` (`.vscode/mcp.json`) so the editor's own MCP list still sees Operator. Stdio when the daemon advertises it, otherwise SSE. +2. Workspace-root `.mcp.json` with `mcpServers.operator` (`command`, `args`, `cwd`) when the descriptor includes stdio. VS Code 1.140 Agent Host and Copilot CLI read this file directly. +3. An optional **Also write Copilot Global** action that merges the same `mcpServers.operator` block into `$COPILOT_HOME/mcp-config.json`, or `~/.copilot/mcp-config.json` when `COPILOT_HOME` is unset. That file is machine-wide, so the next Copilot session in any folder can see the queue. + +Default MCP tools are read-only: health, status, issue types, collections, skills, and `operator_list_tickets`. Create, launch, and review require `[mcp].expose_ticket_write_tools = true` on the daemon. + +Cluster daemons with `[mcp].stdio_advertised = false` stay on SSE in `.vscode/mcp.json`. Portable files are skipped so a local `operator mcp` is not pointed at a different process. + +`.mcp.json` holds an absolute machine-local binary path. Do not commit it. + +The Status MCP row lists which of those files contain `operator`. ## Sidebar Views @@ -90,7 +109,7 @@ The extension exposes a local HTTP API for Operator communication: ## Requirements -- VS Code 1.85.0 or later +- VS Code 1.140.0 or later - Operator CLI (for full functionality) ## Troubleshooting diff --git a/docs/getting-started/integrations/agnt.md b/docs/getting-started/integrations/agnt.md index a1c4369d..9b044178 100644 --- a/docs/getting-started/integrations/agnt.md +++ b/docs/getting-started/integrations/agnt.md @@ -6,7 +6,27 @@ layout: doc [AGNT.gg](https://agnt.gg) is a local-first agent operating system: a desktop app + local runtime with visual graph workflows, agents, a plugin marketplace, -and native MCP support. Operator connects to AGNT in both directions. +and native MCP support. Operator connects to AGNT in three different ways. + +## Who calls what + +| You are | Use | Talks to | +|---------|-----|----------| +| An interactive AGNT agent (Patricia) | Operator MCP tools | The Operator that holds the tickets she should see | +| An AGNT visual workflow | [`operator-plugin`](https://github.com/untra/operator/tree/main/agnt-plugin) nodes | Operator REST | +| An Operator ticket export | The emitted graph, as a scaffold | `operator-run-step` nodes back into Operator REST | + +`operator mcp` is a local subprocess. It opens the tickets of the machine it runs on. It does not call a remote Operator URL. Desktop AGNT registered like this drives the local daemon: + +```json +{ "name": "operator", "command": "operator", "args": ["mcp"] } +``` + +A cluster Operator exposes the same tools over HTTP MCP. `GET /api/v1/mcp/descriptor` returns `transport_url` (normally `https:///api/v1/mcp/sse`). That stream requires the same bearer as the REST API. On that deployment set `[mcp].stdio_advertised = false`: the advertised stdio command is the path inside the pod. + +Write and launch MCP tools stay disabled until `[mcp].expose_ticket_write_tools = true`. Read tools work without that flag. HTTP MCP is additionally limited to the scopes on the bearer. See [Authentication](/security/authentication/). + +If AGNT can only register a stdio MCP server, an agent on a laptop cannot reach a cluster Operator through MCP. The authenticated plugin nodes are the cluster path. Do not point the stdio command at an `https://` URL. ## Operator → AGNT: export a workflow @@ -17,31 +37,22 @@ operator workflow export FEAT-1234 --format agnt # writes FEAT-1234.agnt.workflow.json ``` -Or over REST (also used by the `operator-export-workflow` plugin node): +Or over REST (also used by the `operator-export-workflow` plugin node). The route requires a bearer; see [Authentication](#authentication) below. ```bash -curl -X POST "http://localhost:7008/api/v1/tickets/FEAT-1234/workflow-export?format=agnt" +curl -X POST "http://localhost:7008/api/v1/tickets/FEAT-1234/workflow-export?format=agnt" \ + -H "Authorization: Bearer $OPERATOR_API_TOKEN" ``` -The output is an AGNT `{ name, description, nodes, edges }` graph. Each operator -step becomes one `operator-run-step` node (carrying `{ ticket, step, prompt, … }` -in its config); the `next_step` chain becomes edges. This export runs in AGNT -once the `operator-plugin` (below) is installed, since `operator-run-step` is one -of that plugin's node types. Operator sequences a ticket's steps internally, so -the per-step nodes are a faithful **visualization** of the ticket's shape; running -the graph drives the one underlying Operator ticket. +The output is an AGNT `{ name, description, nodes, edges }` graph. Each operator step becomes one `operator-run-step` node (carrying `{ ticket, step, prompt, … }` in its parameters); the `next_step` chain becomes edges. This export runs in AGNT once the `operator-plugin` (below) is installed, since `operator-run-step` is one of that plugin's node types. + +Operator exports AGNT workflows as runnable visual scaffolds of a ticket's execution shape, not as lossless equivalents of Operator's internal workflow semantics. -> Like the Claude `.js` export, this is a one-way, lossy *flattening*. AGNT nodes -> can't reproduce Operator's terminal coding sessions, and human review gates / -> RAG / MCP / fan-out steps are recorded in each node's `config.gap` rather than -> faithfully executed. Treat the export as a runnable **scaffold**, not an -> equivalent. +The graph is runnable once `operator-plugin` is installed, because each step is an `operator-run-step` node and the `next_step` chain is edges. Running a node calls Operator's launch endpoint for that one ticket. Operator still owns terminal sessions, human gates, RAG, MCP tool calls, delegation, and ticket state. Those show up in a node's `parameters.gap` (`OPERATOR-GAP: ...`) when the graph cannot perform them. There is no reverse import. ## AGNT → Operator: the `operator-plugin` -The [`operator-plugin`](https://github.com/untra/operator/tree/main/agnt-plugin) -adds Operator nodes to AGNT's canvas. Each is a thin wrapper around Operator's -REST API: +The [`operator-plugin`](https://github.com/untra/operator/tree/main/agnt-plugin) adds Operator nodes to AGNT's canvas. Each is a thin wrapper around Operator's REST API: | Node | Endpoint | |------|----------| @@ -52,6 +63,8 @@ REST API: | `operator-export-workflow` | `POST /api/v1/tickets/{id}/workflow-export` | | `operator-alert` | `POST /api/v1/alerts` | +`operator-launch-agent` launches a whole ticket by `id`. The exporter emits `operator-run-step`, one node per issuetype step. + ### Install 1. Start Operator's REST API: `operator api` (default port `7008`). @@ -64,8 +77,9 @@ REST API: it into `~/Library/Application Support/AGNT/plugins/installed/` and `POST http://localhost:3333/api/plugins/reload`). -Set each node's `operatorBaseUrl` (or the `OPERATOR_BASE_URL` env var) if Operator -isn't on the default `http://localhost:7008`. +Set each node's `operatorBaseUrl` (or the `OPERATOR_BASE_URL` env var) if Operator isn't on the default `http://localhost:7008`. + +Authentication is required on every API route. See the next section. ### Example @@ -77,21 +91,33 @@ webhook-trigger → slack-send ``` -## Alternative: the MCP bridge (no plugin) +## Authentication + +Every `/api/` route requires a credential. `GET /api/v1/queue/status` returns 401 until one is presented. A self-signed certificate is a separate check: Node must trust the CA (`NODE_EXTRA_CA_CERTS=/path/to/ca.pem` in the AGNT process). The plugin does not disable TLS verification. -AGNT consumes stdio MCP servers natively. Register Operator without building a -plugin via AGNT's MCP settings: +**Loopback Operator** (`operator api` on `127.0.0.1`). The server writes `.tickets/operator/local-token`, mode `0600`, and replaces it on every start. A non-loopback bind deletes that file. Put the file contents in `OPERATOR_API_TOKEN`. The plugin sends it as `Authorization: Bearer`. It is not an access key. + +**Any other bind, including Kubernetes.** Create a service access key (admin scope) with scopes `read`, `write`, and `execute`. The secret is shown once. Put it in `OPERATOR_ACCESS_KEY` on the AGNT process. The plugin posts it to `POST /api/v1/auth/token`: ```json -{ "name": "operator", "command": "operator", "args": ["mcp"] } +{ "grant_type": "operator:access-key", "access_key": "" } +``` + +and sends the returned `access_token` as the bearer. Access tokens last 15 minutes. The plugin exchanges the key again before expiry. The key is not a bearer; sending it in `Authorization` returns 401. + +`operatorApiToken` and `operatorAccessKey` on a node override the env vars. AGNT stores node parameters in the workflow file, so prefer the env vars. + +Create the key while logged in as admin. The response field `secret` is the access key, shown once: + +```bash +curl -s -X POST "$OPERATOR_BASE_URL/api/v1/auth/keys" \ + -H "Authorization: Bearer $ADMIN_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"agnt","scopes":["read","write","execute"],"expires_in_days":90}' ``` -This surfaces Operator's ~18 MCP tools in AGNT immediately. The plugin's -advantages over the raw bridge are first-class canvas nodes with typed -parameters and marketplace discoverability - but the bridge is zero-build and the same `operator mcp` server works with any MCP-capable platform. +The full account, scope, and key model is in [Authentication](/security/authentication/). Cluster bootstrap is in [Kubernetes](/getting-started/platforms/kubernetes/). ## Trust & permissions -These integrations create tickets and launch agents against your repositories. -Only connect an AGNT instance you control, point `operatorBaseUrl` at a trusted -Operator API, and gate MCP write tools with `[mcp].expose_ticket_write_tools`. +These integrations create tickets and launch agents against your repositories. Only connect an AGNT instance you control, point `operatorBaseUrl` at a trusted Operator API, and gate MCP write tools with `[mcp].expose_ticket_write_tools`. diff --git a/docs/getting-started/integrations/index.md b/docs/getting-started/integrations/index.md index 721b5b55..0834ab24 100644 --- a/docs/getting-started/integrations/index.md +++ b/docs/getting-started/integrations/index.md @@ -41,8 +41,7 @@ Operator already ships: ## Supported integrations -- **[AGNT.gg](/getting-started/integrations/agnt/)** - export Operator workflows - as AGNT graphs, and drive Operator from AGNT workflows via the `operator-plugin`. +- **[AGNT.gg](/getting-started/integrations/agnt/)** - interactive agents use Operator MCP against the Operator that holds the tickets; visual workflows use the `operator-plugin` nodes; `operator workflow export --format agnt` emits a runnable scaffold of a ticket's shape, not a lossless copy of Operator's sessions, gates, and ticket state. > Write/launch tools mutate your repositories. Only connect platforms you trust, > and gate Operator's MCP write tools with `[mcp].expose_ticket_write_tools`. diff --git a/docs/getting-started/kanban/github.md b/docs/getting-started/kanban/github.md index f6008fbb..dccda2eb 100644 --- a/docs/getting-started/kanban/github.md +++ b/docs/getting-started/kanban/github.md @@ -274,6 +274,6 @@ Either your token genuinely has no project access, or the projects you expected ## See Also -- [Jira Cloud setup](./jira.md) -- [Linear setup](./linear.md) -- [Kanban workflow overview](../../kanban/index.md) +- [Jira Cloud setup](/getting-started/kanban/jira/) +- [Linear setup](/getting-started/kanban/linear/) +- [Kanban workflow overview](/getting-started/kanban/) diff --git a/docs/getting-started/model-servers/anthropic.md b/docs/getting-started/model-servers/anthropic.md index 767069fc..5f4fdc61 100644 --- a/docs/getting-started/model-servers/anthropic.md +++ b/docs/getting-started/model-servers/anthropic.md @@ -7,11 +7,11 @@ layout: doc [**Anthropic**](https://www.anthropic.com/) is a first-party model provider - it produces the Claude family of models and serves them from its own API. It is the zero-config default for the `claude` llm tool, and a first-class -[model provider](./) in its own right: once connected, operator lists its +[model provider](/getting-started/model-servers/) in its own right: once connected, operator lists its available models live so delegators can pick one. > **Model provider ≠ llm tool.** Anthropic (the provider) serves the models; -> [Claude Code](../agents/claude/) (the llm tool) is the CLI that drives a coding +> [Claude Code](/getting-started/agents/claude/) (the llm tool) is the CLI that drives a coding > session. A delegator pairs a tool with a provider's model. ## Connect diff --git a/docs/getting-started/model-servers/google.md b/docs/getting-started/model-servers/google.md index 6bb2adfe..c1d716aa 100644 --- a/docs/getting-started/model-servers/google.md +++ b/docs/getting-started/model-servers/google.md @@ -7,11 +7,11 @@ layout: doc [**Google**](https://ai.google.dev/) is a first-party model provider - it produces the Gemini family and serves them from its own API. It is the zero-config default for the `gemini` llm tool, and a first-class -[model provider](./): once connected, operator lists its available models live +[model provider](/getting-started/model-servers/): once connected, operator lists its available models live for delegators. > **Model provider ≠ llm tool.** Google (the provider) serves the models; -> [Gemini CLI](../agents/gemini-cli/) (the llm tool) is the CLI. A delegator +> [Gemini CLI](/getting-started/agents/gemini-cli/) (the llm tool) is the CLI. A delegator > pairs a tool with a provider's model. ## Connect diff --git a/docs/getting-started/model-servers/index.md b/docs/getting-started/model-servers/index.md index c6c2b84d..91e80c7b 100644 --- a/docs/getting-started/model-servers/index.md +++ b/docs/getting-started/model-servers/index.md @@ -6,8 +6,8 @@ layout: doc A **model server** is a named host that serves models via an inference API. It's orthogonal to the LLM tool that runs your coding agent: -- **LLM tools** (claude, codex, gemini) are the agentic CLIs that drive the coding session - they use tools, edit files, resume sessions. -- **Model servers** are where the model weights live - Anthropic's API, OpenAI's API, Google's API, or a many-model provider like [OpenRouter](/getting-started/model-servers/openrouter/), a local [Ollama](/getting-started/model-servers/ollama/) server, lmstudio, or vllm. +- **LLM tools** (claude, codex, gemini, grok) are the agentic CLIs that drive the coding session - they use tools, edit files, resume sessions. +- **Model servers** are where the model weights live - Anthropic's API, OpenAI's API, Google's API, [xAI](/getting-started/model-servers/xai/), or a many-model provider like [OpenRouter](/getting-started/model-servers/openrouter/), a local [Ollama](/getting-started/model-servers/ollama/) server, lmstudio, or vllm. A delegator pairs an LLM tool with a model (and, optionally, a model server). @@ -16,10 +16,8 @@ A delegator pairs an LLM tool with a model (and, optionally, a model server). - **First-party** - a single vendor's own API: [Anthropic](/getting-started/model-servers/anthropic/) (`anthropic-api`), [OpenAI](/getting-started/model-servers/openai/) (`openai-api`), [Google](/getting-started/model-servers/google/) - (`google-api`). These double as the zero-config defaults for the - claude/codex/gemini tools, so you rarely declare them - but they're first-class: - operator lists each one's live models from its `/models` endpoint when the - corresponding key env is set. + (`google-api`), [xAI](/getting-started/model-servers/xai/) (`xai-api`). These double as the zero-config defaults for the + claude/codex/gemini/grok tools, so you rarely declare them - but they're first-class: operator lists models from its `/models` endpoint when the corresponding key env is set. - **Gateways** - a host or aggregator that fronts *many* models behind one endpoint: [OpenRouter](/getting-started/model-servers/openrouter/) (`openrouter`), a local [Ollama](/getting-started/model-servers/ollama/) @@ -36,6 +34,7 @@ A delegator pairs an LLM tool with a model (and, optionally, a model server). │ claude (detected) │ │ anthropic-api (impl.)│ │ codex (detected) │ │ openai-api (impl.)│ │ gemini (detected) │ │ google-api (impl.)│ +│ grok (detected) │ │ xai-api (impl.)│ │ │ │ ollama-local (user) │ └─────────────────────┘ └──────────────────────┘ ▲ ▲ @@ -53,6 +52,7 @@ You don't need to declare a model server for the vendor-default path. Every dete | `claude` | `anthropic-api` | | `codex` | `openai-api` | | `gemini` | `google-api` | +| `grok` | `xai-api` | Delegators that omit `model_server` resolve to these builtins automatically. Existing configs keep working unchanged. @@ -63,6 +63,7 @@ Delegators that omit `model_server` resolve to these builtins automatically. Exi | `anthropic-api` | Anthropic Console / a compatible proxy (bridge for local models) | | `openai-api` | OpenAI / a compatible proxy | | `google-api` | Google Gemini API | +| `xai-api` | [xAI](/getting-started/model-servers/xai/) Grok API | | `ollama` | Local ollama server (`ollama serve`, default `http://localhost:11434`) | | `openrouter` | [OpenRouter](/getting-started/model-servers/openrouter/) hosted gateway to 300+ models (`https://openrouter.ai/api/v1`) | | `openai-compat` | Any OpenAI-API-compatible server (vllm, lmstudio, together.ai, groq, …) | @@ -110,6 +111,7 @@ operator launch \ | llm_tool | ollama-compatible? | Notes | |----------|--------------------|----------------------------------------------------------------------------------------| | `codex` | Yes, directly | Codex speaks OpenAI API; ollama exposes `/v1` out of the box. | +| `grok` | Yes, directly | Grok speaks OpenAI-shaped APIs (xAI, ollama, OpenRouter). | | `claude` | Only via bridge | Claude CLI speaks Anthropic protocol. Run `claude-code-router` (or similar) at a port and point `base_url` at that bridge with `kind = "anthropic-api"`. | | `gemini` | Only via bridge | Same story as claude; use `litellm-proxy` or similar. | diff --git a/docs/getting-started/model-servers/ollama.md b/docs/getting-started/model-servers/ollama.md index ea0abcdc..5f477edb 100644 --- a/docs/getting-started/model-servers/ollama.md +++ b/docs/getting-started/model-servers/ollama.md @@ -6,7 +6,7 @@ layout: doc [**Ollama**](https://ollama.com/) runs open models (Llama, Qwen, Mistral, …) locally and serves them over an OpenAI-compatible API. Declare it as a -[model server](./) to drive agents against models on your own machine - no cloud +[model server](/getting-started/model-servers/) to drive agents against models on your own machine - no cloud key required. ## Prerequisites @@ -15,7 +15,7 @@ key required. then `ollama serve` (default `http://localhost:11434`) - At least one model pulled, e.g. `ollama pull qwen2.5-coder` - An OpenAI-protocol LLM tool - **codex** works directly; claude/gemini need a - bridge (see [Protocol compatibility](./#protocol-compatibility)) + bridge (see [Protocol compatibility](/getting-started/model-servers/#protocol-compatibility)) ## Configuration @@ -57,5 +57,5 @@ you've pulled. Ollama speaks the OpenAI protocol, so when a delegator resolves to it Operator exports `OPENAI_BASE_URL=http://localhost:11434`. A local server needs no key; if you've put one behind a proxy, set `api_key_env` and it is injected **by -reference**. See the [Model Providers overview](./#how-env-injection-works) for +reference**. See the [Model Providers overview](#how-env-injection-works) for the full mechanism. diff --git a/docs/getting-started/model-servers/openai.md b/docs/getting-started/model-servers/openai.md index 8bdff5d8..ac6bf459 100644 --- a/docs/getting-started/model-servers/openai.md +++ b/docs/getting-started/model-servers/openai.md @@ -6,11 +6,11 @@ layout: doc [**OpenAI**](https://openai.com/) is a first-party model provider - it produces the GPT family and serves them from its own API. It is the zero-config default -for the `codex` llm tool, and a first-class [model provider](./): once connected, +for the `codex` llm tool, and a first-class [model provider](/getting-started/model-servers/): once connected, operator lists its available models live for delegators to pick from. > **Model provider ≠ llm tool.** OpenAI (the provider) serves the models; -> [Codex](../agents/codex/) (the llm tool) is the CLI. A delegator pairs a tool +> [Codex](/getting-started/agents/codex/) (the llm tool) is the CLI. A delegator pairs a tool > with a provider's model. ## Connect diff --git a/docs/getting-started/model-servers/openrouter.md b/docs/getting-started/model-servers/openrouter.md index bbf5fbb1..8745a28e 100644 --- a/docs/getting-started/model-servers/openrouter.md +++ b/docs/getting-started/model-servers/openrouter.md @@ -7,14 +7,14 @@ layout: doc [**OpenRouter**](https://openrouter.ai/) is a hosted gateway that fronts hundreds of models (Anthropic, OpenAI, Google, Meta, Mistral, and more) behind a single OpenAI-compatible endpoint and one API key. Declare it once as a -[model server](./) and any delegator can target the whole catalog. +[model server](/getting-started/model-servers/) and any delegator can target the whole catalog. ## Prerequisites - An OpenRouter account and an API key from [openrouter.ai/keys](https://openrouter.ai/keys) - An OpenAI-protocol LLM tool - **codex** works directly; claude/gemini need a - bridge (see [Protocol compatibility](./#protocol-compatibility)) + bridge (see [Protocol compatibility](/getting-started/model-servers/#protocol-compatibility)) ## Configuration @@ -71,4 +71,4 @@ Operator exports: The key is injected **by reference**, never by value - the secret is never written into the on-disk command script. See the -[Model Providers overview](./#how-env-injection-works) for the full mechanism. +[Model Providers overview](#how-env-injection-works) for the full mechanism. diff --git a/docs/getting-started/model-servers/xai.md b/docs/getting-started/model-servers/xai.md new file mode 100644 index 00000000..dd84787b --- /dev/null +++ b/docs/getting-started/model-servers/xai.md @@ -0,0 +1,49 @@ +--- +title: "xAI" +description: "Connect xAI as a first-party model provider and list its models live." +layout: doc +--- + +[**xAI**](https://x.ai/) is a first-party model provider - it produces the Grok +family and serves them from its own API. It is the zero-config default for the +`grok` llm tool, and a first-class [model provider](/getting-started/model-servers/): once connected, +operator lists its available models live for delegators to pick from. + +> **Model provider ≠ llm tool.** xAI (the provider) serves the models; +> [Grok](/getting-started/agents/grok/) (the llm tool) is the CLI. A delegator pairs a tool +> with a provider's model. + +## Connect + +Operator references your key by env-var name - it never stores the secret: + +```bash +export XAI_API_KEY="xai-..." +``` + +A provider is **connected** when its `/models` probe succeeds. xAI then shows +● connected in the Model Providers view with its live model list. + +## Listing models + +Operator probes `https://api.x.ai/v1/models` (OpenAI-shaped) and lists whatever +the API returns: + +```bash +GET /api/v1/model-servers/kinds/xai-api/models # { reachable, models[], error? } +``` + +## Use from a delegator + +```toml +[[delegators]] +name = "grok-default" +llm_tool = "grok" +model = "grok-4" +# model_server omitted → implicit xai-api +``` + +Codex also speaks the OpenAI protocol, so the catalog marks Codex × xAI as +**Native**. Operator's spawn env for `xai-api` currently exports `XAI_API_KEY` +(what the grok CLI reads). Pointing Codex at xAI in this Alpha is a declared +`openai-compat` server at `https://api.x.ai/v1`, or a follow-up dual-env mapping. diff --git a/docs/getting-started/premium/index.md b/docs/getting-started/premium/index.md index 993217e3..041e6405 100644 --- a/docs/getting-started/premium/index.md +++ b/docs/getting-started/premium/index.md @@ -41,11 +41,9 @@ written to logs. Verification is **entirely offline**. Operator never contacts a licensing service, at install time or afterwards, and there is no activation step. -A license is a signed token carrying the customer it was issued to, its license -id, its tier, the configuration it belongs to, and its validity dates. Operator -checks the signature against verification keys compiled into the binary, then -checks those claims. A license is bound to one **configuration**, identified by -a stable id that survives renaming the configuration. +A license key is an token plus a root-signed attestation for the key that signed it. The token identifies customer it was issued to, its license id, the configuration it belongs to, and validity dates. + +Operator checks against a root public keyring compiled into the binary, then checks those claims. A license is bound to one **configuration** at a time. ### Status @@ -61,21 +59,19 @@ Only **Premium** grants remote execution. An unrecognised tier grants nothing. ## When a license expires -Nothing is killed. Running agents keep running, and a completion report from an -agent already in flight is still accepted and recorded. What stops is *starting* -further remote work: the next launch is refused before anything is provisioned. +Nothing is killed. Running agents keep running, and a completion report from an agent already in flight is still accepted and recorded. What stops is *starting* further remote work: the next launch is refused before anything is provisioned. -Configured remote targets stay visible and readable without a license, and -removing one always works. Registering, editing or probing a target requires -Premium. +Configured remote targets stay visible and readable without a license, and removing one always works. Registering, editing or probing a target requires Premium. ## Building from source -Verification keys are supplied at build time and are **not** in this repository, -so a build from source carries none and rejects every license - Premium is -unreachable in such a build, by design. This repository contains no signing key, -issuer service, checkout, or revocation service; it only *consumes* licenses -issued elsewhere. +Verification keys are supplied at build time and are **not** in this repository, so a build from source carries none and rejects every license - Premium is unreachable in such a build, by design. This repository contains no signing key, +issuer service, checkout, or revocation service; it only *consumes* licenses issued elsewhere. + +A release binary compiles three values in, and none of them are runtime settings: + +- the issuer, the literal `operator-licensing` +- the root public keyring, from the `OPERATOR_LICENSE_ROOT_KEYS` repository variable +- the purchase URL, from the `OPERATOR_PURCHASE_URL` repository variable -The build-time inputs are listed under Licensing in the -[CLI reference](/cli/). A release build refuses to compile without them. +The keyring is a JSON object of key id to standard-base64 of the raw 32-byte key, for example `{"root-2026":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}`. The release build sets `OPERATOR_RELEASE=1`. If that keyring variable is empty, the build fails instead of publishing a binary that rejects every license. A source build leaves `OPERATOR_RELEASE` unset, carries no roots, and rejects every license. diff --git a/docs/getting-started/workflows/index.md b/docs/getting-started/workflows/index.md index 38fb9178..8046011b 100644 --- a/docs/getting-started/workflows/index.md +++ b/docs/getting-started/workflows/index.md @@ -54,3 +54,35 @@ curl "http://localhost:7008/api/v1/workflow-formats" In the TUI, web UI, and VS Code, the **Workflows** section lists the formats and links to preview/export. + +## Voting and judging + +Fan-out steps run several agents and then keep one answer. How that answer is chosen at runtime: + +| Step | Setting | Runtime selection | +|---|---|---| +| `multi_model` | `voting_mode = "single_judge"` (default) | Deterministic rule (LLM judge planned) | +| `multi_model` | `voting_mode = "multi_voter"` | Deterministic rule (voting round not yet run) | +| `multi_prompt` | `selection_strategy = "model_choice"` | First variation (LLM judge planned) | +| `multi_prompt` | `selection_strategy = "scored"` | First variation | +| `matrixed` | - | No aggregation yet (`value` is `null`) | + +The **deterministic rule** is: first delegator with output for `majority` / +`ranked`, the longest answer for `unanimous`. + +**Planned: LLM judge.** A judge that picks the winner with a single typed API +call from the Operator daemon (not an agent session), guided by `voting_prompt` +or `selection_prompt`, is implemented but not yet included in release builds. +The configuration it will read is already accepted: + +```toml +[native_llm.judge] +model_server = "anthropic-api" # any declared or implicit model server +model = "claude-sonnet-5" # full API model id, not a CLI alias +timeout_secs = 120 # optional +``` + +Until the judge ships, a configured judge is reported as unavailable in the +ticket history and the deterministic rule applies. + +Exports render the same prompts as a vote/select `agent(...)` call instead. diff --git a/docs/llm-tools/index.md b/docs/llm-tools/index.md index 49461599..53179805 100644 --- a/docs/llm-tools/index.md +++ b/docs/llm-tools/index.md @@ -1,41 +1,66 @@ --- -title: LLM Tools -description: "Configure Claude Code and other LLM tools for AI-powered agent integration with Operator!." +title: "LLM Tools" layout: doc --- -Operator! integrates with LLM tools like Claude Code to power AI-assisted development. + + -## Supported Tools - -### Claude Code +# LLM Tools -The primary LLM tool supported by Operator. +Operator supports multiple LLM CLI tools through a plugin-like configuration system. +Each tool is defined by a JSON configuration file that tells Operator how to detect, invoke, and manage the tool. +An LLM tool is the **agentic CLI** (the process Operator launches). It is not the model provider: a delegator pairs a tool with a model server. `health_ok` means the binary is present on **this host**. -### OpenAI Codex +## Supported Tools -### Google Gemini +| Tool | Binary | Catalog slug | Models | +|------|--------|--------------|--------| +| Claude Code | `claude` | `claude` | opus, sonnet, haiku | +| Google Gemini | `gemini` | `gemini-cli` | pro, flash, ultra | +| OpenAI Codex | `codex` | `codex` | gpt-4o, o1, o3 | +| Grok | `grok` | `grok` | grok-4 | -## Custom Tool Configs +## Adding a New Tool -Beyond the builtin tools (claude, gemini, codex), any LLM CLI can be added at runtime by dropping a JSON config into the user tool-config directory - no rebuild required: +To add support for a new LLM CLI tool, drop a JSON configuration file into your +user tool-config directory - no rebuild required: - Linux: `~/.config/operator/tools/.json` - macOS: `~/Library/Application Support/operator/tools/.json` -Configs are loaded fresh on every startup. A user config whose `tool_name` -matches a builtin **fully replaces** that builtin (no field-by-field merge). -Malformed files are skipped with a logged warning. Runtime-loaded tools work -everywhere the builtins do, including remote (SSH) launches, where the tool's -presence on the remote host is verified by a `command -v` preflight. +```json +{ + "tool_name": "your-tool", + "display_name": "Your Tool Name", + "version_command": "your-tool --version", + "capabilities": { + "supports_sessions": true, + "supports_headless": false + }, + "model_aliases": ["model1", "model2"], + "arg_mapping": { + "model": "--model", + "session_id": "--session", + "prompt": "-p" + }, + "command_template": "your-tool {{model_flag}}--session {{session_id}} \"$(cat {{prompt_file}})\"", + "yolo_flags": ["--auto-approve"] +} +``` + +Configs are loaded fresh on every startup. A user config whose `tool_name` matches a builtin (claude, gemini, codex) **fully replaces** that builtin. +Malformed files are skipped with a logged warning. Runtime-loaded tools work everywhere the builtins do, including +remote (SSH) launches, where the tool's presence on the remote host is verified by a `command -v` preflight. > **Security note:** `command_template` is arbitrary shell executed at launch. -> Operator! only loads tool configs from the -> user-global config directory - never from repository-local paths - so a -> cloned repo cannot inject a tool config. +> Operator only ever loads tool configs from the user-global config directory - +> never from repository-local paths - so a cloned repo cannot inject a tool +> config. -The full config format is documented in the schema reference on this site -(source of truth: `src/llm/tools/tool_config.schema.json`). +New *builtin* tools (shipped with Operator) are added as embedded JSONs in +`src/llm/tools/`, registered in `BUILTIN_TOOL_CONFIGS`, and given a row in +`shipped_llm_tools()` (catalog slug, binary, implicit model server, marker). ## Detection Modes @@ -48,11 +73,10 @@ optional `detection` object overrides this: } ``` -- `mode: "which"` (default) - gate detection on the binary being in PATH -- `mode: "always"` - skip the PATH lookup and use `tool_name` verbatim as the - invocation path; for tools not installed locally, e.g. only present on a - remote SSH host -- `health_command` - health check run at every startup +| Field | Values | Description | +|-------|--------|-------------| +| `mode` | `which` (default), `always` | `always` skips the PATH lookup and uses `tool_name` verbatim as the invocation path - for tools not installed locally (e.g. run over SSH) | +| `health_command` | any command | Health check run at every startup; failure marks the tool unhealthy (`health_ok: false`) | Health is **earned, never assumed**, and re-verified on every startup: @@ -61,66 +85,142 @@ Health is **earned, never assumed**, and re-verified on every startup: | `which` | Healthy - the PATH lookup proves the binary is present | Healthy if still on PATH **and** the command passes | | `always` | **Unhealthy** - nothing is locally verifiable | Healthy if the command passes | -An unhealthy tool stays listed among the detected tools, but launching a local agent with it fails until it is healthy again. +An unhealthy tool stays listed in the detected tools (so you can see it and why), +but launching a local agent with it fails until it is healthy again. Remote (SSH) +launches are unaffected. An `always`-mode tool should therefore define a `health_command` +that proves reachability, e.g. `ssh gpu-vm command -v agy`. -Remote (SSH) launches are unaffected - they are gated by their own `command -v` preflight on the remote host. An `always`-mode tool should therefore define a `health_command` that proves reachability. +## Configuration Schema -## Integration Points +### Required Fields -### Launching Agents +| Field | Type | Description | +|-------|------|-------------| +| `tool_name` | string | Binary/command name (must match executable in PATH) | +| `version_command` | string | Command to check if tool is installed | +| `capabilities` | object | Feature flags for the tool | +| `model_aliases` | array | List of supported model names | +| `arg_mapping` | object | Maps logical args to CLI flags | +| `command_template` | string | Template for building commands | -Operator! launches Claude Code with project context: +### Optional Fields -```bash -# macOS launch command -open -a "Claude" --args --project "/path/to/project" -``` +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `display_name` | string | tool_name | Human-readable name for UI | +| `yolo_flags` | array | [] | Flags for auto-accept/YOLO mode | +| `detection` | object | which-gated | Detection mode + soft health check (see Detection Modes) | +| `idle_detection` | object | - | Idle/activity regex patterns + completion hook config | +| `permission_modes` | array | - | Supported permission modes (Claude-specific) | + +### Capabilities Object -### Initial Prompts +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `supports_sessions` | boolean | required | Session continuity via ID | +| `supports_headless` | boolean | false | Non-interactive mode support | +| `supports_config_override` | boolean | false | Runtime config overrides | +| `supports_permission_mode` | boolean | false | Permission modes (Claude) | +| `supports_json_schema` | boolean | false | Structured output via JSON schema | -Tickets provide context to agents through: +### Argument Mapping -1. **Ticket content** - The markdown ticket file -2. **Project CLAUDE.md** - Project-specific instructions -3. **Clipboard injection** - Initial prompt via paste simulation +| Key | Description | Example | +|-----|-------------|---------| +| `model` | Model selection flag | `--model`, `-m` | +| `session_id` | Session continuity flag | `--session-id`, `--resume` | +| `prompt` | Prompt/instruction flag | `-p`, `--prompt` | +| `quiet` | Non-interactive output flag | `-q`, `--output-format json` | +| `permission_mode` | Permission mode flag (Claude) | `--permission-mode` | +| `json_schema` | JSON schema flag | `--json-schema` | -### Monitoring +### Command Template Placeholders -Operator! tracks agent status: +| Placeholder | Description | +|-------------|-------------| +| `{{model}}` | The selected model name | +| `{{model_flag}}` | Full model flag with value (e.g., `--model opus `) | +| `{{session_id}}` | Session UUID for continuity | +| `{{prompt_file}}` | Path to the prompt file | +| `{{config_flags}}` | Generated permission/config flags | -- **Running** - Agent is actively working -- **Awaiting Input** - Agent needs human response -- **Completed** - Work is finished -- **Failed** - An error occurred +## YOLO Mode Flags -## Configuration +YOLO (auto-accept) mode enables fully autonomous execution by bypassing confirmation prompts. Each tool defines its own flags: -Configure LLM tool settings in your Operator! config: +| Tool | YOLO Flags | Effect | +|------|------------|--------| +| Claude | `--dangerously-skip-permissions` | Skips all permission prompts | +| Gemini | `--auto-approve`, `-y` | Auto-approves all actions | +| Codex | `--full-auto` | Enables full automation | -```toml -[llm] -tool = "claude-code" -max_concurrent = 4 +## Example: Full Configuration -[llm.claude] -path = "/Applications/Claude.app" +Here's a complete example for Claude Code: + +```json +{ + "tool_name": "claude", + "display_name": "Claude Code", + "version_command": "claude --version", + "capabilities": { + "supports_sessions": true, + "supports_headless": false, + "supports_config_override": true, + "supports_permission_mode": true, + "supports_json_schema": true + }, + "model_aliases": ["opus", "sonnet", "haiku"], + "arg_mapping": { + "prompt": "-p", + "model": "--model", + "session_id": "--session-id", + "permission_mode": "--permission-mode", + "json_schema": "--json-schema" + }, + "permission_modes": ["default", "plan", "acceptEdits", "delegate"], + "command_template": "claude {{config_flags}}{{model_flag}}--session-id {{session_id}} \"$(cat {{prompt_file}})\"", + "yolo_flags": ["--dangerously-skip-permissions"] +} ``` -## Known Limitations +## Visual Indicators + +In the TUI, running agents show a tool indicator: + +| Indicator | Tool | Color | +|-----------|------|-------| +| **A** | Claude/Anthropic | Rust (#C15F3C) | +| **G** | Gemini | Purple (#6F42C1) | +| **O** | Codex/OpenAI | Green | + +## Detection Process + +On every startup, Operator: + +1. Loads the embedded builtin tool configurations, then user configurations from `/operator/tools/*.json` +2. For each tool, runs `which ` to check if installed (skipped when `detection.mode` is `always`) +3. If found, runs the `version_command` to get the version (failure degrades to `"unknown"`; a `min_version` mismatch warns but does not block) +4. Computes health from the verified presence plus the `health_command`, if configured (see Detection Modes); an unhealthy tool stays listed but cannot launch locally +5. Builds a list of available providers (tool + model combinations) +6. The first detected tool becomes the default provider -### JSON Schema for Structured Output (Temporarily Disabled) +Already-detected tools keep their cached `path`/`version` across restarts (no +version re-probing); config-sourced fields like the command template and model +aliases are re-derived from the loaded configs each startup. -The `jsonSchema` and `jsonSchemaFile` step properties are currently disabled. These properties configure the `--json-schema` flag for Claude Code to enable structured output validation. +Presence and the `health_command` are re-checked every startup, so an uninstalled binary or a newly failing health command demotes the tool on the next launch of Operator. -**Issue**: Even when writing schemas to files (rather than passing inline JSON), the command line length can exceed OS limits when combined with other flags. +## Troubleshooting -**Workaround**: Until this is resolved, use Claude Code's native structured output capabilities without the `--json-schema` flag, or validate outputs manually in subsequent steps. +### Tool Not Detected -**Tracking**: See `JSON_SCHEMA_ENABLED` constant in `src/agents/launcher/llm_command.rs`. +1. Ensure the binary is in your PATH: `which ` +2. Verify the version command works: ` --version` +3. Check Operator logs for detection errors -## Best Practices +### Command Fails -1. **Clear tickets** - Write detailed ticket descriptions -2. **Project context** - Maintain good CLAUDE.md files -3. **Monitor paired work** - Stay engaged with INV/SPIKE agents -4. **Review autonomous work** - Check completed FEAT/FIX work +1. Test the command manually with the template filled in +2. Verify all argument mappings are correct for your tool version +3. Check if the tool requires additional environment variables diff --git a/docs/llms.txt b/docs/llms.txt index 11c4456f..9440a214 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -22,7 +22,7 @@ Operator runs from the root of your work directory, discovers projects by LLM ma - [Remote Hosts (SSH)](https://operator.untra.io/getting-started/sessions/remote-hosts/): Run launched agents on remote machines over SSH (execution targets). ## Integrations -- [LLM Tools](https://operator.untra.io/llm-tools/): Configure Claude Code and other LLM tools for AI-powered agent integration with Operator!. +- [LLM Tools](https://operator.untra.io/llm-tools/) - [Relay](https://operator.untra.io/relay/): Multi-agent peer-to-peer communication hub embedded in Operator. ## Reference diff --git a/docs/maturity/index.md b/docs/maturity/index.md index d1732ab5..11de352b 100644 --- a/docs/maturity/index.md +++ b/docs/maturity/index.md @@ -32,6 +32,7 @@ Operator integrates with many providers and tools across several **verticals**. | Anthropic | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [Anthropic](https://operator.untra.io/getting-started/model-servers/anthropic/) | | OpenAI | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [OpenAI](https://operator.untra.io/getting-started/model-servers/openai/) | | Google | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [Google](https://operator.untra.io/getting-started/model-servers/google/) | +| xAI | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [xAI](https://operator.untra.io/getting-started/model-servers/xai/) | | Ollama | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [Ollama](https://operator.untra.io/getting-started/model-servers/ollama/) | | OpenRouter | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [OpenRouter](https://operator.untra.io/getting-started/model-servers/openrouter/) | @@ -66,6 +67,7 @@ Operator integrates with many providers and tools across several **verticals**. | Claude | ![GA](https://img.shields.io/badge/GA-1BB91F) | Included | [Claude](https://operator.untra.io/getting-started/agents/claude/) | | Codex | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [Codex](https://operator.untra.io/getting-started/agents/codex/) | | Gemini CLI | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [Gemini CLI](https://operator.untra.io/getting-started/agents/gemini-cli/) | +| Grok | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [Grok](https://operator.untra.io/getting-started/agents/grok/) | ## Platform @@ -113,3 +115,40 @@ Operator integrates with many providers and tools across several **verticals**. |---|---|---|---| | Coder | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Premium | [Coder](https://operator.untra.io/getting-started/remote-targets/coder/) | | SSH Hosts | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Premium | [SSH Hosts](https://operator.untra.io/getting-started/remote-targets/ssh/) | + +## LLM tool capabilities + +Advertising status (GA/Beta/Alpha) is not the same as a live connection probe. LLM tool **health** is `path-version`: the binary is on PATH. That is weaker than a model provider's `/models` probe, which proves an API key is accepted. + +| Tool | Health | Auth | Native protocol | Sessions | Headless | YOLO | Relay | +|---|---|---|---|---|---|---|---| +| Claude | path-version | oauth-and-key | anthropic | yes | no | yes | supported | +| Codex | path-version | api-key | openai | yes | yes | yes | partial | +| Gemini CLI | path-version | api-key | google | yes | yes | yes | none | +| Grok | path-version | oauth-and-key | openai | yes | yes | yes | none | + +## Model provider capabilities + +A model provider is **connected** when its model-list probe succeeds. Gateways (Ollama, OpenRouter, OpenAI-compatible) speak the OpenAI protocol; first-party Anthropic and Google do not. + +| Provider | Protocol | Class | Probe | Key injectable | Implicit for | +|---|---|---|---|---|---| +| Anthropic | anthropic | first-party | yes | yes | claude | +| OpenAI | openai | first-party | yes | yes | codex | +| Google | google | first-party | yes | yes | gemini | +| xAI | openai | first-party | yes | yes | grok | +| Ollama | openai | gateway | yes | optional | - | +| OpenRouter | openai | gateway | yes | yes | - | + +## LLM tool × model provider + +**Native** — the CLI speaks this provider's protocol. **Bridge** — a protocol-preserving front (claude-code-router, litellm, …) is required. **Incompatible** — this first-party API is the wrong protocol for the CLI. + +Operator does not currently block incompatible delegators at launch; this matrix is the catalog fact. + +| Tool | Anthropic | OpenAI | Google | xAI | Ollama | OpenRouter | +|---|---|---|---|---|---|---| +| Claude | Native | Bridge | Incompatible | Bridge | Bridge | Bridge | +| Codex | Incompatible | Native | Incompatible | Native | Native | Native | +| Gemini CLI | Incompatible | Incompatible | Native | Incompatible | Bridge | Bridge | +| Grok | Incompatible | Native | Incompatible | Native | Native | Native | diff --git a/docs/schemas/index.md b/docs/schemas/index.md index 34374733..c1098a09 100644 --- a/docs/schemas/index.md +++ b/docs/schemas/index.md @@ -29,8 +29,8 @@ Machine-readable JSON Schema files for validation and code generation: | [config.json](config.json) | JSON Schema | Configuration file schema (generated via schemars) | | [state.json](state.json) | JSON Schema | Runtime state file schema (generated via schemars) | | [openapi.json](openapi.json) | OpenAPI 3.1 | REST API specification (generated via utoipa) | -| [collections/schema.json](../collections/schema.json) | JSON Schema | Hosted issuetype collection manifest format (collection.json) | -| [collections/index.json](../collections/index.json) | JSON | Index of hosted issuetype collections (fetched during setup) | +| [collections/schema.json](/collections/schema.json) | JSON Schema | Hosted issuetype collection manifest format (collection.json) | +| [collections/index.json](/collections/index.json) | JSON | Index of hosted issuetype collections (fetched during setup) | ## Regenerating Schemas diff --git a/docs/schemas/issuetype.md b/docs/schemas/issuetype.md index 260c4e37..6fc5a0fe 100644 --- a/docs/schemas/issuetype.md +++ b/docs/schemas/issuetype.md @@ -353,7 +353,7 @@ Configuration for multi-model delegation steps (fan-out + vote) | `delegators` | `array` | Yes | Named delegator references (from config.delegators), minimum 2 | | `voting_strategy` | → `VotingStrategy` | Yes | How to aggregate/select the final answer | | `share_answers` | `boolean` | No | Whether to share all answers with all models in the voting round | -| `voting_prompt` | `string` \| `null` | No | Prompt for the voting round (Handlebars, receives {{ answers }} array) | +| `voting_prompt` | `string` \| `null` | No | Instruction prompt for the judge that picks the winner (Handlebars, rendered with the ticket context) | | `voting_mode` | → `VotingMode` | No | How the voting round executes | ### Definition: VotingStrategy @@ -373,7 +373,7 @@ Configuration for multi-prompt interrogation steps (N variations, select best) | `prompt_variations` | `array` | Yes | Prompt variations (Handlebars templates), minimum 2 | | `selection_strategy` | → `SelectionStrategy` | Yes | How to select the best result | | `agent` | `string` \| `null` | No | Agent/delegator to use for all variations | -| `selection_prompt` | `string` \| `null` | No | Prompt for the selection/review round | +| `selection_prompt` | `string` \| `null` | No | Instruction for the judge that picks the best variation with `model_choice` (Handlebars, rendered with the ticket context) | ### Definition: SelectionStrategy @@ -410,9 +410,7 @@ step; iteration is an intra-step concern, never a step-to-step edge. ### Definition: ItemSource -Where a pipeline's iterated items come from. The variant determines *when* -the list resolves: export-time (a literal array → static fan-out width in -the compiled graph) vs runtime (an identifier → symbolic width). +Where a pipeline's iterated items come from. The variant determines *when* the list resolves: export-time vs runtime. ### Definition: PipelineStage diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 7f0055b7..6cb94db9 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -8581,7 +8581,8 @@ "label", "readme_badge", "status", - "premium" + "premium", + "support" ], "properties": { "docs_url": { @@ -8620,6 +8621,9 @@ "$ref": "#/components/schemas/SupportStatus", "description": "Official support / maturity status." }, + "support": { + "description": "Vertical-specific structural support (not the advertising `status` ramp)." + }, "vertical": { "type": "string", "description": "Vertical slug (e.g. \"kanban\", \"model\", \"git\", \"session\", \"editor\")." @@ -11234,10 +11238,8 @@ "model-server", "git-provider", "collection-source", - "hosted-collections", "task-field-config", "session-wrapper-choice", - "execution-target", "worktree-preference", "admin-password", "tmux-onboarding", @@ -11246,6 +11248,8 @@ "zellij-setup", "acceptance-criteria", "startup-tickets", + "hosted-collections", + "execution-target", "confirm" ] }, @@ -11628,6 +11632,19 @@ }, "reachable": { "type": "boolean" + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TargetToolProbe" + }, + "description": "LLM CLIs reported by `opr8r tools --json` on the target. Empty if unreachable/unknown." + }, + "tools_error": { + "type": [ + "string", + "null" + ] } } }, @@ -11657,6 +11674,27 @@ } ] }, + "TargetToolProbe": { + "type": "object", + "required": [ + "name", + "health_ok" + ], + "properties": { + "health_ok": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "version": { + "type": [ + "string", + "null" + ] + } + } + }, "TargetsResponse": { "type": "object", "required": [ diff --git a/docs/security/authentication.md b/docs/security/authentication.md index 92166e53..c17782ed 100644 --- a/docs/security/authentication.md +++ b/docs/security/authentication.md @@ -78,6 +78,10 @@ The cookie is named `__Host-operator_session` and is set `Secure`, `HttpOnly`, ` Because a cookie is sent automatically, cookie-authenticated **mutations** additionally require a CSRF token and a matching `Origin`. +The dashboard holds its CSRF token in memory only, so a page reload leaves it without one. It fetches a token lazily, on the first mutation that needs it, and concurrent mutations share that single fetch. If a mutation is rejected with `csrf_failed`, the dashboard fetches a fresh token and retries **once**; a second rejection surfaces as an error. + +Issuing a token rotates it. The server keeps exactly **one previous token** valid alongside the current one, so a reload in one tab does not break mutations from another tab still holding the old token. A second rotation retires it. Revoking or logging out a session invalidates both tokens at once. + Logging out deletes the session server-side. The cookie becoming invalid is a consequence, not the mechanism, so a copied cookie dies with the session. ## Access tokens and refresh tokens diff --git a/docs/security/index.md b/docs/security/index.md index a243c017..dc19df8c 100644 --- a/docs/security/index.md +++ b/docs/security/index.md @@ -96,6 +96,16 @@ The authentication database at `.tickets/operator/auth.sqlite3` is created owner Plaintext passwords, temporary bootstrap passwords, device codes, refresh tokens, and access keys are never written to disk or logs. +### Credentials Operator uses itself + +Model-server probes are made by the Operator process with a provider API key +attached. The key is read from Operator's own environment at request time (the +server's `api_key_env`, else the provider default such as `ANTHROPIC_API_KEY`) +and is never written to config, state, or logs. Planned native LLM calls, such +as the multi-agent judge, will follow the same rules and the same egress +validation, and will send candidate agent outputs to the configured model +server. + ### Credentials Operator passes to agents Launching an agent injects environment variables into the agent's process, including a short-lived callback credential and whatever provider keys the configured tool needs. Per the trust-boundary discussion above, the agent can read all of them. diff --git a/docs/startup/index.md b/docs/startup/index.md index b95d992d..ff49dc46 100644 --- a/docs/startup/index.md +++ b/docs/startup/index.md @@ -19,18 +19,18 @@ When Operator starts and no `.tickets/` directory exists, the setup wizard guide | 5 | Model Server | Declare which model providers this workspace uses | | 6 | Git Provider | Connect a git provider so agents can branch, push and open PRs | | 7 | Collection Source | Choose which issue type collection to use | -| 8 | Hosted Collections | Browse and select hosted collections (only shown if Browse chosen) | -| 9 | Task Field Config | Configure optional fields for TASK issue type | -| 10 | Session Wrapper Choice | Select which session wrapper to use for launching coding agents | -| 11 | Execution Target | Choose whether agents run locally or in Coder workspaces | -| 12 | Worktree Preference | Choose whether to use git worktrees for ticket isolation | -| 13 | Web UI Password | Optionally set the admin password for the web dashboard | -| 14 | Tmux Onboarding | Help and documentation about tmux session management (shown if tmux selected) | -| 15 | VS Code Setup | VS Code extension setup and verification (shown if VS Code selected) | -| 16 | Cmux Setup | cmux session wrapper setup (shown if cmux selected) | -| 17 | Zellij Setup | Zellij session wrapper setup (shown if Zellij selected) | -| 18 | Acceptance Criteria | Review and configure acceptance criteria for ticket completion | -| 19 | Startup Tickets | Optionally create tickets to bootstrap your projects | +| 8 | Task Field Config | Configure optional fields for TASK issue type | +| 9 | Session Wrapper Choice | Select which session wrapper to use for launching coding agents | +| 10 | Worktree Preference | Choose whether to use git worktrees for ticket isolation | +| 11 | Web UI Password | Optionally set the admin password for the web dashboard | +| 12 | Tmux Onboarding | Help and documentation about tmux session management (shown if tmux selected) | +| 13 | VS Code Setup | VS Code extension setup and verification (shown if VS Code selected) | +| 14 | Cmux Setup | cmux session wrapper setup (shown if cmux selected) | +| 15 | Zellij Setup | Zellij session wrapper setup (shown if Zellij selected) | +| 16 | Acceptance Criteria | Review and configure acceptance criteria for ticket completion | +| 17 | Startup Tickets | Optionally create tickets to bootstrap your projects | +| 18 | Hosted Collections | Browse and select hosted collections (only shown if Browse chosen) | +| 19 | Execution Target | Choose whether agents run locally or in Coder workspaces | | 20 | Confirm | Review settings and confirm initialization | ## Step Details @@ -72,7 +72,7 @@ Both modes support multiple agents running at once. - **This machine**: agents and local containers run beside Operator. - **Remote targets**: agents run on SSH hosts or Coder workspaces and report back to this Operator server. Requires Premium. -Choosing remote leads to target registration; choosing this machine skips it. +Choosing remote adds a target-registration step at the end of setup; choosing this machine skips it. **Navigation**: ↑/↓ to select, Enter to continue, Esc to go back @@ -136,19 +136,7 @@ Select a preset collection of issue types: **Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back -### 8. Hosted Collections - -*Browse and select hosted collections (only shown if Browse chosen)* - -Pick one or more curated collections published at operator.untra.io. - -The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps. - -Selections are additive - choose as many as apply. - -**Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back - -### 9. Task Field Config +### 8. Task Field Config *Configure optional fields for TASK issue type* @@ -161,7 +149,7 @@ These choices propagate to other issue types. The 'summary' field is always requ **Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back -### 10. Session Wrapper Choice +### 9. Session Wrapper Choice *Select which session wrapper to use for launching coding agents* @@ -175,19 +163,7 @@ Your choice determines which setup steps follow. **Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back -### 11. Execution Target - -*Choose whether agents run locally or in Coder workspaces* - -Local runs agent commands on the same machine as Operator. Coder creates or starts a per-ticket workspace and launches there over SSH. - -Coder configuration stores only environment variable names for the deployment URL and session token. Secret values remain in the process environment. - -Coder targets disable git worktrees and relay injection, and cannot be combined with Zellij. - -**Navigation**: ↑/↓ to select, Tab to switch fields, Enter to continue, Esc to go back - -### 12. Worktree Preference +### 10. Worktree Preference *Choose whether to use git worktrees for ticket isolation* @@ -199,7 +175,7 @@ Worktrees allow multiple agents to work on different tickets simultaneously with **Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back -### 13. Web UI Password +### 11. Web UI Password *Optionally set the admin password for the web dashboard* @@ -213,7 +189,7 @@ The password must be at least 12 characters. This step is hidden whe **Navigation**: Tab to switch fields, Enter to continue (blank to skip), Esc to go back -### 14. Tmux Onboarding +### 12. Tmux Onboarding *Help and documentation about tmux session management (shown if tmux selected)* @@ -227,7 +203,7 @@ Operator session names start with 'op-' for easy identification. **Navigation**: Enter to continue, Esc to go back -### 15. VS Code Setup +### 13. VS Code Setup *VS Code extension setup and verification (shown if VS Code selected)* @@ -238,7 +214,7 @@ Install the extension from the VS Code marketplace if prompted. **Navigation**: Enter to continue, Esc to go back -### 16. Cmux Setup +### 14. Cmux Setup *cmux session wrapper setup (shown if cmux selected)* @@ -248,7 +224,7 @@ This step verifies the cmux app's CLI binary exists at the configured binary_pat **Navigation**: Enter to continue, Esc to go back -### 17. Zellij Setup +### 15. Zellij Setup *Zellij session wrapper setup (shown if Zellij selected)* @@ -258,7 +234,7 @@ This step verifies Zellij is installed and configures the layout Operator will u **Navigation**: Enter to continue, Esc to go back -### 18. Acceptance Criteria +### 16. Acceptance Criteria *Review and configure acceptance criteria for ticket completion* @@ -269,7 +245,7 @@ The default criteria cover formatting, tests, and lint checks. You can customize **Navigation**: Enter to continue, Esc to go back -### 19. Startup Tickets +### 17. Startup Tickets *Optionally create tickets to bootstrap your projects* @@ -282,6 +258,30 @@ These tickets are optional and help automate common setup tasks. **Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back +### 18. Hosted Collections + +*Browse and select hosted collections (only shown if Browse chosen)* + +Pick one or more curated collections published at operator.untra.io. + +The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps. + +Selections are additive - choose as many as apply. + +**Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back + +### 19. Execution Target + +*Choose whether agents run locally or in Coder workspaces* + +Local runs agent commands on the same machine as Operator. Coder creates or starts a per-ticket workspace and launches there over SSH. + +Coder configuration stores only environment variable names for the deployment URL and session token. Secret values remain in the process environment. + +Coder targets disable git worktrees and relay injection, and cannot be combined with Zellij. + +**Navigation**: ↑/↓ to select, Tab to switch fields, Enter to continue, Esc to go back + ### 20. Confirm *Review settings and confirm initialization* diff --git a/icons/xai.svg b/icons/xai.svg new file mode 100644 index 00000000..97cb7851 --- /dev/null +++ b/icons/xai.svg @@ -0,0 +1 @@ +xAI diff --git a/opr8r/Cargo.lock b/opr8r/Cargo.lock index 7c12c515..1684e40b 100644 --- a/opr8r/Cargo.lock +++ b/opr8r/Cargo.lock @@ -81,6 +81,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "1.3.2" @@ -121,12 +127,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "clap" version = "4.6.6" @@ -173,6 +173,32 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "dirs" version = "5.0.1" @@ -307,24 +333,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", ] [[package]] @@ -335,7 +345,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] @@ -433,7 +443,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -442,7 +451,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -627,6 +636,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -698,12 +756,6 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "memchr" version = "2.8.0" @@ -762,6 +814,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "operator-relay" version = "0.1.0" @@ -784,6 +842,7 @@ dependencies = [ "clap", "operator-relay", "reqwest", + "rustls", "serde", "serde_json", "tempfile", @@ -818,15 +877,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -846,61 +896,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.52.0", -] - [[package]] name = "quote" version = "1.0.45" @@ -910,47 +905,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -964,11 +924,11 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-core", "http", @@ -981,12 +941,11 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -997,7 +956,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] @@ -1015,10 +973,13 @@ dependencies = [ ] [[package]] -name = "rustc-hash" -version = "2.1.2" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustix" @@ -1047,16 +1008,54 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -1074,12 +1073,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "same-file" version = "1.0.6" @@ -1089,6 +1082,38 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1138,18 +1163,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "shlex" version = "1.3.0" @@ -1166,6 +1179,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1255,7 +1284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1311,21 +1340,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.53.1" @@ -1641,20 +1655,10 @@ dependencies = [ ] [[package]] -name = "web-time" -version = "1.1.0" +name = "webpki-root-certs" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -1665,7 +1669,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1945,26 +1949,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "zerofrom" version = "0.1.8" diff --git a/opr8r/Cargo.toml b/opr8r/Cargo.toml index 68700022..2e051fdb 100644 --- a/opr8r/Cargo.toml +++ b/opr8r/Cargo.toml @@ -9,7 +9,8 @@ repository = "https://github.com/untra/operator" [dependencies] clap = { version = "4", features = ["derive"] } tokio = { version = "1", features = ["rt", "io-util", "net", "sync", "time", "macros", "process"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } serde = { version = "1", features = ["derive"] } serde_json = "1" operator-relay = { path = "../crates/relay" } diff --git a/opr8r/src/api.rs b/opr8r/src/api.rs index a81ab6fc..9665c732 100644 --- a/opr8r/src/api.rs +++ b/opr8r/src/api.rs @@ -221,10 +221,9 @@ fn resolve_base_url( } impl ApiClient { - /// Create a client with an explicit callback credential and, when the - /// configuration is known, the id its callbacks must be routed to. + /// Create a client with an explicit callback credential and its callbacks must be routed to. pub fn with_profile(base_url: &str, token: Option, profile_id: Option) -> Self { - let client = Client::builder() + let client = crate::http_client::client_builder() .timeout(Duration::from_secs(30)) .build() .expect("Failed to create HTTP client"); diff --git a/opr8r/src/cli.rs b/opr8r/src/cli.rs index 23ad2345..695c6a46 100644 --- a/opr8r/src/cli.rs +++ b/opr8r/src/cli.rs @@ -61,6 +61,16 @@ pub enum Cmd { /// relay_ask, relay_reply, relay_broadcast, relay_rename) to LLM agents /// via the MCP stdio protocol. Relay, + /// Report LLM CLIs on this host. + /// + /// Reads a JSON array of `{name, version_command, health_command?}` from + /// stdin and writes a JSON array of probe results to stdout. Operator owns + /// the catalog; this command is only the sensor. + Tools { + /// JSON in on stdin, JSON out on stdout (required for the machine API). + #[arg(long)] + json: bool, + }, } impl Args { @@ -106,6 +116,12 @@ mod tests { assert!(matches!(args.subcommand, Some(Cmd::Relay))); } + #[test] + fn test_tools_json_subcommand_parses() { + let args = Args::try_parse_from(["opr8r", "tools", "--json"]).unwrap(); + assert!(matches!(args.subcommand, Some(Cmd::Tools { json: true }))); + } + #[test] fn test_step_wrapper_mode_still_requires_ticket_id() { let args = Args::try_parse_from(["opr8r", "--step=plan", "--", "claude"]).unwrap(); diff --git a/opr8r/src/http_client.rs b/opr8r/src/http_client.rs new file mode 100644 index 00000000..f3228dc1 --- /dev/null +++ b/opr8r/src/http_client.rs @@ -0,0 +1,33 @@ +//! Single construction point for outbound HTTP clients. +//! +//! reqwest is built with `rustls-no-provider` so the tree stays on ring +//! (no aws-lc C build). rustls then needs a process-default `CryptoProvider` +//! installed before any client is built; every client goes through here. + +use std::sync::Once; + +static INSTALL_PROVIDER: Once = Once::new(); + +fn ensure_crypto_provider() { + INSTALL_PROVIDER.call_once(|| { + // Err means another caller already installed a provider, which is fine. + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +#[allow(clippy::disallowed_methods)] +pub fn client_builder() -> reqwest::ClientBuilder { + ensure_crypto_provider(); + reqwest::Client::builder() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_builds_with_ring_provider() { + assert!(client_builder().build().is_ok()); + assert!(rustls::crypto::CryptoProvider::get_default().is_some()); + } +} diff --git a/opr8r/src/main.rs b/opr8r/src/main.rs index 10fa1d76..9180d684 100644 --- a/opr8r/src/main.rs +++ b/opr8r/src/main.rs @@ -1,9 +1,11 @@ mod api; mod cli; +mod http_client; #[cfg(unix)] mod operator_relay; mod output_parser; mod runner; +mod tools; mod transition; use api::{ApiClient, StepCompleteRequest}; @@ -55,17 +57,33 @@ fn build_step_complete_request( async fn main() -> ExitCode { let args = Args::parse_args(); - // Dispatch relay subcommand before any step-wrapper logic - if args.subcommand == Some(Cmd::Relay) { - #[cfg(unix)] - return operator_relay::run().await; - #[cfg(not(unix))] - { - eprintln!( - "[opr8r relay] relay is not supported on this platform (requires Unix sockets)" - ); - return ExitCode::from(1); + // Dispatch subcommands before any step-wrapper logic + match &args.subcommand { + Some(Cmd::Relay) => { + #[cfg(unix)] + return operator_relay::run().await; + #[cfg(not(unix))] + { + eprintln!( + "[opr8r relay] relay is not supported on this platform (requires Unix sockets)" + ); + return ExitCode::from(1); + } + } + Some(Cmd::Tools { json }) => { + if !json { + eprintln!("[opr8r tools] --json is required"); + return ExitCode::from(EXIT_CONFIG_ERROR); + } + return match tools::run_json(&mut std::io::stdin(), &mut std::io::stdout()) { + Ok(()) => ExitCode::from(EXIT_SUCCESS), + Err(e) => { + eprintln!("[opr8r tools] {e}"); + ExitCode::from(EXIT_CONFIG_ERROR) + } + }; } + None => {} } // Step-wrapper mode: validate required fields diff --git a/opr8r/src/tools.rs b/opr8r/src/tools.rs new file mode 100644 index 00000000..668b4de5 --- /dev/null +++ b/opr8r/src/tools.rs @@ -0,0 +1,174 @@ +//! Host-local LLM CLI inventory. +//! +//! Operator owns the tool catalog and sends a JSON spec on stdin. This module +//! reports what is actually on PATH on the machine running `opr8r`. + +use std::io::{Read, Write}; +use std::process::Command; + +use serde::{Deserialize, Serialize}; + +/// One tool Operator wants this host to look up. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct ToolProbeSpec { + pub name: String, + pub version_command: String, + #[serde(default)] + pub health_command: Option, +} + +/// Presence / version / health for one spec. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct ToolProbeResult { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + pub health_ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Probe every spec. Never panics; failures land on the result row. +pub fn probe_tools(specs: &[ToolProbeSpec]) -> Vec { + specs.iter().map(probe_one).collect() +} + +fn probe_one(spec: &ToolProbeSpec) -> ToolProbeResult { + let path = binary_path(&spec.name); + if path.is_none() { + return ToolProbeResult { + name: spec.name.clone(), + path: None, + version: None, + health_ok: false, + error: Some(format!("'{}' is not on PATH", spec.name)), + }; + } + + let version = run_command_line(&spec.version_command).ok(); + let health_ok = match spec.health_command.as_deref() { + Some(cmd) => run_command_line(cmd).is_ok(), + None => true, + }; + let error = if health_ok { + None + } else { + Some(format!( + "health_command failed: {}", + spec.health_command.as_deref().unwrap_or("none") + )) + }; + + ToolProbeResult { + name: spec.name.clone(), + path, + version, + health_ok, + error, + } +} + +fn binary_path(name: &str) -> Option { + Command::new("which") + .arg(name) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn run_command_line(line: &str) -> Result { + let parts: Vec<&str> = line.split_whitespace().collect(); + let Some((program, args)) = parts.split_first() else { + return Ok(String::new()); + }; + Command::new(program) + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .ok_or(()) +} + +/// Read a JSON spec array from `stdin`, write a JSON result array to `stdout`. +pub fn run_json(stdin: &mut impl Read, stdout: &mut impl Write) -> Result<(), String> { + let mut body = String::new(); + stdin + .read_to_string(&mut body) + .map_err(|e| format!("failed to read spec: {e}"))?; + let specs: Vec = + serde_json::from_str(body.trim()).map_err(|e| format!("invalid tool spec JSON: {e}"))?; + let results = probe_tools(&specs); + serde_json::to_writer(&mut *stdout, &results) + .map_err(|e| format!("failed to write results: {e}"))?; + writeln!(stdout).map_err(|e| format!("failed to write results: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_probe_true_is_healthy() { + let results = probe_tools(&[ToolProbeSpec { + name: "true".into(), + version_command: "true".into(), + health_command: None, + }]); + assert_eq!(results.len(), 1); + assert!(results[0].health_ok); + assert!(results[0].path.as_ref().is_some_and(|p| p.contains("true"))); + assert!(results[0].error.is_none()); + } + + #[test] + fn test_probe_missing_binary_is_unhealthy() { + let results = probe_tools(&[ToolProbeSpec { + name: "opr8r-tool-does-not-exist".into(), + version_command: "opr8r-tool-does-not-exist --version".into(), + health_command: None, + }]); + assert!(!results[0].health_ok); + assert!(results[0].path.is_none()); + assert!(results[0] + .error + .as_ref() + .is_some_and(|e| e.contains("not on PATH"))); + } + + #[test] + fn test_probe_failing_health_command_is_unhealthy() { + let results = probe_tools(&[ToolProbeSpec { + name: "true".into(), + version_command: "true".into(), + health_command: Some("false".into()), + }]); + assert!(results[0].path.is_some()); + assert!(!results[0].health_ok); + assert!(results[0] + .error + .as_ref() + .is_some_and(|e| e.contains("health_command"))); + } + + #[test] + fn test_run_json_roundtrip() { + let spec = r#"[{"name":"true","version_command":"true"}]"#; + let mut out = Vec::new(); + run_json(&mut spec.as_bytes(), &mut out).expect("spec parses"); + let results: Vec = serde_json::from_slice(&out).unwrap(); + assert_eq!(results.len(), 1); + assert!(results[0].health_ok); + } + + #[test] + fn test_run_json_rejects_malformed_spec() { + let err = run_json(&mut b"{not json}".as_slice(), &mut Vec::new()).unwrap_err(); + assert!(err.contains("invalid tool spec JSON")); + } +} diff --git a/package.json b/package.json index 5a32d0f5..b7b4a2c6 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "fmt:ui": "oxfmt --write \"ui/src/**/*.{ts,tsx}\"", "fmt:webcomponents": "oxfmt --write \"webcomponents/src/**/*.{ts,tsx}\" \"webcomponents/stories/**/*.{ts,tsx}\" \"webcomponents/.storybook/**/*.{ts,tsx}\" \"webcomponents/vitest.config.ts\" \"webcomponents/scripts/**/*.mjs\"", "fmt:vscode": "oxfmt --write \"vscode-extension/src/**/*.ts\" \"vscode-extension/test/**/*.ts\" \"vscode-extension/webview-ui/**/*.{ts,tsx}\" \"vscode-extension/scripts/**/*.js\"", + "test:webview": "bun test vscode-extension/webview-ui", "lint:agnt": "oxlint agnt-plugin", "lint:coder-module": "oxlint --type-aware coder-module" }, diff --git a/scripts/cicdprep.sh b/scripts/cicdprep.sh index 09b53846..61e5284c 100755 --- a/scripts/cicdprep.sh +++ b/scripts/cicdprep.sh @@ -167,7 +167,6 @@ needs_vscode() { has_changes '^(vscode-extension/|icons/|\.oxlintrc\.jsonc$) needs_zed() { has_changes '^zed-extension/'; } needs_relay() { has_changes '^crates/relay/'; } needs_charts() { has_changes '^(charts/|\.github/workflows/build\.yaml$)'; } -needs_shell() { has_changes '^(scripts/|\.githooks/)'; } needs_coder() { has_changes '^(coder-module/|\.github/workflows/coder-module\.yaml$|scripts/ci/check-coder-module\.sh$)'; } needs_docs() { has_changes '^(docs/|src/docs_gen/|src/taxonomy/taxonomy\.toml|src/templates/.*\.json|src/collections/|collections/|src/schemas/|webcomponents/|src/workflow_gen/)'; } @@ -237,7 +236,7 @@ if needs_shell; then section "Shell scripts" require_tool shellcheck "shell script lint" - run_step "shellcheck" bash -c 'shellcheck -S warning scripts/*.sh scripts/ci/*.sh .githooks/*' + run_step "shellcheck" bash -c 'shellcheck -S warning scripts/*.sh scripts/ci/*.sh' else skip "Shell scripts" fi diff --git a/shared/types.ts b/shared/types.ts index 8e5dfafa..f9cfa8f7 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1630,7 +1630,11 @@ status: SupportStatus, premium: boolean, /** * Implemented session controllers for an IDE; absent for other categories. */ -session_wrappers: Array | null, }; +session_wrappers: Array | null, +/** + * Vertical-specific structural support (not the advertising `status` ramp). + */ +support: unknown, }; export type KanbanProviderCatalogEntry = { /** diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index 2efc5dfa..10bbee8b 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -29,6 +29,10 @@ pub enum ResolutionError { UnknownProvider(String), #[error("Unknown model_server '{0}'")] UnknownModelServer(String), + #[error( + "Unknown llm_tool '{0}' (no implicit model server; set model_server on the delegator or use a shipped tool)" + )] + UnknownLlmTool(String), /// The delegator declaratively references a remote, named agent on another /// platform (AGNT, `OpenAI`, ...). Operator has no runtime client for those /// platforms, so such a delegator is export-only and cannot be resolved into a @@ -61,10 +65,29 @@ pub(crate) fn resolve_model_server_for_delegator( .find(|s| s.name == name) .cloned() .ok_or_else(|| ResolutionError::UnknownModelServer(name.to_string())), - None => Ok(implicit_model_server_for_tool(&d.llm_tool)), + None => implicit_model_server_for_tool(&d.llm_tool) + .ok_or_else(|| ResolutionError::UnknownLlmTool(d.llm_tool.clone())), } } +/// Look up a model server by name among declared servers, then the implicit builtins. +pub(crate) fn resolve_model_server_by_name( + config: &Config, + name: &str, +) -> Result { + config + .model_servers + .iter() + .find(|s| s.name == name) + .cloned() + .or_else(|| { + crate::config::implicit_model_servers() + .into_iter() + .find(|s| s.name == name) + }) + .ok_or_else(|| ResolutionError::UnknownModelServer(name.to_string())) +} + /// Convert a `Delegator` into an `LlmProvider`, resolving its `model_server` and /// threading the server's env vars (base URL, API key, extra env) into /// [`LlmProvider::env`] so they are exported when the agent spawns. @@ -109,19 +132,9 @@ fn adhoc_model_server_env( model_server: Option<&str>, ) -> Result, ResolutionError> { let server = match model_server { - Some(name) => config - .model_servers - .iter() - .find(|s| s.name == name) - .cloned() - .or_else(|| { - ["claude", "codex", "gemini"] - .iter() - .map(|t| implicit_model_server_for_tool(t)) - .find(|s| s.name == name) - }) - .ok_or_else(|| ResolutionError::UnknownModelServer(name.to_string()))?, - None => implicit_model_server_for_tool(tool), + Some(name) => resolve_model_server_by_name(config, name)?, + None => implicit_model_server_for_tool(tool) + .ok_or_else(|| ResolutionError::UnknownLlmTool(tool.to_string()))?, }; Ok(crate::api::providers::model_server::env_for_server(&server)) } @@ -500,6 +513,17 @@ mod tests { assert!(matches!(err, ResolutionError::UnknownModelServer(_))); } + #[test] + fn test_resolve_model_server_unknown_tool_without_named_server_errors() { + let config = Config::default(); + let d = make_delegator("agy-default", "agy", "default"); + let err = resolve_model_server_for_delegator(&config, &d).unwrap_err(); + assert!( + matches!(err, ResolutionError::UnknownLlmTool(ref tool) if tool == "agy"), + "got {err:?}" + ); + } + fn make_remote_config(host_name: &str) -> Config { let mut config = Config::default(); config.hosts.push(crate::config::RemoteHost { diff --git a/src/agents/judge.rs b/src/agents/judge.rs new file mode 100644 index 00000000..6d33b90d --- /dev/null +++ b/src/agents/judge.rs @@ -0,0 +1,265 @@ +//! LLM judge phase for multi-agent steps. +//! +//! The sync loop is synchronous and holds a `State` snapshot it saves whole, +//! so the judge runs as a detached task that reports through a side file keyed +//! by attempt id (see `StepManager::write_judge_outcome`). The loop polls that +//! file each tick and applies the deterministic rule on failure or deadline. + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; + +use crate::config::Config; +use crate::llm::native::{judge_checked, JudgeOutcome, JudgeVerdict, NativeLlm, NativeLlmError}; +use crate::state::JudgeAttempt; +use crate::templates::step_type::JudgePlan; + +pub struct ConfiguredJudge { + pub llm: Arc, + pub timeout_secs: u64, +} + +/// Builds the judge from config. `Ok(None)` = no judge configured (silent +/// deterministic path); `Err` = configured but unusable (noted in history). +pub type JudgeFactory = + Arc Result, NativeLlmError> + Send + Sync>; + +pub fn default_judge_factory() -> JudgeFactory { + Arc::new(judge_from_config) +} + +fn judge_from_config(config: &Config) -> Result, NativeLlmError> { + let Some(judge) = config.native_llm.judge.as_ref() else { + return Ok(None); + }; + build_judge(config, judge).map(|llm| { + Some(ConfiguredJudge { + llm, + timeout_secs: judge.timeout_secs, + }) + }) +} + +#[cfg(feature = "native-llm")] +fn build_judge( + config: &Config, + judge: &crate::config::JudgeConfig, +) -> Result, NativeLlmError> { + let server = crate::agents::delegator_resolution::resolve_model_server_by_name( + config, + &judge.model_server, + ) + .map_err(|e| NativeLlmError::Config(e.to_string()))?; + let policy = crate::auth::egress::EgressPolicy::from_config(config); + let llm = crate::llm::native::rig::RigJudge::new( + &server, + &judge.model, + &policy, + std::time::Duration::from_secs(judge.timeout_secs), + )?; + Ok(Arc::new(llm)) +} + +#[cfg(not(feature = "native-llm"))] +fn build_judge( + _config: &Config, + _judge: &crate::config::JudgeConfig, +) -> Result, NativeLlmError> { + Err(NativeLlmError::Config( + "operator was built without the native-llm feature".to_string(), + )) +} + +/// Task body: call the judge under a timeout and translate its pick back to +/// the aggregator's index. Never fails; failures become `JudgeOutcome::Failed`. +pub async fn run_judge( + llm: Arc, + plan: JudgePlan, + timeout_secs: u64, +) -> JudgeOutcome { + let call = judge_checked(llm.as_ref(), &plan.request); + let result = + match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), call).await { + Ok(r) => r, + Err(_) => Err(NativeLlmError::Timeout(timeout_secs)), + }; + match result { + Ok(verdict) => match plan.aggregate_index(verdict.winner_index) { + Some(winner_index) => JudgeOutcome::Verdict(JudgeVerdict { + winner_index, + rationale: verdict.rationale, + }), + None => JudgeOutcome::Failed { + reason: NativeLlmError::OutOfRange { + index: verdict.winner_index, + len: plan.aggregate_indices.len(), + } + .to_string(), + }, + }, + Err(e) => JudgeOutcome::Failed { + reason: e.to_string(), + }, + } +} + +/// What the sync loop does with a group in the judging phase this tick. +#[derive(Debug, Clone, PartialEq)] +pub enum JudgingStep { + Wait, + /// Finalize with the judge's pick (an aggregator index). + Apply(JudgeVerdict), + /// Finalize with the deterministic rule; the reason goes to ticket history. + Fallback(String), +} + +pub fn judging_step( + attempt: &JudgeAttempt, + outcome: Option, + now: DateTime, +) -> JudgingStep { + match outcome { + Some(JudgeOutcome::Verdict(v)) => JudgingStep::Apply(v), + Some(JudgeOutcome::Failed { reason }) => JudgingStep::Fallback(reason), + None if now >= attempt.deadline() => JudgingStep::Fallback(format!( + "no verdict by deadline (attempt {})", + attempt.attempt_id + )), + None => JudgingStep::Wait, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::llm::native::fake::FakeNativeLlm; + use crate::llm::native::{JudgeCandidate, JudgeRequest, JudgeVerdict}; + use async_trait::async_trait; + + fn plan(aggregate_indices: Vec) -> JudgePlan { + JudgePlan { + request: JudgeRequest { + instruction: "pick".to_string(), + candidates: aggregate_indices + .iter() + .map(|i| JudgeCandidate { + label: i.to_string(), + text: format!("answer {i}"), + }) + .collect(), + }, + aggregate_indices, + } + } + + fn verdict(i: usize) -> JudgeVerdict { + JudgeVerdict { + winner_index: i, + rationale: "r".to_string(), + } + } + + fn attempt(started_at: DateTime) -> JudgeAttempt { + JudgeAttempt { + attempt_id: "att".to_string(), + started_at, + timeout_secs: 10, + } + } + + #[tokio::test] + async fn run_judge_maps_candidate_index_to_aggregate_index() { + let llm = Arc::new(FakeNativeLlm(Ok(verdict(1)))); + let outcome = run_judge(llm, plan(vec![0, 2]), 5).await; + assert_eq!(outcome, JudgeOutcome::Verdict(verdict(2))); + } + + #[tokio::test] + async fn run_judge_turns_errors_into_failed_outcome() { + let llm = Arc::new(FakeNativeLlm(Err(NativeLlmError::NoToolCall))); + let outcome = run_judge(llm, plan(vec![0, 1]), 5).await; + assert!(matches!(outcome, JudgeOutcome::Failed { reason } if reason.contains("submit"))); + } + + #[tokio::test] + async fn run_judge_rejects_out_of_range_pick() { + let llm = Arc::new(FakeNativeLlm(Ok(verdict(5)))); + let outcome = run_judge(llm, plan(vec![0, 1]), 5).await; + assert!(matches!(outcome, JudgeOutcome::Failed { .. })); + } + + struct NeverAnswers; + + #[async_trait] + impl NativeLlm for NeverAnswers { + async fn judge(&self, _: &JudgeRequest) -> Result { + std::future::pending().await + } + } + + #[tokio::test(start_paused = true)] + async fn run_judge_times_out() { + let outcome = run_judge(Arc::new(NeverAnswers), plan(vec![0, 1]), 3).await; + assert_eq!( + outcome, + JudgeOutcome::Failed { + reason: NativeLlmError::Timeout(3).to_string() + } + ); + } + + #[test] + fn judging_step_applies_verdict() { + let a = attempt(Utc::now()); + let step = judging_step(&a, Some(JudgeOutcome::Verdict(verdict(1))), Utc::now()); + assert_eq!(step, JudgingStep::Apply(verdict(1))); + } + + #[test] + fn judging_step_falls_back_on_failure() { + let a = attempt(Utc::now()); + let failed = JudgeOutcome::Failed { + reason: "boom".to_string(), + }; + assert_eq!( + judging_step(&a, Some(failed), Utc::now()), + JudgingStep::Fallback("boom".to_string()) + ); + } + + #[test] + fn judging_step_waits_before_deadline() { + let a = attempt(Utc::now()); + assert_eq!(judging_step(&a, None, Utc::now()), JudgingStep::Wait); + } + + #[test] + fn judging_step_falls_back_after_deadline_when_task_is_gone() { + // e.g. the daemon restarted mid-judge: no task will ever write the file + let a = attempt(Utc::now() - chrono::Duration::hours(1)); + assert!(matches!( + judging_step(&a, None, Utc::now()), + JudgingStep::Fallback(_) + )); + } + + #[test] + fn factory_returns_none_without_judge_config() { + assert!(judge_from_config(&Config::default()).unwrap().is_none()); + } + + #[cfg(not(feature = "native-llm"))] + #[test] + fn factory_errors_when_configured_but_feature_off() { + let mut config = Config::default(); + config.native_llm.judge = Some(crate::config::JudgeConfig { + model_server: "anthropic-api".to_string(), + model: "claude-sonnet-5".to_string(), + timeout_secs: 10, + }); + assert!(matches!( + judge_from_config(&config), + Err(NativeLlmError::Config(_)) + )); + } +} diff --git a/src/agents/launcher/coder.rs b/src/agents/launcher/coder.rs index 656a4932..a40a0d06 100644 --- a/src/agents/launcher/coder.rs +++ b/src/agents/launcher/coder.rs @@ -304,7 +304,7 @@ fn download_coder_cli(base_url: &str, dest: &Path) -> Result { ) })?; - let client = reqwest::blocking::Client::builder() + let client = crate::http_client::blocking_client_builder() .timeout(std::time::Duration::from_secs( CODER_CLI_DOWNLOAD_TIMEOUT_SECS, )) diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index eb797973..f265d81c 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -77,8 +77,7 @@ fn build_llm_command_impl( ); } - // Build model flag based on tool's arg_mapping - let model_flag = format!("--model {model} "); + let model_flag = model_flag_for(tool_name, model); // Generate config flags from permissions let config_flags = if let (Some(ticket), Some(project_path)) = (ticket, project_path) { @@ -692,6 +691,22 @@ fn locate_relay_command() -> Option<(PathBuf, Vec)> { None } +/// Build `{{model_flag}}` from the tool config's `arg_mapping.model`. +/// +/// Falls back to `--model` only when the tool has no config (or an empty mapping), +/// so a cached DetectedTool without a JSON still produces a usable command. +fn model_flag_for(tool_name: &str, model: &str) -> String { + let flag = crate::llm::tool_config::load_all_tool_configs() + .into_iter() + .find(|config| config.tool_name == tool_name) + .map(|config| config.arg_mapping.model) + .filter(|flag| !flag.is_empty()); + match flag.as_deref() { + Some(flag) => format!("{flag} {model} "), + None => format!("--model {model} "), + } +} + /// Get the detected tool for a given provider fn get_detected_tool<'a>(config: &'a Config, tool_name: &str) -> Option<&'a DetectedTool> { config @@ -1299,6 +1314,82 @@ mod tests { ); } + #[test] + fn test_build_llm_command_uses_tool_arg_mapping_for_model_flag() { + // Codex's builtin arg_mapping.model is `-m`. A template that uses + // {{model_flag}} must not get the hardcoded `--model` the local path + // used to inject. + let tool = DetectedTool { + name: "codex".to_string(), + path: "/usr/bin/codex".to_string(), + version: "0.1.0".to_string(), + min_version: None, + version_ok: true, + model_aliases: vec!["gpt-4o".to_string()], + command_template: "codex {{model_flag}}exec \"$(cat {{prompt_file}})\"".to_string(), + capabilities: crate::config::ToolCapabilities::default(), + yolo_flags: vec![], + health_ok: true, + }; + let config = make_test_config_with_tool(tool); + + let cmd = build_llm_command_impl( + &config, + "codex", + "gpt-4o", + "sess-1", + Path::new("/tmp/prompt.md"), + None, + None, + None, + &|_| true, + ) + .expect("codex is detected and healthy"); + + assert!( + cmd.contains("-m gpt-4o"), + "model flag should come from arg_mapping, got: {cmd}" + ); + assert!( + !cmd.contains("--model gpt-4o"), + "must not hardcode --model, got: {cmd}" + ); + } + + #[test] + fn test_build_llm_command_grok_uses_dash_m_and_session_id() { + let tool = DetectedTool { + name: "grok".to_string(), + path: "/usr/bin/grok".to_string(), + version: "0.1.0".to_string(), + min_version: None, + version_ok: true, + model_aliases: vec!["grok-4".to_string()], + command_template: + "grok {{config_flags}}{{model_flag}}--session-id {{session_id}} \"$(cat {{prompt_file}})\"" + .to_string(), + capabilities: crate::config::ToolCapabilities::default(), + yolo_flags: vec!["--always-approve".to_string()], + health_ok: true, + }; + let config = make_test_config_with_tool(tool); + let cmd = build_llm_command_impl( + &config, + "grok", + "grok-4", + "sess-g", + Path::new("/tmp/prompt.md"), + None, + None, + None, + &|_| true, + ) + .expect("grok is detected"); + assert!(cmd.contains("-m grok-4"), "got: {cmd}"); + assert!(cmd.contains("--session-id sess-g"), "got: {cmd}"); + assert!(cmd.starts_with("grok "), "got: {cmd}"); + } + #[test] fn test_build_llm_command_template_interpolation() { let tool = make_detected_tool(); diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index db148ccb..b52ac7dc 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -493,6 +493,16 @@ impl Launcher { .map_or("claude", |p| p.tool.as_str()); let git_provider = Self::resolve_git_provider(&self.config, &working_dir_str).await; remote::run_preflight(&host, tool, git_provider)?; + if let Some(provider) = &options.provider { + let missing = prompt::missing_env_refs(&provider.env); + if !missing.is_empty() { + anyhow::bail!( + "Cannot launch on remote host '{}': Operator is missing {} (needed for the model server). Set it in the control-plane environment.", + host.name, + missing.join(", ") + ); + } + } } // Dispatch based on session wrapper type @@ -1741,6 +1751,16 @@ impl Launcher { .map_or("claude", |p| p.tool.as_str()); let git_provider = Self::resolve_git_provider(&self.config, &working_dir_str).await; remote::run_preflight(&host, tool, git_provider)?; + if let Some(provider) = &options.launch_options.provider { + let missing = prompt::missing_env_refs(&provider.env); + if !missing.is_empty() { + anyhow::bail!( + "Cannot launch on remote host '{}': Operator is missing {} (needed for the model server). Set it in the control-plane environment.", + host.name, + missing.join(", ") + ); + } + } } // Dispatch based on session wrapper type diff --git a/src/agents/launcher/prompt.rs b/src/agents/launcher/prompt.rs index ef35221a..6a3a9090 100644 --- a/src/agents/launcher/prompt.rs +++ b/src/agents/launcher/prompt.rs @@ -263,8 +263,11 @@ pub fn write_command_file( format!("exec {llm_command}\n") }; + let secret_env_block = + "if [ -f \"$0.env\" ]; then set -a; . \"$0.env\"; set +a; rm -f \"$0.env\"; fi\n"; + let script_content = format!( - "#!/bin/bash\n{env_block}{provider_block}{strip_block}{git_block}{pane_title}cd {} || exit 1\n{}", + "#!/bin/bash\n{secret_env_block}{env_block}{provider_block}{strip_block}{git_block}{pane_title}cd {} || exit 1\n{}", shell_escape(project_path), run ); @@ -294,9 +297,8 @@ pub fn write_command_file( /// /// Keys are sorted for deterministic output. Values are shell-escaped, *except* /// a pure shell-variable reference like `${OLLAMA_API_KEY}` is emitted unquoted -/// so the shell expands it at run time - this lets an API key be passed by -/// reference (inherited from operator's env) without writing the secret value -/// into the on-disk command script. +/// so the shell expands it at run time. This lets an API key be passed by +/// reference (inherited from operator's env) without writing the secret value into the on-disk command script. fn render_env_exports(env: &std::collections::HashMap) -> String { let mut keys: Vec<&String> = env.keys().collect(); keys.sort(); @@ -313,18 +315,69 @@ fn render_env_exports(env: &std::collections::HashMap) -> String out } +/// Env-var names referenced as `${NAME}` in a provider env map that are unset +/// in this process. Remote launches fail closed on a non-empty result. +pub(crate) fn missing_env_refs(env: &std::collections::HashMap) -> Vec { + let mut missing: Vec = env + .values() + .filter_map(|value| shell_var_name(value)) + .filter(|name| std::env::var(name).is_err()) + .map(str::to_string) + .collect(); + missing.sort(); + missing.dedup(); + missing +} + +/// Write resolved API-key values next to a payload script (`{payload}.env`, mode 0600). +/// The payload sources and deletes this file at start. Never called for values that are not `${VAR}` references. +pub(crate) fn write_secret_env_file( + command_file: &std::path::Path, + provider_env: &std::collections::HashMap, +) -> Result> { + let mut lines = String::new(); + let mut keys: Vec<&String> = provider_env.keys().collect(); + keys.sort(); + for key in keys { + let Some(var) = shell_var_name(&provider_env[key]) else { + continue; + }; + let value = std::env::var(var).with_context(|| { + format!("Operator environment is missing {var}, needed to launch on a remote target") + })?; + lines.push_str(&format!("export {key}={}\n", shell_escape(&value))); + } + if lines.is_empty() { + return Ok(None); + } + let env_file = std::path::PathBuf::from(format!("{}.env", command_file.display())); + fs::write(&env_file, lines).context("Failed to write secret env file")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&env_file, fs::Permissions::from_mode(0o600)) + .context("Failed to set secret env file permissions")?; + } + Ok(Some(env_file)) +} + /// Whether a value is exactly a single shell-variable reference like `${FOO}` /// (a valid env-var name in braces). Such values are emitted unquoted so the /// shell expands them; anything else is shell-escaped. fn is_shell_var_reference(value: &str) -> bool { - let Some(inner) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) else { - return false; - }; - !inner.is_empty() - && inner - .chars() - .enumerate() - .all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())) + shell_var_name(value).is_some() +} + +fn shell_var_name(value: &str) -> Option<&str> { + let inner = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))?; + if inner.is_empty() { + return None; + } + let valid = inner + .chars() + .enumerate() + .all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())); + valid.then_some(inner) } /// Shell-escape only when the value contains characters outside a @@ -701,6 +754,37 @@ mod tests { assert!(content.contains("export OPENAI_BASE_URL='http://gpu:8000'")); } + #[test] + fn test_missing_env_refs_reports_unset_vars() { + let mut env = std::collections::HashMap::new(); + env.insert("OPENAI_API_KEY".into(), "${OPR_TEST_MISSING_KEY}".into()); + env.insert("OPENAI_BASE_URL".into(), "http://localhost".into()); + let missing = missing_env_refs(&env); + assert_eq!(missing, vec!["OPR_TEST_MISSING_KEY".to_string()]); + } + + #[test] + fn test_write_secret_env_file_resolves_and_sets_mode() { + use tempfile::tempdir; + std::env::set_var("OPR_TEST_SECRET_ENV", "s3cret"); + let dir = tempdir().unwrap(); + let command_file = dir.path().join("sess.sh"); + std::fs::write(&command_file, "#!/bin/bash\n").unwrap(); + let mut env = std::collections::HashMap::new(); + env.insert("OPENAI_API_KEY".into(), "${OPR_TEST_SECRET_ENV}".into()); + env.insert("OPENAI_BASE_URL".into(), "http://gpu:8000".into()); + let written = write_secret_env_file(&command_file, &env) + .unwrap() + .expect("secret file written"); + assert_eq!(written, dir.path().join("sess.sh.env")); + let content = std::fs::read_to_string(&written).unwrap(); + assert!(content.contains("export OPENAI_API_KEY=")); + assert!(content.contains("s3cret")); + assert!(!content.contains("OPENAI_BASE_URL")); + std::env::remove_var("OPR_TEST_SECRET_ENV"); + let _ = std::fs::remove_file(written); + } + #[test] fn test_is_shell_var_reference() { assert!(is_shell_var_reference("${FOO}")); @@ -731,7 +815,9 @@ mod tests { let content = std::fs::read_to_string(result.unwrap()).unwrap(); assert!(!content.contains("OPERATOR_")); assert!(!content.contains("\\033]2;")); - assert!(content.starts_with("#!/bin/bash\ncd")); + assert!(content.starts_with("#!/bin/bash\n")); + assert!(content.contains("if [ -f \"$0.env\" ]")); + assert!(content.contains("cd '/path/to/project'")); } #[test] fn command_payload_applies_git_identity_and_removes_runtime() { diff --git a/src/agents/launcher/remote.rs b/src/agents/launcher/remote.rs index 5d8c333e..7606dc1e 100644 --- a/src/agents/launcher/remote.rs +++ b/src/agents/launcher/remote.rs @@ -233,6 +233,7 @@ pub(crate) fn build_remote_wrapper_script( local_prompt: &Path, local_payload: &Path, api_port: u16, + local_env: Option<&Path>, ) -> String { let alias = shell_escape(&host.ssh_alias); let f_flag = ssh_config_flag(host); @@ -250,8 +251,23 @@ pub(crate) fn build_remote_wrapper_script( shell_escape(&format!("bash {}", shell_escape(&r_payload))), ); + let env_ship = local_env + .map(|path| { + format!( + "ssh {f}{alias} {cat_env} < {local_env}\n", + f = f_flag, + alias = alias, + cat_env = shell_escape(&format!( + "cat > {}", + shell_escape(&format!("{r_payload}.env")) + )), + local_env = shell_escape(&path.display().to_string()), + ) + }) + .unwrap_or_default(); + format!( - "#!/bin/bash\nset -e\nssh {f}{alias} {mkdir}\nssh {f}{alias} {cat_prompt} < {local_prompt}\nssh {f}{alias} {cat_payload} < {local_payload}\nexec ssh -t {f}-R {port}:localhost:{port} -o ExitOnForwardFailure=yes {alias} {tmux}\n", + "#!/bin/bash\nset -e\nssh {f}{alias} {mkdir}\nssh {f}{alias} {cat_prompt} < {local_prompt}\nssh {f}{alias} {cat_payload} < {local_payload}\n{env_ship}exec ssh -t {f}-R {port}:localhost:{port} -o ExitOnForwardFailure=yes {alias} {tmux}\n", f = f_flag, alias = alias, mkdir = shell_escape(&mkdir_cmd), @@ -349,6 +365,7 @@ pub(crate) fn launch_remote_in_session( Some(operator_env), Some(&provider_env), )?; + let secret_env = super::prompt::write_secret_env_file(&payload_file, &provider_env)?; reconcile_git_runtime(config, host); let runtime_pointer = payload_file.with_extension("git-runtime"); @@ -370,6 +387,7 @@ pub(crate) fn launch_remote_in_session( prompt_file, &payload_file, operator_env.ui_port, + secret_env.as_deref(), ); let wrapper_file = write_remote_wrapper_file(config, session_uuid, &wrapper_content)?; @@ -604,6 +622,7 @@ mod tests { Path::new("/local/.tickets/operator/prompts/uuid-1.txt"), Path::new("/local/.tickets/operator/commands/uuid-1.sh"), 7008, + None, ); assert!(script.starts_with("#!/bin/bash\nset -e\n")); // Ships both files via `cat >` before the exec line. @@ -635,6 +654,7 @@ mod tests { Path::new("/l/p.txt"), Path::new("/l/c.sh"), 7008, + None, ); // The workdir must never appear unquoted (space-split) in any remote command. assert!(!script.contains(" /srv/agent workdir/proj/")); @@ -652,6 +672,7 @@ mod tests { Path::new("/l/p.txt"), Path::new("/l/c.sh"), 7008, + None, ); assert!( script.contains("-F '/local/.tickets/operator/ssh/ws.config'"), diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index 187ea961..5b22a673 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -1803,7 +1803,7 @@ fn test_launch_provider_from_delegator_determines_tool() { "Command should use codex tool, got: {script_content}" ); assert!( - script_content.contains("--model o3"), + script_content.contains("-m o3"), "Command should use o3 model, got: {script_content}" ); } diff --git a/src/agents/mod.rs b/src/agents/mod.rs index ff920978..b7219697 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -9,6 +9,7 @@ pub mod delegator_resolution; mod generator; pub mod hooks; pub mod idle_detector; +mod judge; pub(crate) mod launcher; pub use launcher::step_command::StepLaunchContext; mod monitor; diff --git a/src/agents/sync.rs b/src/agents/sync.rs index 5cb8fe7d..fe510bdc 100644 --- a/src/agents/sync.rs +++ b/src/agents/sync.rs @@ -19,12 +19,18 @@ use anyhow::{Context, Result}; use super::monitor::{HealthCheckResult, SessionMonitor}; use super::tmux::TmuxClient; use super::visual_review::VisualReviewHandler; +use crate::agents::judge::{ + default_judge_factory, judging_step, run_judge, JudgeFactory, JudgingStep, +}; use crate::agents::launcher::worktree_setup::cleanup_ticket_worktree; use crate::agents::ProofResult; use crate::config::Config; +use crate::llm::native::JudgeVerdict; use crate::queue::{Queue, StepAdvanceResult, Ticket}; -use crate::state::{AgentState, State}; -use crate::templates::schema::ReviewType; +use crate::state::{AgentState, MultiAgentGroup, State}; +use crate::steps::manager::StepManager; +use crate::templates::schema::{ReviewType, StepSchema}; +use crate::templates::step_type; /// Status message for a finished proof run - mirrors the strings the /// `complete_step` proof hook (`rest/routes/launch.rs`) produces, so the @@ -54,6 +60,49 @@ fn read_proof_result_message(result_path: &std::path::Path, proof_ref: &str) -> Some(proof_result_message(&result, proof_ref)) } +/// Deterministic aggregation of a finished group's outputs by step type. +fn aggregate_outputs( + group: &MultiAgentGroup, + step_schema: Option<&StepSchema>, +) -> serde_json::Value { + let outputs = &group.individual_outputs; + match group.step_type.as_str() { + "multi_model" => step_schema + .and_then(|s| s.multi_model_config.as_ref()) + .map_or(serde_json::Value::Null, |cfg| { + step_type::aggregate_multi_model(outputs, cfg) + }), + "multi_prompt" => step_schema + .and_then(|s| s.multi_prompt_config.as_ref()) + .map_or(serde_json::Value::Null, |cfg| { + step_type::aggregate_multi_prompt(outputs, cfg) + }), + "matrixed" => step_schema + .and_then(|s| s.matrixed_config.as_ref()) + .map_or(serde_json::Value::Null, |cfg| { + step_type::aggregate_matrixed(outputs, cfg, &group.step_name) + }), + other => { + tracing::warn!( + step_type = other, + "unknown multi-agent step_type, skipping aggregation" + ); + serde_json::Value::Null + } + } +} + +fn note_history(ticket: &mut Ticket, message: &str, result: &mut SyncResult) { + if let Err(e) = ticket.append_history(&format!( + "- **{}** - {message}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), + )) { + result + .errors + .push(format!("Failed to add history for {}: {e}", ticket.id)); + } +} + /// Result of a sync cycle #[derive(Debug, Default)] pub struct SyncResult { @@ -96,6 +145,7 @@ pub struct TicketSessionSync { tmux: Arc, last_sync: Instant, sync_interval: Duration, + judge_factory: JudgeFactory, } impl TicketSessionSync { @@ -108,9 +158,16 @@ impl TicketSessionSync { .checked_sub(Duration::from_secs(config.agents.sync_interval)) .unwrap_or_else(Instant::now), sync_interval: Duration::from_secs(config.agents.sync_interval), + judge_factory: default_judge_factory(), } } + #[cfg(test)] + fn with_judge_factory(mut self, judge_factory: JudgeFactory) -> Self { + self.judge_factory = judge_factory; + self + } + /// Check if it's time to run a sync pub fn should_sync(&self) -> bool { self.last_sync.elapsed() >= self.sync_interval @@ -510,8 +567,6 @@ impl TicketSessionSync { result: &mut SyncResult, ) -> Result<()> { use crate::state::MultiAgentPhase; - use crate::steps::manager::StepManager; - use crate::templates::step_type; // Snapshot the group so we can iterate without holding a borrow on state. let group = state @@ -522,9 +577,19 @@ impl TicketSessionSync { let group_id = group.group_id.clone(); let step_name = group.step_name.clone(); - // Only process sub-agents while the group is still in fan-out phase. - if group.phase != MultiAgentPhase::FanOut { - return Ok(()); + match group.phase { + MultiAgentPhase::FanOut => {} + MultiAgentPhase::Voting => { + let step_schema = ticket.current_step_schema(); + return self.sync_judging_group( + ticket, + state, + &group, + step_schema.as_ref(), + result, + ); + } + MultiAgentPhase::Complete | MultiAgentPhase::Failed => return Ok(()), } // Process each launched sub-agent's health-check action. @@ -563,105 +628,16 @@ impl TicketSessionSync { anyhow::anyhow!("group {group_id} missing after all_done") })?; - // Load the step schema to get the config for aggregation. let step_schema = ticket.current_step_schema(); - let aggregated = match group.step_type.as_str() { - "multi_model" => step_schema - .as_ref() - .and_then(|s| s.multi_model_config.as_ref()) - .map_or(serde_json::Value::Null, |cfg| { - step_type::aggregate_multi_model( - &finished.individual_outputs, - cfg, - ) - }), - "multi_prompt" => step_schema - .as_ref() - .and_then(|s| s.multi_prompt_config.as_ref()) - .map_or(serde_json::Value::Null, |cfg| { - step_type::aggregate_multi_prompt( - &finished.individual_outputs, - cfg, - ) - }), - "matrixed" => step_schema - .as_ref() - .and_then(|s| s.matrixed_config.as_ref()) - .map_or(serde_json::Value::Null, |cfg| { - step_type::aggregate_matrixed( - &finished.individual_outputs, - cfg, - &step_name, - ) - }), - other => { - tracing::warn!( - step_type = other, - "unknown multi-agent step_type, skipping aggregation" - ); - serde_json::Value::Null - } - }; - - // Persist the aggregated artifact for the next step to read. - StepManager::write_step_output_artifact(ticket, &step_name, &aggregated)?; - - // Mark the group complete with the aggregated result. - state.complete_group(&group_id, aggregated)?; - - // Advance the ticket's step exactly once for the group. - let step_display = ticket.current_step_display_name(); - match ticket.advance_step() { - Ok(StepAdvanceResult::Advanced { step, .. }) => { - if let Err(e) = ticket.append_history(&format!( - "- **{}** - Multi-agent step \"{}\" completed, advancing to \"{}\"", - chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), - step_display, - step, - )) { - result.errors.push(format!( - "Failed to add history for {}: {e}", - ticket.id - )); - } - tracing::info!( - ticket_id = %ticket.id, - step = %step_display, - next = %step, - "Multi-agent step aggregated, advanced" - ); - } - Ok(StepAdvanceResult::FinalStep) => { - tracing::info!( - ticket_id = %ticket.id, - step = %step_display, - "Multi-agent final step completed" - ); - } - Err(e) => { - result - .errors - .push(format!("Failed to advance step for {}: {e}", ticket.id)); - } - } - - // Remove all sub-agent records now that the group is done. - // Coder targets: stop each finished workspace first. - for aid in &agent_ids { - if let Some(agent) = state.agents.iter().find(|a| &a.id == aid).cloned() - { - crate::agents::launcher::coder::stop_on_complete_for_agent( - &self.config, - &agent, - ); - } - state.remove_agent(aid)?; - } - state.cleanup_finished_groups()?; - - result.completed.push(ticket.id.clone()); + self.aggregate_or_start_judging( + ticket, + state, + &finished, + step_schema.as_ref(), + result, + )?; // Other sub-agents (if any) were already completing; - // we've recorded the aggregation, exit the loop. + // the group is now judging or finalized, exit the loop. break; } } @@ -686,6 +662,196 @@ impl TicketSessionSync { Ok(()) } + /// All sub-agents reported. Start the LLM judge when the step asks for + /// model-based selection and a judge is usable; otherwise finalize now + /// with the deterministic rule. + fn aggregate_or_start_judging( + &mut self, + ticket: &mut Ticket, + state: &mut State, + group: &MultiAgentGroup, + step_schema: Option<&StepSchema>, + result: &mut SyncResult, + ) -> Result<()> { + let plan = match (step_schema, ticket.worktree_path.clone()) { + (Some(schema), Some(worktree)) => { + let render = |t: &str| { + StepManager::render_ticket_template(t, ticket).unwrap_or_else(|_| t.to_string()) + }; + step_type::judge_plan(schema, &group.individual_outputs, &render) + .map(|plan| (plan, worktree)) + } + _ => None, + }; + let Some((plan, worktree)) = plan else { + return self.finalize_group(ticket, state, group, step_schema, None, result); + }; + + let judge = match (self.judge_factory)(&self.config) { + Ok(Some(judge)) => judge, + Ok(None) => { + return self.finalize_group(ticket, state, group, step_schema, None, result) + } + Err(e) => { + note_history( + ticket, + &format!("Judge unavailable ({e}); used deterministic selection"), + result, + ); + return self.finalize_group(ticket, state, group, step_schema, None, result); + } + }; + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + note_history( + ticket, + "Judge unavailable (no async runtime); used deterministic selection", + result, + ); + return self.finalize_group(ticket, state, group, step_schema, None, result); + }; + + let attempt = state.begin_judging(&group.group_id, judge.timeout_secs)?; + let step_name = group.step_name.clone(); + let ticket_id = ticket.id.clone(); + tracing::info!( + ticket_id = %ticket_id, + step = %step_name, + attempt = %attempt.attempt_id, + "Multi-agent step judging" + ); + runtime.spawn(async move { + let outcome = run_judge(judge.llm, plan, attempt.timeout_secs).await; + if let Err(e) = StepManager::write_judge_outcome( + &worktree, + &step_name, + &attempt.attempt_id, + &outcome, + ) { + // The sync loop's deadline fallback still finalizes the group. + tracing::warn!(ticket_id = %ticket_id, error = %e, "Failed to write judge outcome"); + } + }); + Ok(()) + } + + /// Poll a judging group: finalize on a verdict, on a failure, or once the + /// attempt's deadline passes (covers a daemon restart mid-judge). + fn sync_judging_group( + &mut self, + ticket: &mut Ticket, + state: &mut State, + group: &MultiAgentGroup, + step_schema: Option<&StepSchema>, + result: &mut SyncResult, + ) -> Result<()> { + let step = match group.judge_attempt.as_ref() { + Some(attempt) => judging_step( + attempt, + StepManager::read_judge_outcome(ticket, &group.step_name, &attempt.attempt_id), + chrono::Utc::now(), + ), + None => JudgingStep::Fallback("judging phase without an attempt".to_string()), + }; + match step { + JudgingStep::Wait => Ok(()), + JudgingStep::Apply(verdict) => { + self.finalize_group(ticket, state, group, step_schema, Some(&verdict), result) + } + JudgingStep::Fallback(reason) => { + note_history( + ticket, + &format!("Judge fell back to deterministic selection: {reason}"), + result, + ); + self.finalize_group(ticket, state, group, step_schema, None, result) + } + } + } + + /// Aggregate (applying the judge's pick if any), write the step artifact, + /// complete the group, advance the ticket once, and retire the sub-agents. + fn finalize_group( + &mut self, + ticket: &mut Ticket, + state: &mut State, + group: &MultiAgentGroup, + step_schema: Option<&StepSchema>, + judged: Option<&JudgeVerdict>, + result: &mut SyncResult, + ) -> Result<()> { + let mut aggregated = aggregate_outputs(group, step_schema); + if let (Some(verdict), Some(schema)) = (judged, step_schema) { + let message = if step_type::apply_judge_verdict( + &mut aggregated, + schema, + verdict.winner_index, + &verdict.rationale, + ) { + format!( + "Judge selected candidate {}: {}", + verdict.winner_index, verdict.rationale + ) + } else { + format!( + "Judge verdict {} did not fit the step; used deterministic selection", + verdict.winner_index + ) + }; + note_history(ticket, &message, result); + } + + // Persist the aggregated artifact for the next step to read. + StepManager::write_step_output_artifact(ticket, &group.step_name, &aggregated)?; + + // Mark the group complete with the aggregated result. + state.complete_group(&group.group_id, aggregated)?; + + // Advance the ticket's step exactly once for the group. + let step_display = ticket.current_step_display_name(); + match ticket.advance_step() { + Ok(StepAdvanceResult::Advanced { step, .. }) => { + note_history( + ticket, + &format!( + "Multi-agent step \"{step_display}\" completed, advancing to \"{step}\"" + ), + result, + ); + tracing::info!( + ticket_id = %ticket.id, + step = %step_display, + next = %step, + "Multi-agent step aggregated, advanced" + ); + } + Ok(StepAdvanceResult::FinalStep) => { + tracing::info!( + ticket_id = %ticket.id, + step = %step_display, + "Multi-agent final step completed" + ); + } + Err(e) => { + result + .errors + .push(format!("Failed to advance step for {}: {e}", ticket.id)); + } + } + + // Remove all sub-agent records now that the group is done. + // Coder targets: stop each finished workspace first. + for aid in &group.agent_ids { + if let Some(agent) = state.agents.iter().find(|a| &a.id == aid).cloned() { + crate::agents::launcher::coder::stop_on_complete_for_agent(&self.config, &agent); + } + state.remove_agent(aid)?; + } + state.cleanup_finished_groups()?; + + result.completed.push(ticket.id.clone()); + Ok(()) + } + /// Determine what sync action to take for a ticket based on health check results fn determine_action( &self, @@ -1411,4 +1577,299 @@ mod tests { assert_ne!(action, SyncAction::NoChange); assert_ne!(action, SyncAction::MovedToAwaiting); } + + // ── multi-agent judge phase ───────────────────────────────────── + + mod judge_phase { + use super::*; + use crate::agents::judge::ConfiguredJudge; + use crate::llm::native::fake::FakeNativeLlm; + use crate::llm::native::{JudgeOutcome, NativeLlmError}; + use crate::state::MultiAgentPhase; + + struct Fixture { + _dir: TempDir, + config: Config, + worktree: String, + ticket: Ticket, + state: State, + group_id: String, + } + + fn multi_model_schema() -> StepSchema { + serde_json::from_value(serde_json::json!({ + "name": "review", + "prompt": "Review it", + "outputs": [], + "type": "multi_model", + "multi_model_config": { + "delegators": ["a", "b"], + "voting_strategy": "majority", + "voting_mode": "single_judge" + } + })) + .unwrap() + } + + fn fixture() -> Fixture { + let dir = TempDir::new().unwrap(); + let config = make_test_config(&dir); + let worktree = dir.path().join("wt").to_string_lossy().to_string(); + std::fs::create_dir_all(&worktree).unwrap(); + let ticket_path = dir.path().join("FEAT-1.md"); + std::fs::write(&ticket_path, "# FEAT-1").unwrap(); + let ticket = Ticket { + filename: "FEAT-1.md".to_string(), + filepath: ticket_path.to_string_lossy().to_string(), + timestamp: "20241221-1430".to_string(), + ticket_type: "FEAT".to_string(), + project: "test".to_string(), + id: "FEAT-1".to_string(), + summary: "t".to_string(), + priority: "P2-medium".to_string(), + status: "running".to_string(), + step: "review".to_string(), + content: "# FEAT-1".to_string(), + sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), + llm_task: crate::queue::LlmTask::default(), + worktree_path: Some(worktree.clone()), + branch: None, + external_id: None, + external_url: None, + external_provider: None, + collection: None, + }; + let mut state = State::load(&config).unwrap(); + let group_id = state + .create_multi_agent_group("FEAT-1", "review", "multi_model", Vec::new()) + .unwrap(); + let group = state + .multi_agent_groups + .iter_mut() + .find(|g| g.group_id == group_id) + .unwrap(); + group + .individual_outputs + .insert("a".to_string(), serde_json::json!("answer A")); + group + .individual_outputs + .insert("b".to_string(), serde_json::json!("answer B")); + state.save().unwrap(); + Fixture { + _dir: dir, + config, + worktree, + ticket, + state, + group_id, + } + } + + fn sync_with(config: &Config, factory: JudgeFactory) -> TicketSessionSync { + TicketSessionSync::new(config, Arc::new(MockTmuxClient::new())) + .with_judge_factory(factory) + } + + fn judge_returning(result: Result) -> JudgeFactory { + Arc::new(move |_| { + Ok(Some(ConfiguredJudge { + llm: Arc::new(FakeNativeLlm(result.clone())), + timeout_secs: 5, + })) + }) + } + + fn group(f: &Fixture) -> MultiAgentGroup { + f.state + .multi_agent_groups + .iter() + .find(|g| g.group_id == f.group_id) + .cloned() + .unwrap() + } + + fn artifact(f: &Fixture) -> serde_json::Value { + let path = std::path::Path::new(&f.worktree).join(".tickets/steps/review.output.json"); + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() + } + + fn verdict(i: usize) -> JudgeVerdict { + JudgeVerdict { + winner_index: i, + rationale: "more thorough".to_string(), + } + } + + #[test] + fn no_judge_configured_finalizes_deterministically() { + let mut f = fixture(); + let mut sync = sync_with(&f.config, Arc::new(|_| Ok(None))); + let schema = multi_model_schema(); + let mut result = SyncResult::default(); + let g = group(&f); + + sync.aggregate_or_start_judging( + &mut f.ticket, + &mut f.state, + &g, + Some(&schema), + &mut result, + ) + .unwrap(); + + assert_eq!(artifact(&f)["winner_delegator"], "a"); + assert!(artifact(&f).get("judge").is_none()); + assert!(f.state.multi_agent_groups.is_empty()); + assert_eq!(result.completed, vec!["FEAT-1".to_string()]); + } + + #[test] + fn unusable_judge_finalizes_with_history_note() { + let mut f = fixture(); + let mut sync = sync_with( + &f.config, + Arc::new(|_| Err(NativeLlmError::Config("no key".to_string()))), + ); + let schema = multi_model_schema(); + let mut result = SyncResult::default(); + let g = group(&f); + + sync.aggregate_or_start_judging( + &mut f.ticket, + &mut f.state, + &g, + Some(&schema), + &mut result, + ) + .unwrap(); + + assert_eq!(artifact(&f)["winner_delegator"], "a"); + assert!(f.ticket.content.contains("Judge unavailable")); + assert!(f.state.multi_agent_groups.is_empty()); + } + + #[tokio::test] + async fn judge_verdict_is_applied_on_next_tick() { + let mut f = fixture(); + let mut sync = sync_with(&f.config, judge_returning(Ok(verdict(1)))); + let schema = multi_model_schema(); + let mut result = SyncResult::default(); + let g = group(&f); + + sync.aggregate_or_start_judging( + &mut f.ticket, + &mut f.state, + &g, + Some(&schema), + &mut result, + ) + .unwrap(); + let judging = group(&f); + assert_eq!(judging.phase, MultiAgentPhase::Voting); + let attempt = judging.judge_attempt.clone().unwrap(); + + // Let the spawned judge task write its outcome. + for _ in 0..100 { + if StepManager::read_judge_outcome(&f.ticket, "review", &attempt.attempt_id) + .is_some() + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + sync.sync_judging_group( + &mut f.ticket, + &mut f.state, + &judging, + Some(&schema), + &mut result, + ) + .unwrap(); + + let out = artifact(&f); + assert_eq!(out["winner_delegator"], "b"); + assert_eq!(out["value"], "answer B"); + assert_eq!(out["judge"]["rationale"], "more thorough"); + assert!(f.ticket.content.contains("Judge selected candidate 1")); + assert!(f.state.multi_agent_groups.is_empty()); + } + + #[test] + fn judging_group_waits_for_outcome_and_ignores_stale_attempts() { + let mut f = fixture(); + let mut sync = sync_with(&f.config, Arc::new(|_| Ok(None))); + let schema = multi_model_schema(); + f.state.begin_judging(&f.group_id, 60).unwrap(); + StepManager::write_judge_outcome( + &f.worktree, + "review", + "some-older-attempt", + &JudgeOutcome::Verdict(verdict(1)), + ) + .unwrap(); + let mut result = SyncResult::default(); + let g = group(&f); + + sync.sync_judging_group(&mut f.ticket, &mut f.state, &g, Some(&schema), &mut result) + .unwrap(); + + assert_eq!(group(&f).phase, MultiAgentPhase::Voting); + assert!(result.completed.is_empty()); + } + + #[test] + fn judge_failure_falls_back_with_history_note() { + let mut f = fixture(); + let mut sync = sync_with(&f.config, Arc::new(|_| Ok(None))); + let schema = multi_model_schema(); + let attempt = f.state.begin_judging(&f.group_id, 60).unwrap(); + StepManager::write_judge_outcome( + &f.worktree, + "review", + &attempt.attempt_id, + &JudgeOutcome::Failed { + reason: "HTTP 401".to_string(), + }, + ) + .unwrap(); + let mut result = SyncResult::default(); + let g = group(&f); + + sync.sync_judging_group(&mut f.ticket, &mut f.state, &g, Some(&schema), &mut result) + .unwrap(); + + assert_eq!(artifact(&f)["winner_delegator"], "a"); + assert!(f.ticket.content.contains("fell back")); + assert!(f.ticket.content.contains("HTTP 401")); + assert!(f.state.multi_agent_groups.is_empty()); + } + + #[test] + fn orphaned_attempt_past_deadline_falls_back() { + // e.g. the daemon restarted while judging: nothing will write the file + let mut f = fixture(); + let mut sync = sync_with(&f.config, Arc::new(|_| Ok(None))); + let schema = multi_model_schema(); + f.state.begin_judging(&f.group_id, 60).unwrap(); + let g = f + .state + .multi_agent_groups + .iter_mut() + .find(|g| g.group_id == f.group_id) + .unwrap(); + g.judge_attempt.as_mut().unwrap().started_at = + chrono::Utc::now() - chrono::Duration::hours(1); + let g = g.clone(); + let mut result = SyncResult::default(); + + sync.sync_judging_group(&mut f.ticket, &mut f.state, &g, Some(&schema), &mut result) + .unwrap(); + + assert_eq!(artifact(&f)["winner_delegator"], "a"); + assert!(f.ticket.content.contains("no verdict by deadline")); + assert!(f.state.multi_agent_groups.is_empty()); + } + } } diff --git a/src/api/anthropic.rs b/src/api/anthropic.rs index 4233b0fd..51fd6f30 100644 --- a/src/api/anthropic.rs +++ b/src/api/anthropic.rs @@ -136,7 +136,7 @@ impl AnthropicClient { pub fn from_env() -> Result> { match env::var("OPERATOR_ANTHROPIC_API_KEY") { Ok(key) if !key.is_empty() => { - let client = reqwest::Client::builder() + let client = crate::http_client::client_builder() .user_agent("operator-tui/0.1.0") .build() .context("Failed to build HTTP client")?; diff --git a/src/api/github.rs b/src/api/github.rs index d04d18a7..7874664b 100644 --- a/src/api/github.rs +++ b/src/api/github.rs @@ -149,7 +149,7 @@ impl GitHubClient { pub fn from_env() -> Result> { match env::var("OPERATOR_GITHUB_TOKEN") { Ok(token) if !token.is_empty() => { - let client = reqwest::Client::builder() + let client = crate::http_client::client_builder() .user_agent("operator-tui/0.1.0") .build() .context("Failed to build HTTP client")?; diff --git a/src/api/providers/ai/anthropic.rs b/src/api/providers/ai/anthropic.rs index cf61e393..34992d39 100644 --- a/src/api/providers/ai/anthropic.rs +++ b/src/api/providers/ai/anthropic.rs @@ -37,7 +37,7 @@ struct Message { impl AnthropicProvider { /// Create a new Anthropic provider with the given API key pub fn new(api_key: impl Into) -> Result { - let client = reqwest::Client::builder() + let client = crate::http_client::client_builder() .user_agent("operator-tui/0.1.0") .build() .map_err(|e| ApiError::network(PROVIDER_NAME, e.to_string()))?; diff --git a/src/api/providers/kanban/github_projects.rs b/src/api/providers/kanban/github_projects.rs index 1423c06c..13704573 100644 --- a/src/api/providers/kanban/github_projects.rs +++ b/src/api/providers/kanban/github_projects.rs @@ -118,7 +118,7 @@ impl GithubProjectsProvider { pub fn new(token: String, resolved_env_var: String) -> Self { Self { token, - client: Client::new(), + client: crate::http_client::default_client(), resolved_env_var, status_field_cache: RwLock::new(HashMap::new()), item_lookup: RwLock::new(HashMap::new()), diff --git a/src/api/providers/kanban/jira.rs b/src/api/providers/kanban/jira.rs index f3c94d45..ddd667a7 100644 --- a/src/api/providers/kanban/jira.rs +++ b/src/api/providers/kanban/jira.rs @@ -39,7 +39,7 @@ impl JiraProvider { domain, email, api_token, - client: Client::new(), + client: crate::http_client::default_client(), } } diff --git a/src/api/providers/kanban/linear.rs b/src/api/providers/kanban/linear.rs index a0ca2d8b..848a5324 100644 --- a/src/api/providers/kanban/linear.rs +++ b/src/api/providers/kanban/linear.rs @@ -45,7 +45,7 @@ impl LinearProvider { pub fn new(api_key: String) -> Self { Self { api_key, - client: Client::new(), + client: crate::http_client::default_client(), } } diff --git a/src/api/providers/model_server/mod.rs b/src/api/providers/model_server/mod.rs index a055c319..9d687b68 100644 --- a/src/api/providers/model_server/mod.rs +++ b/src/api/providers/model_server/mod.rs @@ -99,6 +99,20 @@ impl ModelProviderClass { } } +/// Read the server's API key from **this process's** environment: the +/// instance's named env var, else the kind's default (`ANTHROPIC_API_KEY` / +/// `OPENAI_API_KEY` / `GEMINI_API_KEY` / ...). Only for requests the daemon +/// itself makes (model probes, native LLM calls); agent spawns get the key by +/// reference via [`env_for_server`] instead. +pub fn resolve_api_key(server: &ModelServer, kind: ModelServerKind) -> Option { + server + .api_key_env + .as_deref() + .or_else(|| kind.default_api_key_env()) + .and_then(|var| std::env::var(var).ok()) + .filter(|k| !k.is_empty()) +} + /// A model-server protocol kind. /// /// `OpenAiCompat` is the explicit catch-all for any OpenAI-API-compatible server @@ -109,6 +123,7 @@ pub enum ModelServerKind { AnthropicApi, OpenAiApi, GoogleApi, + XaiApi, Ollama, OpenRouter, OpenAiCompat, @@ -119,10 +134,11 @@ impl ModelServerKind { /// The canonical list of supported model-server kinds, in display order. /// /// Single source of truth - every surface derives its catalog from here. - pub const ALL: [ModelServerKind; 7] = [ + pub const ALL: [ModelServerKind; 8] = [ ModelServerKind::AnthropicApi, ModelServerKind::OpenAiApi, ModelServerKind::GoogleApi, + ModelServerKind::XaiApi, ModelServerKind::Ollama, ModelServerKind::OpenRouter, ModelServerKind::OpenAiCompat, @@ -135,7 +151,8 @@ impl ModelServerKind { match self { ModelServerKind::AnthropicApi | ModelServerKind::OpenAiApi - | ModelServerKind::GoogleApi => ModelProviderClass::FirstParty, + | ModelServerKind::GoogleApi + | ModelServerKind::XaiApi => ModelProviderClass::FirstParty, ModelServerKind::Ollama | ModelServerKind::OpenRouter | ModelServerKind::OpenAiCompat @@ -151,6 +168,7 @@ impl ModelServerKind { ModelServerKind::AnthropicApi => "anthropic-api", ModelServerKind::OpenAiApi => "openai-api", ModelServerKind::GoogleApi => "google-api", + ModelServerKind::XaiApi => "xai-api", ModelServerKind::Ollama => "ollama", ModelServerKind::OpenRouter => "openrouter", ModelServerKind::OpenAiCompat => "openai-compat", @@ -169,6 +187,7 @@ impl ModelServerKind { ModelServerKind::AnthropicApi => "Anthropic API", ModelServerKind::OpenAiApi => "OpenAI API", ModelServerKind::GoogleApi => "Google Gemini API", + ModelServerKind::XaiApi => "xAI API", ModelServerKind::Ollama => "Ollama", ModelServerKind::OpenRouter => "OpenRouter", ModelServerKind::OpenAiCompat => "OpenAI-compatible", @@ -182,7 +201,10 @@ impl ModelServerKind { pub fn is_builtin(&self) -> bool { matches!( self, - ModelServerKind::AnthropicApi | ModelServerKind::OpenAiApi | ModelServerKind::GoogleApi + ModelServerKind::AnthropicApi + | ModelServerKind::OpenAiApi + | ModelServerKind::GoogleApi + | ModelServerKind::XaiApi ) } @@ -192,6 +214,7 @@ impl ModelServerKind { ModelServerKind::AnthropicApi => "Anthropic Console or a compatible proxy", ModelServerKind::OpenAiApi => "OpenAI or a compatible proxy", ModelServerKind::GoogleApi => "Google Gemini API", + ModelServerKind::XaiApi => "xAI Console (Grok)", ModelServerKind::Ollama => "Local ollama server (ollama serve)", ModelServerKind::OpenRouter => { "Hosted gateway to 300+ models (one OpenAI-compatible key)" @@ -207,6 +230,7 @@ impl ModelServerKind { ModelServerKind::AnthropicApi => "https://console.anthropic.com/settings/keys", ModelServerKind::OpenAiApi => "https://platform.openai.com/api-keys", ModelServerKind::GoogleApi => "https://aistudio.google.com/app/apikey", + ModelServerKind::XaiApi => "https://console.x.ai/", ModelServerKind::Ollama => "https://ollama.com/download", ModelServerKind::OpenRouter => "https://openrouter.ai/keys", ModelServerKind::OpenAiCompat => { @@ -223,6 +247,7 @@ impl ModelServerKind { ModelServerKind::AnthropicApi | ModelServerKind::OpenAiApi | ModelServerKind::GoogleApi + | ModelServerKind::XaiApi | ModelServerKind::OpenRouter => "cloud", // Self-hosted / local servers. ModelServerKind::Ollama | ModelServerKind::OpenAiCompat | ModelServerKind::LmStudio => { @@ -242,6 +267,7 @@ impl ModelServerKind { match self { ModelServerKind::AnthropicApi => Some("anthropic"), ModelServerKind::GoogleApi => Some("google"), + ModelServerKind::XaiApi => Some("xai"), ModelServerKind::Ollama => Some("ollama"), ModelServerKind::OpenRouter => Some("openrouter"), ModelServerKind::OpenAiApi @@ -264,6 +290,7 @@ impl ModelServerKind { ModelServerKind::OpenRouter => "/models", // OpenAI-protocol model list ModelServerKind::OpenAiApi + | ModelServerKind::XaiApi | ModelServerKind::OpenAiCompat | ModelServerKind::LmStudio => "/v1/models", // Anthropic's model list @@ -283,6 +310,7 @@ impl ModelServerKind { match self { ModelServerKind::AnthropicApi => "ANTHROPIC_BASE_URL", ModelServerKind::OpenAiApi + | ModelServerKind::XaiApi | ModelServerKind::OpenAiCompat | ModelServerKind::Ollama | ModelServerKind::OpenRouter @@ -295,6 +323,7 @@ impl ModelServerKind { pub fn api_key_env_var(&self) -> &'static str { match self { ModelServerKind::AnthropicApi => "ANTHROPIC_API_KEY", + ModelServerKind::XaiApi => "XAI_API_KEY", ModelServerKind::OpenAiApi | ModelServerKind::OpenAiCompat | ModelServerKind::Ollama @@ -318,6 +347,7 @@ impl ModelServerKind { ModelServerKind::AnthropicApi => Some("https://api.anthropic.com"), ModelServerKind::OpenAiApi => Some("https://api.openai.com"), ModelServerKind::GoogleApi => Some("https://generativelanguage.googleapis.com"), + ModelServerKind::XaiApi => Some("https://api.x.ai"), ModelServerKind::OpenRouter => Some("https://openrouter.ai/api/v1"), ModelServerKind::Ollama => Some("http://localhost:11434"), ModelServerKind::OpenAiCompat | ModelServerKind::LmStudio => None, @@ -336,6 +366,7 @@ impl ModelServerKind { ModelServerKind::AnthropicApi => Some("ANTHROPIC_API_KEY"), ModelServerKind::OpenAiApi => Some("OPENAI_API_KEY"), ModelServerKind::GoogleApi => Some("GEMINI_API_KEY"), + ModelServerKind::XaiApi => Some("XAI_API_KEY"), ModelServerKind::OpenRouter => Some("OPENROUTER_API_KEY"), ModelServerKind::Ollama | ModelServerKind::OpenAiCompat | ModelServerKind::LmStudio => { None @@ -428,7 +459,7 @@ mod tests { .collect(); assert_eq!( first_party, - vec!["anthropic-api", "openai-api", "google-api"] + vec!["anthropic-api", "openai-api", "google-api", "xai-api"] ); assert_eq!( gateways, @@ -469,13 +500,16 @@ mod tests { } #[test] - fn test_builtins_are_the_three_vendor_apis() { + fn test_builtins_are_the_vendor_apis() { let builtins: Vec<_> = ModelServerKind::ALL .into_iter() .filter(ModelServerKind::is_builtin) .map(|k| k.slug()) .collect(); - assert_eq!(builtins, vec!["anthropic-api", "openai-api", "google-api"]); + assert_eq!( + builtins, + vec!["anthropic-api", "openai-api", "google-api", "xai-api"] + ); } #[test] diff --git a/src/api/providers/model_server/probe.rs b/src/api/providers/model_server/probe.rs index c028df32..bd8809eb 100644 --- a/src/api/providers/model_server/probe.rs +++ b/src/api/providers/model_server/probe.rs @@ -93,14 +93,7 @@ async fn probe_models_inner( let url = format!("{}{}", base.trim_end_matches('/'), kind.models_endpoint()); - // API key: read the instance's named env var, else the kind's default probe - // env var (ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / …). - let api_key = server - .api_key_env - .as_deref() - .or_else(|| kind.default_api_key_env()) - .and_then(|var| std::env::var(var).ok()) - .filter(|k| !k.is_empty()); + let api_key = super::resolve_api_key(server, kind); // This request carries the provider API key, so where it is allowed to go // matters as much as what it sends. Validating the destination *and* every @@ -203,6 +196,7 @@ fn is_text_model(kind: ModelServerKind, raw: &Value, id: &str) -> bool { ModelServerKind::OpenRouter => openrouter_outputs_text(raw), ModelServerKind::OpenAiApi => openai_id_is_text(id), ModelServerKind::AnthropicApi + | ModelServerKind::XaiApi | ModelServerKind::Ollama | ModelServerKind::OpenAiCompat | ModelServerKind::LmStudio => true, diff --git a/src/api/providers/repo/github.rs b/src/api/providers/repo/github.rs index e66de6ba..33d4a5ba 100644 --- a/src/api/providers/repo/github.rs +++ b/src/api/providers/repo/github.rs @@ -72,7 +72,7 @@ struct ReviewResponse { impl GitHubProvider { /// Create a new GitHub provider with the given token pub fn new(token: impl Into) -> Result { - let client = reqwest::Client::builder() + let client = crate::http_client::client_builder() .user_agent("operator-tui/0.1.0") .build() .map_err(|e| ApiError::network(PROVIDER_NAME, e.to_string()))?; diff --git a/src/app/agents.rs b/src/app/agents.rs index 7f8356be..261cdd59 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -278,39 +278,60 @@ impl App { } pub(super) async fn launch_confirmed(&mut self) -> Result<()> { - if let Some(ticket) = self.confirm_dialog.ticket.take() { - let launcher = Launcher::new(&self.config)?; + let Some(ticket) = self.confirm_dialog.ticket.take() else { + return Ok(()); + }; - // Build launch options from dialog state - // Only set project_override if it differs from the ticket's original project - let project_override = if self.confirm_dialog.is_project_overridden() { - self.confirm_dialog.selected_project_name().cloned() - } else { - None - }; + let restore = |app: &mut Self, ticket: crate::queue::Ticket| { + app.confirm_dialog.ticket = Some(ticket); + app.confirm_dialog.visible = true; + }; - // Dialog target picker resolves by name; "local" (index 0) shields - // the launch.docker.enabled fallback so picker-off = local. - let target = crate::agents::delegator_resolution::resolve_named_target( - &self.config, - self.confirm_dialog.selected_target_name(), - ) - .map_err(|e| anyhow::anyhow!(e.to_string()))?; - - let options = LaunchOptions { - provider: self.confirm_dialog.selected_provider().cloned(), - delegator_name: None, - extra_flags: Vec::new(), - target, - yolo_mode: self.confirm_dialog.yolo_selected, - project_override, - ..Default::default() - }; + let launcher = match Launcher::new(&self.config) { + Ok(launcher) => launcher, + Err(e) => { + restore(self, ticket); + return Err(e); + } + }; - launcher.launch_with_options(&ticket, options).await?; - self.confirm_dialog.hide(); - self.refresh_data()?; + // Build launch options from dialog state + // Only set project_override if it differs from the ticket's original project + let project_override = if self.confirm_dialog.is_project_overridden() { + self.confirm_dialog.selected_project_name().cloned() + } else { + None + }; + + // Dialog target picker resolves by name; "local" (index 0) shields + // the launch.docker.enabled fallback so picker-off = local. + let target = match crate::agents::delegator_resolution::resolve_named_target( + &self.config, + self.confirm_dialog.selected_target_name(), + ) { + Ok(target) => target, + Err(e) => { + restore(self, ticket); + return Err(anyhow::anyhow!(e.to_string())); + } + }; + + let options = LaunchOptions { + provider: self.confirm_dialog.selected_provider().cloned(), + delegator_name: None, + extra_flags: Vec::new(), + target, + yolo_mode: self.confirm_dialog.yolo_selected, + project_override, + ..Default::default() + }; + + if let Err(e) = launcher.launch_with_options(&ticket, options).await { + restore(self, ticket); + return Err(e); } + self.confirm_dialog.hide(); + self.refresh_data()?; Ok(()) } diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index 4259ee74..b4d1ad5e 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -1,19 +1,40 @@ use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crate::ui::create_dialog::CreateDialogResult; use crate::ui::setup::SetupResult; -use crate::ui::status_panel::ActionButton; +use crate::ui::status_panel::{ActionButton, StatusAction}; use crate::ui::{ConfirmSelection, KanbanViewResult, SessionRecoverySelection, SyncConfirmResult}; use super::git_onboarding; use super::{App, AppTerminal}; +pub(super) enum TerminalKeyOp { + None, + CreateTicket(CreateDialogResult), + ViewTicket, + EditTicket, + AttachSession, + StatusAction(StatusAction), +} + impl App { pub(super) async fn handle_key( &mut self, key: KeyEvent, terminal: &mut AppTerminal, ) -> Result<()> { + match self.dispatch_key(key).await? { + TerminalKeyOp::None => Ok(()), + TerminalKeyOp::CreateTicket(result) => self.create_ticket(result, terminal), + TerminalKeyOp::ViewTicket => self.view_ticket(terminal), + TerminalKeyOp::EditTicket => self.edit_ticket(terminal), + TerminalKeyOp::AttachSession => self.attach_to_session(terminal), + TerminalKeyOp::StatusAction(action) => self.execute_status_action(action, terminal), + } + } + + pub(super) async fn dispatch_key(&mut self, key: KeyEvent) -> Result { let code = key.code; let mods = key.modifiers; @@ -105,7 +126,7 @@ impl App { } _ => {} } - return Ok(()); + return Ok(TerminalKeyOp::None); } // Sync confirm dialog handling @@ -120,13 +141,13 @@ impl App { } } } - return Ok(()); + return Ok(TerminalKeyOp::None); } if self.kanban_onboarding_dialog.visible { let action = self.kanban_onboarding_dialog.handle_key(code); self.handle_kanban_onboarding_action(action).await?; - return Ok(()); + return Ok(TerminalKeyOp::None); } // Setup screen takes absolute priority @@ -135,7 +156,7 @@ impl App { && matches!(code, KeyCode::Char(_) | KeyCode::Backspace) { setup.handle_configuration_name_key(code); - return Ok(()); + return Ok(TerminalKeyOp::None); } // The password step needs raw characters, and the wizard bindings // below would eat them: `i` runs initialize_tickets() outright, @@ -155,7 +176,7 @@ impl App { ) { setup.handle_password_key(code); - return Ok(()); + return Ok(TerminalKeyOp::None); } if setup.step == crate::ui::setup::SetupStep::License && matches!( @@ -170,7 +191,7 @@ impl App { ) { setup.handle_license_key(code); - return Ok(()); + return Ok(TerminalKeyOp::None); } if setup.step == crate::ui::setup::SetupStep::ExecutionTarget && setup.execution_target_state.selected() == Some(1) @@ -180,7 +201,7 @@ impl App { ) { setup.handle_execution_target_key(code); - return Ok(()); + return Ok(TerminalKeyOp::None); } match code { @@ -261,13 +282,13 @@ impl App { } _ => {} } - return Ok(()); + return Ok(TerminalKeyOp::None); } // Help dialog takes priority if self.help_dialog.visible { self.help_dialog.visible = false; - return Ok(()); + return Ok(TerminalKeyOp::None); } // Session preview handling @@ -296,15 +317,15 @@ impl App { } _ => {} } - return Ok(()); + return Ok(TerminalKeyOp::None); } // Create dialog handling if self.create_dialog.visible { if let Some(result) = self.create_dialog.handle_key(code) { - self.create_ticket(result, terminal)?; + return Ok(TerminalKeyOp::CreateTicket(result)); } - return Ok(()); + return Ok(TerminalKeyOp::None); } // Projects dialog handling @@ -312,7 +333,7 @@ impl App { if let Some(result) = self.projects_dialog.handle_key(code) { self.execute_project_action(result)?; } - return Ok(()); + return Ok(TerminalKeyOp::None); } // Confirm dialog handling @@ -365,10 +386,10 @@ impl App { self.launch_confirmed().await?; } KeyCode::Char('v' | 'V') => { - self.view_ticket(terminal)?; + return Ok(TerminalKeyOp::ViewTicket); } KeyCode::Char('e' | 'E') => { - self.edit_ticket(terminal)?; + return Ok(TerminalKeyOp::EditTicket); } KeyCode::Char('n' | 'N') | KeyCode::Esc => { self.confirm_dialog.hide(); @@ -401,7 +422,7 @@ impl App { self.launch_confirmed().await?; } ConfirmSelection::View => { - self.view_ticket(terminal)?; + return Ok(TerminalKeyOp::ViewTicket); } ConfirmSelection::No => { self.confirm_dialog.hide(); @@ -410,7 +431,7 @@ impl App { _ => {} } } - return Ok(()); + return Ok(TerminalKeyOp::None); } // Session recovery dialog handling @@ -443,7 +464,7 @@ impl App { } _ => {} } - return Ok(()); + return Ok(TerminalKeyOp::None); } // Collection dialog handling @@ -451,7 +472,7 @@ impl App { if let Some(result) = self.collection_dialog.handle_key(code) { self.handle_collection_switch(result)?; } - return Ok(()); + return Ok(TerminalKeyOp::None); } // Kanban view handling @@ -482,7 +503,7 @@ impl App { } } } - return Ok(()); + return Ok(TerminalKeyOp::None); } // Normal mode @@ -534,7 +555,7 @@ impl App { ActionButton::A }; let action = self.dashboard.status_action(button); - self.execute_status_action(action, terminal)?; + return Ok(TerminalKeyOp::StatusAction(action)); } crate::ui::dashboard::FocusedPanel::Queue => { if mods.contains(KeyModifiers::SHIFT) { @@ -544,7 +565,7 @@ impl App { } } crate::ui::dashboard::FocusedPanel::InProgress => { - self.attach_to_session(terminal)?; + return Ok(TerminalKeyOp::AttachSession); } crate::ui::dashboard::FocusedPanel::Completed => { // No action on completed panel @@ -609,12 +630,12 @@ impl App { { // B-action: go back / collapse section in status panel let action = self.dashboard.status_action(ActionButton::B); - self.execute_status_action(action, terminal)?; + return Ok(TerminalKeyOp::StatusAction(action)); } _ => {} } - Ok(()) + Ok(TerminalKeyOp::None) } /// Handle Ctrl+C for graceful two-stage exit diff --git a/src/app/tests.rs b/src/app/tests.rs index 16608405..4d60ab02 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -83,6 +83,56 @@ fn make_test_config(temp_dir: &TempDir) -> Config { } } +fn blank_for_test(config: Config) -> App { + let tmux_client: std::sync::Arc = + std::sync::Arc::new(crate::agents::tmux::MockTmuxClient::new()); + let (_pr_event_tx, pr_event_rx) = tokio::sync::mpsc::unbounded_channel(); + let (_version_tx, version_rx) = tokio::sync::mpsc::unbounded_channel(); + App { + config: config.clone(), + dashboard: crate::ui::Dashboard::new(&config), + confirm_dialog: crate::ui::ConfirmDialog::new(), + help_dialog: crate::ui::dialogs::HelpDialog::new(config.sessions.wrapper), + create_dialog: crate::ui::create_dialog::CreateDialog::new(), + projects_dialog: crate::ui::ProjectsDialog::new(), + setup_screen: None, + should_quit: false, + exit_message: None, + session_monitor: crate::agents::SessionMonitor::new(&config), + session_preview: crate::ui::SessionPreview::new(), + ticket_sync: crate::agents::TicketSessionSync::new( + &config, + std::sync::Arc::clone(&tmux_client), + ), + sync_status_message: None, + rest_api_server: crate::rest::RestApiServer::new(config.clone(), config.rest_api.port), + exit_confirmation_mode: false, + exit_confirmation_time: None, + start_web_on_launch: false, + open_ui_on_launch: false, + session_recovery_dialog: crate::ui::SessionRecoveryDialog::new(), + collection_dialog: crate::ui::CollectionSwitchDialog::new(), + kanban_view: crate::ui::KanbanView::new(), + sync_confirm_dialog: crate::ui::SyncConfirmDialog::new(), + git_token_dialog: crate::ui::GitTokenDialog::new(), + kanban_onboarding_dialog: crate::ui::KanbanOnboardingDialog::new(), + kanban_onboarding_creds: super::kanban_onboarding::KanbanOnboardingCreds::default(), + kanban_sync_service: crate::services::KanbanSyncService::new(&config), + issue_type_registry: crate::issuetypes::IssueTypeRegistry::new(), + pr_event_rx, + pr_tracked: std::sync::Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), + pr_shutdown_tx: None, + notification_service: crate::notifications::NotificationService::from_config(&config) + .expect("disabled notifications still build a service"), + update_available_version: None, + update_notification_shown_at: None, + version_rx, + #[cfg(unix)] + relay_hub: None, + tmux_client, + } +} + // ============================================ // State Transition Tests // ============================================ @@ -94,44 +144,28 @@ mod state_transitions { fn test_pause_queue_sets_state_paused() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); - - // Initialize state file let mut state = State::load(&config).unwrap(); state.set_paused(false).unwrap(); - // Reload and verify initial state - let state = State::load(&config).unwrap(); - assert!(!state.paused); - - // Simulate pause_queue logic - let mut state = State::load(&config).unwrap(); - state.set_paused(true).unwrap(); + let mut app = blank_for_test(config.clone()); + app.pause_queue().unwrap(); - // Verify state is now paused - let reloaded = State::load(&config).unwrap(); - assert!(reloaded.paused); + assert!(app.dashboard.paused); + assert!(State::load(&config).unwrap().paused); } #[test] fn test_resume_queue_sets_state_resumed() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); - - // Initialize state as paused let mut state = State::load(&config).unwrap(); state.set_paused(true).unwrap(); - // Reload and verify - let state = State::load(&config).unwrap(); - assert!(state.paused); + let mut app = blank_for_test(config.clone()); + app.resume_queue().unwrap(); - // Simulate resume_queue logic - let mut state = State::load(&config).unwrap(); - state.set_paused(false).unwrap(); - - // Verify state is now resumed - let reloaded = State::load(&config).unwrap(); - assert!(!reloaded.paused); + assert!(!app.dashboard.paused); + assert!(!State::load(&config).unwrap().paused); } #[test] @@ -1719,3 +1753,524 @@ mod agent_lifecycle { assert!(state.agent_by_session("op-TASK-remove").is_none()); } } + +mod key_dispatch { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::widgets::ListState; + + use crate::queue::Ticket; + use crate::state::State; + use crate::ui::dashboard::FocusedPanel; + use crate::ui::{KanbanOnboardingAction, KanbanOnboardingProvider, KanbanOnboardingState}; + + use super::super::keyboard::TerminalKeyOp; + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn app() -> (tempfile::TempDir, App) { + let temp_dir = tempfile::TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + State::load(&config).unwrap(); + (temp_dir, blank_for_test(config)) + } + + fn sample_ticket(project: &str) -> Ticket { + Ticket { + filename: format!("20240101-0000-TASK-{project}-sample.md"), + filepath: format!("/tmp/missing-{project}.md"), + timestamp: "20240101-0000".to_string(), + ticket_type: "TASK".to_string(), + project: project.to_string(), + id: format!("TASK-{project}"), + summary: "sample".to_string(), + priority: "P2-medium".to_string(), + status: "queued".to_string(), + step: String::new(), + content: String::new(), + sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), + llm_task: crate::queue::LlmTask::default(), + worktree_path: None, + branch: None, + external_id: None, + external_url: None, + external_provider: None, + collection: None, + } + } + + fn focus_queue(app: &mut App, ticket: Option) { + app.dashboard.focused = FocusedPanel::Queue; + app.dashboard.queue_panel.tickets = ticket.into_iter().collect(); + app.dashboard.queue_panel.state = ListState::default(); + if !app.dashboard.queue_panel.tickets.is_empty() { + app.dashboard.queue_panel.state.select(Some(0)); + } + } + + fn assert_dashboard_untouched(app: &App) { + assert!(!app.should_quit); + assert!(!app.confirm_dialog.visible); + } + + #[tokio::test] + async fn git_token_swallows_dashboard_keys() { + let (_dir, mut app) = app(); + app.git_token_dialog + .show("github", "GitHub", "https://example", "token"); + app.dispatch_key(key(KeyCode::Char('q'))).await.unwrap(); + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + app.dispatch_key(key(KeyCode::Char('a'))).await.unwrap(); + assert_eq!(app.git_token_dialog.token(), "qLa"); + assert_dashboard_untouched(&app); + app.dispatch_key(key(KeyCode::Esc)).await.unwrap(); + assert!(!app.git_token_dialog.visible); + assert!(app.git_token_dialog.token().is_empty()); + } + + #[tokio::test] + async fn git_token_outranks_kanban_onboarding() { + let (_dir, mut app) = app(); + app.show_kanban_onboarding_dialog(); + app.git_token_dialog + .show("github", "GitHub", "https://example", "token"); + app.dispatch_key(key(KeyCode::Char('a'))).await.unwrap(); + assert_eq!(app.git_token_dialog.token(), "a"); + assert_eq!( + app.kanban_onboarding_dialog.state, + KanbanOnboardingState::PickProvider + ); + assert!(app.kanban_onboarding_dialog.visible); + } + + #[tokio::test] + async fn sync_confirm_swallows_dashboard_keys() { + let (_dir, mut app) = app(); + app.sync_confirm_dialog.visible = true; + app.dispatch_key(key(KeyCode::Char('q'))).await.unwrap(); + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(app.sync_confirm_dialog.visible); + assert_dashboard_untouched(&app); + app.dispatch_key(key(KeyCode::Esc)).await.unwrap(); + assert!(!app.sync_confirm_dialog.visible); + assert!(app.sync_status_message.is_none()); + } + + #[tokio::test] + async fn kanban_onboarding_esc_hides_without_quitting() { + let (_dir, mut app) = app(); + app.show_kanban_onboarding_dialog(); + app.dispatch_key(key(KeyCode::Char('q'))).await.unwrap(); + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(app.kanban_onboarding_dialog.visible); + assert_dashboard_untouched(&app); + app.dispatch_key(key(KeyCode::Esc)).await.unwrap(); + assert!(!app.kanban_onboarding_dialog.visible); + assert!(app.kanban_onboarding_creds.jira.is_none()); + assert!(app.kanban_onboarding_creds.linear.is_none()); + } + + #[tokio::test] + async fn help_any_key_closes_without_quitting() { + let (_dir, mut app) = app(); + app.help_dialog.visible = true; + app.dispatch_key(key(KeyCode::Char('q'))).await.unwrap(); + assert!(!app.help_dialog.visible); + assert_dashboard_untouched(&app); + } + + #[tokio::test] + async fn session_preview_q_hides_preview() { + let (_dir, mut app) = app(); + app.session_preview.visible = true; + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(app.session_preview.visible); + assert_dashboard_untouched(&app); + app.dispatch_key(key(KeyCode::Char('q'))).await.unwrap(); + assert!(!app.session_preview.visible); + assert!(!app.should_quit); + } + + #[tokio::test] + async fn create_dialog_letter_does_not_submit() { + let (_dir, mut app) = app(); + app.create_dialog.show(); + let op = app.dispatch_key(key(KeyCode::Char('a'))).await.unwrap(); + assert!(matches!(op, TerminalKeyOp::None)); + assert!(app.create_dialog.visible); + assert_dashboard_untouched(&app); + app.dispatch_key(key(KeyCode::Esc)).await.unwrap(); + assert!(!app.create_dialog.visible); + } + + #[tokio::test] + async fn projects_dialog_esc_hides() { + let (_dir, mut app) = app(); + app.projects_dialog.show(); + app.dispatch_key(key(KeyCode::Char('q'))).await.unwrap(); + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(app.projects_dialog.visible); + assert_dashboard_untouched(&app); + app.dispatch_key(key(KeyCode::Esc)).await.unwrap(); + assert!(!app.projects_dialog.visible); + } + + #[tokio::test] + async fn confirm_n_hides_without_launching() { + let (_dir, mut app) = app(); + let ticket = sample_ticket("test-project"); + let id = ticket.id.clone(); + app.confirm_dialog.show(ticket); + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(app.confirm_dialog.visible); + app.dispatch_key(key(KeyCode::Char('n'))).await.unwrap(); + assert!(!app.confirm_dialog.visible); + assert!(!app.should_quit); + assert_eq!(State::load(&app.config).unwrap().running_agents().len(), 0); + let _ = id; + } + + #[tokio::test] + async fn failed_launch_restores_confirm_ticket() { + let (_dir, mut app) = app(); + let ticket = sample_ticket("missing-project"); + let id = ticket.id.clone(); + app.confirm_dialog.show(ticket); + let err = app.dispatch_key(key(KeyCode::Char('y'))).await; + assert!(err.is_err()); + assert!(app.confirm_dialog.visible); + assert_eq!( + app.confirm_dialog.ticket.as_ref().map(|t| t.id.as_str()), + Some(id.as_str()) + ); + } + + #[tokio::test] + async fn session_recovery_l_does_not_quit() { + let (_dir, mut app) = app(); + app.session_recovery_dialog.visible = true; + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(app.session_recovery_dialog.visible); + assert_dashboard_untouched(&app); + app.dispatch_key(key(KeyCode::Esc)).await.unwrap(); + assert!(!app.session_recovery_dialog.visible); + } + + #[tokio::test] + async fn collection_dialog_q_hides_without_quitting() { + let (_dir, mut app) = app(); + app.collection_dialog.visible = true; + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(app.collection_dialog.visible); + app.dispatch_key(key(KeyCode::Char('q'))).await.unwrap(); + assert!(!app.collection_dialog.visible); + assert_dashboard_untouched(&app); + } + + #[tokio::test] + async fn kanban_view_q_does_not_quit_app() { + let (_dir, mut app) = app(); + app.kanban_view.visible = true; + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(app.kanban_view.visible); + app.dispatch_key(key(KeyCode::Char('q'))).await.unwrap(); + assert!(!app.kanban_view.visible); + assert!(!app.should_quit); + } + + #[tokio::test] + async fn welcome_setup_eats_quit_and_launch_letters() { + let (_dir, mut app) = app(); + app.setup_screen = Some(crate::ui::setup::SetupScreen::new( + app.config.paths.tickets.clone(), + Vec::new(), + std::collections::HashMap::new(), + )); + app.dispatch_key(key(KeyCode::Char('c'))).await.unwrap(); + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(!app.should_quit); + assert!(!app.confirm_dialog.visible); + assert_eq!(app.setup_screen.as_ref().unwrap().configuration_name, "c"); + } + + #[tokio::test] + async fn l_opens_confirm_for_selected_queue_ticket() { + let (_dir, mut app) = app(); + let ticket = sample_ticket("test-project"); + let id = ticket.id.clone(); + focus_queue(&mut app, Some(ticket)); + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(app.confirm_dialog.visible); + assert_eq!( + app.confirm_dialog.ticket.as_ref().map(|t| t.id.as_str()), + Some(id.as_str()) + ); + assert_eq!(State::load(&app.config).unwrap().running_agents().len(), 0); + } + + #[tokio::test] + async fn enter_on_queue_opens_confirm() { + let (_dir, mut app) = app(); + focus_queue(&mut app, Some(sample_ticket("test-project"))); + app.dispatch_key(key(KeyCode::Enter)).await.unwrap(); + assert!(app.confirm_dialog.visible); + } + + #[tokio::test] + async fn shift_enter_without_ticket_does_not_launch() { + let (_dir, mut app) = app(); + focus_queue(&mut app, None); + let before = app.dashboard.status_message.clone(); + app.dispatch_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)) + .await + .unwrap(); + assert!(!app.confirm_dialog.visible); + assert_eq!(app.dashboard.status_message, before); + assert_eq!(State::load(&app.config).unwrap().running_agents().len(), 0); + } + + #[tokio::test] + async fn l_refuses_when_queue_paused() { + let (_dir, mut app) = app(); + focus_queue(&mut app, Some(sample_ticket("test-project"))); + app.dashboard.paused = true; + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(!app.confirm_dialog.visible); + let status = app.dashboard.status_message.unwrap(); + assert!(status.starts_with("Cannot launch:")); + assert!(status.contains("paused")); + } + + #[tokio::test] + async fn l_refuses_when_global_cap_is_full() { + let (_dir, mut app) = app(); + focus_queue(&mut app, Some(sample_ticket("test-project"))); + let cap = app.config.effective_max_agents(); + let mut state = State::load(&app.config).unwrap(); + for i in 0..cap { + state + .add_agent( + format!("other-{i}"), + "TASK".to_string(), + format!("other-{i}"), + false, + ) + .unwrap(); + } + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(!app.confirm_dialog.visible); + let status = app.dashboard.status_message.unwrap(); + assert!(status.starts_with("Cannot launch:")); + assert!(status.contains("agents active")); + } + + #[tokio::test] + async fn l_refuses_when_project_cap_is_full() { + let (_dir, mut app) = app(); + focus_queue(&mut app, Some(sample_ticket("test-project"))); + let mut state = State::load(&app.config).unwrap(); + state + .add_agent( + "busy".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(!app.confirm_dialog.visible); + let status = app.dashboard.status_message.unwrap(); + assert!(status.starts_with("Cannot launch:")); + assert!(status.contains("test-project")); + } + + #[tokio::test] + async fn l_with_nothing_selected_leaves_confirm_hidden() { + let (_dir, mut app) = app(); + focus_queue(&mut app, None); + app.dispatch_key(key(KeyCode::Char('L'))).await.unwrap(); + assert!(!app.confirm_dialog.visible); + assert!(app.dashboard.status_message.is_none()); + } + + #[tokio::test] + async fn p_and_r_pause_and_resume_through_app() { + let (_dir, mut app) = app(); + app.dispatch_key(key(KeyCode::Char('p'))).await.unwrap(); + assert!(app.dashboard.paused); + assert!(State::load(&app.config).unwrap().paused); + app.dispatch_key(key(KeyCode::Char('r'))).await.unwrap(); + assert!(!app.dashboard.paused); + assert!(!State::load(&app.config).unwrap().paused); + } + + #[tokio::test] + async fn normal_mode_opens_dialogs_and_moves_focus() { + let (_dir, mut app) = app(); + let before = app.dashboard.focused; + app.dispatch_key(key(KeyCode::Tab)).await.unwrap(); + assert_ne!(app.dashboard.focused, before); + app.dispatch_key(key(KeyCode::Char('C'))).await.unwrap(); + assert!(app.create_dialog.visible); + app.create_dialog.visible = false; + app.dispatch_key(key(KeyCode::Char('J'))).await.unwrap(); + assert!(app.projects_dialog.visible); + app.projects_dialog.visible = false; + app.dispatch_key(key(KeyCode::Char('?'))).await.unwrap(); + assert!(app.help_dialog.visible); + } + + #[tokio::test] + async fn q_quits_without_a_running_api() { + let (_dir, mut app) = app(); + assert!(!app.rest_api_server.is_running()); + app.dispatch_key(key(KeyCode::Char('q'))).await.unwrap(); + assert!(app.should_quit); + } + + #[tokio::test] + async fn ctrl_c_is_two_stage() { + let (_dir, mut app) = app(); + app.handle_ctrl_c().await; + assert!(app.exit_confirmation_mode); + assert!(!app.should_quit); + app.handle_ctrl_c().await; + assert!(app.should_quit); + } + + #[tokio::test] + async fn s_without_providers_sets_status() { + let (_dir, mut app) = app(); + app.dispatch_key(key(KeyCode::Char('S'))).await.unwrap(); + assert_eq!( + app.sync_status_message.as_deref(), + Some("No kanban providers configured") + ); + assert!(!app.sync_confirm_dialog.visible); + } + + #[tokio::test] + async fn k_without_providers_opens_onboarding() { + let (_dir, mut app) = app(); + app.dispatch_key(key(KeyCode::Char('K'))).await.unwrap(); + assert!(app.kanban_onboarding_dialog.visible); + assert!(!app.kanban_view.visible); + } + + #[tokio::test] + async fn enter_on_in_progress_is_an_attach_op() { + let (_dir, mut app) = app(); + app.dashboard.focused = FocusedPanel::InProgress; + let op = app.dispatch_key(key(KeyCode::Enter)).await.unwrap(); + assert!(matches!(op, TerminalKeyOp::AttachSession)); + } + + #[tokio::test] + async fn enter_on_status_is_a_status_op() { + let (_dir, mut app) = app(); + app.dashboard.focused = FocusedPanel::Status; + let op = app.dispatch_key(key(KeyCode::Enter)).await.unwrap(); + assert!(matches!(op, TerminalKeyOp::StatusAction(_))); + } + + #[tokio::test] + async fn show_onboarding_clears_stashed_creds() { + let (_dir, mut app) = app(); + app.kanban_onboarding_creds.jira = + Some(super::super::kanban_onboarding::JiraCredsInflight { + domain: "acme.atlassian.net".to_string(), + email: "a@b.co".to_string(), + api_token: "secret".to_string(), + }); + app.show_kanban_onboarding_dialog(); + assert!(app.kanban_onboarding_dialog.visible); + assert_eq!( + app.kanban_onboarding_dialog.state, + KanbanOnboardingState::PickProvider + ); + assert!(app.kanban_onboarding_creds.jira.is_none()); + } + + #[tokio::test] + async fn ui_only_onboarding_actions_do_not_write() { + let (_dir, mut app) = app(); + app.show_kanban_onboarding_dialog(); + for action in [ + KanbanOnboardingAction::None, + KanbanOnboardingAction::PickedProvider(KanbanOnboardingProvider::Jira), + KanbanOnboardingAction::Cancelled, + KanbanOnboardingAction::Done, + ] { + app.handle_kanban_onboarding_action(action).await.unwrap(); + } + assert!(app.kanban_onboarding_creds.jira.is_none()); + assert!(app.kanban_onboarding_creds.linear.is_none()); + } + + #[tokio::test] + async fn copy_export_block_sets_status() { + let (_dir, mut app) = app(); + app.show_kanban_onboarding_dialog(); + app.handle_kanban_onboarding_action(KanbanOnboardingAction::CopyExportBlock) + .await + .unwrap(); + assert!(app.sync_status_message.is_some()); + assert!(app.kanban_onboarding_dialog.visible); + } + + #[tokio::test] + async fn picked_project_without_creds_sets_dialog_error() { + let (_dir, mut app) = app(); + app.show_kanban_onboarding_dialog(); + app.handle_kanban_onboarding_action(KanbanOnboardingAction::PickedProject { + provider: KanbanOnboardingProvider::Jira, + project_key: "PROJ".to_string(), + project_name: "Proj".to_string(), + }) + .await + .unwrap(); + assert!(app + .kanban_onboarding_dialog + .error_message() + .contains("Missing stashed Jira credentials")); + assert_eq!( + app.kanban_onboarding_dialog.state, + KanbanOnboardingState::Error + ); + + app.show_kanban_onboarding_dialog(); + app.handle_kanban_onboarding_action(KanbanOnboardingAction::PickedProject { + provider: KanbanOnboardingProvider::Linear, + project_key: "ENG".to_string(), + project_name: "Eng".to_string(), + }) + .await + .unwrap(); + assert!(app + .kanban_onboarding_dialog + .error_message() + .contains("Missing stashed Linear credentials")); + } + + #[test] + fn process_agent_switches_ignores_agents_without_marker() { + let (_dir, app) = app(); + let mut state = State::load(&app.config).unwrap(); + state + .add_agent( + "TASK-plain".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + app.process_agent_switches(&mut state).unwrap(); + assert!(state + .agents + .iter() + .all(|agent| agent.review_state.is_none())); + } +} diff --git a/src/auth/egress.rs b/src/auth/egress.rs index f9183c2d..c50b525e 100644 --- a/src/auth/egress.rs +++ b/src/auth/egress.rs @@ -178,7 +178,7 @@ pub fn validated_client(policy: EgressPolicy, timeout: Duration) -> Result Result { let provided = hash_secret(csrf); self.with_conn(|conn| { - let stored: Option = conn + let stored: Option<(String, Option)> = conn .query_row( - "SELECT csrf_hash FROM session WHERE id = ?1 AND revoked_at IS NULL", + "SELECT csrf_hash, csrf_prev_hash FROM session \ + WHERE id = ?1 AND revoked_at IS NULL", [session_id], - |r| r.get(0), + |r| Ok((r.get(0)?, r.get(1)?)), ) .optional()?; - Ok(stored.is_some_and(|s| crate::auth::secret::hashes_equal(&s, &provided))) + Ok(stored.is_some_and(|(current, previous)| { + let matches_current = crate::auth::secret::hashes_equal(¤t, &provided); + let matches_previous = + previous.is_some_and(|p| crate::auth::secret::hashes_equal(&p, &provided)); + matches_current || matches_previous + })) }) } - /// Issue a fresh CSRF token for an existing session, replacing the old one. + /// Issue a fresh CSRF token for an existing session. /// /// The SPA needs this after a page reload: the session cookie survives, but /// the CSRF token was only ever held in memory. Rotating rather than - /// returning the existing one means the stored value stays hash-only. + /// returning the existing one means the stored value stays hash-only. The + /// superseded hash is kept for exactly one more rotation, so another tab + /// holding it is not broken by this one reloading. pub fn rotate_csrf(&self, session_id: &str) -> Result> { let csrf = generate_secret()?; let hash = hash_secret(&csrf); self.with_conn(|conn| { let n = conn.execute( - "UPDATE session SET csrf_hash = ?1 WHERE id = ?2 AND revoked_at IS NULL", + "UPDATE session SET csrf_prev_hash = csrf_hash, csrf_hash = ?1 \ + WHERE id = ?2 AND revoked_at IS NULL", rusqlite::params![hash, session_id], )?; Ok((n > 0).then_some(csrf)) @@ -1365,26 +1375,57 @@ mod tests { ); } - #[test] - fn test_rotating_csrf_invalidates_the_previous_token() { - let s = store(); - let (token, first_csrf, _) = s.create_session().unwrap(); - let id = s - .authenticate_session(&token) + fn session_id_for(s: &AuthStore, token: &str) -> String { + s.authenticate_session(token) .unwrap() .unwrap() .session_id - .unwrap(); + .unwrap() + } + + #[test] + fn test_rotating_csrf_keeps_the_previous_token_valid() { + let s = store(); + let (token, first_csrf, _) = s.create_session().unwrap(); + let id = session_id_for(&s, &token); let second_csrf = s.rotate_csrf(&id).unwrap().expect("session exists"); assert_ne!(first_csrf, second_csrf); assert!(s.verify_csrf(&id, &second_csrf).unwrap()); + assert!( + s.verify_csrf(&id, &first_csrf).unwrap(), + "another tab still holding the previous token must keep working" + ); + } + + #[test] + fn test_rotating_csrf_twice_invalidates_the_oldest_token() { + let s = store(); + let (token, first_csrf, _) = s.create_session().unwrap(); + let id = session_id_for(&s, &token); + + let second_csrf = s.rotate_csrf(&id).unwrap().expect("session exists"); + let third_csrf = s.rotate_csrf(&id).unwrap().expect("session exists"); + assert!(s.verify_csrf(&id, &third_csrf).unwrap()); + assert!(s.verify_csrf(&id, &second_csrf).unwrap()); assert!( !s.verify_csrf(&id, &first_csrf).unwrap(), - "the superseded CSRF token must stop working" + "only one superseded CSRF token is honoured" ); } + #[test] + fn test_revoked_session_rejects_current_and_previous_csrf() { + let s = store(); + let (token, first_csrf, _) = s.create_session().unwrap(); + let id = session_id_for(&s, &token); + let second_csrf = s.rotate_csrf(&id).unwrap().expect("session exists"); + + s.revoke_session(&id).unwrap(); + assert!(!s.verify_csrf(&id, &second_csrf).unwrap()); + assert!(!s.verify_csrf(&id, &first_csrf).unwrap()); + } + #[test] fn test_rotating_csrf_on_an_unknown_session_returns_none() { let s = store(); diff --git a/src/config.rs b/src/config.rs index 74c0d6f2..9406a19c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -96,6 +96,10 @@ pub struct Config { /// Agent Client Protocol (ACP) agent configuration #[serde(default)] pub acp: AcpConfig, + /// In-daemon LLM calls (judge). Accepted but inert until the `native-llm` + /// build feature ships; a configured judge falls back to the deterministic rule. + #[serde(default)] + pub native_llm: NativeLlmConfig, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] @@ -999,6 +1003,7 @@ impl Default for Config { relay: RelayConfig::default(), mcp: McpConfig::default(), acp: AcpConfig::default(), + native_llm: NativeLlmConfig::default(), } } } diff --git a/src/config/config_tests.rs b/src/config/config_tests.rs index 3b45092a..2122ebe2 100644 --- a/src/config/config_tests.rs +++ b/src/config/config_tests.rs @@ -124,13 +124,47 @@ fn test_delegator_without_model_server_field_still_parses() { #[test] fn test_implicit_model_server_for_known_tools() { - assert_eq!( - implicit_model_server_for_tool("claude").kind, - "anthropic-api" + let pairs = [ + ("claude", "anthropic-api"), + ("codex", "openai-api"), + ("gemini", "google-api"), + ("grok", "xai-api"), + ]; + for (tool, kind) in pairs { + assert_eq!( + implicit_model_server_for_tool(tool) + .unwrap_or_else(|| panic!("{tool} is shipped")) + .kind, + kind + ); + } + assert!( + implicit_model_server_for_tool("unknown").is_none(), + "unknown tools have no implicit server; the delegator must name one" + ); +} + +#[test] +fn test_shipped_llm_tools_identity_table() { + let names: Vec<&str> = shipped_llm_tools().iter().map(|t| t.tool_name).collect(); + assert_eq!(names, vec!["claude", "codex", "gemini", "grok"]); + + let gemini = shipped_llm_tool_by_name("gemini").expect("gemini is shipped"); + assert_eq!(gemini.catalog_slug, "gemini-cli"); + assert_eq!(gemini.tool_name, "gemini"); + assert!(gemini.markers.contains(&"AGENTS.md")); + assert!(gemini.markers.contains(&"GEMINI.md")); + assert_eq!(gemini.implicit_server, "google-api"); + assert!( + implicit_model_server_for_tool("gemini-cli").is_none(), + "lookup is by binary name, not catalog slug" ); - assert_eq!(implicit_model_server_for_tool("codex").kind, "openai-api"); - assert_eq!(implicit_model_server_for_tool("gemini").kind, "google-api"); - assert_eq!(implicit_model_server_for_tool("unknown").kind, "openai-api"); + + let grok = shipped_llm_tool_by_name("grok").expect("grok is shipped"); + assert_eq!(grok.catalog_slug, "grok"); + assert_eq!(grok.implicit_server, "xai-api"); + assert!(grok.markers.contains(&"AGENTS.md")); + assert!(grok.markers.contains(&"GROK.md")); } #[test] @@ -833,3 +867,27 @@ fn test_delegator_launch_config_operator_relay_defaults_to_none() { let d: Delegator = toml::from_str(toml_str).unwrap(); assert!(d.launch_config.as_ref().unwrap().operator_relay.is_none()); } + +#[test] +fn test_native_llm_absent_means_no_judge() { + let cfg: NativeLlmConfig = toml::from_str("").unwrap(); + assert!(cfg.judge.is_none()); + assert!(Config::default().native_llm.judge.is_none()); +} + +#[test] +fn test_native_llm_judge_parses_with_default_timeout() { + let toml_str = r#" +[judge] +model_server = "anthropic-api" +model = "claude-sonnet-5" +"#; + let cfg: NativeLlmConfig = toml::from_str(toml_str).unwrap(); + let judge = cfg.judge.unwrap(); + assert_eq!(judge.model_server, "anthropic-api"); + assert_eq!(judge.model, "claude-sonnet-5"); + assert_eq!( + judge.timeout_secs, + crate::llm::native::DEFAULT_JUDGE_TIMEOUT_SECS + ); +} diff --git a/src/config/llm_tools.rs b/src/config/llm_tools.rs index 4e2eca72..3aca104e 100644 --- a/src/config/llm_tools.rs +++ b/src/config/llm_tools.rs @@ -146,8 +146,7 @@ pub struct RemoteAgentRef { /// Agent delegator configuration for autonomous ticket launching /// -/// A delegator is a named {tool, model} pairing with optional launch configuration -/// that can be used to launch agents for tickets. +/// A delegator is a named {tool, model} pairing with optional launch configuration. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct Delegator { @@ -212,9 +211,7 @@ pub struct Delegator { /// (`llm_tool`, e.g. claude/codex/gemini) with a model-serving endpoint /// (`model_server`, e.g. ollama-local, openai-api, a custom vllm host). /// -/// Implicit builtin servers (`anthropic-api`, `openai-api`, `google-api`) are -/// returned by [`implicit_model_server_for_tool`] and do not need to be declared -/// in config. +/// Implicit builtin servers are returned by [`implicit_model_server_for_tool`] for shipped tools. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct ModelServer { @@ -237,6 +234,34 @@ pub struct ModelServer { pub display_name: Option, } +/// In-daemon LLM calls (built with the `native-llm` feature). Distinct from +/// delegators: these are single typed API calls, not agent CLI sessions. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export, optional_fields = nullable)] +pub struct NativeLlmConfig { + /// Model that picks the winner of `multi_model` (`voting_mode = single_judge`) + /// and `multi_prompt` (`selection_strategy = model_choice`) steps. Unset keeps + /// the deterministic first/longest rule. + #[serde(default)] + pub judge: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct JudgeConfig { + /// Name of a declared or implicit model server (e.g. "anthropic-api") + pub model_server: String, + /// Full API model id (e.g. "claude-sonnet-5"), not a CLI alias like "sonnet" + pub model: String, + /// Seconds before the judge is abandoned and the deterministic rule applies + #[serde(default = "default_judge_timeout_secs")] + pub timeout_secs: u64, +} + +fn default_judge_timeout_secs() -> u64 { + crate::llm::native::DEFAULT_JUDGE_TIMEOUT_SECS +} + /// A named remote machine that agent CLI processes can be launched on over SSH. /// /// Distinct from [`ModelServer`] (where model *inference* lives) and from @@ -260,25 +285,104 @@ pub struct RemoteHost { pub ssh_config_path: Option, } +/// One shipped LLM CLI: catalog identity, binary name, implicit first-party model server, and project marker file. +/// +/// This is the single source of truth for the three (later four) builtins. +/// Catalog slug may differ from the binary (`gemini-cli` vs `gemini`). +/// Lookup of implicit servers is always by [`Self::tool_name`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShippedLlmTool { + /// Vertical catalog slug (`gemini-cli` for Gemini CLI). + pub catalog_slug: &'static str, + /// Binary / `Delegator.llm_tool` name (`gemini`). + pub tool_name: &'static str, + /// Implicit builtin `ModelServer` name and kind (`google-api`). + pub implicit_server: &'static str, + /// Project-root marker files. `AGENTS.md` is the shared default; the vendor + /// file (`CLAUDE.md`, `GROK.md`, …) is the backup. + pub markers: &'static [&'static str], +} + +const SHARED_AGENT_MARKER: &str = "AGENTS.md"; + +const SHIPPED_LLM_TOOLS: &[ShippedLlmTool] = &[ + ShippedLlmTool { + catalog_slug: "claude", + tool_name: "claude", + implicit_server: "anthropic-api", + markers: &[SHARED_AGENT_MARKER, "CLAUDE.md"], + }, + ShippedLlmTool { + catalog_slug: "codex", + tool_name: "codex", + implicit_server: "openai-api", + markers: &[SHARED_AGENT_MARKER, "CODEX.md"], + }, + ShippedLlmTool { + catalog_slug: "gemini-cli", + tool_name: "gemini", + implicit_server: "google-api", + markers: &[SHARED_AGENT_MARKER, "GEMINI.md"], + }, + ShippedLlmTool { + catalog_slug: "grok", + tool_name: "grok", + implicit_server: "xai-api", + markers: &[SHARED_AGENT_MARKER, "GROK.md"], + }, +]; + +/// Shipped LLM CLIs, in catalog display order. +pub fn shipped_llm_tools() -> &'static [ShippedLlmTool] { + SHIPPED_LLM_TOOLS +} + +/// Lookup a shipped tool by binary / `llm_tool` name. +pub fn shipped_llm_tool_by_name(tool_name: &str) -> Option<&'static ShippedLlmTool> { + SHIPPED_LLM_TOOLS + .iter() + .find(|tool| tool.tool_name == tool_name) +} + +/// Implicit builtin servers derived from the shipped-tool table (deduped). +pub fn implicit_model_servers() -> Vec { + let mut servers = Vec::new(); + for tool in SHIPPED_LLM_TOOLS { + if let Some(server) = implicit_model_server_for_tool(tool.tool_name) { + if !servers + .iter() + .any(|existing: &ModelServer| existing.name == server.name) + { + servers.push(server); + } + } + } + servers +} + +/// Whether `name` is one of the implicit vendor builtins (cannot be created, +/// updated, or deleted via the model-server CRUD API). +pub fn is_implicit_model_server_name(name: &str) -> bool { + implicit_model_servers() + .iter() + .any(|server| server.name == name) +} + /// Returns the implicit builtin `ModelServer` associated with a given `llm_tool`. /// /// Used when a `Delegator` has no explicit `model_server`. Unknown tools -/// fall back to an `"openai-api"` server so arbitrary future tools still resolve. -pub fn implicit_model_server_for_tool(tool: &str) -> ModelServer { - let (name, kind) = match tool { - "claude" => ("anthropic-api", "anthropic-api"), - "codex" => ("openai-api", "openai-api"), - "gemini" => ("google-api", "google-api"), - _ => ("openai-api", "openai-api"), - }; - ModelServer { - name: name.to_string(), - kind: kind.to_string(), +/// return `None` — they must name a `model_server` rather than inheriting a +/// vendor default. +pub fn implicit_model_server_for_tool(tool: &str) -> Option { + let shipped = shipped_llm_tool_by_name(tool)?; + Some(ModelServer { + name: shipped.implicit_server.to_string(), + kind: shipped.implicit_server.to_string(), base_url: None, api_key_env: None, extra_env: std::collections::HashMap::new(), display_name: None, - } + }) } #[cfg(test)] diff --git a/src/docs_gen/integrations.rs b/src/docs_gen/integrations.rs index 72b856b2..c0e82d0d 100644 --- a/src/docs_gen/integrations.rs +++ b/src/docs_gen/integrations.rs @@ -9,6 +9,9 @@ use anyhow::Result; use super::{format_header, DocGenerator}; +use crate::integrations::support_catalog::{ + compatibility, llm_tool_supports, model_supports, Compatibility, +}; use crate::integrations::{all_integrations, SupportStatus, Vertical}; /// Generator for the feature-maturity page. @@ -89,10 +92,135 @@ impl DocGenerator for MaturityDocGenerator { } } + content.push_str(&write_llm_capability_section()); + content.push_str(&write_model_capability_section()); + content.push_str(&compat_matrix_section()); + Ok(content) } } +fn write_llm_capability_section() -> String { + let mut out = String::from( + "\n## LLM tool capabilities\n\n\ + Advertising status (GA/Beta/Alpha) is not the same as a live connection probe. \ + LLM tool **health** is `path-version`: the binary is on PATH. That is weaker than a \ + model provider's `/models` probe, which proves an API key is accepted.\n\n\ + | Tool | Health | Auth | Native protocol | Sessions | Headless | YOLO | Relay |\n\ + |---|---|---|---|---|---|---|---|\n", + ); + let entries = all_integrations(); + for (slug, support) in llm_tool_supports() { + let label = entries + .iter() + .find(|e| e.vertical == Vertical::LlmTool && e.slug == *slug) + .map(|e| e.label) + .unwrap_or(*slug); + let protocols: Vec<&str> = support.native_protocols.iter().map(|p| p.slug()).collect(); + out.push_str(&format!( + "| {label} | {health} | {auth} | {protocols} | {sessions} | {headless} | {yolo} | {relay} |\n", + health = support.health.slug(), + auth = support.auth.slug(), + protocols = protocols.join(", "), + sessions = yn(support.sessions), + headless = yn(support.headless), + yolo = yn(support.yolo), + relay = support.relay.slug(), + )); + } + out +} + +fn write_model_capability_section() -> String { + let mut out = String::from( + "\n## Model provider capabilities\n\n\ + A model provider is **connected** when its model-list probe succeeds. \ + Gateways (Ollama, OpenRouter, OpenAI-compatible) speak the OpenAI protocol; \ + first-party Anthropic and Google do not.\n\n\ + | Provider | Protocol | Class | Probe | Key injectable | Implicit for |\n\ + |---|---|---|---|---|---|\n", + ); + let entries = all_integrations(); + for (slug, support) in model_supports() { + let Some(entry) = entries + .iter() + .find(|e| e.vertical == Vertical::Model && e.slug == *slug) + else { + continue; + }; + if !entry.is_public() { + continue; + } + out.push_str(&format!( + "| {label} | {protocol} | {class} | {probe} | {key} | {implicit} |\n", + label = entry.label, + protocol = support.protocol.slug(), + class = support.class.slug(), + probe = yn(support.probe), + key = support.key_injectable.slug(), + implicit = support.implicit_for.unwrap_or("-"), + )); + } + out +} + +fn compat_matrix_section() -> String { + let entries = all_integrations(); + let tools: Vec<_> = llm_tool_supports() + .iter() + .filter_map(|(slug, _)| { + entries + .iter() + .find(|e| e.vertical == Vertical::LlmTool && e.slug == *slug && e.is_public()) + .map(|e| (e.slug, e.label)) + }) + .collect(); + let models: Vec<_> = model_supports() + .iter() + .filter_map(|(slug, _)| { + entries + .iter() + .find(|e| e.vertical == Vertical::Model && e.slug == *slug && e.is_public()) + .map(|e| (e.slug, e.label)) + }) + .collect(); + + let mut out = String::from( + "\n## LLM tool × model provider\n\n\ + **Native** — the CLI speaks this provider's protocol. \ + **Bridge** — a protocol-preserving front (claude-code-router, litellm, …) is required. \ + **Incompatible** — this first-party API is the wrong protocol for the CLI.\n\n\ + Operator does not currently block incompatible delegators at launch; \ + this matrix is the catalog fact.\n\n| Tool |", + ); + for (_, label) in &models { + out.push_str(&format!(" {label} |")); + } + out.push('\n'); + out.push('|'); + out.push_str(&"---|".repeat(models.len() + 1)); + out.push('\n'); + for (tool_slug, tool_label) in &tools { + out.push_str(&format!("| {tool_label} |")); + for (model_slug, _) in &models { + let cell = compatibility(tool_slug, model_slug) + .map(Compatibility::label) + .unwrap_or("-"); + out.push_str(&format!(" {cell} |")); + } + out.push('\n'); + } + out +} + +fn yn(value: bool) -> &'static str { + if value { + "yes" + } else { + "no" + } +} + #[cfg(test)] mod tests { use super::*; @@ -127,6 +255,14 @@ mod tests { assert!(!content.contains("| Cursor |")); // A known row with a docs link. assert!(content.contains("[Jira](https://operator.untra.io/getting-started/kanban/jira/)")); + assert!(content.contains("## LLM tool capabilities")); + assert!(content.contains("## Model provider capabilities")); + assert!(content.contains("## LLM tool × model provider")); + assert!(content.contains("path-version")); + assert!(content.contains("| Claude |")); + assert!(content.contains("Native")); + assert!(content.contains("Bridge")); + assert!(content.contains("Incompatible")); // AUTO-GENERATED header present. assert!(content.contains("AUTO-GENERATED FROM")); } diff --git a/src/docs_gen/llm_tools.rs b/src/docs_gen/llm_tools.rs index 43a4ce0f..f04a8d84 100644 --- a/src/docs_gen/llm_tools.rs +++ b/src/docs_gen/llm_tools.rs @@ -5,8 +5,29 @@ use anyhow::Result; +use crate::config::shipped_llm_tool_by_name; +use crate::llm::tool_config::load_all_tool_configs_with; + use super::{format_header, DocGenerator}; +fn supported_tools_table() -> String { + let mut table = String::from( + "| Tool | Binary | Catalog slug | Models |\n|------|--------|--------------|--------|\n", + ); + for config in load_all_tool_configs_with(None) { + let slug = shipped_llm_tool_by_name(&config.tool_name) + .map(|tool| tool.catalog_slug) + .unwrap_or(config.tool_name.as_str()); + let models = config.model_aliases.join(", "); + table.push_str(&format!( + "| {} | `{}` | `{slug}` | {models} |\n", + config.display_name(), + config.tool_name + )); + } + table +} + /// Generator for LLM tools documentation pub struct LlmToolsDocGenerator; @@ -24,21 +45,22 @@ impl DocGenerator for LlmToolsDocGenerator { } fn generate(&self) -> Result { - let mut content = format_header("LLM Tools Configuration", self.source()); + let mut content = format_header("LLM Tools", self.source()); content.push_str( - r#"# LLM Tools Configuration + r#"# LLM Tools -Operator supports multiple LLM CLI tools through a plugin-like configuration system. Each tool is defined by a JSON configuration file that tells Operator how to detect, invoke, and manage the tool. +Operator supports multiple LLM CLI tools through a plugin-like configuration system. +Each tool is defined by a JSON configuration file that tells Operator how to detect, invoke, and manage the tool. +An LLM tool is the **agentic CLI** (the process Operator launches). It is not the model provider: a delegator pairs a tool with a model server. `health_ok` means the binary is present on **this host**. ## Supported Tools -| Tool | Binary | Display Name | Models | -|------|--------|--------------|--------| -| Claude Code | `claude` | Claude Code | opus, sonnet, haiku | -| Google Gemini | `gemini` | Google Gemini | pro, flash, ultra | -| OpenAI Codex | `codex` | OpenAI Codex | gpt-4o, o1, o3 | - +"#, + ); + content.push_str(&supported_tools_table()); + content.push_str( + r#" ## Adding a New Tool To add support for a new LLM CLI tool, drop a JSON configuration file into your @@ -67,8 +89,8 @@ user tool-config directory - no rebuild required: } ``` -Configs are loaded fresh on every startup. A user config whose `tool_name` matches a builtin (claude, gemini, codex) **fully replaces** that builtin - it -is not merged field-by-field. Malformed files are skipped with a logged warning. Runtime-loaded tools work everywhere the builtins do, including +Configs are loaded fresh on every startup. A user config whose `tool_name` matches a builtin (claude, gemini, codex) **fully replaces** that builtin. +Malformed files are skipped with a logged warning. Runtime-loaded tools work everywhere the builtins do, including remote (SSH) launches, where the tool's presence on the remote host is verified by a `command -v` preflight. > **Security note:** `command_template` is arbitrary shell executed at launch. @@ -76,9 +98,9 @@ remote (SSH) launches, where the tool's presence on the remote host is verified > never from repository-local paths - so a cloned repo cannot inject a tool > config. -New *builtin* tools (shipped with Operator) are instead added as embedded JSONs -in `src/llm/tools/` and registered in the `BUILTIN_TOOL_CONFIGS` list in -`src/llm/tool_config.rs`. +New *builtin* tools (shipped with Operator) are added as embedded JSONs in +`src/llm/tools/`, registered in `BUILTIN_TOOL_CONFIGS`, and given a row in +`shipped_llm_tools()` (catalog slug, binary, implicit model server, marker). ## Detection Modes @@ -105,8 +127,7 @@ Health is **earned, never assumed**, and re-verified on every startup: An unhealthy tool stays listed in the detected tools (so you can see it and why), but launching a local agent with it fails until it is healthy again. Remote (SSH) -launches are unaffected - they are gated by their own `command -v` preflight on -the remote host. An `always`-mode tool should therefore define a `health_command` +launches are unaffected. An `always`-mode tool should therefore define a `health_command` that proves reachability, e.g. `ssh gpu-vm command -v agy`. ## Configuration Schema @@ -226,10 +247,9 @@ On every startup, Operator: Already-detected tools keep their cached `path`/`version` across restarts (no version re-probing); config-sourced fields like the command template and model -aliases are re-derived from the loaded configs each startup. Health is never -carried over from a previous run - presence and the `health_command` are -re-checked every startup, so an uninstalled binary or a newly failing health -command demotes the tool on the next launch of Operator. +aliases are re-derived from the loaded configs each startup. + +Presence and the `health_command` are re-checked every startup, so an uninstalled binary or a newly failing health command demotes the tool on the next launch of Operator. ## Troubleshooting @@ -277,9 +297,10 @@ mod tests { fn test_llm_tools_generator_content() { let gen = LlmToolsDocGenerator; let content = gen.generate().unwrap(); - assert!(content.contains("LLM Tools Configuration")); + assert!(content.contains("# LLM Tools")); assert!(content.contains("Claude Code")); assert!(content.contains("tool_name")); assert!(content.contains("yolo_flags")); + assert!(content.contains("health_ok")); } } diff --git a/src/docs_gen/mod.rs b/src/docs_gen/mod.rs index 409c56a8..d56b3206 100644 --- a/src/docs_gen/mod.rs +++ b/src/docs_gen/mod.rs @@ -93,9 +93,6 @@ pub trait DocGenerator { /// A few keys differ from the generator's `name()` for historical reasons; the /// keys are the documented CLI contract and are what belongs here. /// -/// `LlmToolsDocGenerator` is deliberately **not** listed: `docs/llm-tools/index.md` -/// is hand-written, and running the generator would overwrite it. It stays -/// reachable by key for a deliberate regeneration. pub fn all_generators() -> Vec<(&'static str, Box)> { vec![ ("taxonomy", Box::new(taxonomy::TaxonomyDocGenerator)), @@ -148,13 +145,13 @@ pub fn all_generators() -> Vec<(&'static str, Box)> { Box::new(collections_pages::CollectionsPagesGenerator), ), ("maturity", Box::new(integrations::MaturityDocGenerator)), + ("llm-tools", Box::new(llm_tools::LlmToolsDocGenerator)), ] } -/// Generators reachable by `--only` but excluded from a full run, because they -/// would overwrite a page that is currently maintained by hand. +/// Generators reachable by `--only` but excluded from a full run. fn opt_in_generators() -> Vec<(&'static str, Box)> { - vec![("llm-tools", Box::new(llm_tools::LlmToolsDocGenerator))] + Vec::new() } /// Resolve a `--only` key to its generator. diff --git a/src/docs_gen/schema_index.rs b/src/docs_gen/schema_index.rs index 43c921ff..6101bb01 100644 --- a/src/docs_gen/schema_index.rs +++ b/src/docs_gen/schema_index.rs @@ -83,12 +83,12 @@ impl DocGenerator for SchemaIndexDocGenerator { "REST API specification (generated via utoipa)".to_string(), ], vec![ - "[collections/schema.json](../collections/schema.json)".to_string(), + "[collections/schema.json](/collections/schema.json)".to_string(), "JSON Schema".to_string(), "Hosted issuetype collection manifest format (collection.json)".to_string(), ], vec![ - "[collections/index.json](../collections/index.json)".to_string(), + "[collections/index.json](/collections/index.json)".to_string(), "JSON".to_string(), "Index of hosted issuetype collections (fetched during setup)".to_string(), ], diff --git a/src/env_vars.rs b/src/env_vars.rs index b477a917..da6e7b86 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -48,8 +48,6 @@ pub enum EnvVarCategory { LlmTools, /// Logging configuration Logging, - /// Premium licence verification, supplied at build time - Licensing, } impl EnvVarCategory { @@ -66,7 +64,6 @@ impl EnvVarCategory { EnvVarCategory::Tmux => "Tmux", EnvVarCategory::LlmTools => "LLM Tools", EnvVarCategory::Logging => "Logging", - EnvVarCategory::Licensing => "Licensing (build-time)", } } @@ -83,7 +80,6 @@ impl EnvVarCategory { EnvVarCategory::Tmux, EnvVarCategory::LlmTools, EnvVarCategory::Logging, - EnvVarCategory::Licensing, ] } } @@ -356,35 +352,6 @@ pub static ENV_VARS: &[EnvVar] = &[ default: Some("true"), example: Some("false"), }, - // === Licensing (build-time) === - // Read by `option_env!` in src/licensing.rs, so they are baked into the - // binary at compile time and cannot be set at runtime. A build with no - // verification keys rejects every licence, which is the correct default for - // a source build. - EnvVar { - name: "OPERATOR_LICENSE_PUBLIC_KEYS", - description: "JSON map of key id to base64 Ed25519 public key used to verify Premium licences. Compile-time only", - category: EnvVarCategory::Licensing, - required: false, - default: Some("{}"), - example: Some(r#"{"2026-01":"MCowBQYDK2VwAyEA..."}"#), - }, - EnvVar { - name: "OPERATOR_LICENSE_ISSUER", - description: "Expected `iss` claim on a Premium licence. Compile-time only", - category: EnvVarCategory::Licensing, - required: false, - default: Some("operator-licensing"), - example: Some("operator-licensing"), - }, - EnvVar { - name: "OPERATOR_PURCHASE_URL", - description: "External destination shown by the Premium paywall. Compile-time only", - category: EnvVarCategory::Licensing, - required: false, - default: None, - example: Some("https://operator.untra.io/premium"), - }, // Note: RELAY_HUB_SOCKET and RELAY_AGENT_NAME are intentionally excluded from this // registry because they follow the cross-project claude-relay naming convention // (no OPERATOR_ prefix) for wire compatibility with existing TS relay channels. @@ -469,8 +436,8 @@ mod tests { #[test] fn test_all_categories_in_order() { let all = EnvVarCategory::all(); - assert_eq!(all.len(), 11); + assert_eq!(all.len(), 10); assert_eq!(all[0], EnvVarCategory::Authentication); - assert_eq!(all[10], EnvVarCategory::Licensing); + assert_eq!(all[9], EnvVarCategory::Logging); } } diff --git a/src/http_client.rs b/src/http_client.rs new file mode 100644 index 00000000..5e58b45c --- /dev/null +++ b/src/http_client.rs @@ -0,0 +1,62 @@ +//! Single construction point for outbound HTTP clients. +//! +//! reqwest is built with `rustls-no-provider` so the tree stays on ring +//! (no aws-lc C build). rustls then needs a process-default `CryptoProvider` +//! installed before any client is built; every client goes through here. + +use std::sync::Once; + +static INSTALL_PROVIDER: Once = Once::new(); + +fn ensure_crypto_provider() { + INSTALL_PROVIDER.call_once(|| { + // Err means another caller already installed a provider, which is fine. + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +#[allow(clippy::disallowed_methods)] +pub fn default_client() -> reqwest::Client { + ensure_crypto_provider(); + reqwest::Client::new() +} + +#[allow(clippy::disallowed_methods)] +pub fn client_builder() -> reqwest::ClientBuilder { + ensure_crypto_provider(); + reqwest::Client::builder() +} + +#[allow(clippy::disallowed_methods)] +pub fn blocking_client_builder() -> reqwest::blocking::ClientBuilder { + ensure_crypto_provider(); + reqwest::blocking::Client::builder() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn async_client_builds_with_ring_provider() { + assert!(client_builder().build().is_ok()); + } + + #[test] + fn blocking_client_builds_with_ring_provider() { + assert!(blocking_client_builder().build().is_ok()); + } + + #[test] + fn default_client_installs_ring_provider() { + let _client = default_client(); + assert!(rustls::crypto::CryptoProvider::get_default().is_some()); + } + + #[test] + fn repeated_builds_do_not_reinstall() { + assert!(client_builder().build().is_ok()); + assert!(client_builder().build().is_ok()); + assert!(rustls::crypto::CryptoProvider::get_default().is_some()); + } +} diff --git a/src/integrations/catalog.rs b/src/integrations/catalog.rs index f6daf615..d3256837 100644 --- a/src/integrations/catalog.rs +++ b/src/integrations/catalog.rs @@ -159,6 +159,8 @@ pub struct CatalogEntry { /// Official support / maturity status. pub status: SupportStatus, pub premium: bool, + /// Vertical-specific structural support (not the advertising ramp). + pub support: crate::integrations::VerticalSupport, } impl CatalogEntry { @@ -265,6 +267,15 @@ pub fn all_integrations() -> Vec { true, Alpha, ), + entry( + Model, + "xai-api", + "xAI", + Some("getting-started/model-servers/xai"), + Some("xai"), + true, + Alpha, + ), entry( Model, "ollama", @@ -417,6 +428,15 @@ pub fn all_integrations() -> Vec { true, Alpha, ), + entry( + LlmTool, + "grok", + "Grok", + Some("getting-started/agents/grok"), + Some("xai"), + true, + Alpha, + ), // --- Platforms --- entry( Platform, @@ -574,6 +594,7 @@ fn entry( readme_badge, status, premium: false, + support: crate::integrations::support_for(vertical, slug), } } @@ -586,6 +607,30 @@ mod tests { assert!(!all_integrations().is_empty()); } + #[test] + fn test_llm_tool_and_model_rows_carry_matching_support() { + use crate::integrations::VerticalSupport; + for e in all_integrations() { + match e.vertical { + Vertical::LlmTool => { + assert!( + matches!(e.support, VerticalSupport::LlmTool(_)), + "{} should have LlmTool support", + e.slug + ); + } + Vertical::Model => { + assert!( + matches!(e.support, VerticalSupport::Model(_)), + "{} should have Model support", + e.slug + ); + } + _ => {} + } + } + } + #[test] fn remote_execution_is_premium_independently_of_maturity() { for entry in all_integrations() @@ -686,6 +731,7 @@ mod tests { fn test_onboardable_model_excludes_proto_entries() { let model = slugs(Vertical::Model); assert!(model.contains(&"anthropic-api")); + assert!(model.contains(&"xai-api")); assert!(model.contains(&"ollama")); assert!(!model.contains(&"openai-compat"), "openai-compat is Proto"); assert!(!model.contains(&"lmstudio"), "lmstudio is Proto"); diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index c5bea149..5b0e3775 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -6,8 +6,10 @@ pub mod catalog; pub mod inventory; +pub mod support_catalog; pub mod support_status; pub use catalog::{all_integrations, entry_for, CatalogEntry, Vertical}; pub use inventory::{all_capabilities, Capability}; +pub use support_catalog::{compatibility, support_for, Compatibility, VerticalSupport}; pub use support_status::SupportStatus; diff --git a/src/integrations/support_catalog.rs b/src/integrations/support_catalog.rs new file mode 100644 index 00000000..52539af9 --- /dev/null +++ b/src/integrations/support_catalog.rs @@ -0,0 +1,728 @@ +//! Per-vertical structural support and cross-vertical compatibility. +//! +//! [`SupportStatus`] is the advertising ramp (Proto/Alpha/Beta/GA). This +//! module is the *facts*: what Operator can actually do with an entry, and +//! whether an LLM tool can speak a model server's protocol without a bridge. +//! +//! Distinct from [`crate::integrations::inventory`] (slash/MCP/REST/TUI +//! surface parity). + +use super::catalog::Vertical; + +/// Inference wire protocol. Shared by LLM tools (native set) and model +/// servers (exactly one). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InferenceProtocol { + Anthropic, + OpenAi, + Google, +} + +impl InferenceProtocol { + pub fn slug(self) -> &'static str { + match self { + Self::Anthropic => "anthropic", + Self::OpenAi => "openai", + Self::Google => "google", + } + } +} + +impl ToolHealth { + pub fn slug(self) -> &'static str { + match self { + Self::PathVersion => "path-version", + Self::HealthCommand => "health-command", + Self::AuthProbe => "auth-probe", + Self::Unverifiable => "unverifiable", + } + } +} + +impl ToolAuth { + pub fn slug(self) -> &'static str { + match self { + Self::OauthAndKey => "oauth-and-key", + Self::ApiKey => "api-key", + } + } +} + +impl RemoteInventory { + pub fn slug(self) -> &'static str { + match self { + Self::Opr8rTools => "opr8r-tools", + Self::CommandV => "command-v", + Self::None => "none", + } + } +} + +impl RelaySupport { + pub fn slug(self) -> &'static str { + match self { + Self::Supported => "supported", + Self::Partial => "partial", + Self::None => "none", + } + } +} + +impl PermissionsSupport { + pub fn slug(self) -> &'static str { + match self { + Self::Wired => "wired", + Self::Deferred => "deferred", + } + } +} + +impl ProviderClass { + pub fn slug(self) -> &'static str { + match self { + Self::FirstParty => "first-party", + Self::Gateway => "gateway", + } + } +} + +impl KeyInjectable { + pub fn slug(self) -> &'static str { + match self { + Self::Yes => "yes", + Self::Optional => "optional", + Self::No => "no", + } + } +} + +impl Coverage { + pub fn slug(self) -> &'static str { + match self { + Self::Partial => "partial", + Self::Full => "full", + } + } +} + +/// How Operator knows an LLM CLI is usable on a host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolHealth { + /// `which` + version command. Not proof of login or API key. + PathVersion, + /// `detection.health_command` (always-mode / remote-only tools). + HealthCommand, + /// Operator can prove the CLI's own credential works. + AuthProbe, + /// Always-mode with nothing locally verifiable. + Unverifiable, +} + +/// How a tool authenticates for launch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolAuth { + OauthAndKey, + ApiKey, +} + +/// How a remote target reports this binary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteInventory { + Opr8rTools, + CommandV, + None, +} + +/// Operator relay MCP injection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelaySupport { + Supported, + Partial, + None, +} + +/// Permission-translator wiring into launch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionsSupport { + Wired, + Deferred, +} + +/// First-party vendor vs gateway/host. Mirrors +/// [`crate::api::providers::model_server::ModelProviderClass`] as a catalog fact. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderClass { + FirstParty, + Gateway, +} + +/// Whether `env_for_server` can inject an API key reference. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyInjectable { + Yes, + Optional, + No, +} + +/// Sparse coverage for verticals that do not yet have rich axes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Coverage { + Partial, + Full, +} + +/// LLM tool product claims (not invocation flags from `tools/*.json`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LlmToolSupport { + pub health: ToolHealth, + pub auth: ToolAuth, + pub native_protocols: &'static [InferenceProtocol], + pub sessions: bool, + pub headless: bool, + pub yolo: bool, + pub remote_inventory: RemoteInventory, + pub relay: RelaySupport, + pub permissions: PermissionsSupport, +} + +/// Model provider product claims. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ModelProviderSupport { + pub protocol: InferenceProtocol, + pub class: ProviderClass, + pub probe: bool, + pub connectable_from_defaults: bool, + pub key_injectable: KeyInjectable, + /// Binary name this kind is the implicit default for, if any. + pub implicit_for: Option<&'static str>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KanbanSupport { + pub sync_in: bool, + pub write_back: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GitSupport { + pub pr_cli: bool, + pub remote_preflight: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionSupport { + pub attach: bool, + pub send_keys: bool, + pub idle_detect: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteTargetSupport { + pub probe: bool, + pub tool_inventory: bool, + pub credential_injection: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SparseSupport { + pub coverage: Coverage, + pub notes: Option<&'static str>, +} + +/// Typed support record, one variant per catalog vertical. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerticalSupport { + LlmTool(LlmToolSupport), + Model(ModelProviderSupport), + Kanban(KanbanSupport), + Git(GitSupport), + Session(SessionSupport), + Editor(SparseSupport), + Platform(SparseSupport), + Integration(SparseSupport), + Workflows(SparseSupport), + Notification(SparseSupport), + Transport(RemoteTargetSupport), + AgentRelay(SparseSupport), + RemoteTargets(RemoteTargetSupport), +} + +/// LLM tool × model server protocol relationship. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Compatibility { + Native, + Bridge { note: &'static str }, + Incompatible { reason: &'static str }, +} + +impl Compatibility { + pub fn slug(self) -> &'static str { + match self { + Self::Native => "native", + Self::Bridge { .. } => "bridge", + Self::Incompatible { .. } => "incompatible", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Native => "Native", + Self::Bridge { .. } => "Bridge", + Self::Incompatible { .. } => "Incompatible", + } + } +} + +const BRIDGE_GATEWAY: &str = + "protocol-preserving front (e.g. claude-code-router, litellm) in front of this gateway"; +const BRIDGE_OPENAI_FIRST_PARTY: &str = "Anthropic-protocol proxy in front of the OpenAI API"; +const INCOMPATIBLE_FIRST_PARTY: &str = + "first-party API speaks a different protocol than this CLI natively does"; + +/// Support record for a catalog `(vertical, slug)`, or a sparse default. +pub fn support_for(vertical: Vertical, slug: &str) -> VerticalSupport { + match (vertical, slug) { + (Vertical::LlmTool, slug) => llm_tool_supports() + .iter() + .find(|(s, _)| *s == slug) + .map(|(_, support)| VerticalSupport::LlmTool(*support)) + .unwrap_or(VerticalSupport::LlmTool(UNKNOWN_LLM)), + (Vertical::Model, slug) => model_supports() + .iter() + .find(|(s, _)| *s == slug) + .map(|(_, support)| VerticalSupport::Model(*support)) + .unwrap_or(VerticalSupport::Model(UNKNOWN_MODEL)), + (Vertical::Kanban, "operator") => VerticalSupport::Kanban(KanbanSupport { + sync_in: true, + write_back: true, + }), + (Vertical::Kanban, "jira" | "linear" | "github") => { + VerticalSupport::Kanban(KanbanSupport { + sync_in: true, + write_back: true, + }) + } + (Vertical::Kanban, "openspec") => VerticalSupport::Kanban(KanbanSupport { + sync_in: true, + write_back: false, + }), + (Vertical::Git, _) => VerticalSupport::Git(GitSupport { + pr_cli: true, + remote_preflight: true, + }), + (Vertical::Session, _) => VerticalSupport::Session(SessionSupport { + attach: true, + send_keys: true, + idle_detect: true, + }), + (Vertical::RemoteTargets, "ssh") => VerticalSupport::RemoteTargets(RemoteTargetSupport { + probe: true, + tool_inventory: true, + credential_injection: true, + }), + (Vertical::RemoteTargets, "coder") => VerticalSupport::RemoteTargets(RemoteTargetSupport { + probe: true, + tool_inventory: false, + credential_injection: false, + }), + (Vertical::Transport, "ssh") => VerticalSupport::Transport(RemoteTargetSupport { + probe: true, + tool_inventory: true, + credential_injection: true, + }), + (Vertical::Transport, "local") => VerticalSupport::Transport(RemoteTargetSupport { + probe: true, + tool_inventory: true, + credential_injection: false, + }), + (Vertical::Editor, _) => VerticalSupport::Editor(sparse_partial(None)), + (Vertical::Platform, _) => VerticalSupport::Platform(sparse_partial(None)), + (Vertical::Integration, _) => VerticalSupport::Integration(sparse_partial(None)), + (Vertical::Workflows, _) => VerticalSupport::Workflows(sparse_partial(None)), + (Vertical::Notification, _) => VerticalSupport::Notification(sparse_partial(None)), + (Vertical::AgentRelay, _) => VerticalSupport::AgentRelay(sparse_partial(Some( + "Relay MCP injection is Claude-first; Codex is partial.", + ))), + + (Vertical::Kanban, _) => VerticalSupport::Kanban(KanbanSupport { + sync_in: false, + write_back: false, + }), + (Vertical::Transport, _) => VerticalSupport::Transport(RemoteTargetSupport { + probe: false, + tool_inventory: false, + credential_injection: false, + }), + (Vertical::RemoteTargets, _) => VerticalSupport::RemoteTargets(RemoteTargetSupport { + probe: false, + tool_inventory: false, + credential_injection: false, + }), + } +} + +fn sparse_partial(notes: Option<&'static str>) -> SparseSupport { + SparseSupport { + coverage: Coverage::Partial, + notes, + } +} + +const CLAUDE: LlmToolSupport = LlmToolSupport { + health: ToolHealth::PathVersion, + auth: ToolAuth::OauthAndKey, + native_protocols: &[InferenceProtocol::Anthropic], + sessions: true, + headless: false, + yolo: true, + remote_inventory: RemoteInventory::Opr8rTools, + relay: RelaySupport::Supported, + permissions: PermissionsSupport::Deferred, +}; + +const CODEX: LlmToolSupport = LlmToolSupport { + health: ToolHealth::PathVersion, + auth: ToolAuth::ApiKey, + native_protocols: &[InferenceProtocol::OpenAi], + sessions: true, + headless: true, + yolo: true, + remote_inventory: RemoteInventory::Opr8rTools, + relay: RelaySupport::Partial, + permissions: PermissionsSupport::Deferred, +}; + +const GEMINI: LlmToolSupport = LlmToolSupport { + health: ToolHealth::PathVersion, + auth: ToolAuth::ApiKey, + native_protocols: &[InferenceProtocol::Google], + sessions: true, + headless: true, + yolo: true, + remote_inventory: RemoteInventory::Opr8rTools, + relay: RelaySupport::None, + permissions: PermissionsSupport::Deferred, +}; + +const GROK: LlmToolSupport = LlmToolSupport { + health: ToolHealth::PathVersion, + auth: ToolAuth::OauthAndKey, + native_protocols: &[InferenceProtocol::OpenAi], + sessions: true, + headless: true, + yolo: true, + remote_inventory: RemoteInventory::Opr8rTools, + relay: RelaySupport::None, + permissions: PermissionsSupport::Deferred, +}; + +const UNKNOWN_LLM: LlmToolSupport = LlmToolSupport { + health: ToolHealth::Unverifiable, + auth: ToolAuth::ApiKey, + native_protocols: &[], + sessions: false, + headless: false, + yolo: false, + remote_inventory: RemoteInventory::None, + relay: RelaySupport::None, + permissions: PermissionsSupport::Deferred, +}; + +const ANTHROPIC: ModelProviderSupport = ModelProviderSupport { + protocol: InferenceProtocol::Anthropic, + class: ProviderClass::FirstParty, + probe: true, + connectable_from_defaults: true, + key_injectable: KeyInjectable::Yes, + implicit_for: Some("claude"), +}; + +const OPENAI: ModelProviderSupport = ModelProviderSupport { + protocol: InferenceProtocol::OpenAi, + class: ProviderClass::FirstParty, + probe: true, + connectable_from_defaults: true, + key_injectable: KeyInjectable::Yes, + implicit_for: Some("codex"), +}; + +const GOOGLE: ModelProviderSupport = ModelProviderSupport { + protocol: InferenceProtocol::Google, + class: ProviderClass::FirstParty, + probe: true, + connectable_from_defaults: true, + key_injectable: KeyInjectable::Yes, + implicit_for: Some("gemini"), +}; + +const XAI: ModelProviderSupport = ModelProviderSupport { + protocol: InferenceProtocol::OpenAi, + class: ProviderClass::FirstParty, + probe: true, + connectable_from_defaults: true, + key_injectable: KeyInjectable::Yes, + implicit_for: Some("grok"), +}; + +const OLLAMA: ModelProviderSupport = ModelProviderSupport { + protocol: InferenceProtocol::OpenAi, + class: ProviderClass::Gateway, + probe: true, + connectable_from_defaults: true, + key_injectable: KeyInjectable::Optional, + implicit_for: None, +}; + +const OPENROUTER: ModelProviderSupport = ModelProviderSupport { + protocol: InferenceProtocol::OpenAi, + class: ProviderClass::Gateway, + probe: true, + connectable_from_defaults: true, + key_injectable: KeyInjectable::Yes, + implicit_for: None, +}; + +const OPENAI_COMPAT: ModelProviderSupport = ModelProviderSupport { + protocol: InferenceProtocol::OpenAi, + class: ProviderClass::Gateway, + probe: true, + connectable_from_defaults: false, + key_injectable: KeyInjectable::Yes, + implicit_for: None, +}; + +const LMSTUDIO: ModelProviderSupport = ModelProviderSupport { + protocol: InferenceProtocol::OpenAi, + class: ProviderClass::Gateway, + probe: true, + connectable_from_defaults: false, + key_injectable: KeyInjectable::Optional, + implicit_for: None, +}; + +const UNKNOWN_MODEL: ModelProviderSupport = ModelProviderSupport { + protocol: InferenceProtocol::OpenAi, + class: ProviderClass::Gateway, + probe: false, + connectable_from_defaults: false, + key_injectable: KeyInjectable::No, + implicit_for: None, +}; + +fn llm_support_for_id(id: &str) -> Option { + match id { + "claude" => Some(CLAUDE), + "codex" => Some(CODEX), + "gemini" | "gemini-cli" => Some(GEMINI), + "grok" => Some(GROK), + _ => None, + } +} + +fn model_support_for_kind(kind: &str) -> Option { + model_supports() + .iter() + .find(|(slug, _)| *slug == kind) + .map(|(_, support)| *support) +} + +/// Protocol compatibility of a shipped LLM tool (catalog slug or binary) +/// with a model-server kind slug. +pub fn compatibility(tool_id: &str, model_kind: &str) -> Option { + let tool = llm_support_for_id(tool_id)?; + let model = model_support_for_kind(model_kind)?; + if tool.native_protocols.contains(&model.protocol) { + return Some(Compatibility::Native); + } + if model.class == ProviderClass::Gateway { + return Some(Compatibility::Bridge { + note: BRIDGE_GATEWAY, + }); + } + if tool + .native_protocols + .contains(&InferenceProtocol::Anthropic) + && model.protocol == InferenceProtocol::OpenAi + && model.class == ProviderClass::FirstParty + { + return Some(Compatibility::Bridge { + note: BRIDGE_OPENAI_FIRST_PARTY, + }); + } + Some(Compatibility::Incompatible { + reason: INCOMPATIBLE_FIRST_PARTY, + }) +} + +/// Shipped LLM tools in catalog display order (slug, support). +pub fn llm_tool_supports() -> &'static [(&'static str, LlmToolSupport)] { + &[ + ("claude", CLAUDE), + ("codex", CODEX), + ("gemini-cli", GEMINI), + ("grok", GROK), + ] +} + +/// Model kinds that have a support row, in catalog display order. +pub fn model_supports() -> &'static [(&'static str, ModelProviderSupport)] { + &[ + ("anthropic-api", ANTHROPIC), + ("openai-api", OPENAI), + ("google-api", GOOGLE), + ("xai-api", XAI), + ("ollama", OLLAMA), + ("openrouter", OPENROUTER), + ("openai-compat", OPENAI_COMPAT), + ("lmstudio", LMSTUDIO), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_claude_ollama_is_bridge() { + let c = compatibility("claude", "ollama").unwrap(); + assert_eq!(c.slug(), "bridge"); + } + + #[test] + fn test_codex_ollama_is_native() { + assert_eq!( + compatibility("codex", "ollama"), + Some(Compatibility::Native) + ); + } + + #[test] + fn test_claude_google_is_incompatible() { + let c = compatibility("claude", "google-api").unwrap(); + assert_eq!(c.slug(), "incompatible"); + } + + #[test] + fn test_claude_openai_api_is_bridge() { + assert_eq!( + compatibility("claude", "openai-api").unwrap().slug(), + "bridge" + ); + } + + #[test] + fn test_gemini_openai_api_is_incompatible() { + assert_eq!( + compatibility("gemini", "openai-api").unwrap().slug(), + "incompatible" + ); + assert_eq!( + compatibility("gemini-cli", "openai-api").unwrap().slug(), + "incompatible" + ); + } + + #[test] + fn test_gemini_ollama_is_bridge() { + assert_eq!(compatibility("gemini", "ollama").unwrap().slug(), "bridge"); + } + + #[test] + fn test_codex_anthropic_is_incompatible() { + assert_eq!( + compatibility("codex", "anthropic-api").unwrap().slug(), + "incompatible" + ); + } + + #[test] + fn test_implicit_pairs_are_native() { + assert_eq!( + compatibility("claude", "anthropic-api"), + Some(Compatibility::Native) + ); + assert_eq!( + compatibility("codex", "openai-api"), + Some(Compatibility::Native) + ); + assert_eq!( + compatibility("gemini", "google-api"), + Some(Compatibility::Native) + ); + assert_eq!( + compatibility("grok", "xai-api"), + Some(Compatibility::Native) + ); + } + + #[test] + fn test_compatibility_matrix_table() { + let cases = [ + ("claude", "anthropic-api", "native"), + ("claude", "openai-api", "bridge"), + ("claude", "google-api", "incompatible"), + ("claude", "xai-api", "bridge"), + ("claude", "ollama", "bridge"), + ("codex", "anthropic-api", "incompatible"), + ("codex", "openai-api", "native"), + ("codex", "xai-api", "native"), + ("codex", "ollama", "native"), + ("gemini", "google-api", "native"), + ("gemini", "openai-api", "incompatible"), + ("gemini", "xai-api", "incompatible"), + ("gemini", "ollama", "bridge"), + ("grok", "xai-api", "native"), + ("grok", "openai-api", "native"), + ("grok", "ollama", "native"), + ("grok", "anthropic-api", "incompatible"), + ("grok", "google-api", "incompatible"), + ]; + for (tool, kind, want) in cases { + let got = compatibility(tool, kind).map(Compatibility::slug); + assert_eq!(got, Some(want), "{tool} × {kind}"); + } + } + + #[test] + fn test_unknown_tool_has_no_matrix_row() { + assert!(compatibility("agy", "ollama").is_none()); + } + + #[test] + fn test_claude_health_is_path_version_not_auth_probe() { + match support_for(Vertical::LlmTool, "claude") { + VerticalSupport::LlmTool(s) => { + assert_eq!(s.health, ToolHealth::PathVersion); + assert_ne!(s.health, ToolHealth::AuthProbe); + assert!(!s.headless); + assert_eq!(s.native_protocols, &[InferenceProtocol::Anthropic]); + } + other => panic!("expected LlmTool, got {other:?}"), + } + } + + #[test] + fn test_anthropic_probe_is_stronger_than_tool_health() { + match support_for(Vertical::Model, "anthropic-api") { + VerticalSupport::Model(s) => { + assert!(s.probe); + assert_eq!(s.implicit_for, Some("claude")); + assert_eq!(s.protocol, InferenceProtocol::Anthropic); + } + other => panic!("expected Model, got {other:?}"), + } + } + + #[test] + fn test_ga_llm_bar_claude() { + match support_for(Vertical::LlmTool, "claude") { + VerticalSupport::LlmTool(s) => { + assert!(s.sessions); + assert!(s.yolo); + assert!(!s.native_protocols.is_empty()); + assert_eq!(s.health, ToolHealth::PathVersion); + } + other => panic!("expected LlmTool, got {other:?}"), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index eef1c8e0..816d115e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod collections; pub mod config; pub mod editors; pub mod git; +pub mod http_client; pub mod licensing; pub mod profiles; pub mod queue; @@ -20,6 +21,7 @@ pub mod rest; pub mod setup; pub mod startup; pub mod state; +pub mod trust_verify; pub mod types; // Internal modules required by public modules diff --git a/src/licensing.rs b/src/licensing.rs index 9ed290c5..7ed660a2 100644 --- a/src/licensing.rs +++ b/src/licensing.rs @@ -1,10 +1,7 @@ -use std::collections::BTreeMap; use std::io::Write; use std::sync::Mutex; use anyhow::{Context, Result}; -use base64::{engine::general_purpose::STANDARD, Engine}; -use jsonwebtoken::{Algorithm, DecodingKey, Validation}; use serde::{Deserialize, Serialize}; use ts_rs::TS; use utoipa::ToSchema; @@ -14,10 +11,11 @@ use crate::config::{Config, TargetDef, TargetKind}; pub const PREMIUM_TIER: &str = "premium"; pub const LICENSE_AUDIENCE: &str = "operator-license"; +/// Claim version shared with `license`. The envelope, not this number, refuses a bare JWT. pub const LICENSE_VERSION: u32 = 1; const LICENSE_FILE: &str = "license.key"; const MAX_LICENSE_BYTES: usize = 32 * 1024; -const PUBLIC_KEY_BYTES: usize = 32; +const PRODUCT_ID: &str = "operator"; static LICENSE_UPDATE: Mutex<()> = Mutex::new(()); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, TS)] @@ -93,23 +91,47 @@ impl LicenseResponse { } pub struct Verifier { - keys: BTreeMap, + roots: crate::trust_verify::RootKeyring, issuer: String, } +/// Chain result plus the attestation window. The window stays off +/// [`LicenseTerms`] because that type is public API. +#[derive(Debug, Clone)] +struct DecodedLicense { + terms: LicenseTerms, + attestation_not_before: i64, + attestation_expires_at: i64, +} + +impl DecodedLicense { + fn effective_exp(&self) -> i64 { + crate::trust_verify::VerifiedLicense { + claims: (), + signing_kid: String::new(), + attestation_not_before: self.attestation_not_before, + attestation_expires_at: self.attestation_expires_at, + } + .effective_expiry(self.terms.exp) + } +} + impl Verifier { - /// A verifier over an explicit key set. Enforcement always goes through - /// [`Verifier::bundled`]; this exists so tests and issuing tools can verify - /// against a key that is not compiled in. + /// A verifier over an explicit root keyring. Enforcement always goes through + /// [`Verifier::bundled`]; this exists so tests can verify against a root + /// that is not compiled in. #[allow(dead_code)] // Verification seam: used from tests, not the binary - pub fn from_keys(keys: BTreeMap, issuer: String) -> Self { - Self { keys, issuer } + pub fn from_keys(roots: crate::trust_verify::RootKeyring, issuer: String) -> Self { + Self { roots, issuer } } pub fn bundled() -> Result { + let roots = crate::trust_verify::RootKeyring::from_json( + option_env!("OPERATOR_LICENSE_ROOT_KEYS").unwrap_or("{}"), + ) + .context("invalid bundled license root keys")?; Ok(Self { - keys: serde_json::from_str(option_env!("OPERATOR_LICENSE_PUBLIC_KEYS").unwrap_or("{}")) - .context("invalid bundled license verification keys")?, + roots, issuer: option_env!("OPERATOR_LICENSE_ISSUER") .unwrap_or("operator-licensing") .to_owned(), @@ -121,55 +143,28 @@ impl Verifier { let mut hasher = std::collections::hash_map::DefaultHasher::new(); self.issuer.hash(&mut hasher); - for (key_id, key) in &self.keys { - key_id.hash(&mut hasher); - key.hash(&mut hasher); - } + self.roots.hash(&mut hasher); hasher.finish() } /// Signature and claim checks. Deliberately time-independent so the result /// can be memoised; validity against the clock is [`status_for`]. - fn decode(&self, key: &str, profile_id: Uuid) -> Result { + fn decode(&self, key: &str, profile_id: Uuid) -> Result { anyhow::ensure!( !profile_id.is_nil(), "configuration identity has not been initialized" ); anyhow::ensure!(key.len() <= MAX_LICENSE_BYTES, "license is too large"); - let bytes = STANDARD - .decode(key.trim()) - .context("license must be Base64 encoded")?; - let token = std::str::from_utf8(&bytes).context("license is not a JWT")?; - let header = jsonwebtoken::decode_header(token).context("invalid license JWT")?; - anyhow::ensure!( - header.alg == Algorithm::EdDSA, - "unsupported license algorithm" - ); - let encoded = header - .kid - .as_ref() - .and_then(|kid| self.keys.get(kid)) - .context("unknown license signing key")?; - let public = STANDARD - .decode(encoded) - .context("invalid license verification key")?; - anyhow::ensure!( - public.len() == PUBLIC_KEY_BYTES, - "invalid license verification key" - ); - let mut validation = Validation::new(Algorithm::EdDSA); - validation.set_issuer(&[&self.issuer]); - validation.set_audience(&[LICENSE_AUDIENCE]); - validation.set_required_spec_claims(&["exp", "iat", "nbf", "iss", "aud", "sub"]); - validation.validate_exp = false; - validation.validate_nbf = false; - let terms = jsonwebtoken::decode::( - token, - &DecodingKey::from_ed_der(&public), - &validation, - ) - .context("license signature or claims rejected")? - .claims; + let verified = crate::trust_verify::verify_license::( + key, + &self.roots, + &crate::trust_verify::LicensePolicy { + product: PRODUCT_ID, + issuer: &self.issuer, + audience: LICENSE_AUDIENCE, + }, + )?; + let terms = verified.claims; anyhow::ensure!( terms.version == LICENSE_VERSION, "unsupported license version" @@ -187,7 +182,11 @@ impl Verifier { terms.iat >= 0 && terms.nbf >= terms.iat && terms.exp > terms.nbf, "invalid license validity interval" ); - Ok(terms) + Ok(DecodedLicense { + terms, + attestation_not_before: verified.attestation_not_before, + attestation_expires_at: verified.attestation_expires_at, + }) } fn verify( @@ -196,16 +195,20 @@ impl Verifier { profile_id: Uuid, now: i64, ) -> Result<(LicenseStatus, LicenseTerms)> { - let terms = self.decode(key, profile_id)?; - Ok((status_for(&terms, now), terms)) + let decoded = self.decode(key, profile_id)?; + let status = status_for(&decoded, now); + Ok((status, decoded.terms)) } } -/// Where `now` falls relative to the licence's validity interval. -fn status_for(terms: &LicenseTerms, now: i64) -> LicenseStatus { - if now >= terms.exp { +/// Where `now` falls relative to the licence and its attestation. +fn status_for(decoded: &DecodedLicense, now: i64) -> LicenseStatus { + if now >= decoded.effective_exp() { LicenseStatus::Expired - } else if now < terms.nbf || now < terms.iat { + } else if now < decoded.terms.nbf + || now < decoded.terms.iat + || now < decoded.attestation_not_before + { LicenseStatus::NotYetValid } else { LicenseStatus::Valid @@ -250,7 +253,7 @@ struct FileStamp { /// The verification outcome, without the time-dependent part. #[derive(Debug, Clone)] enum Verified { - Terms(Box), + Terms(Box), Rejected, } @@ -339,7 +342,7 @@ pub fn status_with(config: &Config, verifier: &Verifier, now: i64) -> LicenseRes DECODES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let verified = match std::fs::read_to_string(&path) { Ok(key) => match verifier.decode(&key, config.profile.id) { - Ok(terms) => Verified::Terms(Box::new(terms)), + Ok(decoded) => Verified::Terms(Box::new(decoded)), Err(_) => Verified::Rejected, }, Err(_) => Verified::Rejected, @@ -351,13 +354,13 @@ pub fn status_with(config: &Config, verifier: &Verifier, now: i64) -> LicenseRes match verified { Verified::Rejected => rejected(config), - Verified::Terms(terms) => { - let status = status_for(&terms, now); + Verified::Terms(decoded) => { + let status = status_for(&decoded, now); LicenseResponse { status, profile_id: config.profile.id, premium: status == LicenseStatus::Valid, - terms: Some(*terms), + terms: Some(decoded.terms), purchase_url: purchase_url(), } } @@ -492,16 +495,40 @@ mod tests { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } - use jsonwebtoken::{EncodingKey, Header}; + use base64::{engine::general_purpose::STANDARD, Engine}; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; use ring::signature::{Ed25519KeyPair, KeyPair}; - fn fixture() -> (Verifier, EncodingKey, LicenseTerms) { + const ROOT_KID: &str = "root-test"; + const SIGNING_KID: &str = "test"; + const ATTESTATION_NBF: i64 = 2_000; + const ATTESTATION_EXP: i64 = 3_000; + const NOW: i64 = 2_500; + + struct Fixture { + verifier: Verifier, + license_key: EncodingKey, + attestation: String, + terms: LicenseTerms, + } + + fn generate_ed25519() -> (Ed25519KeyPair, Vec) { let document = Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()).unwrap(); - let pair = Ed25519KeyPair::from_pkcs8(document.as_ref()).unwrap(); - let verifier = Verifier { - keys: BTreeMap::from([("test".into(), STANDARD.encode(pair.public_key().as_ref()))]), - issuer: "test-issuer".into(), - }; + let bytes = document.as_ref().to_vec(); + let pair = Ed25519KeyPair::from_pkcs8(&bytes).unwrap(); + (pair, bytes) + } + + fn mint() -> Fixture { + let (root, root_pkcs8) = generate_ed25519(); + let (signing, signing_pkcs8) = generate_ed25519(); + let root_b64 = STANDARD.encode(root.public_key().as_ref()); + let roots = crate::trust_verify::RootKeyring::from_json(&format!( + r#"{{"{ROOT_KID}":"{root_b64}"}}"# + )) + .unwrap(); + let verifier = Verifier::from_keys(roots, "test-issuer".into()); + let attestation = sign_attestation(&root_pkcs8, signing.public_key().as_ref()); let terms = LicenseTerms { version: LICENSE_VERSION, iss: verifier.issuer.clone(), @@ -510,68 +537,116 @@ mod tests { jti: Uuid::new_v4().to_string(), profile_id: Uuid::new_v4(), tier: PREMIUM_TIER.into(), - iat: 100, - nbf: 100, - exp: 200, + iat: ATTESTATION_NBF, + nbf: ATTESTATION_NBF, + exp: ATTESTATION_EXP, + }; + Fixture { + verifier, + license_key: EncodingKey::from_ed_der(&signing_pkcs8), + attestation, + terms, + } + } + + fn sign_attestation(root_pkcs8: &[u8], signing_public: &[u8]) -> String { + let claims = crate::trust_verify::AttestationClaims { + version: crate::trust_verify::ATTESTATION_VERSION, + iss: crate::trust_verify::ATTESTATION_ISSUER.into(), + aud: crate::trust_verify::ATTESTATION_AUDIENCE.into(), + sub: PRODUCT_ID.into(), + purpose: crate::trust_verify::PURPOSE_LICENSE_SIGNING.into(), + kid: SIGNING_KID.into(), + public_key: STANDARD.encode(signing_public), + iat: ATTESTATION_NBF, + nbf: ATTESTATION_NBF, + exp: ATTESTATION_EXP, }; - (verifier, EncodingKey::from_ed_der(document.as_ref()), terms) + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(ROOT_KID.into()); + jsonwebtoken::encode(&header, &claims, &EncodingKey::from_ed_der(root_pkcs8)).unwrap() } - fn sign(encoding: &EncodingKey, terms: &LicenseTerms) -> String { + fn sign(fixture: &Fixture, terms: &LicenseTerms) -> String { let mut header = Header::new(Algorithm::EdDSA); - header.kid = Some("test".into()); - STANDARD.encode(jsonwebtoken::encode(&header, terms, encoding).unwrap()) + header.kid = Some(SIGNING_KID.into()); + let license = jsonwebtoken::encode(&header, terms, &fixture.license_key).unwrap(); + crate::trust_verify::Envelope::new(license, fixture.attestation.clone()).encode() } #[test] fn verifies_signature_identity_and_time_boundaries() { - let (verifier, encoding, terms) = fixture(); - let key = sign(&encoding, &terms); + let fixture = mint(); + let key = sign(&fixture, &fixture.terms); assert_eq!( - verifier.verify(&key, terms.profile_id, 100).unwrap().0, + fixture + .verifier + .verify(&key, fixture.terms.profile_id, ATTESTATION_NBF) + .unwrap() + .0, LicenseStatus::Valid ); assert_eq!( - verifier.verify(&key, terms.profile_id, 99).unwrap().0, + fixture + .verifier + .verify(&key, fixture.terms.profile_id, ATTESTATION_NBF - 1) + .unwrap() + .0, LicenseStatus::NotYetValid ); assert_eq!( - verifier.verify(&key, terms.profile_id, 200).unwrap().0, + fixture + .verifier + .verify(&key, fixture.terms.profile_id, ATTESTATION_EXP) + .unwrap() + .0, LicenseStatus::Expired ); - assert!(verifier.verify(&key, Uuid::new_v4(), 150).is_err()); - let (_, wrong_key, _) = fixture(); - assert!(verifier - .verify(&sign(&wrong_key, &terms), terms.profile_id, 150) + assert!(fixture.verifier.verify(&key, Uuid::new_v4(), NOW).is_err()); + let other = mint(); + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(SIGNING_KID.into()); + let forged_license = + jsonwebtoken::encode(&header, &fixture.terms, &other.license_key).unwrap(); + let forged = + crate::trust_verify::Envelope::new(forged_license, fixture.attestation.clone()) + .encode(); + assert!(fixture + .verifier + .verify(&forged, fixture.terms.profile_id, NOW) .is_err()); } #[test] fn rejects_wrong_domain_tier_version_and_malformed_input() { - let (verifier, encoding, terms) = fixture(); + let fixture = mint(); for modified in [ LicenseTerms { aud: crate::auth::tokens::AUDIENCE_API.into(), - ..terms.clone() + ..fixture.terms.clone() }, LicenseTerms { iss: "attacker".into(), - ..terms.clone() + ..fixture.terms.clone() }, LicenseTerms { tier: "unknown".into(), - ..terms.clone() + ..fixture.terms.clone() }, LicenseTerms { version: LICENSE_VERSION + 1, - ..terms.clone() + ..fixture.terms.clone() }, ] { - assert!(verifier - .verify(&sign(&encoding, &modified), terms.profile_id, 150) + assert!(fixture + .verifier + .verify(&sign(&fixture, &modified), fixture.terms.profile_id, NOW) .is_err()); } - assert!(verifier.verify("not-a-key", terms.profile_id, 150).is_err()); + assert!(fixture + .verifier + .verify("not-a-key", fixture.terms.profile_id, NOW) + .is_err()); } /// Pins today's default: a source build carries no verification keys, so @@ -581,23 +656,106 @@ mod tests { #[test] fn a_build_with_no_bundled_keys_rejects_every_licence() { let bundled = Verifier::bundled().expect("bundled key set must parse"); - let (_, encoding, terms) = fixture(); - let signed = sign(&encoding, &terms); - let outcome = bundled.decode(&signed, terms.profile_id); + let fixture = mint(); + let signed = sign(&fixture, &fixture.terms); + let outcome = bundled.decode(&signed, fixture.terms.profile_id); - if bundled.keys.is_empty() { - let error = outcome.expect_err("no keys means nothing can verify"); + if bundled.roots.is_empty() { + let error = outcome.expect_err("no roots means nothing can verify"); assert!( - error.to_string().contains("unknown license signing key"), + error + .to_string() + .contains("license attestation was signed by an untrusted root"), "unexpected rejection: {error}" ); } else { - // A build configured with real keys still must not accept a - // licence signed by this test's throwaway key. - assert!(outcome.is_err(), "a foreign key must never verify"); + // A build configured with real roots still must not accept a + // licence signed under this test's throwaway root. + assert!(outcome.is_err(), "a foreign root must never verify"); } } + #[test] + fn a_bare_base64_jwt_is_refused() { + let fixture = mint(); + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(SIGNING_KID.into()); + let jwt = jsonwebtoken::encode(&header, &fixture.terms, &fixture.license_key).unwrap(); + let legacy = STANDARD.encode(jwt); + let error = fixture + .verifier + .decode(&legacy, fixture.terms.profile_id) + .unwrap_err(); + assert!( + error + .to_string() + .contains("license is not a valid envelope"), + "{error}" + ); + } + + #[test] + fn an_hmac_license_inside_a_valid_envelope_is_refused() { + let fixture = mint(); + let mut header = Header::new(Algorithm::HS256); + header.kid = Some(SIGNING_KID.into()); + let hmac = EncodingKey::from_secret(b"not-the-signing-key"); + let license = jsonwebtoken::encode(&header, &fixture.terms, &hmac).unwrap(); + let key = crate::trust_verify::Envelope::new(license, fixture.attestation).encode(); + let error = fixture + .verifier + .verify(&key, fixture.terms.profile_id, NOW) + .unwrap_err(); + assert!( + error + .to_string() + .contains("unsupported signature algorithm"), + "{error}" + ); + } + + #[test] + fn attestation_window_bounds_the_license_clock() { + let fixture = mint(); + let terms = LicenseTerms { + iat: 1_000, + nbf: 1_000, + exp: 5_000, + ..fixture.terms.clone() + }; + let key = sign(&fixture, &terms); + assert_eq!( + fixture + .verifier + .verify(&key, terms.profile_id, NOW) + .unwrap() + .0, + LicenseStatus::Valid + ); + assert_eq!( + fixture + .verifier + .verify(&key, terms.profile_id, 1_500) + .unwrap() + .0, + LicenseStatus::NotYetValid + ); + assert_eq!( + fixture + .verifier + .verify(&key, terms.profile_id, 3_500) + .unwrap() + .0, + LicenseStatus::Expired + ); + let (status, reported) = fixture + .verifier + .verify(&key, terms.profile_id, NOW) + .unwrap(); + assert_eq!(status, LicenseStatus::Valid); + assert_eq!(reported.exp, 5_000, "terms.exp stays the license exp"); + } + /// The launch path calls this once per gate, seven times per launch; the /// signature must be verified once, not seven times. #[test] @@ -605,17 +763,23 @@ mod tests { use std::sync::atomic::Ordering; let _serial = serial(); - let (verifier, encoding, terms) = fixture(); + let fixture = mint(); let directory = tempfile::tempdir().unwrap(); let mut config = Config::default(); config.paths.state = directory.path().to_string_lossy().into_owned(); - config.profile.id = terms.profile_id; - install_with(&config, &verifier, terms.nbf, &sign(&encoding, &terms)).unwrap(); + config.profile.id = fixture.terms.profile_id; + install_with( + &config, + &fixture.verifier, + fixture.terms.nbf, + &sign(&fixture, &fixture.terms), + ) + .unwrap(); invalidate(); let before = DECODES.load(Ordering::Relaxed); for _ in 0..8 { - assert!(entitlements_with(&config, &verifier, terms.nbf).premium); + assert!(entitlements_with(&config, &fixture.verifier, fixture.terms.nbf).premium); } assert_eq!( DECODES.load(Ordering::Relaxed) - before, @@ -629,20 +793,20 @@ mod tests { #[test] fn entitlement_is_recomputed_when_the_licence_changes_on_disk() { let _serial = serial(); - let (verifier, encoding, terms) = fixture(); + let fixture = mint(); let directory = tempfile::tempdir().unwrap(); let mut config = Config::default(); config.paths.state = directory.path().to_string_lossy().into_owned(); - config.profile.id = terms.profile_id; + config.profile.id = fixture.terms.profile_id; - let key = sign(&encoding, &terms); - assert!(install_with(&config, &verifier, 150, &key).is_ok()); - assert!(entitlements_with(&config, &verifier, 150).premium); + let key = sign(&fixture, &fixture.terms); + assert!(install_with(&config, &fixture.verifier, NOW, &key).is_ok()); + assert!(entitlements_with(&config, &fixture.verifier, NOW).premium); remove(&config).unwrap(); - assert!(!entitlements_with(&config, &verifier, 150).premium); + assert!(!entitlements_with(&config, &fixture.verifier, NOW).premium); assert_eq!( - status_with(&config, &verifier, 150).status, + status_with(&config, &fixture.verifier, NOW).status, LicenseStatus::Missing ); } @@ -650,17 +814,23 @@ mod tests { #[test] fn cached_verification_is_scoped_to_the_verifier() { let _serial = serial(); - let (verifier, encoding, terms) = fixture(); - let (other_verifier, _, _) = fixture(); + let fixture = mint(); + let other = mint(); let directory = tempfile::tempdir().unwrap(); let mut config = Config::default(); config.paths.state = directory.path().to_string_lossy().into_owned(); - config.profile.id = terms.profile_id; + config.profile.id = fixture.terms.profile_id; - install_with(&config, &verifier, 150, &sign(&encoding, &terms)).unwrap(); - assert!(entitlements_with(&config, &verifier, 150).premium); + install_with( + &config, + &fixture.verifier, + NOW, + &sign(&fixture, &fixture.terms), + ) + .unwrap(); + assert!(entitlements_with(&config, &fixture.verifier, NOW).premium); assert_eq!( - status_with(&config, &other_verifier, 150).status, + status_with(&config, &other.verifier, NOW).status, LicenseStatus::Invalid ); } @@ -671,17 +841,17 @@ mod tests { #[test] fn a_cached_valid_licence_stops_granting_entitlement_once_it_expires() { let _serial = serial(); - let (verifier, encoding, terms) = fixture(); + let fixture = mint(); let directory = tempfile::tempdir().unwrap(); let mut config = Config::default(); config.paths.state = directory.path().to_string_lossy().into_owned(); - config.profile.id = terms.profile_id; + config.profile.id = fixture.terms.profile_id; - let key = sign(&encoding, &terms); - install_with(&config, &verifier, terms.nbf, &key).unwrap(); - assert!(entitlements_with(&config, &verifier, terms.nbf).premium); + let key = sign(&fixture, &fixture.terms); + install_with(&config, &fixture.verifier, fixture.terms.nbf, &key).unwrap(); + assert!(entitlements_with(&config, &fixture.verifier, fixture.terms.nbf).premium); - let expired = entitlements_with(&config, &verifier, terms.exp); + let expired = entitlements_with(&config, &fixture.verifier, fixture.terms.exp); assert!( !expired.premium, "an expired licence must not grant premium" @@ -689,6 +859,34 @@ mod tests { assert_eq!(expired.status, LicenseStatus::Expired); } + #[test] + fn a_rotated_root_keyring_is_not_served_from_the_memo_cache() { + use std::sync::atomic::Ordering; + let _serial = serial(); + let fixture = mint(); + let directory = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = directory.path().to_string_lossy().into_owned(); + config.profile.id = fixture.terms.profile_id; + let key = sign(&fixture, &fixture.terms); + install_with(&config, &fixture.verifier, fixture.terms.nbf, &key).unwrap(); + invalidate(); + let before = DECODES.load(Ordering::Relaxed); + assert!(entitlements_with(&config, &fixture.verifier, fixture.terms.nbf).premium); + assert_eq!(DECODES.load(Ordering::Relaxed) - before, 1); + + let rotated = mint(); + let after = DECODES.load(Ordering::Relaxed); + let again = status_with(&config, &rotated.verifier, fixture.terms.nbf); + assert_eq!(again.status, LicenseStatus::Invalid); + assert!(!again.premium); + assert_eq!( + DECODES.load(Ordering::Relaxed) - after, + 1, + "rotated roots must recompute" + ); + } + #[test] fn missing_license_allows_local_but_denies_remote() { let _serial = serial(); diff --git a/src/llm/detection.rs b/src/llm/detection.rs index 91700e16..a14e972d 100644 --- a/src/llm/detection.rs +++ b/src/llm/detection.rs @@ -63,23 +63,28 @@ fn refresh_with_configs(existing: &LlmToolsConfig, configs: &[ToolConfig]) -> Ll } } -/// Re-derive config-sourced fields on a cached tool, keeping its probed -/// `path`/`version` (no version re-spawn). Health is always recomputed - a -/// cached `health_ok` is never trusted - so an uninstalled binary or a newly -/// failing health command demotes the tool on the next startup. Also repairs -/// partial entries written by external detectors (e.g. the VS Code extension -/// caches only name/path/version). +/// Re-derive config-sourced fields on a cached tool and re-probe `path` / +/// `version`. Health is always recomputed - a cached `health_ok` is never +/// trusted - so an uninstalled binary or a newly failing health command +/// demotes the tool on the next startup. Also repairs partial entries written +/// by external detectors (e.g. the VS Code extension caches only name/path/version). fn refresh_cached_tool(cached: &DetectedTool, config: &ToolConfig) -> DetectedTool { + let mode = config.detection_mode(); + let probed_path = match mode { + DetectionMode::Which => get_binary_path(&config.tool_name), + DetectionMode::Always => Some(config.tool_name.clone()), + }; + let presence_verified = mode == DetectionMode::Which && probed_path.is_some(); + let path = probed_path.unwrap_or_else(|| cached.path.clone()); + let version = get_version(&config.version_command).unwrap_or_else(|| cached.version.clone()); let version_ok = match &config.min_version { - Some(min_ver) => check_version_meets_minimum(&cached.version, min_ver), + Some(min_ver) => check_version_meets_minimum(&version, min_ver), None => true, }; - let presence_verified = config.detection_mode() == DetectionMode::Which - && get_binary_path(&config.tool_name).is_some(); DetectedTool { name: config.tool_name.clone(), - path: cached.path.clone(), - version: cached.version.clone(), + path, + version, min_version: config.min_version.clone(), version_ok, model_aliases: config.model_aliases.clone(), @@ -399,8 +404,9 @@ mod tests { let refreshed = refresh_with_configs(&existing, &[config]); let tool = &refreshed.detected[0]; - // Probed fields kept, config-sourced fields re-derived - assert_eq!(tool.path, "/usr/bin/toolx"); + // Always-mode path is the tool name; version stays cached when the + // version command cannot run. + assert_eq!(tool.path, "toolx"); assert_eq!(tool.version, "1.0.0"); assert_eq!(tool.model_aliases, vec!["a", "b"]); assert!(!tool.command_template.is_empty()); @@ -453,6 +459,29 @@ mod tests { assert!(!refreshed.detected[0].health_ok); } + #[test] + fn test_refresh_reprobes_path_and_version_on_cached_tool() { + let mut cached = make_cached_tool("true"); + cached.path = "/stale/true".to_string(); + cached.version = "stale-version".to_string(); + let existing = LlmToolsConfig { + detected: vec![cached], + detection_complete: true, + ..Default::default() + }; + let configs = vec![make_tool_config("true", DetectionMode::Which, None)]; + + let refreshed = refresh_with_configs(&existing, &configs); + assert_ne!( + refreshed.detected[0].path, "/stale/true", + "which-mode refresh must re-run PATH lookup" + ); + assert_ne!( + refreshed.detected[0].version, "stale-version", + "refresh must re-run version_command" + ); + } + #[test] fn test_refresh_which_mode_present_binary_is_healthy() { let existing = LlmToolsConfig { diff --git a/src/llm/inventory.rs b/src/llm/inventory.rs new file mode 100644 index 00000000..e0df91c7 --- /dev/null +++ b/src/llm/inventory.rs @@ -0,0 +1,126 @@ +//! Build an `opr8r tools --json` spec from Operator's tool catalog and parse +//! the host-local results. + +use std::io::Write; +use std::process::{Command, Stdio}; + +use serde::{Deserialize, Serialize}; + +use super::tool_config::{load_all_tool_configs, ToolConfig}; + +/// One tool Operator asks `opr8r tools` to look up on a host. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ToolProbeSpec { + pub name: String, + pub version_command: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub health_command: Option, +} + +/// One row returned by `opr8r tools --json`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ToolProbeResult { + pub name: String, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub version: Option, + pub health_ok: bool, + #[serde(default)] + pub error: Option, +} + +/// Spec list for every currently loaded tool config (builtins + user JSON). +pub fn probe_specs() -> Vec { + load_all_tool_configs() + .iter() + .map(spec_from_config) + .collect() +} + +fn spec_from_config(config: &ToolConfig) -> ToolProbeSpec { + ToolProbeSpec { + name: config.tool_name.clone(), + version_command: config.version_command.clone(), + health_command: config + .detection + .as_ref() + .and_then(|d| d.health_command.clone()), + } +} + +/// Parse the JSON array `opr8r tools --json` writes to stdout. +pub fn parse_probe_results(body: &str) -> Result, String> { + serde_json::from_str(body.trim()).map_err(|e| format!("opr8r tools output is not JSON: {e}")) +} + +/// Run `opr8r tools --json` locally (PATH). +pub fn probe_local() -> Result, String> { + let mut command = Command::new("opr8r"); + command.args(["tools", "--json"]); + run_probe(&mut command) +} + +/// Run `opr8r tools --json` on an SSH host. `ssh` is already configured with +/// alias / `-F` / BatchMode; this function only appends the remote command. +pub fn probe_over_ssh(ssh: &mut Command) -> Result, String> { + ssh.arg("opr8r tools --json"); + run_probe(ssh) +} + +fn run_probe(command: &mut Command) -> Result, String> { + let spec = serde_json::to_vec(&probe_specs()).map_err(|e| e.to_string())?; + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command + .spawn() + .map_err(|e| format!("opr8r tools not runnable: {e}"))?; + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(&spec) + .map_err(|e| format!("failed to write tool spec: {e}"))?; + } + let output = child + .wait_with_output() + .map_err(|e| format!("opr8r tools failed: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "opr8r tools exited {}: {stderr}", + output.status.code().unwrap_or(-1) + )); + } + parse_probe_results(&String::from_utf8_lossy(&output.stdout)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_probe_specs_include_shipped_binaries() { + let specs = probe_specs(); + let names: Vec<&str> = specs.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"claude")); + assert!(names.contains(&"codex")); + assert!(names.contains(&"gemini")); + } + + #[test] + fn test_parse_probe_results_roundtrip() { + let body = + r#"[{"name":"claude","path":"/usr/bin/claude","version":"2.1.0","health_ok":true}]"#; + let results = parse_probe_results(body).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].name, "claude"); + assert!(results[0].health_ok); + } + + #[test] + fn test_parse_probe_results_rejects_garbage() { + let err = parse_probe_results("not json").unwrap_err(); + assert!(err.contains("not JSON")); + } +} diff --git a/src/llm/mod.rs b/src/llm/mod.rs index ce36ea6c..6e1ca0cf 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -10,9 +10,13 @@ //! the system PATH unless a tool opts out via `detection.mode: "always"`. mod detection; +mod inventory; +pub mod native; pub mod skill_deployer; pub mod tool_config; +pub use inventory::{probe_local, probe_over_ssh}; + pub use detection::verify_tool_health; #[allow(unused_imports)] // Used by main.rs binary pub use detection::{detect_all_tools, refresh_tool_detection}; diff --git a/src/llm/native/mod.rs b/src/llm/native/mod.rs new file mode 100644 index 00000000..37be65ca --- /dev/null +++ b/src/llm/native/mod.rs @@ -0,0 +1,171 @@ +//! Native (in-daemon) LLM calls. +//! +//! Everything here is Operator-owned and compiles without the `native-llm` feature, +//! callers and tests never see a provider SDK type. The only SDK binding lives in [`rig`], +//! behind the feature; swapping or dropping the SDK touches that submodule alone. + +#[cfg(feature = "native-llm")] +pub mod rig; + +use async_trait::async_trait; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Default judge instruction when a step declares no `voting_prompt` / +/// `selection_prompt`. Selection-only: the judge returns an index, it never synthesizes a new answer. +pub const DEFAULT_SELECTION_INSTRUCTION: &str = + "Review the candidate outputs and select the single best one."; + +pub const DEFAULT_JUDGE_TIMEOUT_SECS: u64 = 120; + +#[derive(Debug, Clone, PartialEq)] +pub struct JudgeCandidate { + pub label: String, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct JudgeRequest { + pub instruction: String, + pub candidates: Vec, +} + +/// The judge's structured answer. `winner_index` is into the request's `candidates`, zero-based. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct JudgeVerdict { + /// Zero-based index of the best candidate + pub winner_index: usize, + /// One or two sentences explaining the choice + pub rationale: String, +} + +// Most variants are only produced by the feature-gated SDK binding. +#[cfg_attr(not(feature = "native-llm"), allow(dead_code))] +#[derive(Debug, Clone, thiserror::Error, PartialEq)] +pub enum NativeLlmError { + #[error("model server rejected by egress policy: {0}")] + Egress(String), + #[error("model server misconfigured: {0}")] + Config(String), + #[error("request failed: {0}")] + Request(String), + #[error("model did not call the submit tool")] + NoToolCall, + #[error("model returned invalid structured output: {0}")] + InvalidOutput(String), + #[error("judge picked candidate {index} but only {len} exist")] + OutOfRange { index: usize, len: usize }, + #[error("judge timed out after {0}s")] + Timeout(u64), +} + +#[async_trait] +pub trait NativeLlm: Send + Sync { + async fn judge(&self, request: &JudgeRequest) -> Result; +} + +/// Run the judge and reject an out-of-range pick, so every caller gets a +/// verdict it can index with, or an error it falls back on. +pub async fn judge_checked( + llm: &dyn NativeLlm, + request: &JudgeRequest, +) -> Result { + let verdict = llm.judge(request).await?; + let len = request.candidates.len(); + if verdict.winner_index >= len { + return Err(NativeLlmError::OutOfRange { + index: verdict.winner_index, + len, + }); + } + Ok(verdict) +} + +/// Terminal result of one judge attempt, persisted as a side file so the sync +/// loop can pick it up without the judge task touching `State`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum JudgeOutcome { + Verdict(JudgeVerdict), + Failed { reason: String }, +} + +#[cfg(test)] +pub mod fake { + use super::*; + + /// Scripted `NativeLlm` for tests: returns the same result for every call. + pub struct FakeNativeLlm(pub Result); + + #[async_trait] + impl NativeLlm for FakeNativeLlm { + async fn judge(&self, _request: &JudgeRequest) -> Result { + self.0.clone() + } + } +} + +#[cfg(test)] +mod tests { + use super::fake::FakeNativeLlm; + use super::*; + + fn request(n: usize) -> JudgeRequest { + JudgeRequest { + instruction: DEFAULT_SELECTION_INSTRUCTION.to_string(), + candidates: (0..n) + .map(|i| JudgeCandidate { + label: format!("c{i}"), + text: format!("answer {i}"), + }) + .collect(), + } + } + + fn verdict(i: usize) -> JudgeVerdict { + JudgeVerdict { + winner_index: i, + rationale: "best".to_string(), + } + } + + #[tokio::test] + async fn judge_checked_passes_in_range_verdict() { + let llm = FakeNativeLlm(Ok(verdict(1))); + assert_eq!(judge_checked(&llm, &request(2)).await, Ok(verdict(1))); + } + + #[tokio::test] + async fn judge_checked_rejects_out_of_range_index() { + let llm = FakeNativeLlm(Ok(verdict(2))); + assert_eq!( + judge_checked(&llm, &request(2)).await, + Err(NativeLlmError::OutOfRange { index: 2, len: 2 }) + ); + } + + #[tokio::test] + async fn judge_checked_propagates_errors() { + let llm = FakeNativeLlm(Err(NativeLlmError::NoToolCall)); + assert_eq!( + judge_checked(&llm, &request(2)).await, + Err(NativeLlmError::NoToolCall) + ); + } + + #[test] + fn outcome_roundtrips_as_tagged_json() { + let v = JudgeOutcome::Verdict(verdict(0)); + let json = serde_json::to_value(&v).unwrap(); + assert_eq!(json["outcome"], "verdict"); + assert_eq!(json["winner_index"], 0); + assert_eq!(serde_json::from_value::(json).unwrap(), v); + + let f = JudgeOutcome::Failed { + reason: "x".to_string(), + }; + let json = serde_json::to_value(&f).unwrap(); + assert_eq!(json["outcome"], "failed"); + assert_eq!(serde_json::from_value::(json).unwrap(), f); + } +} diff --git a/src/llm/native/rig/client.rs b/src/llm/native/rig/client.rs new file mode 100644 index 00000000..09f7a6bf --- /dev/null +++ b/src/llm/native/rig/client.rs @@ -0,0 +1,195 @@ +//! `ModelServer` → Rig completion model. Every client is built on the +//! egress-checked `reqwest::Client`, so a model call can't reach a destination (or redirect) the policy forbids. + +use std::time::Duration; + +use rig_core::client::CompletionClient; +use rig_core::completion::{CompletionError, CompletionModel, CompletionResponse, ToolDefinition}; +use rig_core::message::ToolChoice; +use rig_core::providers::{anthropic, gemini, ollama, openai, openrouter, xai}; + +use crate::api::providers::model_server::{resolve_api_key, ModelServerKind}; +use crate::auth::egress::{self, EgressPolicy}; +use crate::config::ModelServer; +use crate::llm::native::NativeLlmError; + +type AnthropicModel = ::CompletionModel; +type OpenAiModel = ::CompletionModel; +type GeminiModel = ::CompletionModel; +type XaiModel = ::CompletionModel; +type OllamaModel = ::CompletionModel; +type OpenRouterModel = ::CompletionModel; + +/// One completion model per provider protocol. OpenAI-protocol kinds (`openai-api`, `openai-compat`, `lmstudio`) +/// all use Chat Completions, the endpoint every compatible server implements. +#[derive(Clone)] +pub(super) enum JudgeModel { + Anthropic(AnthropicModel), + OpenAi(OpenAiModel), + Gemini(GeminiModel), + Xai(XaiModel), + Ollama(OllamaModel), + OpenRouter(OpenRouterModel), +} + +impl JudgeModel { + /// One tool-forced completion: `tool` is the only tool and must be called. + pub(super) async fn send_forced_tool( + &self, + prompt: String, + preamble: &str, + tool: ToolDefinition, + max_tokens: u64, + ) -> Result { + match self { + Self::Anthropic(m) => send_forced_tool(m, prompt, preamble, tool, max_tokens).await, + Self::OpenAi(m) => send_forced_tool(m, prompt, preamble, tool, max_tokens).await, + Self::Gemini(m) => send_forced_tool(m, prompt, preamble, tool, max_tokens).await, + Self::Xai(m) => send_forced_tool(m, prompt, preamble, tool, max_tokens).await, + Self::Ollama(m) => send_forced_tool(m, prompt, preamble, tool, max_tokens).await, + Self::OpenRouter(m) => send_forced_tool(m, prompt, preamble, tool, max_tokens).await, + } + } +} + +async fn send_forced_tool( + model: &M, + prompt: String, + preamble: &str, + tool: ToolDefinition, + max_tokens: u64, +) -> Result { + model + .completion_request(prompt) + .preamble(preamble.to_string()) + .tool(tool) + .tool_choice(ToolChoice::Required) + .max_tokens(max_tokens) + .send() + .await +} + +pub(super) fn build( + server: &ModelServer, + model: &str, + policy: &EgressPolicy, + timeout: Duration, +) -> Result { + let kind = ModelServerKind::from_slug(&server.kind).ok_or_else(|| { + NativeLlmError::Config(format!("unknown model server kind '{}'", server.kind)) + })?; + let base = server + .base_url + .as_deref() + .filter(|u| !u.is_empty()) + .or_else(|| kind.default_base_url()) + .ok_or_else(|| { + NativeLlmError::Config(format!("model server '{}' has no base_url", server.name)) + })? + .trim_end_matches('/') + .to_string(); + egress::validate(&base, policy).map_err(|e| NativeLlmError::Egress(e.to_string()))?; + + let key_required = server.api_key_env.is_some() || kind.default_api_key_env().is_some(); + let key = match resolve_api_key(server, kind) { + Some(key) => key, + None if key_required => { + let var = server + .api_key_env + .as_deref() + .or_else(|| kind.default_api_key_env()) + .unwrap_or_default(); + return Err(NativeLlmError::Config(format!( + "API key env var {var} is not set in the operator process" + ))); + } + None => String::new(), + }; + + let http = egress::validated_client(policy.clone(), timeout) + .map_err(|e| NativeLlmError::Config(e.to_string()))?; + let built = |e: rig_core::http_client::Error| NativeLlmError::Config(e.to_string()); + + Ok(match kind { + ModelServerKind::AnthropicApi => JudgeModel::Anthropic( + anthropic::Client::builder() + .api_key(key) + .base_url(&base) + .http_client(http) + .build() + .map_err(built)? + .completion_model(model), + ), + ModelServerKind::OpenAiApi | ModelServerKind::OpenAiCompat | ModelServerKind::LmStudio => { + JudgeModel::OpenAi( + openai::CompletionsClient::builder() + .api_key(key) + .base_url(openai_v1_base(&base)) + .http_client(http) + .build() + .map_err(built)? + .completion_model(model), + ) + } + ModelServerKind::GoogleApi => JudgeModel::Gemini( + gemini::Client::builder() + .api_key(key) + .base_url(&base) + .http_client(http) + .build() + .map_err(built)? + .completion_model(model), + ), + ModelServerKind::XaiApi => JudgeModel::Xai( + xai::Client::builder() + .api_key(key) + .base_url(&base) + .http_client(http) + .build() + .map_err(built)? + .completion_model(model), + ), + ModelServerKind::Ollama => JudgeModel::Ollama( + ollama::Client::builder() + .api_key(key) + .base_url(&base) + .http_client(http) + .build() + .map_err(built)? + .completion_model(model), + ), + ModelServerKind::OpenRouter => JudgeModel::OpenRouter( + openrouter::Client::builder() + .api_key(key) + .base_url(&base) + .http_client(http) + .build() + .map_err(built)? + .completion_model(model), + ), + }) +} + +/// Operator stores OpenAI-protocol bases at the host root (the probe appends +/// `/v1/models`); Rig's OpenAI client expects the `/v1` prefix in the base. +fn openai_v1_base(base: &str) -> String { + if base.ends_with("/v1") { + base.to_string() + } else { + format!("{base}/v1") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn openai_base_gains_v1_once() { + assert_eq!( + openai_v1_base("https://api.openai.com"), + "https://api.openai.com/v1" + ); + assert_eq!(openai_v1_base("http://h:1234/v1"), "http://h:1234/v1"); + } +} diff --git a/src/llm/native/rig/mod.rs b/src/llm/native/rig/mod.rs new file mode 100644 index 00000000..dd9aadad --- /dev/null +++ b/src/llm/native/rig/mod.rs @@ -0,0 +1,116 @@ +//! The only module that imports `rig_core`. Nothing here is re-exported with a Rig type in its signature. + +mod client; + +use std::fmt::Write as _; +use std::time::Duration; + +use async_trait::async_trait; +use rig_core::completion::{CompletionResponse, ToolDefinition}; +use rig_core::message::AssistantContent; +use schemars::JsonSchema; +use serde::de::DeserializeOwned; + +use super::{JudgeRequest, JudgeVerdict, NativeLlm, NativeLlmError}; +use crate::auth::egress::EgressPolicy; +use crate::config::ModelServer; + +const SUBMIT_TOOL: &str = "submit"; +const SUBMIT_DESCRIPTION: &str = "Submit your answer as structured data."; +const JUDGE_MAX_TOKENS: u64 = 1024; +const JUDGE_PREAMBLE: &str = "You are a careful reviewer judging candidate outputs \ + from software agents. Answer only by calling the submit tool."; + +pub struct RigJudge { + model: client::JudgeModel, +} + +impl RigJudge { + pub fn new( + server: &ModelServer, + model: &str, + policy: &EgressPolicy, + timeout: Duration, + ) -> Result { + Ok(Self { + model: client::build(server, model, policy, timeout)?, + }) + } +} + +#[async_trait] +impl NativeLlm for RigJudge { + async fn judge(&self, request: &JudgeRequest) -> Result { + extract(&self.model, JUDGE_PREAMBLE, judge_prompt(request)).await + } +} + +fn judge_prompt(request: &JudgeRequest) -> String { + let mut prompt = format!( + "{}\n\nThere are {} candidates. Reply with the zero-based index of the best one.\n", + request.instruction, + request.candidates.len() + ); + for (i, c) in request.candidates.iter().enumerate() { + let _ = write!( + prompt, + "\n\n{}\n\n", + c.label, c.text + ); + } + prompt +} + +/// Structured output via one forced tool call whose parameters are `T`'s JSON +/// Schema - the same technique as Rig's `Extractor` (in the separate +/// `rig-agent` crate), kept here to depend on `rig-core` alone. +async fn extract( + model: &client::JudgeModel, + preamble: &str, + prompt: String, +) -> Result { + let tool = submit_tool::()?; + let response = model + .send_forced_tool(prompt, preamble, tool, JUDGE_MAX_TOKENS) + .await + .map_err(|e| NativeLlmError::Request(e.to_string()))?; + parse_submit(&response) +} + +fn submit_tool() -> Result { + let mut parameters = serde_json::to_value(schemars::schema_for!(T)) + .map_err(|e| NativeLlmError::InvalidOutput(e.to_string()))?; + if let Some(obj) = parameters.as_object_mut() { + obj.remove("$schema"); + obj.remove("title"); + } + Ok(ToolDefinition { + name: SUBMIT_TOOL.to_string(), + description: SUBMIT_DESCRIPTION.to_string(), + parameters, + }) +} + +fn parse_submit(response: &CompletionResponse) -> Result { + let args = response + .choice + .iter() + .find_map(|content| match content { + AssistantContent::ToolCall(call) if call.function.name == SUBMIT_TOOL => { + Some(call.function.arguments.clone()) + } + _ => None, + }) + .ok_or(NativeLlmError::NoToolCall)?; + // Some wires carry arguments as a JSON-encoded string. + let args = match args { + serde_json::Value::String(s) => { + serde_json::from_str(&s).map_err(|e| NativeLlmError::InvalidOutput(e.to_string()))? + } + other => other, + }; + serde_json::from_value(args).map_err(|e| NativeLlmError::InvalidOutput(e.to_string())) +} + +#[cfg(test)] +mod tests; diff --git a/src/llm/native/rig/tests.rs b/src/llm/native/rig/tests.rs new file mode 100644 index 00000000..2df39014 --- /dev/null +++ b/src/llm/native/rig/tests.rs @@ -0,0 +1,340 @@ +//! One test per `ModelServerKind` against a local axum mock: asserts the wire +//! request (path, auth, forced `submit` tool) and parses a canned provider +//! response from `testdata/native_llm/`. No network, no live keys. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::body::Bytes; +use axum::extract::{OriginalUri, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::Router; + +use super::*; +use crate::api::providers::model_server::ModelServerKind; +use crate::llm::native::{JudgeCandidate, JudgeRequest, JudgeVerdict, NativeLlmError}; + +#[derive(Debug, Clone)] +struct Recorded { + path_and_query: String, + headers: HeaderMap, + body: serde_json::Value, +} + +#[derive(Clone)] +struct Mock { + reply: &'static str, + seen: Arc>>, +} + +async fn record( + State(mock): State, + OriginalUri(uri): OriginalUri, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + mock.seen.lock().unwrap().push(Recorded { + path_and_query: uri + .path_and_query() + .map(ToString::to_string) + .unwrap_or_default(), + headers, + body: serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null), + }); + ( + StatusCode::OK, + [("content-type", "application/json")], + mock.reply, + ) +} + +async fn serve(reply: &'static str) -> (String, Arc>>) { + let seen = Arc::new(Mutex::new(Vec::new())); + let app = Router::new().fallback(record).with_state(Mock { + reply, + seen: Arc::clone(&seen), + }); + 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() }); + (format!("http://{addr}"), seen) +} + +fn loopback_policy() -> EgressPolicy { + EgressPolicy { + allow_loopback: true, + allow_private: false, + } +} + +fn server(kind: &str, base_url: &str, key_env: Option<&str>) -> ModelServer { + ModelServer { + name: format!("{kind}-mock"), + kind: kind.to_string(), + base_url: Some(base_url.to_string()), + api_key_env: key_env.map(str::to_string), + extra_env: HashMap::new(), + display_name: None, + } +} + +/// Each test uses its own env var name, so parallel tests never race on it. +fn set_key(var: &str) -> &'static str { + const KEY: &str = "test-key-123"; + std::env::set_var(var, KEY); + KEY +} + +fn request() -> JudgeRequest { + JudgeRequest { + instruction: "Pick the better plan.".to_string(), + candidates: vec![ + JudgeCandidate { + label: "a".to_string(), + text: "plan A".to_string(), + }, + JudgeCandidate { + label: "b".to_string(), + text: "plan B".to_string(), + }, + ], + } +} + +fn expected() -> JudgeVerdict { + JudgeVerdict { + winner_index: 1, + rationale: "more thorough".to_string(), + } +} + +async fn judge_once( + kind: &str, + reply: &'static str, + key_env: Option<&str>, +) -> (Result, Recorded) { + let (base, seen) = serve(reply).await; + let judge = RigJudge::new( + &server(kind, &base, key_env), + "test-model", + &loopback_policy(), + Duration::from_secs(5), + ) + .unwrap(); + let result = judge.judge(&request()).await; + let recorded = seen + .lock() + .unwrap() + .pop() + .expect("no request reached the mock"); + (result, recorded) +} + +fn header<'a>(r: &'a Recorded, name: &str) -> Option<&'a str> { + r.headers.get(name).and_then(|v| v.to_str().ok()) +} + +fn assert_submit_tool_offered(r: &Recorded) { + let body = r.body.to_string(); + assert!(body.contains("\"submit\""), "submit tool missing: {body}"); + assert!(body.contains("winner_index"), "schema missing: {body}"); + assert!(body.contains("plan B"), "candidates missing: {body}"); +} + +#[tokio::test] +async fn anthropic_api() { + let key = set_key("OPERATOR_TEST_JUDGE_KEY_ANTHROPIC"); + let (result, r) = judge_once( + "anthropic-api", + include_str!("../../../../testdata/native_llm/anthropic.json"), + Some("OPERATOR_TEST_JUDGE_KEY_ANTHROPIC"), + ) + .await; + assert_eq!(result, Ok(expected())); + assert_eq!(r.path_and_query, "/v1/messages"); + assert_eq!(header(&r, "x-api-key"), Some(key)); + assert_eq!(r.body["tool_choice"]["type"], "any"); + assert_submit_tool_offered(&r); +} + +#[tokio::test] +async fn openai_api_uses_chat_completions() { + let key = set_key("OPERATOR_TEST_JUDGE_KEY_OPENAI"); + let (result, r) = judge_once( + "openai-api", + include_str!("../../../../testdata/native_llm/openai.json"), + Some("OPERATOR_TEST_JUDGE_KEY_OPENAI"), + ) + .await; + assert_eq!(result, Ok(expected())); + assert_eq!(r.path_and_query, "/v1/chat/completions"); + assert_eq!( + header(&r, "authorization"), + Some(format!("Bearer {key}").as_str()) + ); + assert_eq!(r.body["tool_choice"], "required"); + assert_submit_tool_offered(&r); +} + +#[tokio::test] +async fn openai_compat_uses_chat_completions() { + let key = set_key("OPERATOR_TEST_JUDGE_KEY_COMPAT"); + let (result, r) = judge_once( + "openai-compat", + include_str!("../../../../testdata/native_llm/openai.json"), + Some("OPERATOR_TEST_JUDGE_KEY_COMPAT"), + ) + .await; + assert_eq!(result, Ok(expected())); + assert_eq!(r.path_and_query, "/v1/chat/completions"); + assert_eq!( + header(&r, "authorization"), + Some(format!("Bearer {key}").as_str()) + ); + assert_submit_tool_offered(&r); +} + +#[tokio::test] +async fn lmstudio_needs_no_key() { + let (result, r) = judge_once( + "lmstudio", + include_str!("../../../../testdata/native_llm/openai.json"), + None, + ) + .await; + assert_eq!(result, Ok(expected())); + assert_eq!(r.path_and_query, "/v1/chat/completions"); + assert_submit_tool_offered(&r); +} + +#[tokio::test] +async fn google_api() { + let key = set_key("OPERATOR_TEST_JUDGE_KEY_GOOGLE"); + let (result, r) = judge_once( + "google-api", + include_str!("../../../../testdata/native_llm/gemini.json"), + Some("OPERATOR_TEST_JUDGE_KEY_GOOGLE"), + ) + .await; + assert_eq!(result, Ok(expected())); + assert!( + r.path_and_query + .starts_with("/v1beta/models/test-model:generateContent"), + "{}", + r.path_and_query + ); + let key_sent = r.path_and_query.contains(&format!("key={key}")) + || header(&r, "x-goog-api-key") == Some(key); + assert!(key_sent, "gemini key not sent"); + assert_submit_tool_offered(&r); +} + +#[tokio::test] +async fn xai_api() { + let key = set_key("OPERATOR_TEST_JUDGE_KEY_XAI"); + let (result, r) = judge_once( + "xai-api", + include_str!("../../../../testdata/native_llm/xai.json"), + Some("OPERATOR_TEST_JUDGE_KEY_XAI"), + ) + .await; + assert_eq!(result, Ok(expected())); + assert_eq!(r.path_and_query, "/v1/responses"); + assert_eq!( + header(&r, "authorization"), + Some(format!("Bearer {key}").as_str()) + ); + assert_submit_tool_offered(&r); +} + +#[tokio::test] +async fn ollama() { + let (result, r) = judge_once( + "ollama", + include_str!("../../../../testdata/native_llm/ollama.json"), + None, + ) + .await; + assert_eq!(result, Ok(expected())); + assert_eq!(r.path_and_query, "/api/chat"); + assert_eq!(header(&r, "authorization"), None); + assert_submit_tool_offered(&r); +} + +#[tokio::test] +async fn openrouter() { + let key = set_key("OPERATOR_TEST_JUDGE_KEY_OPENROUTER"); + let (result, r) = judge_once( + "openrouter", + include_str!("../../../../testdata/native_llm/openrouter.json"), + Some("OPERATOR_TEST_JUDGE_KEY_OPENROUTER"), + ) + .await; + assert_eq!(result, Ok(expected())); + assert_eq!(r.path_and_query, "/chat/completions"); + assert_eq!( + header(&r, "authorization"), + Some(format!("Bearer {key}").as_str()) + ); + assert_submit_tool_offered(&r); +} + +#[test] +fn every_kind_is_covered_by_a_test_above() { + // Adding a kind makes `client::build`'s exhaustive match fail to compile; + // this keeps the per-kind wire tests in step with it. + assert_eq!(ModelServerKind::ALL.len(), 8); +} + +#[tokio::test] +async fn hardened_policy_rejects_loopback_before_any_request() { + let (base, seen) = serve("{}").await; + let result = RigJudge::new( + &server("ollama", &base, None), + "m", + &EgressPolicy::hardened(), + Duration::from_secs(5), + ); + assert!(matches!(result, Err(NativeLlmError::Egress(_)))); + assert!(seen.lock().unwrap().is_empty()); +} + +#[test] +fn missing_required_key_is_a_config_error() { + let result = RigJudge::new( + &server( + "anthropic-api", + "https://api.anthropic.com", + Some("OPERATOR_TEST_JUDGE_KEY_NEVER_SET"), + ), + "m", + &EgressPolicy::hardened(), + Duration::from_secs(5), + ); + assert!( + matches!(result, Err(NativeLlmError::Config(msg)) if msg.contains("OPERATOR_TEST_JUDGE_KEY_NEVER_SET")) + ); +} + +#[tokio::test] +async fn reply_without_tool_call_is_no_tool_call() { + const TEXT_ONLY: &str = r#"{"id":"msg_01","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"B is better"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}"#; + set_key("OPERATOR_TEST_JUDGE_KEY_TEXT_ONLY"); + let (result, _) = judge_once( + "anthropic-api", + TEXT_ONLY, + Some("OPERATOR_TEST_JUDGE_KEY_TEXT_ONLY"), + ) + .await; + assert_eq!(result, Err(NativeLlmError::NoToolCall)); +} + +#[test] +fn prompt_numbers_candidates_from_zero() { + let prompt = judge_prompt(&request()); + assert!(prompt.starts_with("Pick the better plan.")); + assert!(prompt.contains("\nplan A")); + assert!(prompt.contains("\nplan B")); +} diff --git a/src/llm/tool_config.rs b/src/llm/tool_config.rs index def1b1c6..72acefca 100644 --- a/src/llm/tool_config.rs +++ b/src/llm/tool_config.rs @@ -2,8 +2,7 @@ //! //! This module loads LLM CLI tool configurations - embedded builtin JSONs plus //! user JSONs from `/operator/tools/` - and provides template-based -//! command building. User configs are only ever read from the user-global -//! config dir, never from repo-local paths (see [`load_user_tool_configs`]). +//! command building. User configs are only ever read from the user-global config dir. use serde::{Deserialize, Serialize}; @@ -164,6 +163,7 @@ const BUILTIN_TOOL_CONFIGS: &[(&str, &str)] = &[ ("claude", include_str!("tools/claude.json")), ("gemini", include_str!("tools/gemini.json")), ("codex", include_str!("tools/codex.json")), + ("grok", include_str!("tools/grok.json")), ]; /// The user tool-config directory: `/operator/tools` @@ -276,12 +276,13 @@ mod tests { #[test] fn test_load_all_tool_configs() { let configs = load_all_tool_configs_with(None); - assert_eq!(configs.len(), 3); + assert_eq!(configs.len(), BUILTIN_TOOL_CONFIGS.len()); let names: Vec<_> = configs.iter().map(|c| c.tool_name.as_str()).collect(); assert!(names.contains(&"claude")); assert!(names.contains(&"gemini")); assert!(names.contains(&"codex")); + assert!(names.contains(&"grok")); } #[test] @@ -290,7 +291,7 @@ mod tests { std::fs::write(dir.path().join("agy.json"), tool_json("agy", "Agy")).unwrap(); let configs = load_all_tool_configs_with(Some(dir.path())); - assert_eq!(configs.len(), 4); + assert_eq!(configs.len(), BUILTIN_TOOL_CONFIGS.len() + 1); let agy = configs.iter().find(|c| c.tool_name == "agy").unwrap(); assert_eq!(agy.display_name(), "Agy"); } @@ -305,7 +306,7 @@ mod tests { .unwrap(); let configs = load_all_tool_configs_with(Some(dir.path())); - assert_eq!(configs.len(), 3); + assert_eq!(configs.len(), BUILTIN_TOOL_CONFIGS.len()); let claude = configs.iter().find(|c| c.tool_name == "claude").unwrap(); // Full replacement: user's file wins entirely, not a field merge assert_eq!(claude.display_name(), "My Claude"); @@ -319,7 +320,7 @@ mod tests { std::fs::write(dir.path().join("agy.json"), tool_json("agy", "Agy")).unwrap(); let configs = load_all_tool_configs_with(Some(dir.path())); - assert_eq!(configs.len(), 4); + assert_eq!(configs.len(), BUILTIN_TOOL_CONFIGS.len() + 1); assert!(configs.iter().any(|c| c.tool_name == "agy")); } @@ -328,7 +329,7 @@ mod tests { let dir = tempfile::TempDir::new().unwrap(); let missing = dir.path().join("does-not-exist"); let configs = load_all_tool_configs_with(Some(&missing)); - assert_eq!(configs.len(), 3); + assert_eq!(configs.len(), BUILTIN_TOOL_CONFIGS.len()); } #[test] @@ -337,7 +338,7 @@ mod tests { std::fs::write(dir.path().join("README.md"), "# tools").unwrap(); let configs = load_all_tool_configs_with(Some(dir.path())); - assert_eq!(configs.len(), 3); + assert_eq!(configs.len(), BUILTIN_TOOL_CONFIGS.len()); } #[test] diff --git a/src/llm/tools/grok.json b/src/llm/tools/grok.json new file mode 100644 index 00000000..8774cec3 --- /dev/null +++ b/src/llm/tools/grok.json @@ -0,0 +1,24 @@ +{ + "$schema": "tool_config.schema.json", + "tool_name": "grok", + "display_name": "Grok", + "version_command": "grok --version", + "capabilities": { + "supports_sessions": true, + "supports_headless": true, + "supports_config_override": true, + "supports_permission_mode": false + }, + "model_aliases": ["grok-4"], + "arg_mapping": { + "prompt": "", + "model": "-m", + "session_id": "--session-id" + }, + "command_template": "grok {{config_flags}}{{model_flag}}--session-id {{session_id}} \"$(cat {{prompt_file}})\"", + "yolo_flags": ["--always-approve"], + "skill_directories": { + "global": [], + "project": ["AGENTS.md"] + } +} diff --git a/src/main.rs b/src/main.rs index cbda6336..66939e27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,9 +9,12 @@ mod collections; mod config; mod editors; mod git; +mod http_client; mod issuetypes; mod licensing; mod profiles; +#[allow(dead_code)] // generated; the bin reaches it through licensing +mod trust_verify; // Vertical catalog + capability inventory: consumed by the lib's REST/docs // layers and the external parity tests; several items read as unused in the bin. #[allow(dead_code, unused_imports)] @@ -320,6 +323,14 @@ enum Commands { }, } +impl Commands { + /// Stdio protocol servers are spawned by editors, often sandboxed away from + /// the user config directory. They serve unregistered rather than not at all. + fn tolerates_unregistered(&self) -> bool { + matches!(self, Commands::Acp | Commands::Mcp) + } +} + #[derive(Subcommand)] enum AuthAction { /// Reset the admin password, revoking every issued credential. @@ -383,8 +394,23 @@ async fn main() -> Result<()> { } else { Config::load(cli.config.as_deref())? }; + let mut registration_error = None; if !matches!(cli.command, Some(Commands::Docs { .. })) && cli.profile.is_none() { - profiles::register_legacy(&mut config)?; + // Registration mutates as it goes; adopt it only whole, so a failure + // midway never leaves a half-registered config behind. + let mut registered = config.clone(); + match profiles::register_legacy(&mut registered) { + Ok(()) => config = registered, + Err(error) + if cli + .command + .as_ref() + .is_some_and(Commands::tolerates_unregistered) => + { + registration_error = Some(error); + } + Err(error) => return Err(error), + } } // Determine if we're running in TUI mode (no subcommand) @@ -392,6 +418,12 @@ async fn main() -> Result<()> { // Initialize logging (file-based for TUI, stderr for CLI) let logging_handle = logging::init_logging(&config, is_tui_mode, cli.debug)?; + if let Some(error) = registration_error { + tracing::warn!( + error = format!("{error:#}"), + "Profile registry unavailable; continuing as an unregistered configuration" + ); + } // Inject the status-section provider into the REST layer. The section logic // lives in `ui` (which `rest` can't depend on - see rest::dto::sections), so @@ -598,9 +630,7 @@ async fn cmd_launch( // Named model_server must exist among declared servers or implicit builtins. if let Some(ref name) = overrides.model_server { let declared = config.model_servers.iter().any(|s| &s.name == name); - let implicit = ["claude", "codex", "gemini"] - .iter() - .any(|t| &config::implicit_model_server_for_tool(t).name == name); + let implicit = config::is_implicit_model_server_name(name); if !declared && !implicit { anyhow::bail!( "Unknown model-server '{name}'. Declare it under [[model_servers]] in your config." @@ -979,7 +1009,12 @@ async fn cmd_auth(config: &Config, action: AuthAction) -> Result<()> { "new_password": password, }); - let response = reqwest::Client::new().post(&url).json(&body).send().await?; + let response = crate::http_client::client_builder() + .build()? + .post(&url) + .json(&body) + .send() + .await?; let status = response.status(); let text = response.text().await.unwrap_or_default(); @@ -1456,8 +1491,7 @@ mod tests { #[test] fn test_detect_llm_tools_returns_vec() { let tools = detect_llm_tools(); - // Just verify it returns a Vec, actual content depends on environment - assert!(tools.len() <= 3); + assert!(tools.len() <= crate::config::llm_tools::shipped_llm_tools().len()); } #[test] diff --git a/src/notifications/webhook_integration.rs b/src/notifications/webhook_integration.rs index 6a41b579..81afe80b 100644 --- a/src/notifications/webhook_integration.rs +++ b/src/notifications/webhook_integration.rs @@ -50,7 +50,7 @@ struct WebhookPayload { /// Client used for webhook delivery, with redirect destinations re-validated. fn egress_client() -> Client { crate::auth::egress::validated_client(EgressPolicy::default(), Duration::from_secs(30)) - .unwrap_or_else(|_| Client::new()) + .unwrap_or_else(|_| crate::http_client::default_client()) } impl WebhookIntegration { diff --git a/src/profiles.rs b/src/profiles.rs index 5a58dbf9..a4f1c82c 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -15,6 +15,7 @@ use crate::rest::state::ApiState; pub const MAX_PROFILE_NAME_LENGTH: usize = 64; pub const LEGACY_PROFILE_NAME: &str = "legacy"; const REGISTRY_FILE: &str = "profiles.sqlite"; +pub const REGISTRY_ENV: &str = "OPERATOR_PROFILE_REGISTRY"; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, ToSchema)] #[ts(export)] @@ -51,6 +52,9 @@ pub fn validate_name(name: &str) -> Result<()> { } pub fn registry_path() -> Result { + if let Some(path) = std::env::var_os(REGISTRY_ENV).filter(|path| !path.is_empty()) { + return Ok(PathBuf::from(path)); + } Ok(dirs::config_dir() .context("User configuration directory unavailable")? .join("operator") @@ -247,15 +251,27 @@ fn register_in(config: &mut Config, registry: &Path) -> Result<()> { if registered { config.save()?; } + let own_state = config.state_path(); connection.execute( "INSERT OR IGNORE INTO settings (key,value) VALUES ('auth_path',?1)", - [config.state_path().to_string_lossy().as_ref()], + [own_state.to_string_lossy().as_ref()], )?; - config.server_auth_path = Some(PathBuf::from(connection.query_row( + let mut auth_path = PathBuf::from(connection.query_row( "SELECT value FROM settings WHERE key='auth_path'", [], |row| row.get::<_, String>(0), - )?)); + )?); + // The first configuration ever registered pins server auth. If its state + // directory has since vanished (a deleted workspace or temp dir), every + // later login would target a database nobody can reach. + if !auth_path.exists() { + connection.execute( + "UPDATE settings SET value=?1 WHERE key='auth_path'", + [own_state.to_string_lossy().as_ref()], + )?; + auth_path = own_state; + } + config.server_auth_path = Some(auth_path); if config.tickets_path().join("queue").is_dir() && !crate::startup::workspace_initialized(config) { @@ -645,4 +661,63 @@ mod tests { let reopened = ServerProfiles::open(primary).unwrap(); assert_eq!(reopened.state(a.id).unwrap().config().profile.name, "first"); } + + fn workspace_config(root: &Path) -> Config { + std::fs::create_dir_all(root).unwrap(); + let mut config = Config::default(); + config.paths.state = root.to_string_lossy().into_owned(); + config.paths.tickets = root.join("tickets").to_string_lossy().into_owned(); + config + } + + fn seed_auth_path(registry: &Path, value: &Path) { + open_registry(registry) + .unwrap() + .execute( + "INSERT INTO settings (key,value) VALUES ('auth_path',?1)", + [value.to_string_lossy().as_ref()], + ) + .unwrap(); + } + + fn stored_auth_path(registry: &Path) -> PathBuf { + PathBuf::from( + open_registry(registry) + .unwrap() + .query_row( + "SELECT value FROM settings WHERE key='auth_path'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + ) + } + + #[test] + fn registration_repins_auth_path_that_no_longer_exists() { + let temp = tempfile::tempdir().unwrap(); + let registry = temp.path().join(REGISTRY_FILE); + seed_auth_path(®istry, &temp.path().join("vanished-workspace")); + let mut config = workspace_config(&temp.path().join("workspace")); + + register_in(&mut config, ®istry).unwrap(); + + assert_eq!(stored_auth_path(®istry), config.state_path()); + assert_eq!(config.server_auth_path, Some(config.state_path())); + } + + #[test] + fn registration_keeps_auth_path_that_still_exists() { + let temp = tempfile::tempdir().unwrap(); + let registry = temp.path().join(REGISTRY_FILE); + let server_auth = temp.path().join("server-auth"); + std::fs::create_dir_all(&server_auth).unwrap(); + seed_auth_path(®istry, &server_auth); + let mut config = workspace_config(&temp.path().join("workspace")); + + register_in(&mut config, ®istry).unwrap(); + + assert_eq!(stored_auth_path(®istry), server_auth); + assert_eq!(config.server_auth_path, Some(server_auth)); + } } diff --git a/src/projects.rs b/src/projects.rs index 334a551b..5f06f886 100644 --- a/src/projects.rs +++ b/src/projects.rs @@ -15,12 +15,14 @@ use std::path::{Path, PathBuf}; use std::process::Command; use ts_rs::TS; -/// Marker files for each LLM tool -pub const TOOL_MARKERS: &[(&str, &str)] = &[ - ("claude", "CLAUDE.md"), - ("gemini", "GEMINI.md"), - ("codex", "CODEX.md"), -]; +/// Marker files for each shipped LLM tool, derived from the identity table. +/// Flattened `(tool_name, marker)` pairs; a tool may appear more than once. +pub fn tool_markers() -> Vec<(&'static str, &'static str)> { + crate::config::shipped_llm_tools() + .iter() + .flat_map(|tool| tool.markers.iter().map(|marker| (tool.tool_name, *marker))) + .collect() +} /// A discovered project with git and LLM tool information #[derive(Debug, Clone, Serialize, Deserialize, TS, JsonSchema)] @@ -90,11 +92,13 @@ pub fn discover_projects_with_git(projects_path: &Path) -> Vec = TOOL_MARKERS - .iter() + let mut llm_tools: Vec = tool_markers() + .into_iter() .filter(|(_, marker)| path.join(marker).exists()) - .map(|(tool, _)| (*tool).to_string()) + .map(|(tool, _)| tool.to_string()) .collect(); + llm_tools.sort(); + llm_tools.dedup(); // Include if has git OR has LLM markers if git_info.is_some() || !llm_tools.is_empty() { @@ -199,12 +203,12 @@ pub fn discover_projects_by_tool(projects_path: &Path) -> HashMap = TOOL_MARKERS.iter().map(|(t, _)| *t).collect(); - assert!(tool_names.contains(&"claude")); - assert!(tool_names.contains(&"gemini")); - assert!(tool_names.contains(&"codex")); + let tool_names: Vec<&str> = tool_markers().iter().map(|(t, _)| *t).collect(); + for name in ["claude", "gemini", "codex", "grok"] { + assert!(tool_names.contains(&name), "missing {name}"); + } } #[test] fn test_tool_markers_has_correct_filenames() { - let markers: std::collections::HashMap<&str, &str> = TOOL_MARKERS.iter().copied().collect(); - assert_eq!(markers.get("claude"), Some(&"CLAUDE.md")); - assert_eq!(markers.get("gemini"), Some(&"GEMINI.md")); - assert_eq!(markers.get("codex"), Some(&"CODEX.md")); + let pairs = tool_markers(); + for (tool, vendor) in [ + ("claude", "CLAUDE.md"), + ("gemini", "GEMINI.md"), + ("codex", "CODEX.md"), + ("grok", "GROK.md"), + ] { + assert!( + pairs.contains(&(tool, "AGENTS.md")), + "{tool} must match AGENTS.md" + ); + assert!( + pairs.contains(&(tool, vendor)), + "{tool} must match {vendor}" + ); + } } #[test] @@ -572,6 +588,24 @@ mod tests { assert!(projects[0].llm_tools.contains(&"codex".to_string())); } + #[test] + fn test_agents_md_matches_every_shipped_tool() { + let temp = tempdir().unwrap(); + let project = temp.path().join("shared-agents"); + fs::create_dir(&project).unwrap(); + File::create(project.join("AGENTS.md")).unwrap(); + + let projects = discover_projects_with_git(temp.path()); + assert_eq!(projects.len(), 1); + let tools = &projects[0].llm_tools; + for name in ["claude", "codex", "gemini", "grok"] { + assert!( + tools.contains(&name.to_string()), + "AGENTS.md should match {name}" + ); + } + } + #[test] fn test_has_git_remote_returns_true_when_remote_exists() { use crate::types::pr::{GitHubRepoInfo, GitProvider}; diff --git a/src/rest/dto/integrations.rs b/src/rest/dto/integrations.rs index 718b2e30..23a2e8ee 100644 --- a/src/rest/dto/integrations.rs +++ b/src/rest/dto/integrations.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; use utoipa::ToSchema; +use crate::integrations::support_catalog::VerticalSupport; use crate::integrations::{all_integrations, SupportStatus}; /// One advertised integration: its vertical, identity, docs link, and support @@ -36,6 +37,9 @@ pub struct IntegrationCatalogEntryDto { /// Implemented session controllers for an IDE; absent for other categories. #[serde(skip_serializing_if = "Option::is_none")] pub session_wrappers: Option>, + /// Vertical-specific structural support (not the advertising `status` ramp). + #[ts(type = "unknown")] + pub support: serde_json::Value, } /// Project the catalog source-of-truth into wire DTOs. @@ -61,10 +65,69 @@ pub fn integration_catalog() -> Vec { .map(|wrapper| wrapper.display_name().to_string()) .collect() }), + support: support_json(e.support), }) .collect() } +fn support_json(support: VerticalSupport) -> serde_json::Value { + match support { + VerticalSupport::LlmTool(s) => serde_json::json!({ + "kind": "llm-tool", + "health": s.health.slug(), + "auth": s.auth.slug(), + "native_protocols": s.native_protocols.iter().map(|p| p.slug()).collect::>(), + "sessions": s.sessions, + "headless": s.headless, + "yolo": s.yolo, + "remote_inventory": s.remote_inventory.slug(), + "relay": s.relay.slug(), + "permissions": s.permissions.slug(), + }), + VerticalSupport::Model(s) => serde_json::json!({ + "kind": "model", + "protocol": s.protocol.slug(), + "class": s.class.slug(), + "probe": s.probe, + "connectable_from_defaults": s.connectable_from_defaults, + "key_injectable": s.key_injectable.slug(), + "implicit_for": s.implicit_for, + }), + VerticalSupport::Kanban(s) => serde_json::json!({ + "kind": "kanban", + "sync_in": s.sync_in, + "write_back": s.write_back, + }), + VerticalSupport::Git(s) => serde_json::json!({ + "kind": "git", + "pr_cli": s.pr_cli, + "remote_preflight": s.remote_preflight, + }), + VerticalSupport::Session(s) => serde_json::json!({ + "kind": "session", + "attach": s.attach, + "send_keys": s.send_keys, + "idle_detect": s.idle_detect, + }), + VerticalSupport::Transport(s) | VerticalSupport::RemoteTargets(s) => serde_json::json!({ + "kind": "remote", + "probe": s.probe, + "tool_inventory": s.tool_inventory, + "credential_injection": s.credential_injection, + }), + VerticalSupport::Editor(s) + | VerticalSupport::Platform(s) + | VerticalSupport::Integration(s) + | VerticalSupport::Workflows(s) + | VerticalSupport::Notification(s) + | VerticalSupport::AgentRelay(s) => serde_json::json!({ + "kind": "sparse", + "coverage": s.coverage.slug(), + "notes": s.notes, + }), + } +} + #[cfg(test)] mod tests { use super::*; @@ -93,4 +156,23 @@ mod tests { .iter() .any(|d| d.vertical == "remote-targets" && d.premium)); } + + #[test] + fn test_claude_support_json_is_llm_tool() { + let dtos = integration_catalog(); + let claude = dtos + .iter() + .find(|d| d.vertical == "llm-tool" && d.slug == "claude") + .unwrap(); + assert_eq!(claude.support["kind"], "llm-tool"); + assert_eq!(claude.support["health"], "path-version"); + assert_eq!(claude.support["headless"], false); + let ollama = dtos + .iter() + .find(|d| d.vertical == "model" && d.slug == "ollama") + .unwrap(); + assert_eq!(ollama.support["kind"], "model"); + assert_eq!(ollama.support["protocol"], "openai"); + assert_eq!(ollama.support["class"], "gateway"); + } } diff --git a/src/rest/routes/auth.rs b/src/rest/routes/auth.rs index c658f4e7..f60797d7 100644 --- a/src/rest/routes/auth.rs +++ b/src/rest/routes/auth.rs @@ -78,6 +78,22 @@ fn oauth_error(status: StatusCode, code: OAuthErrorCode, message: &str) -> Respo .into_response() } +async fn record_bucket_failure(state: &ApiState, bucket: &'static str) { + let s = store(state); + let _ = blocking(move || s.record_failure(bucket)).await; +} + +async fn reject_token(state: &ApiState, code: OAuthErrorCode, message: &str) -> Response { + record_bucket_failure(state, BUCKET_TOKEN).await; + oauth_error(StatusCode::BAD_REQUEST, code, message) +} + +fn rejected_password(password: &str) -> Option { + crate::auth::password::validate_password(password) + .err() + .map(|error| ApiError::ValidationError(error.to_string()).into_response()) +} + fn valid_client_id(client_id: &str) -> bool { !client_id.is_empty() && client_id.len() <= crate::rest::dto::auth::MAX_IDENTIFIER_LENGTH @@ -168,6 +184,10 @@ pub async fn bootstrap_submit( } } + if let Some(rejected) = rejected_password(&req.new_password) { + return Err(rejected); + } + let password = req.new_password.clone(); let s = store(&state); let created = blocking(move || s.create_admin(&password, false)) @@ -215,6 +235,10 @@ pub async fn bootstrap_submit( .into_response()); } + if let Some(rejected) = rejected_password(&req.new_password) { + return Err(rejected); + } + let password = req.new_password.clone(); let s = store(&state); blocking(move || { @@ -547,6 +571,7 @@ pub async fn device_code( return Err(limited); } if !valid_client_id(&req.client_id) { + record_bucket_failure(&state, BUCKET_DEVICE_CODE).await; return Err(oauth_error( StatusCode::BAD_REQUEST, OAuthErrorCode::InvalidClient, @@ -567,6 +592,8 @@ pub async fn device_code( let (device_code, user_code) = blocking(move || { let pair = s.create_device_authorization(&client_id, &scopes)?; s.audit("device code issued", None, true)?; + // A successful issue is not proof of the admin: this endpoint is public. + s.record_failure(BUCKET_DEVICE_CODE)?; Ok(pair) }) .await @@ -625,10 +652,6 @@ pub async fn device_approve( })) } -// ============================================================================= -// Token endpoint -// ============================================================================= - /// Exchange a credential for an access token #[utoipa::path( operation_id = "auth_token", @@ -656,11 +679,12 @@ pub async fn token( client_id, } => { if !valid_client_id(&client_id) { - return Err(oauth_error( - StatusCode::BAD_REQUEST, + return Err(reject_token( + &state, OAuthErrorCode::InvalidClient, "client_id is invalid", - )); + ) + .await); } let s = store(&state); let outcome = blocking(move || s.poll_device(&code, &client_id)) @@ -691,18 +715,20 @@ pub async fn token( )) } DevicePollOutcome::Denied => { - return Err(oauth_error( - StatusCode::BAD_REQUEST, + return Err(reject_token( + &state, OAuthErrorCode::AccessDenied, "the user declined this device", - )) + ) + .await) } DevicePollOutcome::Expired => { - return Err(oauth_error( - StatusCode::BAD_REQUEST, + return Err(reject_token( + &state, OAuthErrorCode::ExpiredToken, "the device code has expired or was already used", - )) + ) + .await) } } } @@ -712,11 +738,12 @@ pub async fn token( client_id, } => { if !valid_client_id(&client_id) { - return Err(oauth_error( - StatusCode::BAD_REQUEST, + return Err(reject_token( + &state, OAuthErrorCode::InvalidClient, "client_id is invalid", - )); + ) + .await); } let s = store(&state); let outcome = blocking(move || s.redeem_refresh_token(&token, &client_id)) @@ -731,6 +758,7 @@ pub async fn token( RefreshOutcome::Reused => { let s = store(&state); let _ = blocking(move || { + s.record_failure(BUCKET_TOKEN)?; s.audit("refresh token reuse", Some("family revoked"), false) }) .await; @@ -741,11 +769,12 @@ pub async fn token( )); } RefreshOutcome::Invalid => { - return Err(oauth_error( - StatusCode::BAD_REQUEST, + return Err(reject_token( + &state, OAuthErrorCode::InvalidGrant, "the refresh token is invalid, expired, or revoked", - )) + ) + .await) } } } @@ -798,10 +827,6 @@ pub async fn token( })) } -// ============================================================================= -// Access keys -// ============================================================================= - /// List access keys #[utoipa::path( operation_id = "auth_list_access_keys", @@ -902,3 +927,1451 @@ pub async fn revoke_access_key( )), } } + +#[cfg(test)] +mod tests { + use axum::body::Body; + use axum::extract::{Path, State}; + use axum::http::{header, HeaderMap, Request, StatusCode}; + use axum::response::{IntoResponse, Response}; + use axum::Json; + use http_body_util::BodyExt; + use serde::de::DeserializeOwned; + use tempfile::TempDir; + use tower::ServiceExt; + + use super::{ + bootstrap_status, bootstrap_submit, create_access_key, csrf_token, current_session, + device_approve, device_code, forgot_password, list_access_keys, list_sessions, login, + logout, reset_password, revoke_access_key, revoke_session, token, + BOOTSTRAP_PASSWORD_FILE_ENV, BUCKET_DEVICE_CODE, BUCKET_LOGIN, BUCKET_PASSWORD_RESET, + BUCKET_TOKEN, + }; + use crate::auth::scope::Principal; + use crate::auth::store::{ + ADMIN_SUBJECT, AUTH_DB_FILENAME, DEVICE_CODE_TTL_SECS, DEVICE_POLL_INTERVAL_SECS, + }; + use crate::auth::tokens::ACCESS_TOKEN_TTL; + use crate::config::Config; + use crate::rest::dto::auth::*; + use crate::rest::error::ApiError; + use crate::rest::middleware::auth::{Authenticated, CSRF_HEADER, SESSION_COOKIE}; + use crate::rest::state::ApiState; + + const GOOD: &str = "correct horse battery staple"; + const REPLACEMENT: &str = "replacement horse battery staple"; + const TEMPORARY: &str = "temporary horse battery"; + const SHORT: &str = "elevenchars"; + + async fn bootstrap_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await + } + + struct RestoreEnv { + previous: Option, + } + + impl RestoreEnv { + fn set(value: &str) -> Self { + let previous = std::env::var(BOOTSTRAP_PASSWORD_FILE_ENV).ok(); + std::env::set_var(BOOTSTRAP_PASSWORD_FILE_ENV, value); + Self { previous } + } + } + + impl Drop for RestoreEnv { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var(BOOTSTRAP_PASSWORD_FILE_ENV, value), + None => std::env::remove_var(BOOTSTRAP_PASSWORD_FILE_ENV), + } + } + } + + fn state_at(path: &std::path::Path) -> ApiState { + let mut config = Config::default(); + config.paths.state = path.to_string_lossy().into_owned(); + ApiState::new(config, path.join("tickets")) + } + + fn fresh() -> (TempDir, ApiState) { + let dir = TempDir::new().unwrap(); + let state = state_at(dir.path()); + (dir, state) + } + + fn settle(result: Result) -> Response { + match result { + Ok(body) => body.into_response(), + Err(response) => response, + } + } + + fn settle_api(result: Result) -> Response { + match result { + Ok(body) => body.into_response(), + Err(error) => error.into_response(), + } + } + + async fn body_json(response: Response) -> (StatusCode, HeaderMap, serde_json::Value) { + let status = response.status(); + let headers = response.headers().clone(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let value = serde_json::from_slice(&bytes).unwrap_or_else(|error| { + panic!( + "response was not json ({error}): {}", + String::from_utf8_lossy(&bytes) + ) + }); + (status, headers, value) + } + + fn parse(value: serde_json::Value) -> T { + serde_json::from_value(value.clone()).unwrap_or_else(|error| panic!("{error}: {value}")) + } + + fn retry_after(headers: &HeaderMap) -> u64 { + headers + .get(header::RETRY_AFTER) + .unwrap_or_else(|| panic!("missing Retry-After: {headers:?}")) + .to_str() + .unwrap() + .parse() + .unwrap() + } + + fn session_cookie(headers: &HeaderMap) -> String { + let cookie = headers + .get(header::SET_COOKIE) + .unwrap_or_else(|| panic!("missing Set-Cookie: {headers:?}")) + .to_str() + .unwrap(); + let token = cookie + .split(';') + .next() + .unwrap() + .strip_prefix(&format!("{SESSION_COOKIE}=")) + .unwrap_or_else(|| panic!("unexpected cookie: {cookie}")); + assert!(!token.is_empty()); + assert_eq!( + cookie, + format!("{SESSION_COOKIE}={token}; HttpOnly; Secure; SameSite=Strict; Path=/") + ); + token.to_string() + } + + fn cleared_cookie() -> String { + format!("{SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0") + } + + fn rewind_device_poll(dir: &std::path::Path) { + let conn = rusqlite::Connection::open(dir.join(AUTH_DB_FILENAME)).unwrap(); + let earlier = (chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339(); + let updated = conn + .execute( + "UPDATE device_authorization SET last_polled_at = ?1", + [earlier], + ) + .unwrap(); + assert_eq!(updated, 1); + } + + fn widen_persisted_backoff(dir: &std::path::Path, bucket: &str) { + let conn = rusqlite::Connection::open(dir.join(AUTH_DB_FILENAME)).unwrap(); + let until = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339(); + let updated = conn + .execute( + "UPDATE rate_limit SET retry_after = ?1 WHERE bucket = ?2 AND attempts >= 4", + rusqlite::params![until, bucket], + ) + .unwrap(); + assert_eq!(updated, 1, "bucket {bucket} was not persisted"); + } + + async fn submit(state: &ApiState, temporary: Option<&str>, new_password: &str) -> Response { + settle( + bootstrap_submit( + State(state.clone()), + Json(BootstrapSubmitRequest { + temporary_password: temporary.map(str::to_string), + new_password: new_password.to_string(), + }), + ) + .await, + ) + } + + async fn bootstrap_admin(state: &ApiState) { + let (status, _, value) = body_json(submit(state, None, GOOD).await).await; + assert_eq!(status, StatusCode::OK, "{value}"); + } + + async fn sign_in(state: &ApiState, username: &str, password: &str) -> Response { + settle( + login( + State(state.clone()), + Json(LoginRequest { + username: username.to_string(), + password: password.to_string(), + }), + ) + .await, + ) + } + + async fn device( + state: &ApiState, + client_id: &str, + host: Option<&str>, + scopes: Vec, + ) -> Response { + let mut headers = HeaderMap::new(); + if let Some(host) = host { + headers.insert(header::HOST, host.parse().unwrap()); + } + settle( + device_code( + State(state.clone()), + headers, + Json(DeviceAuthorizationRequest { + client_id: client_id.to_string(), + scopes, + }), + ) + .await, + ) + } + + async fn exchange(state: &ApiState, request: TokenRequest) -> Response { + settle(token(State(state.clone()), Json(request)).await) + } + + fn is_user_code(code: &str) -> bool { + let alphabet = b"ABCDEFGHJKMNPQRSTVWXYZ23456789"; + let Some((left, right)) = code.split_once('-') else { + return false; + }; + [left, right] + .into_iter() + .all(|part| part.len() == 4 && part.bytes().all(|byte| alphabet.contains(&byte))) + } + + fn session_principal(state: &ApiState, cookie: &str) -> Principal { + state + .auth + .store + .authenticate_session(cookie) + .unwrap() + .unwrap() + } + + #[tokio::test] + async fn test_bootstrap_short_password_stays_uninitialized_until_a_valid_one() { + let _lock = bootstrap_lock().await; + let (_dir, state) = fresh(); + let status = bootstrap_status(State(state.clone())).await.unwrap().0; + assert_eq!(status.state, BootstrapState::Uninitialized); + assert!(!status.requires_temporary_password); + + for _ in 0..4 { + let (status, _, value) = body_json(submit(&state, None, SHORT).await).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!(value["error"], "validation_error"); + } + + let (status, _, value) = body_json(submit(&state, None, GOOD).await).await; + assert_eq!(status, StatusCode::OK, "{value}"); + let body: BootstrapSubmitResponse = parse(value); + assert_eq!(body.state, BootstrapState::Complete); + assert_eq!(body.username, ADMIN_SUBJECT); + + let (status, _, value) = body_json(submit(&state, None, GOOD).await).await; + assert_eq!(status, StatusCode::CONFLICT, "{value}"); + assert_eq!(value["error"], "conflict"); + assert_eq!( + bootstrap_status(State(state)).await.unwrap().0.state, + BootstrapState::Complete + ); + } + + #[tokio::test] + async fn test_mounted_bootstrap_secret_backs_off_before_an_account_exists() { + let _lock = bootstrap_lock().await; + let (dir, state) = fresh(); + let secret_path = dir.path().join("bootstrap-secret"); + std::fs::write(&secret_path, "mounted-temporary-secret\n").unwrap(); + let _env = RestoreEnv::set(&secret_path.to_string_lossy()); + + let status = bootstrap_status(State(state.clone())).await.unwrap().0; + assert!(status.requires_temporary_password); + assert_eq!(status.state, BootstrapState::Uninitialized); + + for _ in 0..4 { + let (status, _, value) = + body_json(submit(&state, Some("wrong-temporary"), GOOD).await).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{value}"); + assert_eq!(value["error"], "unauthorized"); + } + let (status, headers, value) = + body_json(submit(&state, Some("mounted-temporary-secret"), GOOD).await).await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS, "{value}"); + assert!(retry_after(&headers) >= 1); + assert_eq!(value["error"], "rate_limited"); + assert_eq!( + bootstrap_status(State(state)).await.unwrap().0.state, + BootstrapState::Uninitialized + ); + } + + #[tokio::test] + async fn test_mounted_bootstrap_accepts_the_secret_and_rejects_a_short_password() { + let _lock = bootstrap_lock().await; + let (dir, state) = fresh(); + let secret_path = dir.path().join("bootstrap-secret"); + std::fs::write(&secret_path, "mounted-temporary-secret").unwrap(); + let _env = RestoreEnv::set(secret_path.to_str().unwrap()); + + let (status, _, value) = + body_json(submit(&state, Some("mounted-temporary-secret"), SHORT).await).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + bootstrap_status(State(state.clone())) + .await + .unwrap() + .0 + .state, + BootstrapState::Uninitialized + ); + + let (status, _, value) = body_json(submit(&state, Some("wrong"), GOOD).await).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{value}"); + + let (status, _, value) = + body_json(submit(&state, Some("mounted-temporary-secret"), GOOD).await).await; + assert_eq!(status, StatusCode::OK, "{value}"); + assert_eq!( + bootstrap_status(State(state)).await.unwrap().0.state, + BootstrapState::Complete + ); + } + + #[tokio::test] + async fn test_awaiting_password_requires_the_temporary_password() { + let _lock = bootstrap_lock().await; + let (_dir, state) = fresh(); + state.auth.store.create_admin(TEMPORARY, true).unwrap(); + assert_eq!( + bootstrap_status(State(state.clone())) + .await + .unwrap() + .0 + .state, + BootstrapState::AwaitingPassword + ); + + let (status, _, value) = + body_json(submit(&state, Some("wrong-temporary"), GOOD).await).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{value}"); + + let (status, _, value) = body_json(submit(&state, Some(TEMPORARY), SHORT).await).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + bootstrap_status(State(state.clone())) + .await + .unwrap() + .0 + .state, + BootstrapState::AwaitingPassword + ); + + let (status, _, value) = + body_json(submit(&state, Some(TEMPORARY), REPLACEMENT).await).await; + assert_eq!(status, StatusCode::OK, "{value}"); + let body: BootstrapSubmitResponse = parse(value); + assert_eq!(body.state, BootstrapState::Complete); + + let (status, _, _) = body_json(sign_in(&state, ADMIN_SUBJECT, TEMPORARY).await).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, _, _) = body_json(sign_in(&state, ADMIN_SUBJECT, REPLACEMENT).await).await; + assert_eq!(status, StatusCode::OK); + } + + #[tokio::test] + async fn test_login_failure_message_does_not_reveal_which_part_was_wrong() { + let _lock = bootstrap_lock().await; + let (_dir, state) = fresh(); + bootstrap_admin(&state).await; + + let (wrong_status, _, wrong) = + body_json(sign_in(&state, ADMIN_SUBJECT, REPLACEMENT).await).await; + let (unknown_status, _, unknown) = body_json(sign_in(&state, "someone", GOOD).await).await; + assert_eq!(wrong_status, StatusCode::UNAUTHORIZED); + assert_eq!(unknown_status, StatusCode::UNAUTHORIZED); + assert_eq!(wrong, unknown); + assert_eq!(wrong["message"], "incorrect username or password"); + } + + #[tokio::test] + async fn test_login_backoff_is_persisted_for_a_reopened_store() { + let (dir, state) = fresh(); + for _ in 0..4 { + let (status, _, value) = body_json(sign_in(&state, "", "x").await).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{value}"); + } + let (status, headers, value) = body_json(sign_in(&state, "", "x").await).await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS, "{value}"); + assert!(retry_after(&headers) >= 1); + assert_eq!(value["error"], "rate_limited"); + + drop(state); + widen_persisted_backoff(dir.path(), BUCKET_LOGIN); + let state = state_at(dir.path()); + let (status, headers, _) = body_json(sign_in(&state, ADMIN_SUBJECT, GOOD).await).await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert!(retry_after(&headers) >= 1); + } + + #[tokio::test] + async fn test_forgot_password_response_ignores_the_username() { + let first = forgot_password(Json(ForgotPasswordRequest { + username: ADMIN_SUBJECT.to_string(), + })) + .await + .0 + .message; + for username in ["missing-user", ""] { + let message = forgot_password(Json(ForgotPasswordRequest { + username: username.to_string(), + })) + .await + .0 + .message; + assert_eq!(message, first); + } + assert!(first.contains("operator auth reset-admin-password")); + } + + #[tokio::test] + async fn test_reset_password_replaces_the_credential_and_ignores_short_passwords() { + let _lock = bootstrap_lock().await; + let (_dir, state) = fresh(); + bootstrap_admin(&state).await; + + let (status, _, value) = body_json(settle( + reset_password( + State(state.clone()), + Json(ResetPasswordRequest { + username: ADMIN_SUBJECT.to_string(), + current_password: "not the current password".into(), + new_password: REPLACEMENT.into(), + }), + ) + .await, + )) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{value}"); + assert_eq!(value["message"], "incorrect username or password"); + + for _ in 0..5 { + let (status, _, value) = body_json(settle( + reset_password( + State(state.clone()), + Json(ResetPasswordRequest { + username: ADMIN_SUBJECT.to_string(), + current_password: GOOD.into(), + new_password: SHORT.into(), + }), + ) + .await, + )) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!(value["error"], "validation_error"); + } + + let (status, _, value) = body_json(settle( + reset_password( + State(state.clone()), + Json(ResetPasswordRequest { + username: ADMIN_SUBJECT.to_string(), + current_password: GOOD.into(), + new_password: REPLACEMENT.into(), + }), + ) + .await, + )) + .await; + assert_eq!(status, StatusCode::OK, "{value}"); + let body: ResetPasswordResponse = parse(value); + assert!(body.changed); + + let (status, _, _) = body_json(sign_in(&state, ADMIN_SUBJECT, GOOD).await).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, _, _) = body_json(sign_in(&state, ADMIN_SUBJECT, REPLACEMENT).await).await; + assert_eq!(status, StatusCode::OK); + } + + #[tokio::test] + async fn test_reset_password_backoff_is_persisted() { + let (dir, state) = fresh(); + for _ in 0..4 { + let (status, _, _) = body_json(settle( + reset_password( + State(state.clone()), + Json(ResetPasswordRequest { + username: String::new(), + current_password: "x".into(), + new_password: SHORT.into(), + }), + ) + .await, + )) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + let (status, headers, value) = body_json(settle( + reset_password( + State(state.clone()), + Json(ResetPasswordRequest { + username: String::new(), + current_password: "x".into(), + new_password: SHORT.into(), + }), + ) + .await, + )) + .await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS, "{value}"); + assert!(retry_after(&headers) >= 1); + + drop(state); + widen_persisted_backoff(dir.path(), BUCKET_PASSWORD_RESET); + let state = state_at(dir.path()); + let (status, _, _) = body_json(settle( + reset_password( + State(state), + Json(ResetPasswordRequest { + username: ADMIN_SUBJECT.into(), + current_password: GOOD.into(), + new_password: REPLACEMENT.into(), + }), + ) + .await, + )) + .await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn test_login_cookie_csrf_logout_and_session_revocation() { + let _lock = bootstrap_lock().await; + let (_dir, state) = fresh(); + bootstrap_admin(&state).await; + let (status, headers, value) = body_json(sign_in(&state, ADMIN_SUBJECT, GOOD).await).await; + assert_eq!(status, StatusCode::OK, "{value}"); + let cookie = session_cookie(&headers); + let login_body: LoginResponse = parse(value); + assert_ne!(cookie, login_body.csrf_token); + assert_eq!(login_body.scopes, Scope::ALL.to_vec()); + + let current = current_session(Authenticated(session_principal(&state, &cookie))) + .await + .0; + assert_eq!(current.subject, ADMIN_SUBJECT); + assert_eq!(current.principal_kind, PrincipalKind::Session); + assert_eq!(current.scopes, Scope::ALL.to_vec()); + + let principal = session_principal(&state, &cookie); + let session_id = principal.session_id.clone().unwrap(); + let rotated = csrf_token(State(state.clone()), Authenticated(principal.clone())) + .await + .unwrap() + .0 + .csrf_token; + assert_ne!(rotated, login_body.csrf_token); + assert!(state + .auth + .store + .verify_csrf(&session_id, &login_body.csrf_token) + .unwrap()); + assert!(state.auth.store.verify_csrf(&session_id, &rotated).unwrap()); + + let rotated_again = csrf_token(State(state.clone()), Authenticated(principal.clone())) + .await + .unwrap() + .0 + .csrf_token; + assert!(!state + .auth + .store + .verify_csrf(&session_id, &login_body.csrf_token) + .unwrap()); + assert!(state.auth.store.verify_csrf(&session_id, &rotated).unwrap()); + assert!(state + .auth + .store + .verify_csrf(&session_id, &rotated_again) + .unwrap()); + + let bearer = Principal { + kind: PrincipalKind::AccessToken, + session_id: None, + ..Principal::local(ADMIN_SUBJECT) + }; + assert_eq!( + csrf_token(State(state.clone()), Authenticated(bearer)) + .await + .unwrap_err() + .parts() + .0, + StatusCode::BAD_REQUEST + ); + + let listed = list_sessions(State(state.clone()), Authenticated(principal.clone())) + .await + .unwrap() + .0; + assert!(listed.sessions.iter().any(|session| session.current)); + assert!(listed.devices.is_empty()); + + let (extra, _, _) = state.auth.store.create_session().unwrap(); + let extra_id = session_principal(&state, &extra).session_id.unwrap(); + assert!( + revoke_session(State(state.clone()), Path(extra_id)) + .await + .unwrap() + .0 + .ended + ); + assert!(state + .auth + .store + .authenticate_session(&extra) + .unwrap() + .is_none()); + + let response = logout(State(state.clone()), Authenticated(principal)) + .await + .unwrap(); + let (status, headers, value) = body_json(response).await; + assert_eq!(status, StatusCode::OK, "{value}"); + assert_eq!( + headers.get(header::SET_COOKIE).unwrap().to_str().unwrap(), + cleared_cookie() + ); + let body: LogoutResponse = parse(value); + assert!(body.ended); + assert!(state + .auth + .store + .authenticate_session(&cookie) + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn test_device_code_uses_host_until_a_public_url_is_configured() { + let (_dir, state) = fresh(); + let (status, _, value) = body_json( + device( + &state, + "ide.editor_1:main-box", + Some("operator.example:7008"), + vec![], + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::OK, "{value}"); + let body: DeviceAuthorizationResponse = parse(value); + assert!(is_user_code(&body.user_code)); + assert_ne!(body.device_code, body.user_code); + assert_eq!(body.expires_in, DEVICE_CODE_TTL_SECS); + assert_eq!(body.interval, DEVICE_POLL_INTERVAL_SECS); + assert_eq!( + body.verification_uri, + "http://operator.example:7008/#/device" + ); + assert_eq!( + body.verification_uri_complete, + format!( + "http://operator.example:7008/#/device?user_code={}", + body.user_code + ) + ); + + let dir = TempDir::new().unwrap(); + let mut config = Config::default(); + config.paths.state = dir.path().to_string_lossy().into_owned(); + config.rest_api.public_url = Some("https://operator.example.com/".into()); + let state = ApiState::new(config, dir.path().join("tickets")); + let (status, _, value) = + body_json(device(&state, "vscode", Some("evil.example"), vec![]).await).await; + assert_eq!(status, StatusCode::OK, "{value}"); + let body: DeviceAuthorizationResponse = parse(value); + assert_eq!( + body.verification_uri, + "https://operator.example.com/#/device" + ); + assert!(body + .verification_uri_complete + .starts_with("https://operator.example.com/#/device?user_code=")); + } + + #[tokio::test] + async fn test_device_code_rejects_a_malformed_client_id() { + let (_dir, state) = fresh(); + for client_id in ["", "bad/id", &"a".repeat(129)] { + let (status, _, value) = body_json(device(&state, client_id, None, vec![]).await).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + let body: OAuthErrorResponse = parse(value); + assert_eq!(body.error, OAuthErrorCode::InvalidClient); + } + } + + #[tokio::test] + async fn test_device_approval_reports_granted_scopes_and_unknown_codes() { + let (_dir, state) = fresh(); + let (status, _, value) = body_json(settle_api( + device_approve( + State(state.clone()), + Json(DeviceApprovalRequest { + user_code: "0000-0000".into(), + }), + ) + .await, + )) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{value}"); + assert_eq!(value["error"], "not_found"); + + let issued: DeviceAuthorizationResponse = parse( + body_json(device(&state, "vscode", None, vec![Scope::Read]).await) + .await + .2, + ); + let approved: DeviceApprovalResponse = parse( + body_json(settle_api( + device_approve( + State(state.clone()), + Json(DeviceApprovalRequest { + user_code: issued.user_code.to_lowercase(), + }), + ) + .await, + )) + .await + .2, + ); + assert!(approved.approved); + assert_eq!(approved.client_id, "vscode"); + assert_eq!(approved.scopes, vec![Scope::Read]); + + let all: DeviceAuthorizationResponse = parse( + body_json(device(&state, "codex", None, vec![]).await) + .await + .2, + ); + let approved: DeviceApprovalResponse = parse( + body_json(settle_api( + device_approve( + State(state), + Json(DeviceApprovalRequest { + user_code: all.user_code, + }), + ) + .await, + )) + .await + .2, + ); + assert_eq!(approved.scopes, Scope::ALL.to_vec()); + } + + #[tokio::test] + async fn test_device_poll_exchange_rotates_refresh_tokens_and_lists_the_device() { + let (dir, state) = fresh(); + let issued: DeviceAuthorizationResponse = parse( + body_json(device(&state, "vscode", Some("localhost:7008"), vec![]).await) + .await + .2, + ); + + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::DeviceCode { + device_code: issued.device_code.clone(), + client_id: "vscode".into(), + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + parse::(value).error, + OAuthErrorCode::AuthorizationPending + ); + + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::DeviceCode { + device_code: issued.device_code.clone(), + client_id: "vscode".into(), + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + parse::(value).error, + OAuthErrorCode::SlowDown + ); + + let approved = device_approve( + State(state.clone()), + Json(DeviceApprovalRequest { + user_code: issued.user_code.clone(), + }), + ) + .await + .unwrap() + .0; + assert!(approved.approved); + rewind_device_poll(dir.path()); + + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::DeviceCode { + device_code: issued.device_code.clone(), + client_id: "other-client".into(), + }, + ) + .await, + ) + .await; + assert_eq!( + parse::(value).error, + OAuthErrorCode::ExpiredToken + ); + assert_eq!(status, StatusCode::BAD_REQUEST); + + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::DeviceCode { + device_code: issued.device_code.clone(), + client_id: "vscode".into(), + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::OK, "{value}"); + let issued_token: TokenResponse = parse(value); + assert_eq!(issued_token.token_type, "Bearer"); + assert_eq!( + issued_token.expires_in, + ACCESS_TOKEN_TTL.num_seconds().max(0) as u64 + ); + assert_eq!(issued_token.scopes, Scope::ALL.to_vec()); + let refresh = issued_token.refresh_token.clone().unwrap(); + assert_ne!(refresh, issued.device_code); + + let listed = list_sessions( + State(state.clone()), + Authenticated(Principal::local(ADMIN_SUBJECT)), + ) + .await + .unwrap() + .0; + assert!(listed + .devices + .iter() + .any(|device| device.client_id == "vscode" && device.revoked_at.is_none())); + + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::RefreshToken { + refresh_token: refresh.clone(), + client_id: "vscode".into(), + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::OK, "{value}"); + let rotated: TokenResponse = parse(value); + let next = rotated.refresh_token.unwrap(); + assert_ne!(next, refresh); + + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::RefreshToken { + refresh_token: refresh, + client_id: "vscode".into(), + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + parse::(value).error, + OAuthErrorCode::InvalidGrant + ); + + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::RefreshToken { + refresh_token: next, + client_id: "bad/id".to_string(), + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + parse::(value).error, + OAuthErrorCode::InvalidClient + ); + + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::DeviceCode { + device_code: issued.device_code, + client_id: "vscode".into(), + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + parse::(value).error, + OAuthErrorCode::ExpiredToken + ); + } + + #[tokio::test] + async fn test_password_reset_denies_a_pending_device() { + let _lock = bootstrap_lock().await; + let (_dir, state) = fresh(); + bootstrap_admin(&state).await; + let issued: DeviceAuthorizationResponse = parse( + body_json(device(&state, "vscode", None, vec![]).await) + .await + .2, + ); + let (status, _, _) = body_json(settle( + reset_password( + State(state.clone()), + Json(ResetPasswordRequest { + username: ADMIN_SUBJECT.into(), + current_password: GOOD.into(), + new_password: REPLACEMENT.into(), + }), + ) + .await, + )) + .await; + assert_eq!(status, StatusCode::OK); + + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::DeviceCode { + device_code: issued.device_code, + client_id: "vscode".into(), + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + parse::(value).error, + OAuthErrorCode::AccessDenied + ); + } + + #[tokio::test] + async fn test_device_code_backoff_persists_and_blocks_a_later_valid_client() { + let (dir, state) = fresh(); + for _ in 0..4 { + let (status, _, value) = body_json(device(&state, "bad/id", None, vec![]).await).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + parse::(value).error, + OAuthErrorCode::InvalidClient + ); + } + let (status, headers, value) = + body_json(device(&state, "vscode", None, vec![]).await).await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS, "{value}"); + assert!(retry_after(&headers) >= 1); + + drop(state); + widen_persisted_backoff(dir.path(), BUCKET_DEVICE_CODE); + let state = state_at(dir.path()); + let (status, _, _) = body_json(device(&state, "vscode", None, vec![]).await).await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn test_pending_polls_do_not_fill_the_token_bucket() { + let (_dir, state) = fresh(); + for index in 0..4 { + let (status, _, value) = + body_json(device(&state, &format!("client-{index}"), None, vec![]).await).await; + assert_eq!(status, StatusCode::OK, "{value}"); + let issued: DeviceAuthorizationResponse = parse(value); + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::DeviceCode { + device_code: issued.device_code.clone(), + client_id: format!("client-{index}"), + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + parse::(value).error, + OAuthErrorCode::AuthorizationPending + ); + } + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::AccessKey { + access_key: "not-a-key".into(), + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + parse::(value).error, + OAuthErrorCode::InvalidGrant + ); + } + + #[tokio::test] + async fn test_token_invalid_client_backoff_is_persisted() { + let (dir, state) = fresh(); + let request = || TokenRequest::DeviceCode { + device_code: "device-code".into(), + client_id: "bad/id".into(), + }; + for _ in 0..4 { + let (status, _, value) = body_json(exchange(&state, request()).await).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + parse::(value).error, + OAuthErrorCode::InvalidClient + ); + } + let (status, headers, value) = body_json(exchange(&state, request()).await).await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS, "{value}"); + assert!(retry_after(&headers) >= 1); + + drop(state); + widen_persisted_backoff(dir.path(), BUCKET_TOKEN); + let state = state_at(dir.path()); + let (status, _, _) = body_json(exchange(&state, request()).await).await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn test_access_key_is_returned_once_and_exchanges_without_a_refresh_token() { + let (_dir, state) = fresh(); + let created = create_access_key( + State(state.clone()), + Json(CreateAccessKeyRequest { + name: " ci ".into(), + scopes: vec![Scope::Read], + expires_in_days: 30, + }), + ) + .await + .unwrap() + .0; + assert_eq!(created.key.name, "ci"); + assert_eq!(created.secret.len(), 47); + assert!(created.secret.starts_with("opk_")); + + let listed = list_access_keys(State(state.clone())).await.unwrap().0; + let json = serde_json::to_string(&listed).unwrap(); + assert!(!json.contains(&created.secret)); + assert_eq!(listed.keys.len(), 1); + + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::AccessKey { + access_key: "not-a-key".into(), + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + parse::(value).error, + OAuthErrorCode::InvalidGrant + ); + + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::AccessKey { + access_key: created.secret, + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::OK, "{value}"); + let token_body: TokenResponse = parse(value); + assert_eq!(token_body.token_type, "Bearer"); + assert!(token_body.refresh_token.is_none()); + assert_eq!(token_body.scopes, vec![Scope::Read]); + + let revoked = revoke_access_key(State(state.clone()), Path(created.key.id.clone())) + .await + .unwrap() + .0; + assert_eq!(revoked.id, created.key.id); + let error = revoke_access_key(State(state), Path(created.key.id)) + .await + .unwrap_err(); + assert_eq!(error.parts().0, StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_access_key_creation_rejects_bad_names_scopes_and_expiry() { + let (_dir, state) = fresh(); + let cases = [ + (String::new(), vec![Scope::Read], 30), + (" ".into(), vec![Scope::Read], 30), + ("n".repeat(129), vec![Scope::Read], 30), + ("ci".into(), vec![], 30), + ("ci".into(), vec![Scope::Read, Scope::Read], 30), + ("ci".into(), vec![Scope::Read], 0), + ("ci".into(), vec![Scope::Read], 366), + ]; + for (name, scopes, expires_in_days) in cases { + let error = create_access_key( + State(state.clone()), + Json(CreateAccessKeyRequest { + name, + scopes, + expires_in_days, + }), + ) + .await + .unwrap_err(); + assert_eq!(error.parts().0, StatusCode::BAD_REQUEST); + } + assert_eq!( + revoke_access_key(State(state), Path("missing".into())) + .await + .unwrap_err() + .parts() + .0, + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn test_access_key_exchange_backoff_is_persisted() { + let (dir, state) = fresh(); + let bad = || TokenRequest::AccessKey { + access_key: "not-a-key".into(), + }; + for _ in 0..4 { + let (status, _, value) = body_json(exchange(&state, bad()).await).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{value}"); + assert_eq!( + parse::(value).error, + OAuthErrorCode::InvalidGrant + ); + } + let (status, headers, _) = body_json(exchange(&state, bad()).await).await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert!(retry_after(&headers) >= 1); + + drop(state); + widen_persisted_backoff(dir.path(), BUCKET_TOKEN); + let state = state_at(dir.path()); + let (status, _, _) = body_json(exchange(&state, bad()).await).await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + } + + fn router_request( + method: &str, + uri: &str, + cookie: Option<&str>, + csrf: Option<&str>, + origin: &str, + ) -> Request { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header(header::HOST, "127.0.0.1:7008") + .header(header::ORIGIN, origin); + if let Some(cookie) = cookie { + builder = builder.header(header::COOKIE, format!("{SESSION_COOKIE}={cookie}")); + } + if let Some(csrf) = csrf { + builder = builder.header(CSRF_HEADER, csrf); + } + builder.body(Body::empty()).unwrap() + } + + #[tokio::test] + async fn test_cookie_mutations_require_csrf_and_same_origin() { + let _lock = bootstrap_lock().await; + let (_dir, state) = fresh(); + bootstrap_admin(&state).await; + let (status, headers, value) = body_json(sign_in(&state, ADMIN_SUBJECT, GOOD).await).await; + assert_eq!(status, StatusCode::OK, "{value}"); + let cookie = session_cookie(&headers); + let csrf = parse::(value).csrf_token; + let app = crate::rest::build_profile_router(state.clone()); + let same_origin = "http://127.0.0.1:7008"; + + let (status, _, value) = body_json( + app.clone() + .oneshot(router_request( + "GET", + "/api/v1/auth/session", + Some(&cookie), + None, + same_origin, + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{value}"); + assert_eq!( + parse::(value).subject, + ADMIN_SUBJECT + ); + + let (status, _, value) = body_json( + app.clone() + .oneshot(router_request( + "GET", + "/api/v1/auth/keys", + Some(&cookie), + None, + same_origin, + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{value}"); + + let (status, _, value) = body_json( + app.clone() + .oneshot(router_request( + "GET", + "/api/v1/auth/keys", + None, + None, + same_origin, + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{value}"); + + let (extra, _, _) = state.auth.store.create_session().unwrap(); + let extra_id = session_principal(&state, &extra).session_id.unwrap(); + let (status, _, value) = body_json( + app.clone() + .oneshot(router_request( + "DELETE", + &format!("/api/v1/auth/sessions/{extra_id}"), + None, + None, + same_origin, + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{value}"); + + let (_, other_csrf, _) = state.auth.store.create_session().unwrap(); + for (csrf_header, origin) in [ + (None, same_origin), + (Some(csrf.as_str()), "https://evil.example.com"), + (Some(other_csrf.as_str()), same_origin), + ] { + let (status, _, value) = body_json( + app.clone() + .oneshot(router_request( + "POST", + "/api/v1/auth/logout", + Some(&cookie), + csrf_header, + origin, + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{value}"); + assert_eq!(value["error"], "csrf_failed"); + } + + let (status, _, _) = body_json( + app.clone() + .oneshot(router_request( + "DELETE", + &format!("/api/v1/auth/sessions/{extra_id}"), + Some(&cookie), + Some(&csrf), + same_origin, + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(state + .auth + .store + .authenticate_session(&extra) + .unwrap() + .is_none()); + + // One superseded token stays valid, so retiring the login token takes two. + const ROTATIONS_TO_RETIRE_A_TOKEN: usize = 2; + let mut rotated = csrf.clone(); + for _ in 0..ROTATIONS_TO_RETIRE_A_TOKEN { + let (status, _, value) = body_json( + app.clone() + .oneshot(router_request( + "GET", + "/api/v1/auth/csrf", + Some(&cookie), + None, + same_origin, + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{value}"); + rotated = parse::(value).csrf_token; + assert_ne!(rotated, csrf); + } + + let (status, _, value) = body_json( + app.clone() + .oneshot(router_request( + "POST", + "/api/v1/auth/logout", + Some(&cookie), + Some(&csrf), + same_origin, + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{value}"); + + let (status, headers, value) = body_json( + app.oneshot(router_request( + "POST", + "/api/v1/auth/logout", + Some(&cookie), + Some(&rotated), + same_origin, + )) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{value}"); + assert_eq!( + headers.get(header::SET_COOKIE).unwrap().to_str().unwrap(), + cleared_cookie() + ); + } + + #[tokio::test] + async fn test_bearer_logout_does_not_require_a_csrf_token() { + let (_dir, state) = fresh(); + let issued: DeviceAuthorizationResponse = parse( + body_json(device(&state, "vscode", None, vec![]).await) + .await + .2, + ); + let approved = device_approve( + State(state.clone()), + Json(DeviceApprovalRequest { + user_code: issued.user_code, + }), + ) + .await + .unwrap() + .0; + assert!(approved.approved); + let (status, _, value) = body_json( + exchange( + &state, + TokenRequest::DeviceCode { + device_code: issued.device_code, + client_id: "vscode".into(), + }, + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::OK, "{value}"); + let access_token = parse::(value).access_token; + + let app = crate::rest::build_profile_router(state); + let (status, _, value) = body_json( + app.oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/auth/logout") + .header(header::HOST, "127.0.0.1:7008") + .header(header::AUTHORIZATION, format!("Bearer {access_token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{value}"); + assert!(parse::(value).ended); + } +} diff --git a/src/rest/routes/model_servers.rs b/src/rest/routes/model_servers.rs index f12bf481..59fb6c71 100644 --- a/src/rest/routes/model_servers.rs +++ b/src/rest/routes/model_servers.rs @@ -11,7 +11,7 @@ use axum::{ }; use crate::api::providers::model_server::{probe_models, ModelServerKind}; -use crate::config::{implicit_model_server_for_tool, Config, ModelServer}; +use crate::config::{implicit_model_servers, is_implicit_model_server_name, Config, ModelServer}; use crate::rest::dto::{ CreateModelServerRequest, ModelEntry, ModelServerKindEntry, ModelServerModelsResponse, ModelServerResponse, ModelServersResponse, UpdateModelServerRequest, @@ -19,16 +19,13 @@ use crate::rest::dto::{ use crate::rest::error::ApiError; use crate::rest::state::ApiState; -const IMPLICIT_TOOL_NAMES: &[&str] = &["claude", "codex", "gemini"]; - /// Find a server by name among user-declared servers, then implicit builtins. fn find_server(config: &Config, name: &str) -> Option<(ModelServer, bool)> { if let Some(s) = config.model_servers.iter().find(|s| s.name == name) { return Some((s.clone(), true)); } - IMPLICIT_TOOL_NAMES - .iter() - .map(|t| implicit_model_server_for_tool(t)) + implicit_model_servers() + .into_iter() .find(|s| s.name == name) .map(|s| (s, false)) } @@ -63,8 +60,7 @@ pub async fn list(State(state): State) -> Json { .map(|s| server_to_response(s, true)) .collect(); - for tool in IMPLICIT_TOOL_NAMES { - let implicit = implicit_model_server_for_tool(tool); + for implicit in implicit_model_servers() { if !servers.iter().any(|s| s.name == implicit.name) { servers.push(server_to_response(&implicit, false)); } @@ -95,11 +91,11 @@ pub async fn get_one( if let Some(server) = state.config().model_servers.iter().find(|s| s.name == name) { return Ok(Json(server_to_response(server, true))); } - for tool in IMPLICIT_TOOL_NAMES { - let implicit = implicit_model_server_for_tool(tool); - if implicit.name == name { - return Ok(Json(server_to_response(&implicit, false))); - } + if let Some(implicit) = implicit_model_servers() + .into_iter() + .find(|server| server.name == name) + { + return Ok(Json(server_to_response(&implicit, false))); } Err(ApiError::NotFound(format!( "Model server '{name}' not found" @@ -122,10 +118,7 @@ pub async fn create( State(state): State, Json(req): Json, ) -> Result, ApiError> { - if IMPLICIT_TOOL_NAMES - .iter() - .any(|t| implicit_model_server_for_tool(t).name == req.name) - { + if is_implicit_model_server_name(&req.name) { return Err(ApiError::Conflict(format!( "'{}' is a reserved implicit builtin name", req.name @@ -182,10 +175,7 @@ pub async fn delete( State(state): State, Path(name): Path, ) -> Result, ApiError> { - if IMPLICIT_TOOL_NAMES - .iter() - .any(|t| implicit_model_server_for_tool(t).name == name) - { + if is_implicit_model_server_name(&name) { return Err(ApiError::Conflict(format!( "'{name}' is an implicit builtin and cannot be deleted" ))); @@ -227,10 +217,7 @@ pub async fn update( Path(name): Path, Json(req): Json, ) -> Result, ApiError> { - if IMPLICIT_TOOL_NAMES - .iter() - .any(|t| implicit_model_server_for_tool(t).name == name) - { + if is_implicit_model_server_name(&name) { return Err(ApiError::Conflict(format!( "'{name}' is an implicit builtin and cannot be updated" ))); @@ -405,8 +392,7 @@ mod tests { let state = ApiState::new(config, PathBuf::from("/tmp/test-ms")); let resp = list(State(state)).await; - // At minimum, one implicit server for each known tool. - assert!(resp.total >= IMPLICIT_TOOL_NAMES.len()); + assert!(resp.total >= crate::config::shipped_llm_tools().len()); assert!(resp.servers.iter().any(|s| s.name == "anthropic-api")); assert!(resp.servers.iter().any(|s| s.name == "openai-api")); assert!(resp.servers.iter().any(|s| s.name == "google-api")); diff --git a/src/rest/routes/profiles.rs b/src/rest/routes/profiles.rs index a0451f5b..eed485d8 100644 --- a/src/rest/routes/profiles.rs +++ b/src/rest/routes/profiles.rs @@ -65,3 +65,186 @@ pub async fn get_one( .map(|state| Json(profiles.summary(&state.config()))) .ok_or_else(|| ApiError::NotFound("Configuration not found".into())) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::config::Config; + use crate::profiles::{ServerProfiles, LEGACY_PROFILE_NAME}; + use crate::rest::error::ApiError; + use crate::rest::state::ApiState; + use axum::extract::Path; + use axum::http::StatusCode; + use axum::{Extension, Json}; + use uuid::Uuid; + + fn fixture() -> (tempfile::TempDir, Arc) { + let dir = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = dir.path().join("state").to_string_lossy().into_owned(); + let state = ApiState::new(config, dir.path().join("tickets")); + let profiles = ServerProfiles::open(state).unwrap(); + (dir, profiles) + } + + fn status_of(error: ApiError) -> StatusCode { + error.parts().0 + } + + #[tokio::test] + async fn test_profile_list_create_and_get() { + let (_dir, profiles) = fixture(); + let listed = list(Extension(Arc::clone(&profiles))).await.0; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].name, LEGACY_PROFILE_NAME); + + let created = create( + Extension(Arc::clone(&profiles)), + Json(ProfileNameRequest { + name: "demo_1".into(), + }), + ) + .await + .unwrap() + .0; + assert_eq!(created.name, "demo_1"); + assert!(list(Extension(Arc::clone(&profiles))) + .await + .0 + .iter() + .any(|profile| profile.id == created.id && profile.name == "demo_1")); + + let fetched = get_one(Extension(Arc::clone(&profiles)), Path(created.id)) + .await + .unwrap() + .0; + assert_eq!(fetched.id, created.id); + assert_eq!(fetched.name, "demo_1"); + assert_eq!( + status_of( + get_one(Extension(profiles), Path(Uuid::new_v4())) + .await + .unwrap_err() + ), + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn test_profile_create_rejects_duplicates_and_invalid_names() { + let (_dir, profiles) = fixture(); + let created = create( + Extension(Arc::clone(&profiles)), + Json(ProfileNameRequest { + name: "demo_1".into(), + }), + ) + .await + .unwrap() + .0; + assert_eq!(created.name, "demo_1"); + assert_eq!( + status_of( + create( + Extension(Arc::clone(&profiles)), + Json(ProfileNameRequest { + name: "demo_1".into(), + }), + ) + .await + .unwrap_err() + ), + StatusCode::CONFLICT + ); + for name in ["Upper", "", "../escape"] { + assert_eq!( + status_of( + create( + Extension(Arc::clone(&profiles)), + Json(ProfileNameRequest { name: name.into() }), + ) + .await + .unwrap_err() + ), + StatusCode::BAD_REQUEST, + "{name}" + ); + } + } + + #[tokio::test] + async fn test_profile_rename_updates_the_list_and_rejects_conflicts() { + let (_dir, profiles) = fixture(); + let created = create( + Extension(Arc::clone(&profiles)), + Json(ProfileNameRequest { + name: "demo_1".into(), + }), + ) + .await + .unwrap() + .0; + + let renamed = rename( + Extension(Arc::clone(&profiles)), + Path(created.id), + Json(ProfileNameRequest { + name: "demo_2".into(), + }), + ) + .await + .unwrap() + .0; + assert_eq!(renamed.name, "demo_2"); + assert!(list(Extension(Arc::clone(&profiles))) + .await + .0 + .iter() + .any(|profile| profile.id == created.id && profile.name == "demo_2")); + + assert_eq!( + status_of( + rename( + Extension(Arc::clone(&profiles)), + Path(created.id), + Json(ProfileNameRequest { + name: LEGACY_PROFILE_NAME.into(), + }), + ) + .await + .unwrap_err() + ), + StatusCode::CONFLICT + ); + assert_eq!( + status_of( + rename( + Extension(Arc::clone(&profiles)), + Path(Uuid::new_v4()), + Json(ProfileNameRequest { + name: "demo_3".into(), + }), + ) + .await + .unwrap_err() + ), + StatusCode::NOT_FOUND + ); + assert_eq!( + status_of( + rename( + Extension(profiles), + Path(created.id), + Json(ProfileNameRequest { + name: "Upper".into(), + }), + ) + .await + .unwrap_err() + ), + StatusCode::BAD_REQUEST + ); + } +} diff --git a/src/rest/routes/targets.rs b/src/rest/routes/targets.rs index bd8d14b4..ab86f052 100644 --- a/src/rest/routes/targets.rs +++ b/src/rest/routes/targets.rs @@ -35,11 +35,25 @@ pub struct TargetsResponse { pub total: usize, } +#[derive(Serialize, ToSchema, TS)] +#[ts(export)] +pub struct TargetToolProbe { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + pub health_ok: bool, +} + #[derive(Serialize, ToSchema, TS)] #[ts(export)] pub struct TargetProbeResponse { pub reachable: bool, pub message: String, + /// LLM CLIs reported by `opr8r tools --json` on the target. Empty if unreachable/unknown. + #[serde(default)] + pub tools: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools_error: Option, } fn response(config: &Config, target: TargetDef) -> TargetResponse { @@ -256,7 +270,7 @@ pub async fn probe( licensing::require_premium(&config, PremiumFeature::RemoteTargets)?; let session = crate::agents::launcher::coder::resolve_session(coder) .map_err(|error| ApiError::ValidationError(error.to_string()))?; - let client = reqwest::Client::builder() + let client = crate::http_client::client_builder() .timeout(PROBE_TIMEOUT) .build() .map_err(|error| ApiError::InternalError(error.to_string()))?; @@ -283,6 +297,11 @@ pub async fn probe( .is_ok_and(|result| result.is_ok_and(|status| status.success())) } }; + let (tools, tools_error) = if reachable { + inventory_for_target(&target) + } else { + (Vec::new(), None) + }; Ok(Json(TargetProbeResponse { reachable, message: if reachable { @@ -291,9 +310,42 @@ pub async fn probe( "Connection failed; check the target configuration and credentials" } .into(), + tools, + tools_error, })) } +fn inventory_for_target(target: &TargetDef) -> (Vec, Option) { + let result = match &target.kind { + TargetKind::Local => crate::llm::probe_local(), + TargetKind::Ssh(ssh) => { + let mut command = std::process::Command::new("ssh"); + command.args(["-o", "BatchMode=yes", "-o", SSH_CONNECT_TIMEOUT]); + if let Some(path) = &ssh.ssh_config_path { + command.args(["-F", path]); + } + command.arg(&ssh.ssh_alias); + crate::llm::probe_over_ssh(&mut command) + } + TargetKind::Docker(_) | TargetKind::Coder(_) => { + return (Vec::new(), None); + } + }; + match result { + Ok(rows) => ( + rows.into_iter() + .map(|row| TargetToolProbe { + name: row.name, + version: row.version, + health_ok: row.health_ok, + }) + .collect(), + None, + ), + Err(error) => (Vec::new(), Some(error)), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/rest/server.rs b/src/rest/server.rs index 93929ca6..b25da103 100644 --- a/src/rest/server.rs +++ b/src/rest/server.rs @@ -216,7 +216,7 @@ impl RestApiServer { /// conflict we should report. Always connects over loopback. pub async fn probe_external(&self) -> ExternalApiProbe { let url = format!("http://127.0.0.1:{}/api/v1/health", self.port); - let client = match reqwest::Client::builder() + let client = match crate::http_client::client_builder() .timeout(std::time::Duration::from_secs(2)) .build() { diff --git a/src/schemas/issuetype_schema.json b/src/schemas/issuetype_schema.json index f30062a5..984598f8 100644 --- a/src/schemas/issuetype_schema.json +++ b/src/schemas/issuetype_schema.json @@ -1195,7 +1195,7 @@ "default": true }, "voting_prompt": { - "description": "Prompt for the voting round (Handlebars, receives {{ answers }} array)", + "description": "Instruction prompt for the judge that picks the winner (Handlebars, rendered with the ticket context)", "type": [ "string", "null" @@ -1237,12 +1237,12 @@ "description": "How the voting round is executed in multi-model steps", "oneOf": [ { - "description": "One agent reviews all answers and picks winner (uses 1 slot)", + "description": "An LLM judge (`[native_llm.judge]`, not yet in release builds) picks the\nwinner; the voting strategy's deterministic rule applies until then", "type": "string", "const": "single_judge" }, { - "description": "All original delegators re-run with shared answers, each votes (uses N slots)", + "description": "All original delegators re-run with shared answers, each votes (uses N\nslots). Not yet run: the deterministic rule applies", "type": "string", "const": "multi_voter" } @@ -1272,7 +1272,7 @@ "default": null }, "selection_prompt": { - "description": "Prompt for the selection/review round", + "description": "Instruction for the judge that picks the best variation with\n`model_choice` (Handlebars, rendered with the ticket context)", "type": [ "string", "null" @@ -1289,7 +1289,7 @@ "description": "Selection strategy for multi-prompt steps", "oneOf": [ { - "description": "Model reviews all outputs and picks the best", + "description": "An LLM judge (`[native_llm.judge]`, not yet in release builds) picks the\nbest; the first variation is used until then", "type": "string", "const": "model_choice" }, diff --git a/src/services/git_onboarding.rs b/src/services/git_onboarding.rs index 357cd6d6..1d87e6af 100644 --- a/src/services/git_onboarding.rs +++ b/src/services/git_onboarding.rs @@ -49,7 +49,8 @@ fn grab_cli_token(command: &str, args: &[&str]) -> Option { } pub fn validate_github_token(token: &str) -> Result { - let response = reqwest::blocking::Client::new() + let response = crate::http_client::blocking_client_builder() + .build()? .get("https://api.github.com/user") .header("Authorization", format!("Bearer {token}")) .header("User-Agent", "operator") @@ -68,7 +69,8 @@ pub fn validate_github_token(token: &str) -> Result { } pub fn validate_gitlab_token(token: &str) -> Result { - let response = reqwest::blocking::Client::new() + let response = crate::http_client::blocking_client_builder() + .build()? .get("https://gitlab.com/api/v4/user") .header("Private-Token", token) .header("User-Agent", "operator") diff --git a/src/startup/steps.rs b/src/startup/steps.rs index a7de07d9..7ce447f1 100644 --- a/src/startup/steps.rs +++ b/src/startup/steps.rs @@ -54,18 +54,12 @@ pub enum SetupStep { /// Choose which issue type collection to use #[serde(rename = "collection-source")] CollectionSource, - /// Browse and multi-select hosted collections - #[serde(rename = "hosted-collections")] - HostedCollectionFetch, /// Configure optional TASK fields #[serde(rename = "task-field-config")] TaskFieldConfig, /// Select the session wrapper agents launch into #[serde(rename = "session-wrapper-choice")] SessionWrapperChoice, - /// Choose where agent commands execute - #[serde(rename = "execution-target")] - ExecutionTarget, /// Choose in-place branches or per-ticket worktrees #[serde(rename = "worktree-preference")] WorktreePreference, @@ -90,6 +84,12 @@ pub enum SetupStep { /// Optionally create bootstrap tickets #[serde(rename = "startup-tickets")] StartupTickets, + /// Browse and multi-select hosted collections + #[serde(rename = "hosted-collections")] + HostedCollectionFetch, + /// Choose where agent commands execute + #[serde(rename = "execution-target")] + ExecutionTarget, /// Review and confirm initialization #[serde(rename = "confirm")] Confirm, @@ -97,8 +97,15 @@ pub enum SetupStep { #[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs impl SetupStep { - /// Every step, in the order the wizard walks them. Conditional steps + /// Every step, in the order the wizard presents them. Conditional steps /// (the per-wrapper ones) appear here even though a given run skips most. + /// + /// The two steps a run may skip outright - `HostedCollectionFetch` and + /// `ExecutionTarget` - are gathered just before `Confirm` so that answering + /// an earlier question never renumbers the steps already shown. The web + /// wizard walks this order directly; `src/ui/setup/mod.rs` is a hand-written + /// state machine that still visits both inline, a known divergence pending + /// its realignment. pub const ALL: [SetupStep; 20] = [ SetupStep::Welcome, SetupStep::License, @@ -107,10 +114,8 @@ impl SetupStep { SetupStep::ModelServer, SetupStep::GitProvider, SetupStep::CollectionSource, - SetupStep::HostedCollectionFetch, SetupStep::TaskFieldConfig, SetupStep::SessionWrapperChoice, - SetupStep::ExecutionTarget, SetupStep::WorktreePreference, SetupStep::AdminPassword, SetupStep::TmuxOnboarding, @@ -119,6 +124,8 @@ impl SetupStep { SetupStep::ZellijSetup, SetupStep::AcceptanceCriteria, SetupStep::StartupTickets, + SetupStep::HostedCollectionFetch, + SetupStep::ExecutionTarget, SetupStep::Confirm, ]; @@ -132,10 +139,8 @@ impl SetupStep { SetupStep::ModelServer => "model-server", SetupStep::GitProvider => "git-provider", SetupStep::CollectionSource => "collection-source", - SetupStep::HostedCollectionFetch => "hosted-collections", SetupStep::TaskFieldConfig => "task-field-config", SetupStep::SessionWrapperChoice => "session-wrapper-choice", - SetupStep::ExecutionTarget => "execution-target", SetupStep::WorktreePreference => "worktree-preference", SetupStep::AdminPassword => "admin-password", SetupStep::TmuxOnboarding => "tmux-onboarding", @@ -144,6 +149,8 @@ impl SetupStep { SetupStep::ZellijSetup => "zellij-setup", SetupStep::AcceptanceCriteria => "acceptance-criteria", SetupStep::StartupTickets => "startup-tickets", + SetupStep::HostedCollectionFetch => "hosted-collections", + SetupStep::ExecutionTarget => "execution-target", SetupStep::Confirm => "confirm", } } @@ -183,7 +190,8 @@ impl SetupStep { - **This machine**: agents and local containers run beside Operator.\n\ - **Remote targets**: agents run on SSH hosts or Coder workspaces and \ report back to this Operator server. Requires Premium.\n\n\ - Choosing remote leads to target registration; choosing this machine skips it.", + Choosing remote adds a target-registration step at the end of setup; \ + choosing this machine skips it.", navigation: "↑/↓ to select, Enter to continue, Esc to go back", }, SetupStep::KanbanInfo => SetupStepInfo { @@ -253,14 +261,6 @@ impl SetupStep { - **Custom Selection**: Choose individual issue types", navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", }, - SetupStep::HostedCollectionFetch => SetupStepInfo { - name: "Hosted Collections", - description: "Browse and select hosted collections (only shown if Browse chosen)", - help_text: "Pick one or more curated collections published at operator.untra.io.\n\n\ - The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps.\n\n\ - Selections are additive - choose as many as apply.", - navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", - }, SetupStep::TaskFieldConfig => SetupStepInfo { name: "Task Field Config", description: "Configure optional fields for TASK issue type", @@ -284,12 +284,6 @@ impl SetupStep { Your choice determines which setup steps follow.", navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", }, - SetupStep::ExecutionTarget => SetupStepInfo { - name: "Execution Target", - description: "Choose whether agents run locally or in Coder workspaces", - help_text: "Local runs agent commands on the same machine as Operator. Coder creates or starts a per-ticket workspace and launches there over SSH.\n\nCoder configuration stores only environment variable names for the deployment URL and session token. Secret values remain in the process environment.\n\nCoder targets disable git worktrees and relay injection, and cannot be combined with Zellij.", - navigation: "↑/↓ to select, Tab to switch fields, Enter to continue, Esc to go back", - }, SetupStep::WorktreePreference => SetupStepInfo { name: "Worktree Preference", description: "Choose whether to use git worktrees for ticket isolation", @@ -367,6 +361,20 @@ impl SetupStep { These tickets are optional and help automate common setup tasks.", navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", }, + SetupStep::HostedCollectionFetch => SetupStepInfo { + name: "Hosted Collections", + description: "Browse and select hosted collections (only shown if Browse chosen)", + help_text: "Pick one or more curated collections published at operator.untra.io.\n\n\ + The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps.\n\n\ + Selections are additive - choose as many as apply.", + navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", + }, + SetupStep::ExecutionTarget => SetupStepInfo { + name: "Execution Target", + description: "Choose whether agents run locally or in Coder workspaces", + help_text: "Local runs agent commands on the same machine as Operator. Coder creates or starts a per-ticket workspace and launches there over SSH.\n\nCoder configuration stores only environment variable names for the deployment URL and session token. Secret values remain in the process environment.\n\nCoder targets disable git worktrees and relay injection, and cannot be combined with Zellij.", + navigation: "↑/↓ to select, Tab to switch fields, Enter to continue, Esc to go back", + }, SetupStep::Confirm => SetupStepInfo { name: "Confirm", description: "Review settings and confirm initialization", @@ -421,8 +429,9 @@ mod tests { assert_eq!(unique.len(), SetupStep::ALL.len()); } - /// Slugs key docs URLs and the web renderer's component map, so a rename is - /// a breaking change and must be deliberate. + /// Slugs key docs URLs and the web renderer's component map, and the order + /// is what the wizard's sidebar numbers, so both a rename and a reorder are + /// breaking changes and must be deliberate. #[test] fn test_slugs_match_frozen_snapshot() { let slugs: Vec<&str> = SetupStep::ALL.iter().map(|s| s.slug()).collect(); @@ -436,10 +445,8 @@ mod tests { "model-server", "git-provider", "collection-source", - "hosted-collections", "task-field-config", "session-wrapper-choice", - "execution-target", "worktree-preference", "admin-password", "tmux-onboarding", @@ -448,6 +455,8 @@ mod tests { "zellij-setup", "acceptance-criteria", "startup-tickets", + "hosted-collections", + "execution-target", "confirm", ] ); diff --git a/src/state.rs b/src/state.rs index 7d245e81..ecea7ebe 100644 --- a/src/state.rs +++ b/src/state.rs @@ -225,8 +225,34 @@ pub struct MultiAgentGroup { /// Maps launched `agent_id` to the `variant_key` used as the output key. #[serde(default)] pub agent_variant_keys: HashMap, + /// The in-flight LLM judge call (set when phase = Voting). Its verdict + /// arrives as a side file keyed by `attempt_id`, never through `State`. + #[serde(default)] + pub judge_attempt: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct JudgeAttempt { + pub attempt_id: String, + #[ts(type = "string")] + pub started_at: DateTime, + /// Judge timeout copied at start, so a config edit can't strand the attempt + pub timeout_secs: u64, +} + +impl JudgeAttempt { + /// When a missing verdict counts as abandoned (task timed out, or the + /// daemon restarted and the task is gone). + pub fn deadline(&self) -> DateTime { + let secs = self.timeout_secs.saturating_add(JUDGE_DEADLINE_GRACE_SECS); + self.started_at + chrono::Duration::seconds(i64::try_from(secs).unwrap_or(i64::MAX)) + } +} + +/// Slack past the judge timeout for the task to write its outcome file. +const JUDGE_DEADLINE_GRACE_SECS: u64 = 30; + /// A sub-agent that has been planned but not yet launched (slot queue). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] @@ -1055,6 +1081,7 @@ impl State { expected_total, pending_launches: pending, agent_variant_keys: HashMap::new(), + judge_attempt: None, }; self.multi_agent_groups.push(group); self.save()?; @@ -1154,6 +1181,25 @@ impl State { Ok(()) } + /// Move a group into the judging (Voting) phase with a fresh attempt. + /// Persists state and returns the attempt, whose id keys the verdict file. + pub fn begin_judging(&mut self, group_id: &str, timeout_secs: u64) -> Result { + let group = self + .multi_agent_groups + .iter_mut() + .find(|g| g.group_id == group_id) + .ok_or_else(|| anyhow::anyhow!("group {group_id} not found"))?; + let attempt = JudgeAttempt { + attempt_id: Uuid::new_v4().to_string(), + started_at: Utc::now(), + timeout_secs, + }; + group.phase = MultiAgentPhase::Voting; + group.judge_attempt = Some(attempt.clone()); + self.save()?; + Ok(attempt) + } + /// Set the aggregated output for a group and mark as complete pub fn complete_group(&mut self, group_id: &str, aggregated: serde_json::Value) -> Result<()> { if let Some(group) = self @@ -2140,6 +2186,55 @@ mod tests { ); } + #[test] + fn test_begin_judging_sets_voting_and_persists_attempt() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + let gid = state + .create_multi_agent_group("FEAT-1", "review", "multi_model", vec![pending("a", "a")]) + .unwrap(); + + let attempt = state.begin_judging(&gid, 120).unwrap(); + + let reloaded = State::load(&config).unwrap(); + let group = reloaded + .multi_agent_groups + .iter() + .find(|g| g.group_id == gid) + .unwrap(); + assert_eq!(group.phase, MultiAgentPhase::Voting); + assert_eq!(group.judge_attempt.as_ref(), Some(&attempt)); + assert_eq!( + attempt.deadline() - attempt.started_at, + chrono::Duration::seconds(150) + ); + } + + #[test] + fn test_begin_judging_twice_issues_a_new_attempt_id() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + let gid = state + .create_multi_agent_group("FEAT-1", "review", "multi_model", vec![pending("a", "a")]) + .unwrap(); + + let first = state.begin_judging(&gid, 120).unwrap(); + let second = state.begin_judging(&gid, 120).unwrap(); + assert_ne!(first.attempt_id, second.attempt_id); + } + + #[test] + fn test_group_without_judge_attempt_field_still_deserializes() { + let json = r#"{ + "group_id": "g", "ticket_id": "FEAT-1", "step_name": "review", + "step_type": "multi_model", "agent_ids": [], "phase": "fan_out" + }"#; + let group: MultiAgentGroup = serde_json::from_str(json).unwrap(); + assert!(group.judge_attempt.is_none()); + } + #[test] fn test_group_roundtrips_through_disk_with_pending_queue() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/steps/manager.rs b/src/steps/manager.rs index 0b7a4252..a7863939 100644 --- a/src/steps/manager.rs +++ b/src/steps/manager.rs @@ -203,6 +203,14 @@ impl StepManager { serde_json::Value::Object(data) } + /// Render a template (e.g. a step's `voting_prompt`) against the ticket context. + pub fn render_ticket_template(template: &str, ticket: &Ticket) -> Result { + let mut hbs = Handlebars::new(); + hbs.set_strict_mode(false); + hbs.render_template(template, &Self::build_ticket_context(ticket, None)) + .context("Failed to render ticket template") + } + /// Render a prompt template with ticket data fn render_prompt( &self, @@ -267,6 +275,46 @@ impl StepManager { Ok(()) } + fn judge_outcome_path(worktree: &str, step_name: &str, attempt_id: &str) -> std::path::PathBuf { + std::path::PathBuf::from(worktree) + .join(".tickets") + .join("steps") + .join(step_name) + .join(format!("judge-{attempt_id}.json")) + } + + /// Atomically write a judge attempt's outcome (temp file + rename) + pub fn write_judge_outcome( + worktree: &str, + step_name: &str, + attempt_id: &str, + outcome: &crate::llm::native::JudgeOutcome, + ) -> anyhow::Result<()> { + let path = Self::judge_outcome_path(worktree, step_name, attempt_id); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir) + .with_context(|| format!("create_dir_all {}", dir.display()))?; + } + let temp = path.with_extension("json.tmp"); + std::fs::write(&temp, serde_json::to_string_pretty(outcome)?) + .with_context(|| format!("write {}", temp.display()))?; + std::fs::rename(&temp, &path).with_context(|| format!("rename to {}", path.display()))?; + Ok(()) + } + + /// Read a judge attempt's outcome; `None` until the judge task has written it. + pub fn read_judge_outcome( + ticket: &Ticket, + step_name: &str, + attempt_id: &str, + ) -> Option { + let worktree = ticket.worktree_path.as_deref()?; + let contents = + std::fs::read_to_string(Self::judge_outcome_path(worktree, step_name, attempt_id)) + .ok()?; + serde_json::from_str(&contents).ok() + } + /// Write the aggregated step output artifact at /// `{worktree}/.tickets/steps/{step_name}.output.json`. /// This is the file `load_step_outputs` reads into `{{ steps.{name}.* }}`. @@ -648,4 +696,46 @@ mod tests { .unwrap_err(); assert!(err.to_string().contains("worktree_path")); } + + #[test] + fn test_judge_outcome_roundtrip_is_keyed_by_attempt() { + use crate::llm::native::{JudgeOutcome, JudgeVerdict}; + let tmp = tempfile::tempdir().unwrap(); + let worktree = tmp.path().to_string_lossy().to_string(); + let mut ticket = make_test_ticket("FEAT", "plan"); + ticket.worktree_path = Some(worktree.clone()); + + assert!(StepManager::read_judge_outcome(&ticket, "review", "att-1").is_none()); + + let outcome = JudgeOutcome::Verdict(JudgeVerdict { + winner_index: 1, + rationale: "clearer".to_string(), + }); + StepManager::write_judge_outcome(&worktree, "review", "att-1", &outcome).unwrap(); + + assert_eq!( + StepManager::read_judge_outcome(&ticket, "review", "att-1"), + Some(outcome) + ); + assert!(StepManager::read_judge_outcome(&ticket, "review", "att-2").is_none()); + assert!(!tmp + .path() + .join(".tickets/steps/review/judge-att-1.json.tmp") + .exists()); + } + + #[test] + fn test_judge_outcome_file_does_not_leak_into_step_outputs() { + use crate::llm::native::JudgeOutcome; + let tmp = tempfile::tempdir().unwrap(); + let worktree = tmp.path().to_string_lossy().to_string(); + let mut ticket = make_test_ticket("FEAT", "plan"); + ticket.worktree_path = Some(worktree.clone()); + + let failed = JudgeOutcome::Failed { + reason: "timeout".to_string(), + }; + StepManager::write_judge_outcome(&worktree, "review", "att-1", &failed).unwrap(); + assert!(StepManager::load_step_outputs(&ticket).is_empty()); + } } diff --git a/src/templates/schema.rs b/src/templates/schema.rs index 00bc5d44..72e2905d 100644 --- a/src/templates/schema.rs +++ b/src/templates/schema.rs @@ -497,7 +497,7 @@ pub struct MultiModelConfig { /// Whether to share all answers with all models in the voting round #[serde(default = "default_true")] pub share_answers: bool, - /// Prompt for the voting round (Handlebars, receives {{ answers }} array) + /// Instruction prompt for the judge that picks the winner (Handlebars, rendered with the ticket context) #[serde(default)] pub voting_prompt: Option, /// How the voting round executes @@ -523,10 +523,12 @@ pub enum VotingStrategy { #[ts(export)] #[serde(rename_all = "snake_case")] pub enum VotingMode { - /// One agent reviews all answers and picks winner (uses 1 slot) + /// An LLM judge (`[native_llm.judge]`, not yet in release builds) picks the + /// winner; the voting strategy's deterministic rule applies until then #[default] SingleJudge, - /// All original delegators re-run with shared answers, each votes (uses N slots) + /// All original delegators re-run with shared answers, each votes (uses N + /// slots). Not yet run: the deterministic rule applies MultiVoter, } @@ -543,7 +545,8 @@ pub struct MultiPromptConfig { /// Agent/delegator to use for all variations #[serde(default)] pub agent: Option, - /// Prompt for the selection/review round + /// Instruction for the judge that picks the best variation with + /// `model_choice` (Handlebars, rendered with the ticket context) #[serde(default)] pub selection_prompt: Option, } @@ -553,7 +556,8 @@ pub struct MultiPromptConfig { #[ts(export)] #[serde(rename_all = "snake_case")] pub enum SelectionStrategy { - /// Model reviews all outputs and picks the best + /// An LLM judge (`[native_llm.judge]`, not yet in release builds) picks the + /// best; the first variation is used until then ModelChoice, /// Model scores each and highest wins Scored, @@ -629,8 +633,8 @@ pub struct PipelineStage { } /// Where a pipeline's iterated items come from. The variant determines *when* -/// the list resolves: export-time (a literal array → static fan-out width in -/// the compiled graph) vs runtime (an identifier → symbolic width). +/// the list resolves: export-time (a literal array -> static fan-out width in +/// the compiled graph) vs runtime (an identifier -> symbolic width). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] #[serde(tag = "type", rename_all = "snake_case")] diff --git a/src/templates/step_type.rs b/src/templates/step_type.rs index fccf0c99..00a03eea 100644 --- a/src/templates/step_type.rs +++ b/src/templates/step_type.rs @@ -465,6 +465,131 @@ pub fn apply_aggregation(base: &mut serde_json::Value, result: serde_json::Value base["value"] = result; } +use crate::llm::native::{JudgeCandidate, JudgeRequest, DEFAULT_SELECTION_INSTRUCTION}; +use crate::templates::schema::{SelectionStrategy, VotingMode}; + +/// A judge request plus the map from its (non-empty) candidates back to the +/// aggregator's `responses` / `variations` index. +#[derive(Debug, Clone, PartialEq)] +pub struct JudgePlan { + pub request: JudgeRequest, + pub aggregate_indices: Vec, +} + +impl JudgePlan { + pub fn aggregate_index(&self, candidate_index: usize) -> Option { + self.aggregate_indices.get(candidate_index).copied() + } +} + +/// Build a judge request when the step asks for model-based selection and +/// there is an actual choice to make (at least two non-empty candidates). +/// `render` turns the step's prompt template into the final instruction. +pub fn judge_plan( + step: &StepSchema, + outputs: &HashMap, + render: &dyn Fn(&str) -> String, +) -> Option { + let (keys, labels, instruction): (Vec, Vec, Option<&String>) = + match step.step_type { + StepTypeTag::MultiModel => { + let cfg = step.multi_model_config.as_ref()?; + if cfg.voting_mode != VotingMode::SingleJudge { + return None; + } + ( + cfg.delegators.clone(), + cfg.delegators.clone(), + cfg.voting_prompt.as_ref(), + ) + } + StepTypeTag::MultiPrompt => { + let cfg = step.multi_prompt_config.as_ref()?; + if cfg.selection_strategy != SelectionStrategy::ModelChoice { + return None; + } + let n = cfg.prompt_variations.len(); + ( + (0..n).map(|i| i.to_string()).collect(), + (0..n).map(|i| format!("variation {i}")).collect(), + cfg.selection_prompt.as_ref(), + ) + } + _ => return None, + }; + + let mut candidates = Vec::new(); + let mut aggregate_indices = Vec::new(); + for (i, (key, label)) in keys.iter().zip(labels).enumerate() { + if let Some(text) = outputs.get(key).and_then(candidate_text) { + candidates.push(JudgeCandidate { label, text }); + aggregate_indices.push(i); + } + } + if candidates.len() < 2 { + return None; + } + + let instruction = + instruction.map_or_else(|| DEFAULT_SELECTION_INSTRUCTION.to_string(), |t| render(t)); + Some(JudgePlan { + request: JudgeRequest { + instruction, + candidates, + }, + aggregate_indices, + }) +} + +fn candidate_text(value: &serde_json::Value) -> Option { + let text = match value { + serde_json::Value::Null => return None, + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + (!text.trim().is_empty()).then_some(text) +} + +/// Overwrite the deterministic winner with the judge's pick. +/// `winner` is an aggregator index (see [`JudgePlan::aggregate_index`]). +/// Returns `false`, leaving `base` untouched, when the index doesn't fit this step. +pub fn apply_judge_verdict( + base: &mut serde_json::Value, + step: &StepSchema, + winner: usize, + rationale: &str, +) -> bool { + let applied = match step.step_type { + StepTypeTag::MultiModel => match step.multi_model_config.as_ref() { + Some(cfg) if winner < cfg.delegators.len() => { + let votes = HashMap::from([(JUDGE_VOTER.to_string(), winner)]); + apply_votes(base, &votes, cfg); + true + } + _ => false, + }, + StepTypeTag::MultiPrompt => { + let fits = base["variations"] + .as_array() + .is_some_and(|v| winner < v.len()); + if fits { + apply_selection(base, winner); + } + fits + } + _ => false, + }; + if applied { + base["judge"] = serde_json::json!({ + "winner_index": winner, + "rationale": rationale, + }); + } + applied +} + +const JUDGE_VOTER: &str = "judge"; + use std::collections::HashMap; #[cfg(test)] @@ -894,4 +1019,151 @@ mod tests { assert_eq!(result["aggregated_result"], "synthesized answer"); assert_eq!(result["value"], "synthesized answer"); } + + fn no_render(t: &str) -> String { + t.to_string() + } + + fn multi_model_step(voting_mode: VotingMode, voting_prompt: Option<&str>) -> StepSchema { + let mut step = make_base_step(StepTypeTag::MultiModel); + step.multi_model_config = Some(MultiModelConfig { + delegators: vec!["a".to_string(), "b".to_string(), "c".to_string()], + voting_strategy: VotingStrategy::Majority, + share_answers: true, + voting_prompt: voting_prompt.map(str::to_string), + voting_mode, + }); + step + } + + fn multi_prompt_step(strategy: SelectionStrategy) -> StepSchema { + let mut step = make_base_step(StepTypeTag::MultiPrompt); + step.multi_prompt_config = Some(MultiPromptConfig { + prompt_variations: vec!["p0".to_string(), "p1".to_string()], + selection_strategy: strategy, + agent: None, + selection_prompt: Some("Pick for {{ id }}".to_string()), + }); + step + } + + fn outputs(pairs: &[(&str, serde_json::Value)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), v.clone())) + .collect() + } + + #[test] + fn judge_plan_multi_model_single_judge_uses_default_instruction() { + let step = multi_model_step(VotingMode::SingleJudge, None); + let out = outputs(&[ + ("a", serde_json::json!("A")), + ("b", serde_json::json!("B")), + ("c", serde_json::json!("C")), + ]); + let plan = judge_plan(&step, &out, &no_render).unwrap(); + assert_eq!(plan.request.instruction, DEFAULT_SELECTION_INSTRUCTION); + let labels: Vec<_> = plan.request.candidates.iter().map(|c| &c.label).collect(); + assert_eq!(labels, ["a", "b", "c"]); + assert_eq!(plan.aggregate_indices, [0, 1, 2]); + } + + #[test] + fn judge_plan_skips_empty_candidates_and_maps_indices() { + let step = multi_model_step(VotingMode::SingleJudge, Some("custom")); + let out = outputs(&[ + ("a", serde_json::Value::Null), + ("b", serde_json::json!("B")), + ("c", serde_json::json!({"k": 1})), + ]); + let plan = judge_plan(&step, &out, &no_render).unwrap(); + assert_eq!(plan.request.instruction, "custom"); + assert_eq!(plan.request.candidates.len(), 2); + assert_eq!(plan.request.candidates[1].text, r#"{"k":1}"#); + assert_eq!(plan.aggregate_index(0), Some(1)); + assert_eq!(plan.aggregate_index(1), Some(2)); + assert_eq!(plan.aggregate_index(2), None); + } + + #[test] + fn judge_plan_none_with_fewer_than_two_candidates() { + let step = multi_model_step(VotingMode::SingleJudge, None); + let out = outputs(&[ + ("a", serde_json::json!("A")), + ("b", serde_json::json!(" ")), + ]); + assert!(judge_plan(&step, &out, &no_render).is_none()); + } + + #[test] + fn judge_plan_none_for_multi_voter() { + let step = multi_model_step(VotingMode::MultiVoter, None); + let out = outputs(&[("a", serde_json::json!("A")), ("b", serde_json::json!("B"))]); + assert!(judge_plan(&step, &out, &no_render).is_none()); + } + + #[test] + fn judge_plan_multi_prompt_model_choice_renders_selection_prompt() { + let step = multi_prompt_step(SelectionStrategy::ModelChoice); + let out = outputs(&[("0", serde_json::json!("x")), ("1", serde_json::json!("y"))]); + let render = |t: &str| t.replace("{{ id }}", "FEAT-1"); + let plan = judge_plan(&step, &out, &render).unwrap(); + assert_eq!(plan.request.instruction, "Pick for FEAT-1"); + assert_eq!(plan.request.candidates[0].label, "variation 0"); + } + + #[test] + fn judge_plan_none_for_scored_or_matrixed() { + let scored = multi_prompt_step(SelectionStrategy::Scored); + let out = outputs(&[("0", serde_json::json!("x")), ("1", serde_json::json!("y"))]); + assert!(judge_plan(&scored, &out, &no_render).is_none()); + + let matrixed = make_base_step(StepTypeTag::Matrixed); + assert!(judge_plan(&matrixed, &out, &no_render).is_none()); + } + + #[test] + fn apply_judge_verdict_overrides_multi_model_winner() { + let step = multi_model_step(VotingMode::SingleJudge, None); + let cfg = step.multi_model_config.clone().unwrap(); + let out = outputs(&[ + ("a", serde_json::json!("A")), + ("b", serde_json::json!("B")), + ("c", serde_json::json!("C")), + ]); + let mut base = aggregate_multi_model(&out, &cfg); + assert_eq!(base["winner_index"], 0); + + assert!(apply_judge_verdict(&mut base, &step, 2, "most complete")); + assert_eq!(base["winner_index"], 2); + assert_eq!(base["winner_delegator"], "c"); + assert_eq!(base["value"], "C"); + assert_eq!(base["judge"]["rationale"], "most complete"); + } + + #[test] + fn apply_judge_verdict_overrides_multi_prompt_selection() { + let step = multi_prompt_step(SelectionStrategy::ModelChoice); + let cfg = step.multi_prompt_config.clone().unwrap(); + let out = outputs(&[("0", serde_json::json!("x")), ("1", serde_json::json!("y"))]); + let mut base = aggregate_multi_prompt(&out, &cfg); + + assert!(apply_judge_verdict(&mut base, &step, 1, "why")); + assert_eq!(base["selected_index"], 1); + assert_eq!(base["value"], "y"); + assert_eq!(base["judge"]["winner_index"], 1); + } + + #[test] + fn apply_judge_verdict_rejects_out_of_range_and_leaves_base() { + let step = multi_model_step(VotingMode::SingleJudge, None); + let cfg = step.multi_model_config.clone().unwrap(); + let out = outputs(&[("a", serde_json::json!("A")), ("b", serde_json::json!("B"))]); + let mut base = aggregate_multi_model(&out, &cfg); + let before = base.clone(); + + assert!(!apply_judge_verdict(&mut base, &step, 3, "x")); + assert_eq!(base, before); + } } diff --git a/src/trust_verify.rs b/src/trust_verify.rs new file mode 100644 index 00000000..c4efe34c --- /dev/null +++ b/src/trust_verify.rs @@ -0,0 +1,418 @@ +// @generated by `trust vendor` from untra-trust a8fac3142170fe7aebeb5c556500605000e88732-dirty +// source: trust-core/src/vendored.rs +// sha256: 23aeb017215667af15f957915fc1f93fb139c0913305b39f2cfe6b792d351e3b +// +// DO NOT EDIT. This file is generated, and three repositories carry +// byte-identical copies so they cannot drift apart on what a valid +// license is. Local edits here become a divergence nobody sees until a +// license is rejected in the field. +// +// To change it: edit trust-core/src/vendored.rs in untra-trust, then +// regenerate here and in every other consumer: +// trust vendor --out src/trust_verify.rs +// trust vectors --out testdata/trust-vectors.json + +//! Verification of untra software licenses against a root of trust. +//! +//! **This file is copied verbatim into every consuming repository** by +//! `trust vendor`. Two constraints follow from that, and breaking either one +//! breaks every consumer at once: +//! +//! 1. It must be self-contained. No `use crate::...`, no reference to any other +//! module in this workspace — it has to compile when dropped into a crate +//! that has never heard of `trust-core`. +//! 2. It must carry no `#[cfg(test)]` code. A consumer runs its own `cargo +//! test`, and tests referencing fixtures that do not exist there would fail +//! to compile. This crate's tests live in `tests.rs` and exercise these exact +//! bytes. +//! +//! Its only dependencies are `base64`, `serde`, `serde_json` and `jsonwebtoken` +//! — deliberately not `thiserror`, so vendoring imposes as little as possible on +//! a consumer's manifest. + +use std::collections::BTreeMap; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +/// Raw Ed25519 public key length. Keys travel as standard-base64 of these bytes, +/// matching the encoding the license registry uses for its `public_keys` map. +pub const PUBLIC_KEY_BYTES: usize = 32; + +/// Envelope format version. Bumped only for a breaking change to the wrapper +/// itself, never for a change to the claims inside either token. +pub const ENVELOPE_VERSION: u32 = 1; + +/// Upper bound on an encoded license key. Two JWTs and a small wrapper are well +/// under this; the cap exists so a hostile input cannot force a large allocation +/// before any signature has been checked. +pub const MAX_ENVELOPE_BYTES: usize = 32 * 1024; + +/// Claim-set version for the attestation itself. +pub const ATTESTATION_VERSION: u32 = 1; + +/// Fixed issuer and audience. Platform-wide constants rather than per-product +/// configuration, so a token minted for some other purpose can never be replayed +/// as a signing delegation. +pub const ATTESTATION_ISSUER: &str = "untra-root"; +pub const ATTESTATION_AUDIENCE: &str = "untra-trust"; + +/// The only purpose this code will honour. A future purpose (say, telemetry +/// signing) must not be accepted as authority to sign licenses. +pub const PURPOSE_LICENSE_SIGNING: &str = "license-signing"; + +/// Why a license was not trusted. +/// +/// No variant carries key material or token bytes: these strings reach logs and +/// user-facing surfaces, and a rejection reason must never leak what was +/// rejected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrustError { + MalformedEnvelope, + UnsupportedEnvelopeVersion, + MalformedKeyring, + MalformedKey, + KeyGeneration, + AttestationRejected, + MissingKeyId, + UnknownRoot, + ProductMismatch, + KeyMismatch, + UnsupportedAlgorithm, + LicenseRejected, +} + +impl TrustError { + /// A stable identifier for this rejection. + /// + /// The conformance vectors assert on these strings, and every consuming + /// repository asserts against those vectors — so a code is contract. The + /// `Display` message above is for humans and may be reworded; this may not. + pub fn code(&self) -> &'static str { + match self { + Self::MalformedEnvelope => "malformed_envelope", + Self::UnsupportedEnvelopeVersion => "unsupported_envelope_version", + Self::MalformedKeyring => "malformed_keyring", + Self::MalformedKey => "malformed_key", + Self::KeyGeneration => "key_generation", + Self::AttestationRejected => "attestation_rejected", + Self::MissingKeyId => "missing_key_id", + Self::UnknownRoot => "unknown_root", + Self::ProductMismatch => "product_mismatch", + Self::KeyMismatch => "key_mismatch", + Self::UnsupportedAlgorithm => "unsupported_algorithm", + Self::LicenseRejected => "license_rejected", + } + } +} + +impl std::fmt::Display for TrustError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::MalformedEnvelope => "license is not a valid envelope", + Self::UnsupportedEnvelopeVersion => "unsupported license envelope version", + Self::MalformedKeyring => "malformed trust root keyring", + Self::MalformedKey => "malformed verification key", + Self::KeyGeneration => "key generation failed", + Self::AttestationRejected => "license attestation was rejected", + Self::MissingKeyId => "license attestation names no trust root", + Self::UnknownRoot => "license attestation was signed by an untrusted root", + Self::ProductMismatch => "license attestation is for a different product", + Self::KeyMismatch => "license was not signed by the attested key", + Self::UnsupportedAlgorithm => "unsupported signature algorithm", + Self::LicenseRejected => "license signature or claims were rejected", + }; + f.write_str(message) + } +} + +impl std::error::Error for TrustError {} + +/// The trust roots a client is willing to accept attestations from. +/// +/// A map rather than a single key so a root can be rotated: ship a release +/// trusting both roots, then start attesting under the new one. +/// +/// `Hash` is part of the contract, not incidental. Consumers memoise +/// verification and must fold the keyring into their cache key, or a rotated +/// root is answered from a stale entry. Iteration order is the `BTreeMap`'s, so +/// the hash depends on the roots themselves and not on how they were parsed. +/// It is a process-local value: do not persist it or compare it across builds. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RootKeyring { + keys: BTreeMap>, +} + +impl RootKeyring { + /// Parses a `{"": ""}` map, the + /// same shape consumers bake in at build time. + pub fn from_json(json: &str) -> Result { + let encoded: BTreeMap = + serde_json::from_str(json).map_err(|_| TrustError::MalformedKeyring)?; + + let mut keys = BTreeMap::new(); + for (kid, value) in encoded { + let bytes = STANDARD + .decode(value.trim()) + .map_err(|_| TrustError::MalformedKey)?; + if bytes.len() != PUBLIC_KEY_BYTES { + return Err(TrustError::MalformedKey); + } + keys.insert(kid, bytes); + } + Ok(Self { keys }) + } + + pub fn get(&self, kid: &str) -> Option<&[u8]> { + self.keys.get(kid).map(Vec::as_slice) + } + + pub fn is_empty(&self) -> bool { + self.keys.is_empty() + } +} + +/// A license as it is handed to a customer: the license JWT together with the +/// root-signed attestation for the key that signed it. +/// +/// Both travel together so verification stays entirely offline — a client never +/// fetches a key to validate a license. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Envelope { + pub v: u32, + pub lic: String, + pub att: String, +} + +impl Envelope { + pub fn new(license: String, attestation: String) -> Self { + Self { + v: ENVELOPE_VERSION, + lic: license, + att: attestation, + } + } + + /// Standard-base64 of the JSON wrapper. Base64-wrapping keeps the license + /// key a single opaque line a customer can paste. + pub fn encode(&self) -> String { + STANDARD.encode(serde_json::to_vec(self).expect("envelope serializes")) + } + + pub fn decode(license_key: &str) -> Result { + let trimmed = license_key.trim(); + if trimmed.len() > MAX_ENVELOPE_BYTES { + return Err(TrustError::MalformedEnvelope); + } + + let bytes = STANDARD + .decode(trimmed) + .map_err(|_| TrustError::MalformedEnvelope)?; + let envelope: Self = + serde_json::from_slice(&bytes).map_err(|_| TrustError::MalformedEnvelope)?; + + if envelope.v != ENVELOPE_VERSION { + return Err(TrustError::UnsupportedEnvelopeVersion); + } + Ok(envelope) + } +} + +/// The root-signed statement that delegates license signing to a product key. +/// +/// It says: "the root trusts key `kid`, whose public half is `pub`, to sign +/// licenses for product `sub`, between `nbf` and `exp`." It is the only thing +/// standing between a baked root key and a license signature, which is why every +/// field is checked rather than merely parsed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AttestationClaims { + pub version: u32, + pub iss: String, + pub aud: String, + /// The product this key may sign for. + pub sub: String, + pub purpose: String, + /// The key being attested. + pub kid: String, + /// Standard-base64 raw 32-byte Ed25519 public key. + #[serde(rename = "pub")] + pub public_key: String, + pub iat: i64, + pub nbf: i64, + pub exp: i64, +} + +/// What the caller expects this license to be. `issuer` and `audience` are +/// per-product, so they are supplied rather than assumed. +pub struct LicensePolicy<'a> { + pub product: &'a str, + pub issuer: &'a str, + pub audience: &'a str, +} + +/// A root-signed delegation whose signature and claims have been checked. +/// +/// Exposed because a license service must verify its *own* attestation at +/// startup, before it holds any license to verify — and must fail to start +/// rather than sign licenses nobody can validate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedAttestation { + /// The product this key may sign licenses for. + pub product: String, + /// The key this attestation delegates to. + pub signing_kid: String, + /// The attested key, raw 32 bytes. + pub signing_public_key: Vec, + pub not_before: i64, + pub expires_at: i64, +} + +impl VerifiedAttestation { + /// The attested key in the standard-base64 raw-32-byte form the product + /// registry stores, re-encoded so a comparison is on value rather than text. + pub fn signing_public_key_b64(&self) -> String { + STANDARD.encode(&self.signing_public_key) + } +} + +/// A license whose signature chain has been checked, with the claims projected +/// into the caller's own type. +/// +/// Deliberately **time-independent**: nothing here consults a clock. Callers +/// memoise verification against file contents (Operator does), and folding the +/// clock in would let a cached verdict outlive the trust that produced it. +/// `attestation_expires_at` is returned so the caller's own clock check can +/// respect it — see [`VerifiedLicense::effective_expiry`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedLicense { + pub claims: C, + pub signing_kid: String, + pub attestation_not_before: i64, + pub attestation_expires_at: i64, +} + +impl VerifiedLicense { + /// The earlier of the license's own expiry and the attestation's. A license + /// cannot outlive the delegation that authorised its signature. + pub fn effective_expiry(&self, license_expires_at: i64) -> i64 { + license_expires_at.min(self.attestation_expires_at) + } +} + +/// Verifies a license key's full chain: root -> attestation -> license. +/// +/// Performs no clock checks. See [`VerifiedLicense`] for why, and +/// [`VerifiedLicense::effective_expiry`] for what a caller must fold into its own. +pub fn verify_license( + license_key: &str, + roots: &RootKeyring, + policy: &LicensePolicy<'_>, +) -> Result, TrustError> { + let envelope = Envelope::decode(license_key)?; + let attestation = verify_attestation(&envelope.att, roots, policy.product)?; + let claims = verify_signed_license(&envelope.lic, &attestation, policy)?; + + Ok(VerifiedLicense { + claims, + signing_kid: attestation.signing_kid, + attestation_not_before: attestation.not_before, + attestation_expires_at: attestation.expires_at, + }) +} + +/// Verifies a root-signed attestation on its own. +/// +/// Like [`verify_license`], this consults no clock: it returns the validity +/// window for the caller to check. A caller that needs the attestation to cover +/// a term must compare `expires_at` against its own longest term. +pub fn verify_attestation( + token: &str, + roots: &RootKeyring, + product: &str, +) -> Result { + let header = jsonwebtoken::decode_header(token).map_err(|_| TrustError::AttestationRejected)?; + // Pinned, never read from the token: honouring the token's own algorithm + // would let an attacker HMAC-sign with the root's public key as the secret. + if header.alg != jsonwebtoken::Algorithm::EdDSA { + return Err(TrustError::UnsupportedAlgorithm); + } + let root_kid = header.kid.ok_or(TrustError::MissingKeyId)?; + let root_key = roots.get(&root_kid).ok_or(TrustError::UnknownRoot)?; + + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::EdDSA); + validation.set_issuer(&[ATTESTATION_ISSUER]); + validation.set_audience(&[ATTESTATION_AUDIENCE]); + validation.set_required_spec_claims(&["iss", "aud", "sub", "exp", "nbf", "iat"]); + validation.validate_exp = false; + validation.validate_nbf = false; + + let claims = jsonwebtoken::decode::( + token, + &jsonwebtoken::DecodingKey::from_ed_der(root_key), + &validation, + ) + .map_err(|_| TrustError::AttestationRejected)? + .claims; + + if claims.version != ATTESTATION_VERSION + || claims.purpose != PURPOSE_LICENSE_SIGNING + || claims.kid.is_empty() + || claims.nbf < claims.iat + || claims.exp <= claims.nbf + { + return Err(TrustError::AttestationRejected); + } + if claims.sub != product { + return Err(TrustError::ProductMismatch); + } + + let signing_public_key = STANDARD + .decode(claims.public_key.trim()) + .map_err(|_| TrustError::MalformedKey)?; + if signing_public_key.len() != PUBLIC_KEY_BYTES { + return Err(TrustError::MalformedKey); + } + + Ok(VerifiedAttestation { + product: claims.sub, + signing_kid: claims.kid, + signing_public_key, + not_before: claims.nbf, + expires_at: claims.exp, + }) +} + +fn verify_signed_license( + token: &str, + attestation: &VerifiedAttestation, + policy: &LicensePolicy<'_>, +) -> Result { + let header = jsonwebtoken::decode_header(token).map_err(|_| TrustError::LicenseRejected)?; + if header.alg != jsonwebtoken::Algorithm::EdDSA { + return Err(TrustError::UnsupportedAlgorithm); + } + let kid = header.kid.ok_or(TrustError::MissingKeyId)?; + if kid != attestation.signing_kid { + return Err(TrustError::KeyMismatch); + } + + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::EdDSA); + validation.set_issuer(&[policy.issuer]); + validation.set_audience(&[policy.audience]); + // `exp` must be present even though this layer does not compare it to a + // clock, so a token with no expiry can never be treated as a license. + validation.set_required_spec_claims(&["iss", "aud", "exp"]); + validation.validate_exp = false; + validation.validate_nbf = false; + + Ok(jsonwebtoken::decode::( + token, + &jsonwebtoken::DecodingKey::from_ed_der(&attestation.signing_public_key), + &validation, + ) + .map_err(|_| TrustError::LicenseRejected)? + .claims) +} diff --git a/src/ui/dialogs/kanban_onboarding.rs b/src/ui/dialogs/kanban_onboarding.rs index d1188881..64521c63 100644 --- a/src/ui/dialogs/kanban_onboarding.rs +++ b/src/ui/dialogs/kanban_onboarding.rs @@ -228,7 +228,10 @@ impl KanbanOnboardingDialog { &self.export_block } - // ─── Input helpers ─────────────────────────────────────────────────── + #[allow(dead_code)] + pub fn error_message(&self) -> &str { + &self.error_message + } fn current_buf(&self) -> &str { match self.state { diff --git a/src/ui/in_progress_panel.rs b/src/ui/in_progress_panel.rs index 8c553618..b41f2293 100644 --- a/src/ui/in_progress_panel.rs +++ b/src/ui/in_progress_panel.rs @@ -96,6 +96,7 @@ impl InProgressPanel { Some("claude") => ("A", Color::Rgb(224, 93, 68)), Some("gemini") => ("G", Color::Rgb(111, 66, 193)), Some("codex") => ("O", Color::Green), + Some("grok") => ("X", Color::Gray), _ => (" ", Color::Reset), }; diff --git a/src/ui/sections/llm_section.rs b/src/ui/sections/llm_section.rs index 94397fcb..679113c6 100644 --- a/src/ui/sections/llm_section.rs +++ b/src/ui/sections/llm_section.rs @@ -27,14 +27,21 @@ impl StatusSection for LlmSection { } fn description(&self, snapshot: &StatusSnapshot) -> String { - match (&snapshot.default_llm_tool, &snapshot.default_llm_model) { + let healthy = snapshot.llm_tools.iter().filter(|t| t.health_ok).count(); + let total = snapshot.llm_tools.len(); + let default = match (&snapshot.default_llm_tool, &snapshot.default_llm_model) { (Some(tool), Some(model)) => format!("Default: {tool}:{model}"), (Some(tool), None) => format!("Default: {tool}"), _ => snapshot .llm_tools .first() .map(|t| t.name.clone()) - .unwrap_or_else(|| "No tools detected".into()), + .unwrap_or_else(|| "No tools on this host".into()), + }; + if total == 0 { + default + } else { + format!("{default} · {healthy}/{total} launchable") } } @@ -51,7 +58,7 @@ impl StatusSection for LlmSection { description: if tool.health_ok { tool.version.clone() } else { - format!("{} (health check failed)", tool.version) + format!("{} (not launchable on this host)", tool.version) }, icon: StatusIcon::Tool, brand_icon: None, diff --git a/src/ui/sections/modelserver_section.rs b/src/ui/sections/modelserver_section.rs index 10bc2187..5bb61661 100644 --- a/src/ui/sections/modelserver_section.rs +++ b/src/ui/sections/modelserver_section.rs @@ -21,7 +21,9 @@ impl StatusSection for ModelServerSection { fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { if snapshot.model_servers.iter().any(|s| s.user_declared) { - SectionHealth::Green + // Declared is not connected. Live probe lives on the web Model + // Providers view; the TUI must not paint Green for "exists in config". + SectionHealth::Yellow } else { SectionHealth::Gray } @@ -36,7 +38,7 @@ impl StatusSection for ModelServerSection { if declared == 0 { "builtins only".into() } else { - format!("{declared} declared") + format!("{declared} declared · not probed") } } @@ -97,7 +99,7 @@ impl StatusSection for ModelServerSection { is_header: false, actions: action, health: if s.user_declared { - SectionHealth::Green + SectionHealth::Yellow } else { SectionHealth::Gray }, @@ -106,11 +108,7 @@ impl StatusSection for ModelServerSection { .collect(); // Catalog "Add " rows for the addable (non-builtin) kinds, derived - // from ModelServerKind::ALL so the options can't drift from the other - // surfaces. The vendor builtins always exist, so they aren't offered here. - // Multiple servers of the same kind are allowed, so these are always shown. - // Rows are grouped under a category header (the *Model Provider* vertical) - // so the catalog reads the same way as the README/docs/web surfaces. + // from ModelServerKind::ALL so the options can't drift from the other surfaces. let mut last_category: Option = None; for kind in ModelServerKind::ALL { if kind.is_builtin() { @@ -248,8 +246,8 @@ mod tests { declared("vllm-gpu", "openai-compat", "http://gpu:8000"), ]); let section = ModelServerSection; - assert_eq!(section.description(&snapshot), "2 declared"); - assert!(matches!(section.health(&snapshot), SectionHealth::Green)); + assert_eq!(section.description(&snapshot), "2 declared · not probed"); + assert!(matches!(section.health(&snapshot), SectionHealth::Yellow)); } #[test] diff --git a/src/ui/setup/steps/welcome.rs b/src/ui/setup/steps/welcome.rs index 6ebfa14f..be2b6ec7 100644 --- a/src/ui/setup/steps/welcome.rs +++ b/src/ui/setup/steps/welcome.rs @@ -1,6 +1,6 @@ //! Welcome step rendering -use crate::projects::TOOL_MARKERS; +use crate::projects::tool_markers; use crate::ui::dialogs::centered_rect; use crate::ui::setup::SetupScreen; use ratatui::{ @@ -108,14 +108,14 @@ impl SetupScreen { ))]; // Show each known tool with detection status - for (tool_name, _marker) in TOOL_MARKERS { - let detected = self.detected_tools.iter().find(|t| t.name == *tool_name); + for (tool_name, _marker) in tool_markers() { + let detected = self.detected_tools.iter().find(|t| t.name == tool_name); let line = if let Some(tool) = detected { Line::from(vec![ Span::styled(" + ", Style::default().fg(Color::Green)), Span::styled( - (*tool_name).to_string(), + tool_name.to_string(), Style::default() .fg(Color::Green) .add_modifier(Modifier::BOLD), @@ -128,10 +128,7 @@ impl SetupScreen { } else { Line::from(vec![ Span::styled(" - ", Style::default().fg(Color::DarkGray)), - Span::styled( - (*tool_name).to_string(), - Style::default().fg(Color::DarkGray), - ), + Span::styled(tool_name.to_string(), Style::default().fg(Color::DarkGray)), Span::styled(" - not installed", Style::default().fg(Color::DarkGray)), ]) }; @@ -148,8 +145,8 @@ impl SetupScreen { ))]; let mut has_any_projects = false; - for (tool_name, _marker) in TOOL_MARKERS { - if let Some(projects) = self.projects_by_tool.get(*tool_name) { + for (tool_name, _marker) in tool_markers() { + if let Some(projects) = self.projects_by_tool.get(tool_name) { if !projects.is_empty() { has_any_projects = true; let project_list = projects.join(", "); diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index da1caa61..8ab713e0 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -768,8 +768,7 @@ impl StatusSnapshot { user_declared: true, }) .collect(); - for tool in ["claude", "codex", "gemini"] { - let implicit = crate::config::implicit_model_server_for_tool(tool); + for implicit in crate::config::implicit_model_servers() { if !model_servers.iter().any(|s| s.name == implicit.name) { model_servers.push(ModelServerInfo { name: implicit.name, diff --git a/src/workflow_gen/agnt.rs b/src/workflow_gen/agnt.rs index ec403190..e9b256c6 100644 --- a/src/workflow_gen/agnt.rs +++ b/src/workflow_gen/agnt.rs @@ -39,6 +39,12 @@ use crate::queue::Ticket; use crate::steps::manager::StepManager; use crate::templates::schema::{RagSource, ReviewType, StepSchema, StepTypeTag}; +const SCAFFOLD_NOTE: &str = "Operator exports AGNT workflows as runnable visual scaffolds of a ticket's execution shape, not as lossless equivalents of Operator's internal workflow semantics."; + +fn agnt_description(it: &IssueType) -> String { + format!("{}\n\n{SCAFFOLD_NOTE}", meta_description(it)) +} + /// The node type every exported step maps to. Defined by the companion AGNT /// plugin (`agnt-plugin/run-step.js`), whose `execute()` reads `config.ticket` /// and calls Operator's REST API to run the ticket for that step. Distinct from @@ -141,7 +147,7 @@ pub fn export_workflow_agnt( let wf = AgntWorkflow { name: meta_name(ticket, issuetype), - description: meta_description(issuetype), + description: agnt_description(issuetype), nodes, edges, }; @@ -439,6 +445,14 @@ mod tests { assert!(v["name"].is_string(), "missing name"); assert!(v["description"].is_string(), "missing description"); + assert!( + v["description"] + .as_str() + .unwrap() + .contains("runnable visual scaffolds of a ticket's execution shape"), + "export must say it is a scaffold: {}", + v["description"] + ); let nodes = v["nodes"].as_array().expect("nodes array"); assert_eq!(nodes.len(), feat.steps.len(), "one node per step expected"); diff --git a/src/workflow_gen/export.rs b/src/workflow_gen/export.rs index c980d532..79904dc3 100644 --- a/src/workflow_gen/export.rs +++ b/src/workflow_gen/export.rs @@ -455,10 +455,8 @@ fn render_matrixed(hbs: &Handlebars, ctx: &Value, step: &StepSchema, var: &str) )) } -/// Render a pipeline step: one top-level `const r_x = await pipeline(items, -/// …stage thunks);`. Items resolve per `ItemSource` (literal array → static -/// fan-out width in the compiled graph; identifier → symbolic). The step graph -/// stays linear - the N-item fan-out lives entirely inside this one statement. +/// Render a pipeline step. Items resolve per `ItemSource` (literal array -> static fan-out width in the compiled graph). +/// The step graph stays linear - the N-item fan-out lives entirely inside this one statement. fn render_pipeline( hbs: &Handlebars, ctx: &Value, diff --git a/testdata/native_llm/anthropic.json b/testdata/native_llm/anthropic.json new file mode 100644 index 00000000..2527e0f4 --- /dev/null +++ b/testdata/native_llm/anthropic.json @@ -0,0 +1 @@ +{"id":"msg_01","type":"message","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_01","name":"submit","input":{"winner_index":1,"rationale":"more thorough"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":5}} diff --git a/testdata/native_llm/gemini.json b/testdata/native_llm/gemini.json new file mode 100644 index 00000000..57609b4b --- /dev/null +++ b/testdata/native_llm/gemini.json @@ -0,0 +1 @@ +{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"submit","args":{"winner_index":1,"rationale":"more thorough"}}}]},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15},"modelVersion":"gemini-test","responseId":"resp-01"} diff --git a/testdata/native_llm/ollama.json b/testdata/native_llm/ollama.json new file mode 100644 index 00000000..f4f8bb89 --- /dev/null +++ b/testdata/native_llm/ollama.json @@ -0,0 +1 @@ +{"model":"qwen-test","created_at":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"submit","arguments":{"winner_index":1,"rationale":"more thorough"}}}]},"done":true,"done_reason":"stop","total_duration":1,"load_duration":1,"prompt_eval_count":10,"prompt_eval_duration":1,"eval_count":5,"eval_duration":1} diff --git a/testdata/native_llm/openai.json b/testdata/native_llm/openai.json new file mode 100644 index 00000000..483d3a6a --- /dev/null +++ b/testdata/native_llm/openai.json @@ -0,0 +1 @@ +{"id":"chatcmpl-01","object":"chat.completion","created":1700000000,"model":"gpt-test","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_01","type":"function","function":{"name":"submit","arguments":"{\"winner_index\":1,\"rationale\":\"more thorough\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} diff --git a/testdata/native_llm/openrouter.json b/testdata/native_llm/openrouter.json new file mode 100644 index 00000000..483d3a6a --- /dev/null +++ b/testdata/native_llm/openrouter.json @@ -0,0 +1 @@ +{"id":"chatcmpl-01","object":"chat.completion","created":1700000000,"model":"gpt-test","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_01","type":"function","function":{"name":"submit","arguments":"{\"winner_index\":1,\"rationale\":\"more thorough\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} diff --git a/testdata/native_llm/xai.json b/testdata/native_llm/xai.json new file mode 100644 index 00000000..f690cade --- /dev/null +++ b/testdata/native_llm/xai.json @@ -0,0 +1 @@ +{"id":"resp_01","object":"response","created_at":1700000000,"status":"completed","model":"grok-test","output":[{"type":"function_call","id":"fc_01","call_id":"call_01","name":"submit","arguments":"{\"winner_index\":1,\"rationale\":\"more thorough\"}","status":"completed"}],"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}} diff --git a/testdata/trust-vectors.json b/testdata/trust-vectors.json new file mode 100644 index 00000000..1f1d0c3d --- /dev/null +++ b/testdata/trust-vectors.json @@ -0,0 +1,115 @@ +{ + "version": 1, + "product": "operator", + "issuer": "operator-licensing", + "audience": "operator-license", + "roots": { + "root-vectors": "qbdIk3cyC85THgyrTunNdWwpU/UpkKNAtsnD5+evcJ8=" + }, + "cases": [ + { + "name": "valid_chain", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5ObElpd2ljM1ZpSWpvaVkzVnpYM1psWTNSdmNuTWlMQ0pxZEdraU9pSXpabUU0TldZMk5DMDFOekUzTFRRMU5qSXRZak5tWXkweVl6azJNMlkyTm1GbVlUWWlMQ0p3Y205bWFXeGxYMmxrSWpvaU1UWm1aREkzTURZdE9HSmhaaTAwTXpOaUxUZ3laV0l0T0dNM1ptRmtZVGcwTjJSaElpd2lkR2xsY2lJNkluQnlaVzFwZFcwaUxDSndjbTlrZFdOMElqb2liM0JsY21GMGIzSWlMQ0p6YTNVaU9pSndjbVZ0YVhWdExYbGxZWEpzZVNJc0ltWmxZWFIxY21WeklqcDdJbkpsYlc5MFpWOTBZWEpuWlhSeklqcDBjblZsZlN3aWFXRjBJam94T0RBd01EQXdNREF3TENKdVltWWlPakU0TURBd01EQXdNREFzSW1WNGNDSTZNVGd6TVRZeU1qUXdNSDAuX2RIQldvRF91YUxzcHh2Mko2S3hFU0Q3bUMzNll1UGk0TzVXVDd0aTFOTzI1X2pYMm9jc1AyVzdWbml2VEowMlBfMEpTS2M3TGNyMWxLQXhtU1hIQmciLCJhdHQiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluSnZiM1F0ZG1WamRHOXljeUo5LmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSjFiblJ5WVMxeWIyOTBJaXdpWVhWa0lqb2lkVzUwY21FdGRISjFjM1FpTENKemRXSWlPaUp2Y0dWeVlYUnZjaUlzSW5CMWNuQnZjMlVpT2lKc2FXTmxibk5sTFhOcFoyNXBibWNpTENKcmFXUWlPaUoyWldOMGIzSnpJaXdpY0hWaUlqb2llVWhwT1hCUlR6ZHFWamR2Y1hseFNIQlZSMnRwZGtkUmFtbEhNMnRPY0VzMGNuWm5SMEZTU0VSTmF6MGlMQ0pwWVhRaU9qRTRNREF3TURBd01EQXNJbTVpWmlJNk1UZ3dNREF3TURBd01Dd2laWGh3SWpveE9UVTRNVEV5TURBd2ZRLlhiNExPMldxS3hxb0NITy1xQVphTmx1dzhpR3hTY0IxdmdEMU5QZlZfdDVfWjVnT1BCVTgtVFBENEUxZnltbl8xNXJKWVQ5MkQtZnFMTUs2OHdLeEJRIn0=", + "expect": "ok", + "claims": { + "aud": "operator-license", + "exp": 1831622400, + "features": { + "remote_targets": true + }, + "iat": 1800000000, + "iss": "operator-licensing", + "jti": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "nbf": 1800000000, + "product": "operator", + "profile_id": "16fd2706-8baf-433b-82eb-8c7fada847da", + "sku": "premium-yearly", + "sub": "cus_vectors", + "tier": "premium", + "version": 1 + } + }, + { + "name": "unknown_root_kid", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5ObElpd2ljM1ZpSWpvaVkzVnpYM1psWTNSdmNuTWlMQ0pxZEdraU9pSXpabUU0TldZMk5DMDFOekUzTFRRMU5qSXRZak5tWXkweVl6azJNMlkyTm1GbVlUWWlMQ0p3Y205bWFXeGxYMmxrSWpvaU1UWm1aREkzTURZdE9HSmhaaTAwTXpOaUxUZ3laV0l0T0dNM1ptRmtZVGcwTjJSaElpd2lkR2xsY2lJNkluQnlaVzFwZFcwaUxDSndjbTlrZFdOMElqb2liM0JsY21GMGIzSWlMQ0p6YTNVaU9pSndjbVZ0YVhWdExYbGxZWEpzZVNJc0ltWmxZWFIxY21WeklqcDdJbkpsYlc5MFpWOTBZWEpuWlhSeklqcDBjblZsZlN3aWFXRjBJam94T0RBd01EQXdNREF3TENKdVltWWlPakU0TURBd01EQXdNREFzSW1WNGNDSTZNVGd6TVRZeU1qUXdNSDAuX2RIQldvRF91YUxzcHh2Mko2S3hFU0Q3bUMzNll1UGk0TzVXVDd0aTFOTzI1X2pYMm9jc1AyVzdWbml2VEowMlBfMEpTS2M3TGNyMWxLQXhtU1hIQmciLCJhdHQiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluSnZiM1F0ZG1WamRHOXljeUo5LmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSjFiblJ5WVMxeWIyOTBJaXdpWVhWa0lqb2lkVzUwY21FdGRISjFjM1FpTENKemRXSWlPaUp2Y0dWeVlYUnZjaUlzSW5CMWNuQnZjMlVpT2lKc2FXTmxibk5sTFhOcFoyNXBibWNpTENKcmFXUWlPaUoyWldOMGIzSnpJaXdpY0hWaUlqb2llVWhwT1hCUlR6ZHFWamR2Y1hseFNIQlZSMnRwZGtkUmFtbEhNMnRPY0VzMGNuWm5SMEZTU0VSTmF6MGlMQ0pwWVhRaU9qRTRNREF3TURBd01EQXNJbTVpWmlJNk1UZ3dNREF3TURBd01Dd2laWGh3SWpveE9UVTRNVEV5TURBd2ZRLlhiNExPMldxS3hxb0NITy1xQVphTmx1dzhpR3hTY0IxdmdEMU5QZlZfdDVfWjVnT1BCVTgtVFBENEUxZnltbl8xNXJKWVQ5MkQtZnFMTUs2OHdLeEJRIn0=", + "roots": { + "root-elsewhere": "qbdIk3cyC85THgyrTunNdWwpU/UpkKNAtsnD5+evcJ8=" + }, + "expect": "unknown_root" + }, + { + "name": "empty_keyring", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5ObElpd2ljM1ZpSWpvaVkzVnpYM1psWTNSdmNuTWlMQ0pxZEdraU9pSXpabUU0TldZMk5DMDFOekUzTFRRMU5qSXRZak5tWXkweVl6azJNMlkyTm1GbVlUWWlMQ0p3Y205bWFXeGxYMmxrSWpvaU1UWm1aREkzTURZdE9HSmhaaTAwTXpOaUxUZ3laV0l0T0dNM1ptRmtZVGcwTjJSaElpd2lkR2xsY2lJNkluQnlaVzFwZFcwaUxDSndjbTlrZFdOMElqb2liM0JsY21GMGIzSWlMQ0p6YTNVaU9pSndjbVZ0YVhWdExYbGxZWEpzZVNJc0ltWmxZWFIxY21WeklqcDdJbkpsYlc5MFpWOTBZWEpuWlhSeklqcDBjblZsZlN3aWFXRjBJam94T0RBd01EQXdNREF3TENKdVltWWlPakU0TURBd01EQXdNREFzSW1WNGNDSTZNVGd6TVRZeU1qUXdNSDAuX2RIQldvRF91YUxzcHh2Mko2S3hFU0Q3bUMzNll1UGk0TzVXVDd0aTFOTzI1X2pYMm9jc1AyVzdWbml2VEowMlBfMEpTS2M3TGNyMWxLQXhtU1hIQmciLCJhdHQiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluSnZiM1F0ZG1WamRHOXljeUo5LmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSjFiblJ5WVMxeWIyOTBJaXdpWVhWa0lqb2lkVzUwY21FdGRISjFjM1FpTENKemRXSWlPaUp2Y0dWeVlYUnZjaUlzSW5CMWNuQnZjMlVpT2lKc2FXTmxibk5sTFhOcFoyNXBibWNpTENKcmFXUWlPaUoyWldOMGIzSnpJaXdpY0hWaUlqb2llVWhwT1hCUlR6ZHFWamR2Y1hseFNIQlZSMnRwZGtkUmFtbEhNMnRPY0VzMGNuWm5SMEZTU0VSTmF6MGlMQ0pwWVhRaU9qRTRNREF3TURBd01EQXNJbTVpWmlJNk1UZ3dNREF3TURBd01Dd2laWGh3SWpveE9UVTRNVEV5TURBd2ZRLlhiNExPMldxS3hxb0NITy1xQVphTmx1dzhpR3hTY0IxdmdEMU5QZlZfdDVfWjVnT1BCVTgtVFBENEUxZnltbl8xNXJKWVQ5MkQtZnFMTUs2OHdLeEJRIn0=", + "roots": {}, + "expect": "unknown_root" + }, + { + "name": "attestation_from_untrusted_root", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5ObElpd2ljM1ZpSWpvaVkzVnpYM1psWTNSdmNuTWlMQ0pxZEdraU9pSXpabUU0TldZMk5DMDFOekUzTFRRMU5qSXRZak5tWXkweVl6azJNMlkyTm1GbVlUWWlMQ0p3Y205bWFXeGxYMmxrSWpvaU1UWm1aREkzTURZdE9HSmhaaTAwTXpOaUxUZ3laV0l0T0dNM1ptRmtZVGcwTjJSaElpd2lkR2xsY2lJNkluQnlaVzFwZFcwaUxDSndjbTlrZFdOMElqb2liM0JsY21GMGIzSWlMQ0p6YTNVaU9pSndjbVZ0YVhWdExYbGxZWEpzZVNJc0ltWmxZWFIxY21WeklqcDdJbkpsYlc5MFpWOTBZWEpuWlhSeklqcDBjblZsZlN3aWFXRjBJam94T0RBd01EQXdNREF3TENKdVltWWlPakU0TURBd01EQXdNREFzSW1WNGNDSTZNVGd6TVRZeU1qUXdNSDAuX2RIQldvRF91YUxzcHh2Mko2S3hFU0Q3bUMzNll1UGk0TzVXVDd0aTFOTzI1X2pYMm9jc1AyVzdWbml2VEowMlBfMEpTS2M3TGNyMWxLQXhtU1hIQmciLCJhdHQiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluSnZiM1F0ZG1WamRHOXljeUo5LmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSjFiblJ5WVMxeWIyOTBJaXdpWVhWa0lqb2lkVzUwY21FdGRISjFjM1FpTENKemRXSWlPaUp2Y0dWeVlYUnZjaUlzSW5CMWNuQnZjMlVpT2lKc2FXTmxibk5sTFhOcFoyNXBibWNpTENKcmFXUWlPaUoyWldOMGIzSnpJaXdpY0hWaUlqb2llVWhwT1hCUlR6ZHFWamR2Y1hseFNIQlZSMnRwZGtkUmFtbEhNMnRPY0VzMGNuWm5SMEZTU0VSTmF6MGlMQ0pwWVhRaU9qRTRNREF3TURBd01EQXNJbTVpWmlJNk1UZ3dNREF3TURBd01Dd2laWGh3SWpveE9UVTRNVEV5TURBd2ZRLlhiNExPMldxS3hxb0NITy1xQVphTmx1dzhpR3hTY0IxdmdEMU5QZlZfdDVfWjVnT1BCVTgtVFBENEUxZnltbl8xNXJKWVQ5MkQtZnFMTUs2OHdLeEJRIn0=", + "roots": { + "root-vectors": "yHi9pQO7jV7oqyqHpUGkivGQjiG3kNpK4rvgGARHDMk=" + }, + "expect": "attestation_rejected" + }, + { + "name": "attestation_for_another_product", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5ObElpd2ljM1ZpSWpvaVkzVnpYM1psWTNSdmNuTWlMQ0pxZEdraU9pSXpabUU0TldZMk5DMDFOekUzTFRRMU5qSXRZak5tWXkweVl6azJNMlkyTm1GbVlUWWlMQ0p3Y205bWFXeGxYMmxrSWpvaU1UWm1aREkzTURZdE9HSmhaaTAwTXpOaUxUZ3laV0l0T0dNM1ptRmtZVGcwTjJSaElpd2lkR2xsY2lJNkluQnlaVzFwZFcwaUxDSndjbTlrZFdOMElqb2liM0JsY21GMGIzSWlMQ0p6YTNVaU9pSndjbVZ0YVhWdExYbGxZWEpzZVNJc0ltWmxZWFIxY21WeklqcDdJbkpsYlc5MFpWOTBZWEpuWlhSeklqcDBjblZsZlN3aWFXRjBJam94T0RBd01EQXdNREF3TENKdVltWWlPakU0TURBd01EQXdNREFzSW1WNGNDSTZNVGd6TVRZeU1qUXdNSDAuX2RIQldvRF91YUxzcHh2Mko2S3hFU0Q3bUMzNll1UGk0TzVXVDd0aTFOTzI1X2pYMm9jc1AyVzdWbml2VEowMlBfMEpTS2M3TGNyMWxLQXhtU1hIQmciLCJhdHQiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluSnZiM1F0ZG1WamRHOXljeUo5LmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSjFiblJ5WVMxeWIyOTBJaXdpWVhWa0lqb2lkVzUwY21FdGRISjFjM1FpTENKemRXSWlPaUp6WldKaGMzUnBZVzRpTENKd2RYSndiM05sSWpvaWJHbGpaVzV6WlMxemFXZHVhVzVuSWl3aWEybGtJam9pZG1WamRHOXljeUlzSW5CMVlpSTZJbmxJYVRsd1VVODNhbFkzYjNGNWNVaHdWVWRyYVhaSFVXcHBSek5yVG5CTE5ISjJaMGRCVWtoRVRXczlJaXdpYVdGMElqb3hPREF3TURBd01EQXdMQ0p1WW1ZaU9qRTRNREF3TURBd01EQXNJbVY0Y0NJNk1UazFPREV4TWpBd01IMC5UWS1wVk1HZXVVQ00waHVGUHB5OXdzTkdpLU8tcXZ2RWNYMjNiQWZhVmlRNTQydXVfUmtlcm5XVHZoQTRCanExWTI2cjFpWHZkZjlOWUtZNW40eVJDZyJ9", + "expect": "product_mismatch" + }, + { + "name": "attestation_with_malformed_key", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5ObElpd2ljM1ZpSWpvaVkzVnpYM1psWTNSdmNuTWlMQ0pxZEdraU9pSXpabUU0TldZMk5DMDFOekUzTFRRMU5qSXRZak5tWXkweVl6azJNMlkyTm1GbVlUWWlMQ0p3Y205bWFXeGxYMmxrSWpvaU1UWm1aREkzTURZdE9HSmhaaTAwTXpOaUxUZ3laV0l0T0dNM1ptRmtZVGcwTjJSaElpd2lkR2xsY2lJNkluQnlaVzFwZFcwaUxDSndjbTlrZFdOMElqb2liM0JsY21GMGIzSWlMQ0p6YTNVaU9pSndjbVZ0YVhWdExYbGxZWEpzZVNJc0ltWmxZWFIxY21WeklqcDdJbkpsYlc5MFpWOTBZWEpuWlhSeklqcDBjblZsZlN3aWFXRjBJam94T0RBd01EQXdNREF3TENKdVltWWlPakU0TURBd01EQXdNREFzSW1WNGNDSTZNVGd6TVRZeU1qUXdNSDAuX2RIQldvRF91YUxzcHh2Mko2S3hFU0Q3bUMzNll1UGk0TzVXVDd0aTFOTzI1X2pYMm9jc1AyVzdWbml2VEowMlBfMEpTS2M3TGNyMWxLQXhtU1hIQmciLCJhdHQiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluSnZiM1F0ZG1WamRHOXljeUo5LmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSjFiblJ5WVMxeWIyOTBJaXdpWVhWa0lqb2lkVzUwY21FdGRISjFjM1FpTENKemRXSWlPaUp2Y0dWeVlYUnZjaUlzSW5CMWNuQnZjMlVpT2lKc2FXTmxibk5sTFhOcFoyNXBibWNpTENKcmFXUWlPaUoyWldOMGIzSnpJaXdpY0hWaUlqb2libTkwSUdKaGMyVTJOQ0VpTENKcFlYUWlPakU0TURBd01EQXdNREFzSW01aVppSTZNVGd3TURBd01EQXdNQ3dpWlhod0lqb3hPVFU0TVRFeU1EQXdmUS50X2V6NFdTLUpHa0FkX19SbUcxTXl1dXFxcHpUY001ejRRSUZqNFhNYmI4TGpBZ1BQUXJkZGlfcVZtRzJFdDVObTluR0FON3BXV0tRMmg3VWlmLV9BQSJ9", + "expect": "malformed_key" + }, + { + "name": "attestation_for_another_purpose", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5ObElpd2ljM1ZpSWpvaVkzVnpYM1psWTNSdmNuTWlMQ0pxZEdraU9pSXpabUU0TldZMk5DMDFOekUzTFRRMU5qSXRZak5tWXkweVl6azJNMlkyTm1GbVlUWWlMQ0p3Y205bWFXeGxYMmxrSWpvaU1UWm1aREkzTURZdE9HSmhaaTAwTXpOaUxUZ3laV0l0T0dNM1ptRmtZVGcwTjJSaElpd2lkR2xsY2lJNkluQnlaVzFwZFcwaUxDSndjbTlrZFdOMElqb2liM0JsY21GMGIzSWlMQ0p6YTNVaU9pSndjbVZ0YVhWdExYbGxZWEpzZVNJc0ltWmxZWFIxY21WeklqcDdJbkpsYlc5MFpWOTBZWEpuWlhSeklqcDBjblZsZlN3aWFXRjBJam94T0RBd01EQXdNREF3TENKdVltWWlPakU0TURBd01EQXdNREFzSW1WNGNDSTZNVGd6TVRZeU1qUXdNSDAuX2RIQldvRF91YUxzcHh2Mko2S3hFU0Q3bUMzNll1UGk0TzVXVDd0aTFOTzI1X2pYMm9jc1AyVzdWbml2VEowMlBfMEpTS2M3TGNyMWxLQXhtU1hIQmciLCJhdHQiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluSnZiM1F0ZG1WamRHOXljeUo5LmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSjFiblJ5WVMxeWIyOTBJaXdpWVhWa0lqb2lkVzUwY21FdGRISjFjM1FpTENKemRXSWlPaUp2Y0dWeVlYUnZjaUlzSW5CMWNuQnZjMlVpT2lKMFpXeGxiV1YwY25rdGMybG5ibWx1WnlJc0ltdHBaQ0k2SW5abFkzUnZjbk1pTENKd2RXSWlPaUo1U0drNWNGRlBOMnBXTjI5eGVYRkljRlZIYTJsMlIxRnFhVWN6YTA1d1N6UnlkbWRIUVZKSVJFMXJQU0lzSW1saGRDSTZNVGd3TURBd01EQXdNQ3dpYm1KbUlqb3hPREF3TURBd01EQXdMQ0psZUhBaU9qRTVOVGd4TVRJd01EQjkubEdabjBEY2pnLXFMUU9lWXB6VE15Tjh6X25jb0VSdFZiMmYzX1NtU0xNMThpb2Vwa1pKbklLaU5yOTRuOTdMWUY5RjdJQWw2T1l5RGhrYzQzT2lwRHcifQ==", + "expect": "attestation_rejected" + }, + { + "name": "attestation_without_key_id", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5ObElpd2ljM1ZpSWpvaVkzVnpYM1psWTNSdmNuTWlMQ0pxZEdraU9pSXpabUU0TldZMk5DMDFOekUzTFRRMU5qSXRZak5tWXkweVl6azJNMlkyTm1GbVlUWWlMQ0p3Y205bWFXeGxYMmxrSWpvaU1UWm1aREkzTURZdE9HSmhaaTAwTXpOaUxUZ3laV0l0T0dNM1ptRmtZVGcwTjJSaElpd2lkR2xsY2lJNkluQnlaVzFwZFcwaUxDSndjbTlrZFdOMElqb2liM0JsY21GMGIzSWlMQ0p6YTNVaU9pSndjbVZ0YVhWdExYbGxZWEpzZVNJc0ltWmxZWFIxY21WeklqcDdJbkpsYlc5MFpWOTBZWEpuWlhSeklqcDBjblZsZlN3aWFXRjBJam94T0RBd01EQXdNREF3TENKdVltWWlPakU0TURBd01EQXdNREFzSW1WNGNDSTZNVGd6TVRZeU1qUXdNSDAuX2RIQldvRF91YUxzcHh2Mko2S3hFU0Q3bUMzNll1UGk0TzVXVDd0aTFOTzI1X2pYMm9jc1AyVzdWbml2VEowMlBfMEpTS2M3TGNyMWxLQXhtU1hIQmciLCJhdHQiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSjkuZXlKMlpYSnphVzl1SWpveExDSnBjM01pT2lKMWJuUnlZUzF5YjI5MElpd2lZWFZrSWpvaWRXNTBjbUV0ZEhKMWMzUWlMQ0p6ZFdJaU9pSnZjR1Z5WVhSdmNpSXNJbkIxY25CdmMyVWlPaUpzYVdObGJuTmxMWE5wWjI1cGJtY2lMQ0pyYVdRaU9pSjJaV04wYjNKeklpd2ljSFZpSWpvaWVVaHBPWEJSVHpkcVZqZHZjWGx4U0hCVlIydHBka2RSYW1sSE0ydE9jRXMwY25ablIwRlNTRVJOYXowaUxDSnBZWFFpT2pFNE1EQXdNREF3TURBc0ltNWlaaUk2TVRnd01EQXdNREF3TUN3aVpYaHdJam94T1RVNE1URXlNREF3ZlEuNnhvQ09UM25DZHgza0FORnM4RFZFdlNEVDhVMTBGWTBXX21KMW1hTXdla042Mk1QU0tJNTJpQkc4V3lyNl8xRmVNelFFbWtzTVRBS01IWmYtWG5YRGcifQ==", + "expect": "missing_key_id" + }, + { + "name": "hmac_signed_attestation", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5ObElpd2ljM1ZpSWpvaVkzVnpYM1psWTNSdmNuTWlMQ0pxZEdraU9pSXpabUU0TldZMk5DMDFOekUzTFRRMU5qSXRZak5tWXkweVl6azJNMlkyTm1GbVlUWWlMQ0p3Y205bWFXeGxYMmxrSWpvaU1UWm1aREkzTURZdE9HSmhaaTAwTXpOaUxUZ3laV0l0T0dNM1ptRmtZVGcwTjJSaElpd2lkR2xsY2lJNkluQnlaVzFwZFcwaUxDSndjbTlrZFdOMElqb2liM0JsY21GMGIzSWlMQ0p6YTNVaU9pSndjbVZ0YVhWdExYbGxZWEpzZVNJc0ltWmxZWFIxY21WeklqcDdJbkpsYlc5MFpWOTBZWEpuWlhSeklqcDBjblZsZlN3aWFXRjBJam94T0RBd01EQXdNREF3TENKdVltWWlPakU0TURBd01EQXdNREFzSW1WNGNDSTZNVGd6TVRZeU1qUXdNSDAuX2RIQldvRF91YUxzcHh2Mko2S3hFU0Q3bUMzNll1UGk0TzVXVDd0aTFOTzI1X2pYMm9jc1AyVzdWbml2VEowMlBfMEpTS2M3TGNyMWxLQXhtU1hIQmciLCJhdHQiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpJVXpJMU5pSXNJbXRwWkNJNkluSnZiM1F0ZG1WamRHOXljeUo5LmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSjFiblJ5WVMxeWIyOTBJaXdpWVhWa0lqb2lkVzUwY21FdGRISjFjM1FpTENKemRXSWlPaUp2Y0dWeVlYUnZjaUlzSW5CMWNuQnZjMlVpT2lKc2FXTmxibk5sTFhOcFoyNXBibWNpTENKcmFXUWlPaUoyWldOMGIzSnpJaXdpY0hWaUlqb2llVWhwT1hCUlR6ZHFWamR2Y1hseFNIQlZSMnRwZGtkUmFtbEhNMnRPY0VzMGNuWm5SMEZTU0VSTmF6MGlMQ0pwWVhRaU9qRTRNREF3TURBd01EQXNJbTVpWmlJNk1UZ3dNREF3TURBd01Dd2laWGh3SWpveE9UVTRNVEV5TURBd2ZRLlhneUdMWFc2ZW9taGtJM0dMN1lYRFlXVjV5OHo4UWN4ZGdvdk9tbHM5VkUifQ==", + "expect": "unsupported_algorithm" + }, + { + "name": "license_names_a_different_key", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluTnZiV1V0YjNSb1pYSXRhMmxrSW4wLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5ObElpd2ljM1ZpSWpvaVkzVnpYM1psWTNSdmNuTWlMQ0pxZEdraU9pSXpabUU0TldZMk5DMDFOekUzTFRRMU5qSXRZak5tWXkweVl6azJNMlkyTm1GbVlUWWlMQ0p3Y205bWFXeGxYMmxrSWpvaU1UWm1aREkzTURZdE9HSmhaaTAwTXpOaUxUZ3laV0l0T0dNM1ptRmtZVGcwTjJSaElpd2lkR2xsY2lJNkluQnlaVzFwZFcwaUxDSndjbTlrZFdOMElqb2liM0JsY21GMGIzSWlMQ0p6YTNVaU9pSndjbVZ0YVhWdExYbGxZWEpzZVNJc0ltWmxZWFIxY21WeklqcDdJbkpsYlc5MFpWOTBZWEpuWlhSeklqcDBjblZsZlN3aWFXRjBJam94T0RBd01EQXdNREF3TENKdVltWWlPakU0TURBd01EQXdNREFzSW1WNGNDSTZNVGd6TVRZeU1qUXdNSDAuUHBGWFhSNDVvTlJtVEZOVXlDM3JuZTZ2NzVjZlVNU3haa3RYVGNSRGVZWlM5Y1E3UWhPNnZJZEZIWlJkUGNsUFUxeXBjMFAyTW8wN0VfNXhVc3BtQ3ciLCJhdHQiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluSnZiM1F0ZG1WamRHOXljeUo5LmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSjFiblJ5WVMxeWIyOTBJaXdpWVhWa0lqb2lkVzUwY21FdGRISjFjM1FpTENKemRXSWlPaUp2Y0dWeVlYUnZjaUlzSW5CMWNuQnZjMlVpT2lKc2FXTmxibk5sTFhOcFoyNXBibWNpTENKcmFXUWlPaUoyWldOMGIzSnpJaXdpY0hWaUlqb2llVWhwT1hCUlR6ZHFWamR2Y1hseFNIQlZSMnRwZGtkUmFtbEhNMnRPY0VzMGNuWm5SMEZTU0VSTmF6MGlMQ0pwWVhRaU9qRTRNREF3TURBd01EQXNJbTVpWmlJNk1UZ3dNREF3TURBd01Dd2laWGh3SWpveE9UVTRNVEV5TURBd2ZRLlhiNExPMldxS3hxb0NITy1xQVphTmx1dzhpR3hTY0IxdmdEMU5QZlZfdDVfWjVnT1BCVTgtVFBENEUxZnltbl8xNXJKWVQ5MkQtZnFMTUs2OHdLeEJRIn0=", + "expect": "key_mismatch" + }, + { + "name": "license_signed_by_unattested_key", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5ObElpd2ljM1ZpSWpvaVkzVnpYM1psWTNSdmNuTWlMQ0pxZEdraU9pSXpabUU0TldZMk5DMDFOekUzTFRRMU5qSXRZak5tWXkweVl6azJNMlkyTm1GbVlUWWlMQ0p3Y205bWFXeGxYMmxrSWpvaU1UWm1aREkzTURZdE9HSmhaaTAwTXpOaUxUZ3laV0l0T0dNM1ptRmtZVGcwTjJSaElpd2lkR2xsY2lJNkluQnlaVzFwZFcwaUxDSndjbTlrZFdOMElqb2liM0JsY21GMGIzSWlMQ0p6YTNVaU9pSndjbVZ0YVhWdExYbGxZWEpzZVNJc0ltWmxZWFIxY21WeklqcDdJbkpsYlc5MFpWOTBZWEpuWlhSeklqcDBjblZsZlN3aWFXRjBJam94T0RBd01EQXdNREF3TENKdVltWWlPakU0TURBd01EQXdNREFzSW1WNGNDSTZNVGd6TVRZeU1qUXdNSDAuWnJldGhoVkhmMUh2eVdlVUNyOWRES0xrb3VkTHB0Y3FhUkUySHhuLTVwSlg2RWcyLUlEWW9yZzZPYnlXWnBndmxibVBFMjFPUWt1S1BuLW5OMnBRQUEiLCJhdHQiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluSnZiM1F0ZG1WamRHOXljeUo5LmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSjFiblJ5WVMxeWIyOTBJaXdpWVhWa0lqb2lkVzUwY21FdGRISjFjM1FpTENKemRXSWlPaUp2Y0dWeVlYUnZjaUlzSW5CMWNuQnZjMlVpT2lKc2FXTmxibk5sTFhOcFoyNXBibWNpTENKcmFXUWlPaUoyWldOMGIzSnpJaXdpY0hWaUlqb2llVWhwT1hCUlR6ZHFWamR2Y1hseFNIQlZSMnRwZGtkUmFtbEhNMnRPY0VzMGNuWm5SMEZTU0VSTmF6MGlMQ0pwWVhRaU9qRTRNREF3TURBd01EQXNJbTVpWmlJNk1UZ3dNREF3TURBd01Dd2laWGh3SWpveE9UVTRNVEV5TURBd2ZRLlhiNExPMldxS3hxb0NITy1xQVphTmx1dzhpR3hTY0IxdmdEMU5QZlZfdDVfWjVnT1BCVTgtVFBENEUxZnltbl8xNXJKWVQ5MkQtZnFMTUs2OHdLeEJRIn0=", + "expect": "license_rejected" + }, + { + "name": "license_for_another_issuer", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnpiMjFsYjI1bExXVnNjMlVpTENKaGRXUWlPaUp2Y0dWeVlYUnZjaTFzYVdObGJuTmxJaXdpYzNWaUlqb2lZM1Z6WDNabFkzUnZjbk1pTENKcWRHa2lPaUl6Wm1FNE5XWTJOQzAxTnpFM0xUUTFOakl0WWpObVl5MHlZemsyTTJZMk5tRm1ZVFlpTENKd2NtOW1hV3hsWDJsa0lqb2lNVFptWkRJM01EWXRPR0poWmkwME16TmlMVGd5WldJdE9HTTNabUZrWVRnME4yUmhJaXdpZEdsbGNpSTZJbkJ5WlcxcGRXMGlMQ0p3Y205a2RXTjBJam9pYjNCbGNtRjBiM0lpTENKemEzVWlPaUp3Y21WdGFYVnRMWGxsWVhKc2VTSXNJbVpsWVhSMWNtVnpJanA3SW5KbGJXOTBaVjkwWVhKblpYUnpJanAwY25WbGZTd2lhV0YwSWpveE9EQXdNREF3TURBd0xDSnVZbVlpT2pFNE1EQXdNREF3TURBc0ltVjRjQ0k2TVRnek1UWXlNalF3TUgwLmFxeDVTZFVsY0VFdFV3VFF1YTc0Q09IY3U2QS1Ea1NmNFhTaEFJc01MTm5ERHU3MkpZb0JVc0NqVVVvZXJaN0dBbXA2dGFodmY4ZXV5M1gyZXlleUF3IiwiYXR0IjoiZXlKMGVYQWlPaUpLVjFRaUxDSmhiR2NpT2lKRlpFUlRRU0lzSW10cFpDSTZJbkp2YjNRdGRtVmpkRzl5Y3lKOS5leUoyWlhKemFXOXVJam94TENKcGMzTWlPaUoxYm5SeVlTMXliMjkwSWl3aVlYVmtJam9pZFc1MGNtRXRkSEoxYzNRaUxDSnpkV0lpT2lKdmNHVnlZWFJ2Y2lJc0luQjFjbkJ2YzJVaU9pSnNhV05sYm5ObExYTnBaMjVwYm1jaUxDSnJhV1FpT2lKMlpXTjBiM0p6SWl3aWNIVmlJam9pZVVocE9YQlJUemRxVmpkdmNYbHhTSEJWUjJ0cGRrZFJhbWxITTJ0T2NFczBjblpuUjBGU1NFUk5hejBpTENKcFlYUWlPakU0TURBd01EQXdNREFzSW01aVppSTZNVGd3TURBd01EQXdNQ3dpWlhod0lqb3hPVFU0TVRFeU1EQXdmUS5YYjRMTzJXcUt4cW9DSE8tcUFaYU5sdXc4aUd4U2NCMXZnRDFOUGZWX3Q1X1o1Z09QQlU4LVRQRDRFMWZ5bW5fMTVySllUOTJELWZxTE1LNjh3S3hCUSJ9", + "expect": "license_rejected" + }, + { + "name": "license_for_another_audience", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMWhjR2tpTENKemRXSWlPaUpqZFhOZmRtVmpkRzl5Y3lJc0ltcDBhU0k2SWpObVlUZzFaalkwTFRVM01UY3RORFUyTWkxaU0yWmpMVEpqT1RZelpqWTJZV1poTmlJc0luQnliMlpwYkdWZmFXUWlPaUl4Tm1aa01qY3dOaTA0WW1GbUxUUXpNMkl0T0RKbFlpMDRZemRtWVdSaE9EUTNaR0VpTENKMGFXVnlJam9pY0hKbGJXbDFiU0lzSW5CeWIyUjFZM1FpT2lKdmNHVnlZWFJ2Y2lJc0luTnJkU0k2SW5CeVpXMXBkVzB0ZVdWaGNteDVJaXdpWm1WaGRIVnlaWE1pT25zaWNtVnRiM1JsWDNSaGNtZGxkSE1pT25SeWRXVjlMQ0pwWVhRaU9qRTRNREF3TURBd01EQXNJbTVpWmlJNk1UZ3dNREF3TURBd01Dd2laWGh3SWpveE9ETXhOakl5TkRBd2ZRLk9iaXJlVzBlOGM3NlNmZmRPLXUyN1pzdnBRSnNFeHNmSnhpV0V2T1RTRUREN3MyZ2FiOENDUlFhWTZZWkZSbGluZzZaSUJJVlhiU2I1a1JSM0NlQUJnIiwiYXR0IjoiZXlKMGVYQWlPaUpLVjFRaUxDSmhiR2NpT2lKRlpFUlRRU0lzSW10cFpDSTZJbkp2YjNRdGRtVmpkRzl5Y3lKOS5leUoyWlhKemFXOXVJam94TENKcGMzTWlPaUoxYm5SeVlTMXliMjkwSWl3aVlYVmtJam9pZFc1MGNtRXRkSEoxYzNRaUxDSnpkV0lpT2lKdmNHVnlZWFJ2Y2lJc0luQjFjbkJ2YzJVaU9pSnNhV05sYm5ObExYTnBaMjVwYm1jaUxDSnJhV1FpT2lKMlpXTjBiM0p6SWl3aWNIVmlJam9pZVVocE9YQlJUemRxVmpkdmNYbHhTSEJWUjJ0cGRrZFJhbWxITTJ0T2NFczBjblpuUjBGU1NFUk5hejBpTENKcFlYUWlPakU0TURBd01EQXdNREFzSW01aVppSTZNVGd3TURBd01EQXdNQ3dpWlhod0lqb3hPVFU0TVRFeU1EQXdmUS5YYjRMTzJXcUt4cW9DSE8tcUFaYU5sdXc4aUd4U2NCMXZnRDFOUGZWX3Q1X1o1Z09QQlU4LVRQRDRFMWZ5bW5fMTVySllUOTJELWZxTE1LNjh3S3hCUSJ9", + "expect": "license_rejected" + }, + { + "name": "license_with_tampered_payload", + "license_key": "eyJ2IjoxLCJsaWMiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluWmxZM1J2Y25NaWZRLmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5OcGJtY2lMQ0poZFdRaU9pSnZjR1Z5WVhSdmNpMXNhV05sYm5ObElpd2ljM1ZpSWpvaVkzVnpYM1psWTNSdmNuTWlMQ0pxZEdraU9pSXpabUU0TldZMk5DMDFOekUzTFRRMU5qSXRZak5tWXkweVl6azJNMlkyTm1GbVlUWWlMQ0p3Y205bWFXeGxYMmxrSWpvaU1UWm1aREkzTURZdE9HSmhaaTAwTXpOaUxUZ3laV0l0T0dNM1ptRmtZVGcwTjJSaElpd2lkR2xsY2lJNkltVnVkR1Z5Y0hKcGMyVWlMQ0p3Y205a2RXTjBJam9pYjNCbGNtRjBiM0lpTENKemEzVWlPaUp3Y21WdGFYVnRMWGxsWVhKc2VTSXNJbVpsWVhSMWNtVnpJanA3SW5KbGJXOTBaVjkwWVhKblpYUnpJanAwY25WbGZTd2lhV0YwSWpveE9EQXdNREF3TURBd0xDSnVZbVlpT2pFNE1EQXdNREF3TURBc0ltVjRjQ0k2TVRnek1UWXlNalF3TUgwLl9kSEJXb0RfdWFMc3B4djJKNkt4RVNEN21DMzZZdVBpNE81V1Q3dGkxTk8yNV9qWDJvY3NQMlc3Vm5pdlRKMDJQXzBKU0tjN0xjcjFsS0F4bVNYSEJnIiwiYXR0IjoiZXlKMGVYQWlPaUpLVjFRaUxDSmhiR2NpT2lKRlpFUlRRU0lzSW10cFpDSTZJbkp2YjNRdGRtVmpkRzl5Y3lKOS5leUoyWlhKemFXOXVJam94TENKcGMzTWlPaUoxYm5SeVlTMXliMjkwSWl3aVlYVmtJam9pZFc1MGNtRXRkSEoxYzNRaUxDSnpkV0lpT2lKdmNHVnlZWFJ2Y2lJc0luQjFjbkJ2YzJVaU9pSnNhV05sYm5ObExYTnBaMjVwYm1jaUxDSnJhV1FpT2lKMlpXTjBiM0p6SWl3aWNIVmlJam9pZVVocE9YQlJUemRxVmpkdmNYbHhTSEJWUjJ0cGRrZFJhbWxITTJ0T2NFczBjblpuUjBGU1NFUk5hejBpTENKcFlYUWlPakU0TURBd01EQXdNREFzSW01aVppSTZNVGd3TURBd01EQXdNQ3dpWlhod0lqb3hPVFU0TVRFeU1EQXdmUS5YYjRMTzJXcUt4cW9DSE8tcUFaYU5sdXc4aUd4U2NCMXZnRDFOUGZWX3Q1X1o1Z09QQlU4LVRQRDRFMWZ5bW5fMTVySllUOTJELWZxTE1LNjh3S3hCUSJ9", + "expect": "license_rejected" + }, + { + "name": "legacy_bare_jwt", + "license_key": "ZXlKMGVYQWlPaUpLVjFRaUxDSmhiR2NpT2lKRlpFUlRRU0lzSW10cFpDSTZJblpsWTNSdmNuTWlmUS5leUoyWlhKemFXOXVJam94TENKcGMzTWlPaUp2Y0dWeVlYUnZjaTFzYVdObGJuTnBibWNpTENKaGRXUWlPaUp2Y0dWeVlYUnZjaTFzYVdObGJuTmxJaXdpYzNWaUlqb2lZM1Z6WDNabFkzUnZjbk1pTENKcWRHa2lPaUl6Wm1FNE5XWTJOQzAxTnpFM0xUUTFOakl0WWpObVl5MHlZemsyTTJZMk5tRm1ZVFlpTENKd2NtOW1hV3hsWDJsa0lqb2lNVFptWkRJM01EWXRPR0poWmkwME16TmlMVGd5WldJdE9HTTNabUZrWVRnME4yUmhJaXdpZEdsbGNpSTZJbkJ5WlcxcGRXMGlMQ0p3Y205a2RXTjBJam9pYjNCbGNtRjBiM0lpTENKemEzVWlPaUp3Y21WdGFYVnRMWGxsWVhKc2VTSXNJbVpsWVhSMWNtVnpJanA3SW5KbGJXOTBaVjkwWVhKblpYUnpJanAwY25WbGZTd2lhV0YwSWpveE9EQXdNREF3TURBd0xDSnVZbVlpT2pFNE1EQXdNREF3TURBc0ltVjRjQ0k2TVRnek1UWXlNalF3TUgwLl9kSEJXb0RfdWFMc3B4djJKNkt4RVNEN21DMzZZdVBpNE81V1Q3dGkxTk8yNV9qWDJvY3NQMlc3Vm5pdlRKMDJQXzBKU0tjN0xjcjFsS0F4bVNYSEJn", + "expect": "malformed_envelope" + }, + { + "name": "unsupported_envelope_version", + "license_key": "eyJhdHQiOiJleUowZVhBaU9pSktWMVFpTENKaGJHY2lPaUpGWkVSVFFTSXNJbXRwWkNJNkluSnZiM1F0ZG1WamRHOXljeUo5LmV5SjJaWEp6YVc5dUlqb3hMQ0pwYzNNaU9pSjFiblJ5WVMxeWIyOTBJaXdpWVhWa0lqb2lkVzUwY21FdGRISjFjM1FpTENKemRXSWlPaUp2Y0dWeVlYUnZjaUlzSW5CMWNuQnZjMlVpT2lKc2FXTmxibk5sTFhOcFoyNXBibWNpTENKcmFXUWlPaUoyWldOMGIzSnpJaXdpY0hWaUlqb2llVWhwT1hCUlR6ZHFWamR2Y1hseFNIQlZSMnRwZGtkUmFtbEhNMnRPY0VzMGNuWm5SMEZTU0VSTmF6MGlMQ0pwWVhRaU9qRTRNREF3TURBd01EQXNJbTVpWmlJNk1UZ3dNREF3TURBd01Dd2laWGh3SWpveE9UVTRNVEV5TURBd2ZRLlhiNExPMldxS3hxb0NITy1xQVphTmx1dzhpR3hTY0IxdmdEMU5QZlZfdDVfWjVnT1BCVTgtVFBENEUxZnltbl8xNXJKWVQ5MkQtZnFMTUs2OHdLeEJRIiwibGljIjoiZXlKMGVYQWlPaUpLVjFRaUxDSmhiR2NpT2lKRlpFUlRRU0lzSW10cFpDSTZJblpsWTNSdmNuTWlmUS5leUoyWlhKemFXOXVJam94TENKcGMzTWlPaUp2Y0dWeVlYUnZjaTFzYVdObGJuTnBibWNpTENKaGRXUWlPaUp2Y0dWeVlYUnZjaTFzYVdObGJuTmxJaXdpYzNWaUlqb2lZM1Z6WDNabFkzUnZjbk1pTENKcWRHa2lPaUl6Wm1FNE5XWTJOQzAxTnpFM0xUUTFOakl0WWpObVl5MHlZemsyTTJZMk5tRm1ZVFlpTENKd2NtOW1hV3hsWDJsa0lqb2lNVFptWkRJM01EWXRPR0poWmkwME16TmlMVGd5WldJdE9HTTNabUZrWVRnME4yUmhJaXdpZEdsbGNpSTZJbkJ5WlcxcGRXMGlMQ0p3Y205a2RXTjBJam9pYjNCbGNtRjBiM0lpTENKemEzVWlPaUp3Y21WdGFYVnRMWGxsWVhKc2VTSXNJbVpsWVhSMWNtVnpJanA3SW5KbGJXOTBaVjkwWVhKblpYUnpJanAwY25WbGZTd2lhV0YwSWpveE9EQXdNREF3TURBd0xDSnVZbVlpT2pFNE1EQXdNREF3TURBc0ltVjRjQ0k2TVRnek1UWXlNalF3TUgwLl9kSEJXb0RfdWFMc3B4djJKNkt4RVNEN21DMzZZdVBpNE81V1Q3dGkxTk8yNV9qWDJvY3NQMlc3Vm5pdlRKMDJQXzBKU0tjN0xjcjFsS0F4bVNYSEJnIiwidiI6Mn0=", + "expect": "unsupported_envelope_version" + } + ] +} diff --git a/tests/acp_integration.rs b/tests/acp_integration.rs index 62740e7e..f9d963be 100644 --- a/tests/acp_integration.rs +++ b/tests/acp_integration.rs @@ -4,12 +4,59 @@ //! Phase A: `initialize` roundtrip. Phase B adds `session/new` and //! `session/prompt` with `/bin/cat` as a stand-in delegator. +use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; const PER_LINE_TIMEOUT: Duration = Duration::from_secs(5); +const REGISTRY_ENV: &str = "OPERATOR_PROFILE_REGISTRY"; +const REGISTRY_FILE: &str = "profiles.sqlite"; +const INITIALIZE_REQUEST: &[u8] = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"acp-integration-test","version":"0.0.0"}}}"#; + +/// Spawnable `operator` pointed at `registry` instead of the developer's own +/// profile registry, so tests neither depend on nor pollute it. +fn operator_command(registry: &Path, config: Option<&Path>, subcommand: &str) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_operator")); + command.env(REGISTRY_ENV, registry); + if let Some(config) = config { + command.arg("--config").arg(config); + } + command + .arg(subcommand) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command +} + +/// A directory nothing can be created in, restored on drop so the tempdir can +/// clean itself up even when an assertion panics. +#[cfg(unix)] +struct ReadOnlyDir(tempfile::TempDir); + +#[cfg(unix)] +impl ReadOnlyDir { + fn new() -> Self { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::TempDir::new().unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap(); + Self(dir) + } + + fn registry(&self) -> PathBuf { + self.0.path().join("operator").join(REGISTRY_FILE) + } +} + +#[cfg(unix)] +impl Drop for ReadOnlyDir { + fn drop(&mut self) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(self.0.path(), std::fs::Permissions::from_mode(0o755)); + } +} async fn read_line( reader: &mut tokio::io::Lines>, @@ -23,12 +70,11 @@ async fn read_line( #[tokio::test] async fn test_operator_acp_stdio_initialize_roundtrip() { - let exe = env!("CARGO_BIN_EXE_operator"); - let mut child = Command::new(exe) - .arg("acp") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + let tickets = tempfile::TempDir::new().unwrap(); + let registry_dir = tempfile::TempDir::new().unwrap(); + let registry = registry_dir.path().join(REGISTRY_FILE); + let (_config_keep, config_path) = write_cat_delegator_config(tickets.path()); + let mut child = operator_command(®istry, Some(&config_path), "acp") .spawn() .expect("spawn operator acp"); @@ -36,9 +82,11 @@ async fn test_operator_acp_stdio_initialize_roundtrip() { let stdout = child.stdout.take().expect("take stdout"); let mut reader = BufReader::new(stdout).lines(); - let request = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"acp-integration-test","version":"0.0.0"}}} -"#; - stdin.write_all(request).await.expect("write request"); + stdin + .write_all(INITIALIZE_REQUEST) + .await + .expect("write request"); + stdin.write_all(b"\n").await.expect("terminate request"); stdin.flush().await.expect("flush request"); let line = read_line(&mut reader).await; @@ -57,11 +105,64 @@ async fn test_operator_acp_stdio_initialize_roundtrip() { result["agentInfo"]["name"], "operator", "agentInfo.name should identify operator: {result:?}" ); + assert!( + registry.exists(), + "{REGISTRY_ENV} should redirect registration to {}", + registry.display() + ); + + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; +} + +#[cfg(unix)] +#[tokio::test] +async fn test_acp_initialize_survives_unwritable_registry() { + let unwritable = ReadOnlyDir::new(); + let tickets = tempfile::TempDir::new().unwrap(); + let (_config_keep, config_path) = write_cat_delegator_config(tickets.path()); + let mut child = operator_command(&unwritable.registry(), Some(&config_path), "acp") + .spawn() + .expect("spawn operator acp"); + + let mut stdin = child.stdin.take().expect("take stdin"); + let stdout = child.stdout.take().expect("take stdout"); + let mut reader = BufReader::new(stdout).lines(); + + stdin.write_all(INITIALIZE_REQUEST).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + let response: serde_json::Value = + serde_json::from_str(&read_line(&mut reader).await).expect("response should be valid JSON"); + assert_eq!( + response["result"]["agentInfo"]["name"], "operator", + "acp must still serve when the registry is unwritable: {response}" + ); drop(stdin); let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; } +#[cfg(unix)] +#[tokio::test] +async fn test_unwritable_registry_still_fails_non_protocol_commands() { + let unwritable = ReadOnlyDir::new(); + let tickets = tempfile::TempDir::new().unwrap(); + let (_config_keep, config_path) = write_cat_delegator_config(tickets.path()); + + let output = operator_command(&unwritable.registry(), Some(&config_path), "queue") + .output() + .await + .expect("run operator queue"); + + assert!( + !output.status.success(), + "only stdio protocol servers may run unregistered; queue exited {}", + output.status + ); +} + fn write_sleep_delegator_config( tickets_dir: &std::path::Path, ) -> (tempfile::TempDir, std::path::PathBuf) { @@ -139,19 +240,14 @@ default_delegator = "test-cat" #[tokio::test] async fn test_operator_acp_session_new_and_prompt_with_cat_delegator() { - let exe = env!("CARGO_BIN_EXE_operator"); let tickets = tempfile::TempDir::new().unwrap(); + let registry_dir = tempfile::TempDir::new().unwrap(); + let registry = registry_dir.path().join(REGISTRY_FILE); let cwd = tempfile::TempDir::new().unwrap(); let canonical_cwd = std::fs::canonicalize(cwd.path()).unwrap(); let (_config_keep, config_path) = write_cat_delegator_config(tickets.path()); - let mut child = Command::new(exe) - .arg("--config") - .arg(&config_path) - .arg("acp") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + let mut child = operator_command(®istry, Some(&config_path), "acp") .spawn() .expect("spawn operator acp with cat-delegator config"); @@ -240,19 +336,14 @@ async fn test_operator_acp_session_new_and_prompt_with_cat_delegator() { #[tokio::test] async fn test_cancel_kills_delegator() { - let exe = env!("CARGO_BIN_EXE_operator"); let tickets = tempfile::TempDir::new().unwrap(); + let registry_dir = tempfile::TempDir::new().unwrap(); + let registry = registry_dir.path().join(REGISTRY_FILE); let cwd = tempfile::TempDir::new().unwrap(); let canonical_cwd = std::fs::canonicalize(cwd.path()).unwrap(); let (_config_keep, config_path) = write_sleep_delegator_config(tickets.path()); - let mut child = Command::new(exe) - .arg("--config") - .arg(&config_path) - .arg("acp") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + let mut child = operator_command(®istry, Some(&config_path), "acp") .spawn() .expect("spawn operator acp with sleep-delegator config"); diff --git a/tests/docs_structure.rs b/tests/docs_structure.rs index 9e2cdbb2..32b77abf 100644 --- a/tests/docs_structure.rs +++ b/tests/docs_structure.rs @@ -6,8 +6,9 @@ //! must be reachable from the sidebar with its catalog icon, and the nav may //! not advertise integrations the catalog doesn't know. The suite also guards //! general docs hygiene: every nav URL resolves, every published page is -//! reachable, internal links resolve, and no page duplicates the layout's -//! front-matter title with a body H1. +//! reachable, internal `/path/` links resolve, markdown links are not +//! filesystem-relative (`../`, `./`, `.md`), and no page duplicates the +//! layout's front-matter title with a body H1. use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; @@ -438,6 +439,59 @@ fn test_docs_pages_reachable() { } } +/// Markdown `](href)` destinations on a line, excluding fenced code (caller skips). +fn markdown_link_hrefs(line: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut rest = line; + while let Some(i) = rest.find("](") { + let after = &rest[i + 2..]; + let Some(end) = after.find(')') else { break }; + out.push(&after[..end]); + rest = &after[end..]; + } + out +} + +#[test] +fn test_jekyll_links_are_not_parent_relative() { + for page in published_pages() { + let content = std::fs::read_to_string(repo_path(&format!("docs/{page}"))) + .expect("page should be readable"); + let mut in_fence = false; + for (lineno, line) in content.lines().enumerate() { + if line.trim_start().starts_with("```") { + in_fence = !in_fence; + continue; + } + if in_fence { + continue; + } + for href in markdown_link_hrefs(line) { + let path = href.split(['#', '?']).next().unwrap_or(href); + assert!( + !path.contains(".."), + "docs/{page}:{} links to '{href}' with '..'. Use a site-root Jekyll path \ + (e.g. /getting-started/agents/grok/).", + lineno + 1 + ); + assert!( + path != "." && !path.starts_with("./"), + "docs/{page}:{} links to '{href}'. './' is the current pretty-permalink \ + directory, not the section index. Use /getting-started/.../ or a same-page #anchor.", + lineno + 1 + ); + assert!( + !std::path::Path::new(path) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")), + "docs/{page}:{} links to '{href}'. Jekyll serves pages as /path/, not .md files.", + lineno + 1 + ); + } + } + } +} + #[test] fn test_internal_links_resolve() { for page in published_pages() { diff --git a/tests/licensing_integration.rs b/tests/licensing_integration.rs index 4fbca3a0..568fda5a 100644 --- a/tests/licensing_integration.rs +++ b/tests/licensing_integration.rs @@ -5,8 +5,6 @@ //! `status_with` / `install_with` entry points the bundled path uses. Nothing //! here depends on how the shipped binary was configured. -use std::collections::BTreeMap; - use base64::{engine::general_purpose::STANDARD, Engine}; use jsonwebtoken::{Algorithm, EncodingKey, Header}; use ring::signature::{Ed25519KeyPair, KeyPair}; @@ -17,9 +15,16 @@ use operator::licensing::{ install_with, status_with, LicenseStatus, LicenseTerms, Verifier, LICENSE_AUDIENCE, LICENSE_VERSION, PREMIUM_TIER, }; +use operator::trust_verify::{ + AttestationClaims, Envelope, RootKeyring, ATTESTATION_AUDIENCE, ATTESTATION_ISSUER, + ATTESTATION_VERSION, PURPOSE_LICENSE_SIGNING, +}; const KID: &str = "test-key"; +const ROOT_KID: &str = "root-test"; const ISSUER: &str = "operator-licensing-test"; +const ATTESTATION_NBF: i64 = 1_000; +const ATTESTATION_EXP: i64 = 2_000; /// A configuration rooted in `directory`, bound to `profile`. fn config_for(directory: &std::path::Path, profile: Uuid) -> Config { @@ -33,16 +38,42 @@ fn config_for(directory: &std::path::Path, profile: Uuid) -> Config { struct Issuer { verifier: Verifier, encoding: EncodingKey, + attestation: String, +} + +fn generate_ed25519() -> (Ed25519KeyPair, Vec) { + let document = Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()).unwrap(); + let bytes = document.as_ref().to_vec(); + let pair = Ed25519KeyPair::from_pkcs8(&bytes).unwrap(); + (pair, bytes) } impl Issuer { fn new() -> Self { - let document = Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()).unwrap(); - let pair = Ed25519KeyPair::from_pkcs8(document.as_ref()).unwrap(); - let keys = BTreeMap::from([(KID.to_string(), STANDARD.encode(pair.public_key().as_ref()))]); + let (root, root_pkcs8) = generate_ed25519(); + let (signing, signing_pkcs8) = generate_ed25519(); + let root_b64 = STANDARD.encode(root.public_key().as_ref()); + let roots = RootKeyring::from_json(&format!(r#"{{"{ROOT_KID}":"{root_b64}"}}"#)).unwrap(); + let claims = AttestationClaims { + version: ATTESTATION_VERSION, + iss: ATTESTATION_ISSUER.into(), + aud: ATTESTATION_AUDIENCE.into(), + sub: "operator".into(), + purpose: PURPOSE_LICENSE_SIGNING.into(), + kid: KID.into(), + public_key: STANDARD.encode(signing.public_key().as_ref()), + iat: ATTESTATION_NBF, + nbf: ATTESTATION_NBF, + exp: ATTESTATION_EXP, + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(ROOT_KID.into()); + let attestation = + jsonwebtoken::encode(&header, &claims, &EncodingKey::from_ed_der(&root_pkcs8)).unwrap(); Self { - verifier: Verifier::from_keys(keys, ISSUER.to_string()), - encoding: EncodingKey::from_ed_der(document.as_ref()), + verifier: Verifier::from_keys(roots, ISSUER.to_string()), + encoding: EncodingKey::from_ed_der(&signing_pkcs8), + attestation, } } @@ -53,7 +84,8 @@ impl Issuer { fn sign_with(&self, terms: &LicenseTerms, alg: Algorithm, kid: Option) -> String { let mut header = Header::new(alg); header.kid = kid; - STANDARD.encode(jsonwebtoken::encode(&header, terms, &self.encoding).unwrap()) + let license = jsonwebtoken::encode(&header, terms, &self.encoding).unwrap(); + Envelope::new(license, self.attestation.clone()).encode() } } @@ -200,9 +232,10 @@ fn a_symmetric_algorithm_is_refused() { let mut header = Header::new(Algorithm::HS256); header.kid = Some(KID.to_string()); let hmac = EncodingKey::from_secret(b"not-the-signing-key"); - let token = jsonwebtoken::encode(&header, &terms_for(profile), &hmac).unwrap(); + let license = jsonwebtoken::encode(&header, &terms_for(profile), &hmac).unwrap(); + let key = Envelope::new(license, issuer.attestation.clone()).encode(); - let (accepted, status) = install_then_status(&issuer, &STANDARD.encode(token), 1_500); + let (accepted, status) = install_then_status(&issuer, &key, 1_500); assert!(!accepted, "an HS256 licence must not install"); assert_eq!(status, LicenseStatus::Missing); @@ -228,26 +261,25 @@ fn a_tampered_payload_is_refused() { let profile = Uuid::new_v4(); let signed = issuer.sign(&terms_for(profile)); - // Re-encode the token with one payload byte changed. - let token = String::from_utf8(STANDARD.decode(&signed).unwrap()).unwrap(); - let mut parts: Vec = token.split('.').map(str::to_string).collect(); + let envelope = Envelope::decode(&signed).unwrap(); + let mut parts: Vec = envelope.lic.split('.').map(str::to_string).collect(); let payload = parts[1].clone(); parts[1] = payload .chars() .enumerate() - .map(|(i, c)| { - if i == 4 { - if c == 'A' { + .map(|(index, character)| { + if index == 4 { + if character == 'A' { 'B' } else { 'A' } } else { - c + character } }) .collect(); - let tampered = STANDARD.encode(parts.join(".")); + let tampered = Envelope::new(parts.join("."), envelope.att).encode(); let (accepted, status) = install_then_status(&issuer, &tampered, 1_500); diff --git a/tests/mcp_stdio_integration.rs b/tests/mcp_stdio_integration.rs index bccd9cc1..7062dd4c 100644 --- a/tests/mcp_stdio_integration.rs +++ b/tests/mcp_stdio_integration.rs @@ -1,19 +1,34 @@ //! End-to-end test: spawn `operator mcp` as a subprocess and roundtrip //! a real JSON-RPC handshake over stdio. +use std::path::Path; use std::process::Stdio; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; -#[tokio::test] -async fn test_operator_mcp_stdio_initialize_and_list_tools() { - let exe = env!("CARGO_BIN_EXE_operator"); - let mut child = Command::new(exe) +const REGISTRY_ENV: &str = "OPERATOR_PROFILE_REGISTRY"; +const REGISTRY_FILE: &str = "profiles.sqlite"; +const INITIALIZE_REQUEST: &[u8] = + b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n"; + +/// Spawnable `operator mcp` pointed at `registry` instead of the developer's +/// own profile registry, so tests neither depend on nor pollute it. +fn operator_mcp(registry: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_operator")); + command + .env(REGISTRY_ENV, registry) .arg("mcp") .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + .stderr(Stdio::piped()); + command +} + +#[tokio::test] +async fn test_operator_mcp_stdio_initialize_and_list_tools() { + let registry_dir = tempfile::TempDir::new().unwrap(); + let mut child = operator_mcp(®istry_dir.path().join(REGISTRY_FILE)) .spawn() .expect("spawn operator mcp"); @@ -21,10 +36,7 @@ async fn test_operator_mcp_stdio_initialize_and_list_tools() { let stdout = child.stdout.take().expect("take stdout"); let mut reader = BufReader::new(stdout).lines(); - stdin - .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n") - .await - .unwrap(); + stdin.write_all(INITIALIZE_REQUEST).await.unwrap(); stdin .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n") .await @@ -66,3 +78,32 @@ async fn test_operator_mcp_stdio_initialize_and_list_tools() { drop(stdin); let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; } + +#[cfg(unix)] +#[tokio::test] +async fn test_mcp_initialize_survives_unwritable_registry() { + use std::os::unix::fs::PermissionsExt; + let unwritable = tempfile::TempDir::new().unwrap(); + std::fs::set_permissions(unwritable.path(), std::fs::Permissions::from_mode(0o555)).unwrap(); + let registry = unwritable.path().join("operator").join(REGISTRY_FILE); + + let mut child = operator_mcp(®istry).spawn().expect("spawn operator mcp"); + let mut stdin = child.stdin.take().expect("take stdin"); + let stdout = child.stdout.take().expect("take stdout"); + let mut reader = BufReader::new(stdout).lines(); + + stdin.write_all(INITIALIZE_REQUEST).await.unwrap(); + stdin.flush().await.unwrap(); + + let line = tokio::time::timeout(Duration::from_secs(5), reader.next_line()).await; + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; + std::fs::set_permissions(unwritable.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + + let line = line + .expect("timeout waiting for initialize response") + .expect("read err") + .expect("mcp must still serve when the registry is unwritable"); + let response: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(response["result"]["serverInfo"]["name"], "operator"); +} diff --git a/tests/rest_api_integration.rs b/tests/rest_api_integration.rs index 8600845b..a5d64e45 100644 --- a/tests/rest_api_integration.rs +++ b/tests/rest_api_integration.rs @@ -203,7 +203,7 @@ impl RestApiTestContext { /// Health request with an explicit credential (`None` sends no auth header). async fn get_health(&self, token: Option<&str>) -> Result { let url = format!("http://localhost:{}/api/v1/health", self.port); - let client = reqwest::Client::builder() + let client = operator::http_client::client_builder() .timeout(Duration::from_secs(5)) .build() .map_err(|e| e.to_string())?; diff --git a/tests/setup_parity.rs b/tests/setup_parity.rs index a03ddfd8..af46c84b 100644 --- a/tests/setup_parity.rs +++ b/tests/setup_parity.rs @@ -15,6 +15,8 @@ const SETUP_MOD_RS: &str = include_str!("../src/ui/setup/mod.rs"); const GIT_STEP_RS: &str = include_str!("../src/ui/setup/steps/git.rs"); const MODEL_STEP_RS: &str = include_str!("../src/ui/setup/steps/model_server.rs"); const WEB_STEPS_TSX: &str = include_str!("../ui/src/routes/onboarding/steps.tsx"); +const API_DEFINITIONS_TS: &str = include_str!("../ui/src/api/definitions.ts"); +const ONBOARDING_PAGE_TSX: &str = include_str!("../ui/src/routes/onboarding/OnboardingPage.tsx"); /// The assertions below scrape TSX source, so collapse what the formatter is /// free to rewrite: quote style and line wrapping. Prose keeps single spaces. @@ -35,6 +37,13 @@ fn tsx_contains_code(needle: &str) -> bool { compact(&WEB_STEPS_TSX.replace('\'', "\"")).contains(&compact(needle)) } +fn api_definitions_contains_code(needle: &str) -> bool { + fn compact(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).collect() + } + compact(&API_DEFINITIONS_TS.replace('\'', "\"")).contains(&compact(needle)) +} + /// Variant names in `SetupStep::ALL`, in declaration order. fn catalog_order() -> Vec { let all = STEPS_RS @@ -199,8 +208,10 @@ fn test_web_wizard_has_an_exhaustive_component_map() { #[test] fn test_web_wizard_derives_provider_lists_from_rest_catalogs() { assert!(tsx_contains_code("entry.vertical === \"model\"")); - assert!(tsx_contains_code("api.gitProviders()")); - assert!(tsx_contains_code("api.kanbanProviders()")); + assert!(tsx_contains_code("useApiQuery(gitProvidersQuery())")); + assert!(api_definitions_contains_code("api.gitProviders()")); + assert!(tsx_contains_code("useApiQuery(kanbanProvidersQuery())")); + assert!(api_definitions_contains_code("api.kanbanProviders()")); } #[test] @@ -266,18 +277,85 @@ fn test_confirm_step_reports_parameter_count_without_values() { /// Empty and duplicate names are rejected before submit; values are sent verbatim #[test] fn test_web_wizard_validates_coder_parameter_names() { - const PAGE_TSX: &str = include_str!("../ui/src/routes/onboarding/OnboardingPage.tsx"); - assert!( - PAGE_TSX.contains("Coder parameter names cannot be empty."), + ONBOARDING_PAGE_TSX.contains("Coder parameter names cannot be empty."), "the wizard must reject an empty parameter name" ); assert!( - PAGE_TSX.contains("Coder parameter names must be unique."), + ONBOARDING_PAGE_TSX.contains("Coder parameter names must be unique."), "the wizard must reject duplicate parameter names" ); assert!( - PAGE_TSX.contains("[name.trim(), value]"), + ONBOARDING_PAGE_TSX.contains("[name.trim(), value]"), "parameter names are trimmed but values must be submitted verbatim" ); } + +/// The slugs the web wizard may skip entirely, gathered at the end of the walk so +/// that answering an earlier question never renumbers the steps already shown. +const OPTIONAL_TAIL: [&str; 2] = ["hosted-collections", "execution-target"]; + +/// Keys of the `OPTIONAL_STEPS` record in `steps.tsx`, in declaration order. +fn web_optional_steps() -> Vec { + let body = WEB_STEPS_TSX + .split_once("export const OPTIONAL_STEPS") + .expect("steps.tsx must declare OPTIONAL_STEPS") + .1 + .split_once('{') + .unwrap() + .1 + .split_once('}') + .unwrap() + .0; + body.lines() + .filter_map(|l| l.trim().strip_prefix('"')) + .map(|rest| rest.split('"').next().unwrap_or("").to_string()) + .collect() +} + +/// Optional steps are configured last, so the numbered part of the sidebar is +/// stable from the first screen onward. +#[test] +fn test_optional_steps_are_last_in_the_catalog() { + let slugs = catalog_slugs(); + let tail: Vec<&str> = slugs + .iter() + .rev() + .take(3) + .rev() + .map(String::as_str) + .collect(); + assert_eq!( + tail, + vec![OPTIONAL_TAIL[0], OPTIONAL_TAIL[1], "confirm"], + "the optional steps must sit immediately before confirm in SetupStep::ALL" + ); +} + +/// `OPTIONAL_STEPS` drives the dimmed placeholder rows; if it drifts from the +/// catalog tail the sidebar either hides a step or invents one. +#[test] +fn test_web_optional_steps_match_the_catalog_tail() { + assert_eq!( + web_optional_steps(), + OPTIONAL_TAIL.map(String::from).to_vec(), + "steps.tsx OPTIONAL_STEPS must list exactly the catalog's optional tail, in order" + ); +} + +/// Defect: the `?new=1` screen hardcoded a three-item sidebar in front of a +/// fourteen-step wizard, and nothing here looked at it. The sidebar must be +/// derived from the catalog so it cannot drift again. +#[test] +fn test_web_wizard_sidebar_is_derived_not_hardcoded() { + assert!( + ONBOARDING_PAGE_TSX.contains("stepRows("), + "the sidebar must be built from stepRows(), not written out by hand" + ); + for label in ["Name configuration", "Operator Premium", "Execution mode"] { + assert!( + !ONBOARDING_PAGE_TSX.contains(label), + "OnboardingPage.tsx hardcodes the step label {label:?}; render it from the catalog" + ); + } +} diff --git a/tests/trust_vectors.rs b/tests/trust_vectors.rs new file mode 100644 index 00000000..017017e7 --- /dev/null +++ b/tests/trust_vectors.rs @@ -0,0 +1,77 @@ +//! The vendored verifier is a generated copy. These two tests are the +//! stand-in for a shared crate: the digest catches a local edit, and the +//! vectors catch an edit that also rewrote the digest. + +use serde::Deserialize; +use serde_json::Value; + +use operator::trust_verify::{verify_license, LicensePolicy, RootKeyring}; + +#[test] +fn vendored_verifier_digest_matches_header() { + let source = include_str!("../src/trust_verify.rs"); + let (header, body) = source.split_once("\n\n").expect("header blank line"); + let declared = header + .lines() + .find_map(|line| line.strip_prefix("// sha256: ")) + .expect("sha256 line"); + assert_eq!(declared, hex_sha256(body)); +} + +fn hex_sha256(body: &str) -> String { + use sha2::{Digest, Sha256}; + use std::fmt::Write; + let mut encoded = String::with_capacity(64); + for byte in Sha256::digest(body.as_bytes()) { + write!(encoded, "{byte:02x}").expect("writing to a string"); + } + encoded +} + +#[derive(Deserialize)] +struct Suite { + product: String, + issuer: String, + audience: String, + roots: Value, + cases: Vec, +} + +#[derive(Deserialize)] +struct Case { + name: String, + license_key: String, + roots: Option, + expect: String, + claims: Option, +} + +#[test] +fn trust_vectors_match_verify_license() { + let suite: Suite = + serde_json::from_str(include_str!("../testdata/trust-vectors.json")).unwrap(); + for case in &suite.cases { + let roots_json = + serde_json::to_string(case.roots.as_ref().unwrap_or(&suite.roots)).unwrap(); + let roots = RootKeyring::from_json(&roots_json).unwrap_or_else(|error| { + panic!("{}: roots failed to parse: {error}", case.name); + }); + let policy = LicensePolicy { + product: &suite.product, + issuer: &suite.issuer, + audience: &suite.audience, + }; + match verify_license::(&case.license_key, &roots, &policy) { + Ok(verified) => { + assert_eq!(case.expect, "ok", "{}", case.name); + assert_eq!( + verified.claims, + case.claims.clone().expect("ok case carries claims"), + "{}", + case.name + ); + } + Err(error) => assert_eq!(error.code(), case.expect, "{}", case.name), + } + } +} diff --git a/tests/ui_packaging.rs b/tests/ui_packaging.rs index b621e2b2..106dc27e 100644 --- a/tests/ui_packaging.rs +++ b/tests/ui_packaging.rs @@ -15,6 +15,11 @@ const ALLOWED_UI_DEPS: &[&str] = &[ "react", "react-dom", "react-router-dom", + "redux", + "react-redux", + "rxjs", + "lodash", + "@untra/naiveasync", "@dnd-kit/core", "@dnd-kit/sortable", "@dnd-kit/utilities", diff --git a/tests/vertical_parity.rs b/tests/vertical_parity.rs index b3c9509e..cf9500d9 100644 --- a/tests/vertical_parity.rs +++ b/tests/vertical_parity.rs @@ -23,7 +23,9 @@ use std::path::{Path, PathBuf}; use operator::api::providers::kanban::KanbanProviderType; use operator::api::providers::model_server::ModelServerKind; +use operator::config::shipped_llm_tools; use operator::config::SessionWrapperType; +use operator::integrations::support_catalog::{model_supports, VerticalSupport}; use operator::integrations::{all_integrations, CatalogEntry, SupportStatus, Vertical}; use operator::types::pr::GitProvider; use operator::workflow_gen::WorkflowFormat; @@ -99,6 +101,70 @@ fn test_every_provider_enum_variant_has_catalog_entry() { } } +/// Shipped LLM CLI identity (binary, catalog slug, marker) must stay aligned +/// with the advertised LlmTool catalog and the VS Code detector list. +#[test] +fn test_shipped_llm_tools_match_catalog_and_vscode() { + let shipped = shipped_llm_tools(); + let catalog_slugs: HashSet<&str> = all_integrations() + .iter() + .filter(|e| e.vertical == Vertical::LlmTool) + .map(|e| e.slug) + .collect(); + let shipped_slugs: HashSet<&str> = shipped.iter().map(|t| t.catalog_slug).collect(); + assert_eq!( + catalog_slugs, shipped_slugs, + "every LlmTool catalog entry needs a shipped identity row and vice versa" + ); + + let walkthrough = include_str!("../vscode-extension/src/walkthrough.ts"); + let tools_line = walkthrough + .lines() + .find(|line| line.contains("export const LLM_TOOLS")) + .expect("vscode walkthrough exports LLM_TOOLS"); + let expected: Vec<&str> = shipped.iter().map(|t| t.tool_name).collect(); + for name in &expected { + assert!( + tools_line.contains(&format!("\"{name}\"")), + "vscode LLM_TOOLS missing '{name}': {tools_line}" + ); + } + assert_eq!( + expected.len(), + tools_line.matches('"').count() / 2, + "vscode LLM_TOOLS must list exactly the shipped binaries: {tools_line}" + ); +} + +#[test] +fn test_model_implicit_for_matches_shipped_binaries() { + let binaries: HashSet<&str> = shipped_llm_tools().iter().map(|t| t.tool_name).collect(); + for (slug, support) in model_supports() { + if let Some(tool) = support.implicit_for { + assert!( + binaries.contains(tool), + "model '{slug}' implicit_for '{tool}' is not a shipped binary" + ); + } + } + for e in all_integrations() { + if e.vertical == Vertical::LlmTool { + assert!( + matches!(e.support, VerticalSupport::LlmTool(_)), + "{} missing LlmTool support", + e.slug + ); + } + if e.vertical == Vertical::Model { + assert!( + matches!(e.support, VerticalSupport::Model(_)), + "{} missing Model support", + e.slug + ); + } + } +} + /// Every badged entry has a README badge linking to its docs URL, and that docs /// page exists on disk. #[test] diff --git a/ui/README.md b/ui/README.md index f21949c9..f51e2b63 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,6 +1,6 @@ # operator/ui -The embedded web UI for Operator - a [Vite](https://vite.dev) + React 19 single-page app that talks to the operator REST API (`/api/v1/*`). It is one of Operator's **four rendering surfaces** (alongside the Ratatui TUI, the Jekyll docs site, and the VS Code webview); see the root `CLAUDE.md` "Design & UI Consistency" section for how they stay consistent. +The embedded web UI for Operator - a [Vite](https://vite.dev) + React 19 single-page app that talks to the operator REST API (`/api/v1/*`). It is one of Operator's **four rendering surfaces** (alongside the Ratatui TUI, the Jekyll docs site, and the VS Code webview); see the root `AGENTS.md` "Design & UI Consistency" section for how they stay consistent. At runtime this SPA is compiled and **baked into the Rust binary** - there is no separate web server to deploy. The TUI opens it in a browser (or the VS Code extension hosts it in a webview). @@ -58,7 +58,7 @@ Brand colors come from the single shared source of truth, [`src/index.css`](src/index.css). On top of that palette `index.css` layers app-only **semantic tokens** (`--surface`, `--border`, `--text`, `--danger`, `--warning`, `--success`, radii, fonts) with light/dark variants. Components use **CSS Modules** (`*.module.css`) and -reference semantic tokens - never raw hex (per `CLAUDE.md`). +reference semantic tokens - never raw hex (per `AGENTS.md`). ## Icons diff --git a/ui/bun.lock b/ui/bun.lock index 9635dc90..b845bc6d 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -5,15 +5,24 @@ "": { "name": "@operator/ui", "dependencies": { + "@untra/naiveasync": "^2.0.0", "@vscode/codicons": "^0.0.45", + "lodash": "^4.18.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-redux": "^9.3.0", "react-router-dom": "^7.6.1", + "redux": "^5.0.1", + "rxjs": "^7.8.2", }, "devDependencies": { + "@happy-dom/global-registrator": "^20.14.5", + "@testing-library/react": "^16.3.3", + "@types/bun": "^1.4.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", + "happy-dom": "^20.14.5", "typescript": "^5.7.3", "vite": "^6.0.7", }, @@ -52,6 +61,8 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], @@ -110,6 +121,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.14.5" } }, "sha512-B05ID9DhSwLs6mlm1fzlkAtTIvB3duCvjJjfr19LBrlTK7VZtRjDqoTRIVv13GuYuNdhByu8LSZgThwV3Rkj7g=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -172,6 +185,12 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + "@testing-library/dom": ["@testing-library/dom@10.4.2", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q=="], + + "@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -180,20 +199,42 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], + + "@untra/naiveasync": ["@untra/naiveasync@2.0.0", "", { "peerDependencies": { "lodash": "^4.x.x", "react": ">= 16.x.x", "redux": "^5.x.x", "rxjs": "^7.x.x" } }, "sha512-mdabn2CfCtgK3hFPyq5N9m30dNeKoGJthgHUDJFjmqHAnvifXvD4KqJ689cAVHS2P2Wgsxp9K13irzKAYqv52Q=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vscode/codicons": ["@vscode/codicons@0.0.45", "", {}, "sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -204,8 +245,14 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.361", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -216,14 +263,20 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], @@ -236,18 +289,28 @@ "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-router": ["react-router@7.15.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A=="], "react-router-dom": ["react-router-dom@7.15.1", "", { "dependencies": { "react-router": "7.15.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg=="], + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -258,12 +321,22 @@ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], } } diff --git a/ui/bunfig.toml b/ui/bunfig.toml new file mode 100644 index 00000000..93907f57 --- /dev/null +++ b/ui/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./src/test-dom.ts"] diff --git a/ui/naiveasyncplan.md b/ui/naiveasyncplan.md new file mode 100644 index 00000000..fae5edd0 --- /dev/null +++ b/ui/naiveasyncplan.md @@ -0,0 +1,56 @@ +# Adopt NaiveAsync for All `ui/` API State + +## Summary + +- Add `@untra/naiveasync@^2.0.0` and Redux 5 to `ui/`, using NaiveAsync lifecycles as the sole owner of API response, loading, error, polling, and mutation state. +- Build an Operator-specific hook layer because NaiveAsync exposes `call`, `sync`, `onData`, and `onError`, while components need the requested query/mutation interface. Queries will use `.sync()` to preserve cached data; mutations will use `.call()`. See the [NaiveAsync documentation](https://github.com/untra/naiveasync). +- Migrate every API flow, including authentication, onboarding, polling, settings, panels, and mutations. `webcomponents/` remains presentation-only and receives no NaiveAsync, Redux, API-client, or lifecycle exposure. +- Keep `OperatorApi` as the transport layer. Only modules under `ui/src/api/` may call it; components must use typed query and mutation hooks. + +## Implementation Changes + +- Add direct runtime dependencies to `ui/package.json` and the canonical Bun lockfile: + - `@untra/naiveasync@^2.0.0` + - `redux@^5.0.1` + - Required runtime peers: `react-redux@^9.3.0`, `rxjs@^7.8.2`, and `lodash@^4.18.1` + - Extend the UI dependency allowlist in `tests/ui_packaging.rs`; do not change the `webcomponents/` allowlist. +- Mount one Redux/NaiveAsync provider above the router. Scope lifecycle IDs by API base URL, profile ID, query key, and safe query parameters so cached data cannot leak between configurations or parameterized resources. +- Export all query-key constants from `api/queries/`; components, tests, and stories must import them rather than reproduce string keys. +- Provide typed UI-only interfaces: + - `useApiQuery(definition, { enabled?, pollIntervalMs? })` returning `data`, `error`, `isLoading`, `isFetching`, and `refetch`. + - `useApiMutation(definition)` returning `data`, `error`, `isPending`, `mutate(variables, { onSuccess?, onError? })`, `mutateAsync(variables)`, and `reset`. + - `isLoading` is true only when enabled and no cached data exists. Background polling or invalidation sets `isFetching` while retaining cached data. + - Use `mutate()` callbacks by default; use `mutateAsync()` only where returned data controls a subsequent operation, navigation, or wizard decision. +- Keep passwords, tokens, license keys, and credentials out of Redux action parameters. Mutation/query coordinators will pass opaque request IDs through NaiveAsync and retain sensitive inputs only in a transient in-memory registry that is cleared in `finally`. +- Define query lifecycles for every current read, including auth/session state, profiles, setup catalogs, status/sections, queue/board/agents, configuration, issue types/workflows, licensing/targets, security, providers/models, and delegators. Preserve existing 3-second dashboard/section/queue polling and 5-second agent polling through lifecycle synchronization. +- Define each write as a mutation with an explicit affected-query list. Invalidation runs after both success and failure so partial server-side writes cannot leave cached data stale. Multi-step onboarding/auth flows remain separate mutations so each completed or failed step performs its own invalidation. +- Reset all session-scoped lifecycle state on login/logout boundaries. Broad setup initialization invalidates all queries for the affected profile; narrower mutations invalidate their related families, such as: + - Ticket/queue actions -> board, queue status, agents, sections. + - Profile/setup/configuration changes -> profiles, setup status, configuration, status, sections. + - License/target changes -> license, targets, execution targets. + - Issue-type/collection changes -> issue types, workflow documents, collections, status. + - Session/access-key changes -> current session, sessions, access keys. + - Model-server/delegator changes -> provider models, model servers, delegators, configuration. +- Remove component-owned server-data `useState`/`useEffect`, manual cancellation flags, empty promise catches, and direct `OperatorApi` construction. Retain React state only for local form drafts, selection, visibility, navigation, and other presentation state. + +## Test Plan + +- Write failing adapter tests first, then implement: + - Initial query without cached data reports loading. + - `.sync()` preserves cached data during refresh and reports fetching rather than loading. + - Query errors retain cached data. + - Parameterized and profile-scoped queries cannot overwrite one another. + - Mutation success and failure callbacks fire correctly. + - Every declared affected query is invalidated on success and error, including failure of the second operation in a chain. + - Sensitive variables never appear in Redux state or dispatched lifecycle parameters. + - Poll cleanup prevents updates after unmount and does not expose stale parameter results. +- Add an architectural test that rejects direct `fetch` or `OperatorApi` usage in UI components/routes/contexts and verifies NaiveAsync remains confined to `ui/`. +- Update affected component tests to import query keys and seed lifecycle states with NaiveAsync's mock initial, pending, error, and success shapes. +- Run `bun run typecheck`, UI tests, UI lint/format checks, the packaging tests, and finally the required full `make check`. + +## Assumptions + +- The migration intentionally targets the currently published NaiveAsync 2.0 API and its `"" | "pending" | "error" | "success"` statuses. +- The archived upstream repository is accepted as an intentional dependency choice; Operator's adapter isolates components from that API and provides one replacement boundary if the store changes later. +- `ui/bun.lock` is the maintained UI lockfile used by CI; the already-stale npm lockfile is left untouched. +- Existing user-visible behavior, polling cadence, authentication redirects, and local form state remain unchanged unless lifecycle correctness requires eliminating stale server data. diff --git a/ui/package.json b/ui/package.json index aa623c77..9ccfb7e4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,18 +8,28 @@ "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", + "test": "bun test", "lint": "cd .. && bun run lint:ui" }, "dependencies": { + "@untra/naiveasync": "^2.0.0", "@vscode/codicons": "^0.0.45", + "lodash": "^4.18.1", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.6.1" + "react-redux": "^9.3.0", + "react-router-dom": "^7.6.1", + "redux": "^5.0.1", + "rxjs": "^7.8.2" }, "devDependencies": { + "@happy-dom/global-registrator": "^20.14.5", + "@testing-library/react": "^16.3.3", + "@types/bun": "^1.4.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", + "happy-dom": "^20.14.5", "typescript": "^5.7.3", "vite": "^6.0.7" } diff --git a/ui/public/icons/xai.svg b/ui/public/icons/xai.svg new file mode 100644 index 00000000..97cb7851 --- /dev/null +++ b/ui/public/icons/xai.svg @@ -0,0 +1 @@ +xAI diff --git a/ui/src/Layout.tsx b/ui/src/Layout.tsx index e375d958..1d5ad06e 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -2,11 +2,11 @@ import { useCallback, useState } from "react"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { AppShell, + AccountFooter, BrandName, NavGroup, NavRow, RightPanel as RightPanelView, - SignOutButton, ThemeToggle, } from "@operator/webcomponents"; import { useTheme } from "./theme"; @@ -15,16 +15,14 @@ import { CONCEPTS, STATUS_KEYS, PAGE_KEYS } from "./concepts"; import { SectionsProvider, useSections } from "./sections-context"; import { RightPanelProvider, useRightPanel } from "./right-panel"; import type { SectionDto } from "./api-client"; -import { OperatorApi, setCsrfToken } from "./api-client"; -import { useHost } from "./host"; -import { ProfileSelector } from "./profiles-context"; +import { useApiMutation, useApiQuery, useResetSession } from "./api"; +import { currentSessionQuery, logoutMutation } from "./api/definitions"; +import { useProfiles } from "./profiles-context"; // The "Status" group mirrors the canonical section order shared with the TUI and // VS Code extension (the SectionId enum in src/ui/status_panel.rs) and reflects // each section's live health from GET /api/v1/sections. A section whose -// prerequisites aren't met yet is shown disabled with a tooltip naming what it -// needs - the user sees it exists and why it isn't reachable. "Pages" are -// web-only views (Dashboard, Queue) with no section analog. +// prerequisites aren't met yet is shown disabled with a tooltip naming what it needs. function ConceptNavRow({ concept, section }: { concept: Concept; section?: SectionDto }) { const renderLink = useCallback( @@ -85,26 +83,24 @@ function RightPanelController() { export function Layout() { const { theme, toggleTheme } = useTheme(); - const host = useHost(); + const { selected } = useProfiles(); const navigate = useNavigate(); - const [signingOut, setSigningOut] = useState(false); + const session = useApiQuery(currentSessionQuery()); + const logout = useApiMutation(logoutMutation); + const resetSession = useResetSession(); + const username = session.error ? "Account unavailable" : (session.data?.subject ?? null); const [signOutError, setSignOutError] = useState(false); const signOut = useCallback(async () => { - setSigningOut(true); setSignOutError(false); try { - const api = new OperatorApi(host); - await api.refreshCsrf(); - await api.logout(); - setCsrfToken(null); + await logout.mutateAsync({}); + resetSession(); void navigate("/login", { replace: true }); } catch { setSignOutError(true); - } finally { - setSigningOut(false); } - }, [host, navigate]); + }, [logout, navigate, resetSession]); return ( @@ -118,12 +114,19 @@ export function Layout() { } groups={ <> - } - footer={} + footer={ + + } panel={} > diff --git a/ui/src/WorkspaceGate.tsx b/ui/src/WorkspaceGate.tsx index aa9c984f..0a9fc950 100644 --- a/ui/src/WorkspaceGate.tsx +++ b/ui/src/WorkspaceGate.tsx @@ -1,25 +1,12 @@ -import { useEffect, useState } from "react"; import { Navigate, Outlet } from "react-router-dom"; -import { OperatorApi } from "./api-client"; -import { useHost } from "./host"; +import { useApiQuery } from "./api"; +import { setupStatusQuery } from "./api/definitions"; export function WorkspaceGate() { - const host = useHost(); - const [initialized, setInitialized] = useState(null); + const { data, isLoading } = useApiQuery(setupStatusQuery()); - useEffect(() => { - let active = true; - new OperatorApi(host) - .setupStatus() - .then((status) => active && setInitialized(status.initialized)) - .catch(() => active && setInitialized(null)); - return () => { - active = false; - }; - }, [host]); - - if (initialized === null) { + if (isLoading || !data) { return null; } - return initialized ? : ; + return data.initialized ? : ; } diff --git a/ui/src/api-client.test.ts b/ui/src/api-client.test.ts new file mode 100644 index 00000000..b333cd63 --- /dev/null +++ b/ui/src/api-client.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { ApiError, OperatorApi, getCsrfToken, setCsrfToken } from "./api-client"; +import type { Host } from "./host"; + +const ORIGIN = "http://operator.test"; +const CSRF_PATH = "/api/v1/auth/csrf"; +const CSRF_HEADER = "x-operator-csrf"; +const CSRF_FAILED = "csrf_failed"; +const FORBIDDEN = 403; + +type Call = { url: string; method: string; csrf: string | null }; + +const host: Host = { + baseUrl: () => ORIGIN, + openExternal: () => undefined, + browseFolder: () => Promise.resolve(null), + openFile: () => undefined, +}; + +const realFetch = globalThis.fetch; +let calls: Call[] = []; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function stubFetch(respond: (call: Call) => Response | Promise): void { + const stub = async (input: RequestInfo | URL, init?: RequestInit) => { + const call: Call = { + url: input instanceof Request ? input.url : input.toString(), + method: (init?.method ?? "GET").toUpperCase(), + csrf: new Headers(init?.headers).get(CSRF_HEADER), + }; + calls.push(call); + return respond(call); + }; + globalThis.fetch = Object.assign(stub, { preconnect: realFetch.preconnect }); +} + +function csrfFetches(): Call[] { + return calls.filter((c) => c.url === `${ORIGIN}${CSRF_PATH}`); +} + +function mutations(): Call[] { + return calls.filter((c) => c.method !== "GET"); +} + +beforeEach(() => { + calls = []; + setCsrfToken(null); +}); + +afterEach(() => { + globalThis.fetch = realFetch; + setCsrfToken(null); +}); + +describe("CSRF on mutations", () => { + test("a mutation with no token fetches one and sends it", async () => { + stubFetch((call) => + call.method === "GET" ? json({ csrf_token: "fresh" }) : json({ status: "removed" }), + ); + + await new OperatorApi(host).removeLicense(); + + expect(csrfFetches()).toHaveLength(1); + expect(mutations()).toHaveLength(1); + expect(mutations()[0].csrf).toBe("fresh"); + expect(getCsrfToken()).toBe("fresh"); + }); + + test("concurrent mutations share a single refresh", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + stubFetch(async (call) => { + if (call.method === "GET") { + await gate; + return json({ csrf_token: "shared" }); + } + return json({ status: "ok" }); + }); + + const api = new OperatorApi(host); + const pending = Promise.all([api.removeLicense(), api.removeLicense()]); + release(); + await pending; + + expect(csrfFetches()).toHaveLength(1); + expect(mutations().map((c) => c.csrf)).toEqual(["shared", "shared"]); + }); + + test("a rejected token is refreshed and retried once, then the error surfaces", async () => { + setCsrfToken("stale"); + let issued = 0; + stubFetch((call) => { + if (call.method === "GET") { + issued += 1; + return json({ csrf_token: `fresh-${issued}` }); + } + return json({ error: CSRF_FAILED, message: "CSRF token is invalid" }, FORBIDDEN); + }); + + const error = await new OperatorApi(host).removeLicense().catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(FORBIDDEN); + expect((error as ApiError).code).toBe(CSRF_FAILED); + expect(csrfFetches()).toHaveLength(1); + expect(mutations().map((c) => c.csrf)).toEqual(["stale", "fresh-1"]); + }); + + test("a failed refresh is not cached, so the next mutation tries again", async () => { + let attempt = 0; + stubFetch((call) => { + if (call.method === "GET") { + attempt += 1; + return attempt === 1 + ? json({ error: "internal_error", message: "boom" }, 500) + : json({ csrf_token: "second" }); + } + return json({ status: "ok" }); + }); + + const api = new OperatorApi(host); + await expect(api.removeLicense()).rejects.toBeInstanceOf(ApiError); + await api.removeLicense(); + + expect(csrfFetches()).toHaveLength(2); + expect(mutations().map((c) => c.csrf)).toEqual(["second"]); + }); + + test("sessionless mutations such as login do not fetch a token", async () => { + stubFetch(() => json({ csrf_token: "issued", scopes: [], expires_at: "" })); + + await new OperatorApi(host).login("admin", "password"); + + expect(csrfFetches()).toHaveLength(0); + expect(getCsrfToken()).toBe("issued"); + }); +}); + +describe("ApiError", () => { + test("carries the server's machine-readable error as its code", async () => { + stubFetch(() => json({ error: "not_found", message: "no such target" }, 404)); + + const error = await new OperatorApi(host).targets().catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).code).toBe("not_found"); + expect((error as ApiError).message).toBe("no such target"); + }); +}); diff --git a/ui/src/api-client.ts b/ui/src/api-client.ts index 8f82fc78..3f62f442 100644 --- a/ui/src/api-client.ts +++ b/ui/src/api-client.ts @@ -156,11 +156,11 @@ export class ApiError extends Error { constructor( status: number, message: string, - details?: { code?: string; feature?: string; required_tier?: string }, + details?: { error?: string; feature?: string; required_tier?: string }, ) { super(message); this.status = status; - this.code = details?.code; + this.code = details?.error; this.feature = details?.feature; this.requiredTier = details?.required_tier; } @@ -174,17 +174,35 @@ export class ApiError extends Error { * - `credentials: 'same-origin'` so the session cookie is actually sent. The * cookie is `HttpOnly`, so script cannot read or attach it by hand. * - The CSRF header on mutations. The cookie rides along automatically, so a - * mutation needs proof the request was intended. + * mutation needs proof the request was intended. The token is fetched lazily + * (a page reload leaves none in memory) and a rejected one is refreshed and + * retried exactly once. * - A `401` handler that redirects to login (or setup, on a server with no * admin account yet) instead of surfacing an error the user cannot act on. */ const CSRF_HEADER = "x-operator-csrf"; +const CSRF_PATH = "/api/v1/auth/csrf"; +const CSRF_FAILED = "csrf_failed"; +const HTTP_UNAUTHORIZED = 401; +const HTTP_FORBIDDEN = 403; + +/** Public mutations: no session exists yet, so there is no CSRF token to fetch. */ +const SESSIONLESS_MUTATION_PATHS: ReadonlySet = new Set([ + "/api/v1/auth/bootstrap", + "/api/v1/auth/login", + "/api/v1/auth/forgot-password", + "/api/v1/auth/reset-password", +]); /** In-memory only: a CSRF token in localStorage outlives the session it belongs to. */ let csrfToken: string | null = null; +/** The one in-flight token fetch, shared so concurrent mutations issue a single request. */ +let csrfRefresh: Promise | null = null; + export function setCsrfToken(token: string | null): void { csrfToken = token; + csrfRefresh = null; } export function getCsrfToken(): string | null { @@ -229,14 +247,61 @@ export function toJson(value: unknown): string { return JSON.stringify(value, (_key, v) => (typeof v === "bigint" ? Number(v) : (v as unknown))); } -function authInit(init?: RequestInit): RequestInit { +function authInit(init: RequestInit | undefined, csrf: string | null): RequestInit { const headers = new Headers(init?.headers); - if (isMutation(init?.method) && csrfToken) { - headers.set(CSRF_HEADER, csrfToken); + if (csrf) { + headers.set(CSRF_HEADER, csrf); } return { ...init, headers, credentials: "same-origin" }; } +/** + * The current token, fetching one if none is held. + * + * Deliberately not tied to any caller's abort signal: the fetch is shared, so + * one caller aborting must not fail the others waiting on it. + */ +function ensureCsrf(origin: string): Promise { + if (csrfToken) { + return Promise.resolve(csrfToken); + } + if (!csrfRefresh) { + const refresh: Promise = request(origin, CSRF_PATH) + .then(({ csrf_token }) => { + // A logout (or login) while this was in flight owns the token now. + if (csrfRefresh === refresh) { + csrfToken = csrf_token; + } + return csrf_token; + }) + .finally(() => { + if (csrfRefresh === refresh) { + csrfRefresh = null; + } + }); + csrfRefresh = refresh; + } + return csrfRefresh; +} + +/** Drop `rejected` unless another request already replaced it. */ +function discardCsrf(rejected: string): void { + if (csrfToken === rejected) { + csrfToken = null; + } +} + +async function isCsrfRejection(res: Response): Promise { + if (res.status !== HTTP_FORBIDDEN) { + return false; + } + const body = (await res + .clone() + .json() + .catch(() => null)) as { error?: string } | null; + return body?.error === CSRF_FAILED; +} + type ApiConnection = { origin: string; profileId?: string; signal?: AbortSignal }; export function profileApiPath(path: string, profileId?: string): string { @@ -257,13 +322,27 @@ async function send( path, typeof connection === "string" ? undefined : connection.profileId, ); - signal?.throwIfAborted(); - const res = await fetch( - `${base}${scopedPath}`, - authInit({ ...init, signal: signal ?? init?.signal }), - ); - signal?.throwIfAborted(); - if (res.status === 401) { + const needsCsrf = isMutation(init?.method) && !SESSIONLESS_MUTATION_PATHS.has(path); + + const attempt = async (): Promise<{ res: Response; csrf: string | null }> => { + signal?.throwIfAborted(); + const csrf = needsCsrf ? await ensureCsrf(base) : null; + signal?.throwIfAborted(); + const res = await fetch( + `${base}${scopedPath}`, + authInit({ ...init, signal: signal ?? init?.signal }, csrf), + ); + signal?.throwIfAborted(); + return { res, csrf }; + }; + + const first = await attempt(); + let res = first.res; + if (first.csrf && (await isCsrfRejection(res))) { + discardCsrf(first.csrf); + res = (await attempt()).res; + } + if (res.status === HTTP_UNAUTHORIZED) { await redirectToAuth(base); } if (!res.ok) { @@ -418,13 +497,6 @@ export class OperatorApi { return request(this.base, "/api/v1/auth/session"); } - /** Re-issue a CSRF token, e.g. after a page reload where the cookie survived. */ - async refreshCsrf(): Promise { - const res = await request(this.base, "/api/v1/auth/csrf"); - setCsrfToken(res.csrf_token); - return res.csrf_token; - } - listSessions(): Promise { return request(this.base, "/api/v1/auth/sessions"); } diff --git a/ui/src/api/adapter.test.ts b/ui/src/api/adapter.test.ts new file mode 100644 index 00000000..f9ba1fb4 --- /dev/null +++ b/ui/src/api/adapter.test.ts @@ -0,0 +1,535 @@ +import { afterEach, describe, expect, jest, test } from "bun:test"; +import { asyncableEmoji } from "@untra/naiveasync"; +import type { Host } from "../host"; +import type { OperatorApi } from "../api-client"; +import { + createApiStore, + lifecycleId, + mapQueryState, + mutationController, + queryController, + resetSessionState, + snapshotSecrets, + type ApiStore, + type QueryDefinition, + type MutationDefinition, +} from "./adapter"; +import { QUERY_KEYS } from "./queries"; + +const PASSWORD = "hunter2-secret-password"; +const TOKEN = "tok_live_secret_value"; + +function host(overrides: Partial = {}): Host { + return { + baseUrl: () => "http://operator.test", + openExternal: () => undefined, + browseFolder: () => Promise.resolve(null), + openFile: () => undefined, + ...overrides, + }; +} + +function storeParams(store: ApiStore): unknown[] { + const slice = store.getState()[asyncableEmoji]; + return Object.values(slice).map((entry) => entry.params); +} + +async function flushAsync(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +afterEach(() => { + resetSessionState(); +}); + +describe("lifecycleId", () => { + test("scopes by base URL, profile, query key, and params", () => { + const a = lifecycleId({ baseUrl: "http://a", profileId: "p1" }, QUERY_KEYS.agent, { + agentId: "1", + }); + const b = lifecycleId({ baseUrl: "http://b", profileId: "p1" }, QUERY_KEYS.agent, { + agentId: "1", + }); + const c = lifecycleId({ baseUrl: "http://a", profileId: "p2" }, QUERY_KEYS.agent, { + agentId: "1", + }); + const d = lifecycleId({ baseUrl: "http://a", profileId: "p1" }, QUERY_KEYS.agent, { + agentId: "2", + }); + expect(new Set([a, b, c, d]).size).toBe(4); + }); +}); + +describe("mapQueryState", () => { + test("initial query without cached data reports loading when enabled", () => { + const mapped = mapQueryState( + { status: "", error: "", params: {}, data: null }, + { enabled: true }, + ); + expect(mapped.isLoading).toBe(true); + expect(mapped.isFetching).toBe(false); + expect(mapped.data).toBeNull(); + }); + + test("pending with no data is loading and fetching", () => { + const mapped = mapQueryState( + { status: "pending", error: "", params: {}, data: null }, + { enabled: true }, + ); + expect(mapped.isLoading).toBe(true); + expect(mapped.isFetching).toBe(true); + }); + + test("pending with cached data is fetching rather than loading", () => { + const mapped = mapQueryState( + { status: "pending", error: "", params: {}, data: { n: 1 } }, + { enabled: true }, + ); + expect(mapped.isLoading).toBe(false); + expect(mapped.isFetching).toBe(true); + expect(mapped.data).toEqual({ n: 1 }); + }); + + test("error retains cached data", () => { + const mapped = mapQueryState( + { status: "error", error: "boom", params: {}, data: { n: 1 } }, + { enabled: true }, + ); + expect(mapped.isLoading).toBe(false); + expect(mapped.data).toEqual({ n: 1 }); + expect(mapped.error?.message).toBe("boom"); + }); + + test("disabled queries are not loading", () => { + const mapped = mapQueryState( + { status: "", error: "", params: {}, data: null }, + { enabled: false }, + ); + expect(mapped.isLoading).toBe(false); + expect(mapped.isFetching).toBe(false); + }); +}); + +describe("queryController", () => { + test("initial query without cached data reports loading", async () => { + const store = createApiStore(); + let release!: (value: string) => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const def: QueryDefinition = { + key: QUERY_KEYS.health, + params: {}, + fetch: () => gate, + }; + const query = queryController(store, host(), def); + const pending = query.start(); + const loading = query.snapshot(); + expect(loading.isLoading).toBe(true); + expect(loading.isFetching).toBe(true); + expect(loading.data).toBeNull(); + release("ok"); + await pending; + expect(query.snapshot().data).toBe("ok"); + expect(query.snapshot().isLoading).toBe(false); + }); + + test("sync preserves cached data during refresh and reports fetching", async () => { + const store = createApiStore(); + let n = 0; + let release!: () => void; + const def: QueryDefinition = { + key: QUERY_KEYS.status, + params: {}, + fetch: async () => { + n += 1; + if (n === 1) { + return "first"; + } + await new Promise((resolve) => { + release = resolve; + }); + return "second"; + }, + }; + const query = queryController(store, host(), def); + await query.start(); + expect(query.snapshot().data).toBe("first"); + const pending = query.refetch(); + expect(query.snapshot().data).toBe("first"); + expect(query.snapshot().isLoading).toBe(false); + expect(query.snapshot().isFetching).toBe(true); + release(); + await pending; + expect(query.snapshot().data).toBe("second"); + expect(query.snapshot().isFetching).toBe(false); + }); + + test("query errors retain cached data", async () => { + const store = createApiStore(); + let n = 0; + const def: QueryDefinition = { + key: QUERY_KEYS.queueStatus, + params: {}, + fetch: () => { + n += 1; + if (n === 1) { + return Promise.resolve("cached"); + } + return Promise.reject(new Error("upstream")); + }, + }; + const query = queryController(store, host(), def); + await query.start(); + await expect(query.refetch()).rejects.toBeInstanceOf(Error); + const snap = query.snapshot(); + expect(snap.data).toBe("cached"); + expect(snap.error?.message).toBe("upstream"); + expect(snap.isLoading).toBe(false); + }); + + test("parameterized and profile-scoped queries cannot overwrite one another", async () => { + const store = createApiStore(); + const defA: QueryDefinition = { + key: QUERY_KEYS.agent, + params: { agentId: "a" }, + fetch: (_api, params) => Promise.resolve(`agent-${params.agentId}`), + }; + const defB: QueryDefinition = { + key: QUERY_KEYS.agent, + params: { agentId: "b" }, + fetch: (_api, params) => Promise.resolve(`agent-${params.agentId}`), + }; + const profileHost = host({ profileId: "other" }); + const a = queryController(store, host(), defA); + const b = queryController(store, host(), defB); + const c = queryController(store, profileHost, defA); + await Promise.all([a.start(), b.start(), c.start()]); + expect(a.snapshot().data).toBe("agent-a"); + expect(b.snapshot().data).toBe("agent-b"); + expect(c.snapshot().data).toBe("agent-a"); + expect(a.snapshot().data).toBe("agent-a"); + }); + + test("poll cleanup prevents updates after unmount", async () => { + jest.useFakeTimers(); + const store = createApiStore(); + let calls = 0; + const def: QueryDefinition = { + key: QUERY_KEYS.sections, + params: {}, + fetch: () => { + calls += 1; + return Promise.resolve(calls); + }, + }; + const query = queryController(store, host(), def, { pollIntervalMs: 20 }); + await query.start(); + const afterStart = calls; + query.stop(); + jest.advanceTimersByTime(60); + await flushAsync(); + expect(calls).toBe(afterStart); + jest.useRealTimers(); + }); + + test("poll does not expose stale parameter results", async () => { + jest.useFakeTimers(); + const store = createApiStore(); + const seen: string[] = []; + const make = (agentId: string) => { + const def: QueryDefinition = { + key: QUERY_KEYS.agent, + params: { agentId }, + fetch: (_api, params) => { + seen.push(params.agentId); + return Promise.resolve(params.agentId); + }, + }; + return queryController(store, host(), def, { pollIntervalMs: 20 }); + }; + const first = make("one"); + await first.start(); + first.stop(); + const second = make("two"); + await second.start(); + jest.advanceTimersByTime(50); + await flushAsync(); + second.stop(); + expect(second.snapshot().data).toBe("two"); + expect(seen.every((id) => id === "one" || id === "two")).toBe(true); + expect(seen.filter((id) => id === "two").length).toBeGreaterThan(0); + jest.useRealTimers(); + }); +}); + +describe("mutationController", () => { + test("success and failure callbacks fire", async () => { + const store = createApiStore(); + let n = 0; + const def: MutationDefinition = { + key: "echo", + affected: [], + run: (_api, variables) => { + n += 1; + if (variables.n === 0) { + return Promise.reject(new Error("nope")); + } + return Promise.resolve(`ok-${variables.n}`); + }, + }; + const mutation = mutationController(store, host(), def); + const ok: string[] = []; + const err: string[] = []; + mutation.mutate( + { n: 1 }, + { onSuccess: (data) => ok.push(data), onError: (e) => err.push(e.message) }, + ); + await mutation.wait(); + mutation.mutate( + { n: 0 }, + { onSuccess: (data) => ok.push(data), onError: (e) => err.push(e.message) }, + ); + await mutation.wait().catch(() => undefined); + expect(ok).toEqual(["ok-1"]); + expect(err).toEqual(["nope"]); + expect(n).toBe(2); + }); + + test("invalidates every affected query on success and on error", async () => { + const store = createApiStore(); + let reads = 0; + const queryDef: QueryDefinition = { + key: QUERY_KEYS.kanban, + params: {}, + fetch: () => { + reads += 1; + return Promise.resolve(reads); + }, + }; + const query = queryController(store, host(), queryDef); + await query.start(); + const loaded = query.snapshot().data; + expect(loaded).toBeGreaterThan(0); + const mutation: MutationDefinition = { + key: "write-ticket", + affected: [QUERY_KEYS.kanban], + run: (_api, variables) => { + if (variables.fail) { + return Promise.reject(new Error("partial write")); + } + return Promise.resolve("saved"); + }, + }; + const write = mutationController(store, host(), mutation); + await write.mutateAsync({ fail: false }); + await flushAsync(); + if (loaded === null) { + throw new Error("expected cached query data"); + } + expect(query.snapshot().data).toBe(loaded + 1); + await expect(write.mutateAsync({ fail: true })).rejects.toBeInstanceOf(Error); + await flushAsync(); + expect(query.snapshot().data).toBe(loaded + 2); + }); + + test("invalidates after failure of the second operation in a chain", async () => { + const store = createApiStore(); + let reads = 0; + const queryDef: QueryDefinition = { + key: QUERY_KEYS.collections, + params: {}, + fetch: () => { + reads += 1; + return Promise.resolve(reads); + }, + }; + const query = queryController(store, host(), queryDef); + await query.start(); + const loaded = query.snapshot().data; + expect(loaded).toBeGreaterThan(0); + const first: MutationDefinition> = { + key: "first-write", + affected: [QUERY_KEYS.collections], + run: () => Promise.resolve("one"), + }; + const second: MutationDefinition> = { + key: "second-write", + affected: [QUERY_KEYS.collections], + run: () => Promise.reject(new Error("second failed")), + }; + await mutationController(store, host(), first).mutateAsync({}); + await flushAsync(); + if (loaded === null) { + throw new Error("expected cached query data"); + } + expect(query.snapshot().data).toBe(loaded + 1); + await expect(mutationController(store, host(), second).mutateAsync({})).rejects.toBeInstanceOf( + Error, + ); + await flushAsync(); + expect(query.snapshot().data).toBe(loaded + 2); + }); + + test("sensitive variables never appear in Redux state or dispatched lifecycle parameters", async () => { + const store = createApiStore(); + const def: MutationDefinition = { + key: "login", + affected: [], + sensitive: true, + run: (_api: OperatorApi, variables) => { + expect(variables.password).toBe(PASSWORD); + expect(variables.token).toBe(TOKEN); + return Promise.resolve("session"); + }, + }; + const mutation = mutationController(store, host(), def); + await mutation.mutateAsync({ password: PASSWORD, token: TOKEN }); + const encoded = JSON.stringify(store.getState()); + expect(encoded).not.toContain(PASSWORD); + expect(encoded).not.toContain(TOKEN); + for (const params of storeParams(store)) { + expect(JSON.stringify(params)).not.toContain(PASSWORD); + expect(JSON.stringify(params)).not.toContain(TOKEN); + } + expect(snapshotSecrets()).toEqual([]); + }); + + test("clears sensitive references after a failed request", async () => { + const store = createApiStore(); + const def: MutationDefinition = { + key: "login-failure", + affected: [], + sensitive: true, + run: () => Promise.reject(new Error("denied")), + }; + const mutation = mutationController(store, host(), def); + await expect(mutation.mutateAsync({ password: PASSWORD })).rejects.toBeInstanceOf(Error); + expect(snapshotSecrets()).toEqual([]); + expect(JSON.stringify(store.getState())).not.toContain(PASSWORD); + }); + + test("invalidates only queries in the mutation scope", async () => { + const store = createApiStore(); + let firstReads = 0; + let secondReads = 0; + const first = queryController(store, host({ profileId: "first" }), { + key: QUERY_KEYS.sessions, + params: {}, + fetch: () => Promise.resolve(++firstReads), + }); + const second = queryController(store, host({ profileId: "second" }), { + key: QUERY_KEYS.sessions, + params: {}, + fetch: () => Promise.resolve(++secondReads), + }); + await Promise.all([first.start(), second.start()]); + const mutation = mutationController(store, host({ profileId: "first" }), { + key: "scoped-write", + affected: [QUERY_KEYS.sessions], + run: () => Promise.resolve("ok"), + }); + await mutation.mutateAsync({}); + await flushAsync(); + expect(firstReads).toBe(2); + expect(secondReads).toBe(1); + }); + + test("wildcard invalidation never replays mutations", async () => { + const store = createApiStore(); + let writes = 0; + const first = mutationController(store, host(), { + key: "first-write", + affected: [], + run: () => Promise.resolve(++writes), + }); + await first.mutateAsync({}); + const wildcard = mutationController(store, host(), { + key: "wildcard-write", + affected: "*", + run: () => Promise.resolve("ok"), + }); + await wildcard.mutateAsync({}); + await flushAsync(); + expect(writes).toBe(1); + }); + + test("mutation settlement does not wait for affected query refetches", async () => { + const store = createApiStore(); + let release!: (value: string) => void; + let reads = 0; + const query = queryController(store, host(), { + key: QUERY_KEYS.health, + params: {}, + fetch: () => { + reads += 1; + return reads === 1 + ? Promise.resolve("initial") + : new Promise((resolve) => { + release = resolve; + }); + }, + }); + await query.start(); + const mutation = mutationController(store, host(), { + key: "write-health", + affected: [QUERY_KEYS.health], + run: () => Promise.resolve("saved"), + }); + await expect(mutation.mutateAsync({})).resolves.toBe("saved"); + expect(query.snapshot().isFetching).toBe(true); + release("refreshed"); + await flushAsync(); + }); + + test("rejects a duplicate call while the mutation is pending", async () => { + const store = createApiStore(); + let release!: (value: string) => void; + const mutation = mutationController(store, host(), { + key: "single-flight", + affected: [], + run: () => + new Promise((resolve) => { + release = resolve; + }), + }); + const first = mutation.mutateAsync({}); + await expect(mutation.mutateAsync({})).rejects.toThrow("already pending"); + release("done"); + await expect(first).resolves.toBe("done"); + }); + + test("callback-style failures are consumed after onError", async () => { + const store = createApiStore(); + const errors: string[] = []; + const mutation = mutationController(store, host(), { + key: "callback-failure", + affected: [], + run: () => Promise.reject(new Error("expected")), + }); + mutation.mutate({}, { onError: (error) => errors.push(error.message) }); + await expect(mutation.wait()).resolves.toBeUndefined(); + expect(errors).toEqual(["expected"]); + }); + + test("stopped queries are not invalidated", async () => { + const store = createApiStore(); + let reads = 0; + const query = queryController(store, host(), { + key: QUERY_KEYS.status, + params: {}, + fetch: () => Promise.resolve(++reads), + }); + await query.start(); + query.stop(); + const mutation = mutationController(store, host(), { + key: "write-status", + affected: [QUERY_KEYS.status], + run: () => Promise.resolve("ok"), + }); + await mutation.mutateAsync({}); + await flushAsync(); + expect(reads).toBe(1); + }); +}); diff --git a/ui/src/api/adapter.ts b/ui/src/api/adapter.ts new file mode 100644 index 00000000..18b2503e --- /dev/null +++ b/ui/src/api/adapter.ts @@ -0,0 +1,406 @@ +import { + asyncLifecycle, + findLifecycleById, + naiveAsyncMiddleware, + naiveAsyncReducer, + type AsyncableSlice, + type AsyncLifecycle, + type AsyncState, + type AnyAction, +} from "@untra/naiveasync"; +import { applyMiddleware, legacy_createStore, type Store } from "redux"; +import { OperatorApi } from "../api-client"; +import type { Host } from "../host"; +import type { QueryKey } from "./queries"; + +const ID_SEP = "\u001f"; +const SECRET_REF = "secretRef"; + +type SecretRef = { readonly [SECRET_REF]: string }; + +type LifecycleEntry = { + key: string; + scope: QueryScope; + store: ApiStore; + subscribers: number; +}; + +const queries = new Map(); +const mutations = new Map(); +const secrets = new Map(); +const lastErrors = new Map(); +const pollRefs = new Map(); + +export type QueryDefinition> = { + key: string; + params: Params; + fetch: (api: OperatorApi, params: Params) => Promise; +}; + +export type MutationDefinition = { + key: string; + affected: readonly string[] | "*"; + run: (api: OperatorApi, variables: Variables) => Promise; + sensitive?: boolean; +}; + +export type QuerySnapshot = { + data: Data | null; + error: Error | null; + isLoading: boolean; + isFetching: boolean; +}; + +export type ApiStore = Store; + +export function createApiStore(): ApiStore { + return legacy_createStore(naiveAsyncReducer, applyMiddleware(naiveAsyncMiddleware)); +} + +export type QueryScope = { + baseUrl: string; + profileId?: string; +}; + +export function scopeFromHost(host: Host): QueryScope { + return { baseUrl: host.baseUrl(), profileId: host.profileId }; +} + +export function lifecycleId(scope: QueryScope, key: string, params: object): string { + return [scope.baseUrl, scope.profileId ?? "_", key, stableSerialize(params)].join(ID_SEP); +} + +function stableSerialize(params: object): string { + const keys = Object.keys(params).toSorted(); + const sorted: Record = {}; + for (const key of keys) { + sorted[key] = (params as Record)[key]; + } + return JSON.stringify(sorted); +} + +export function mapQueryState( + state: AsyncState, + options: { enabled: boolean }, + id?: string, +): QuerySnapshot { + const enabled = options.enabled; + const data = state.data; + const isFetching = enabled && state.status === "pending"; + const isLoading = enabled && data == null && (state.status === "pending" || state.status === ""); + const stored = id ? lastErrors.get(id) : undefined; + const error = + state.status === "error" ? (stored ?? new Error(state.error || "Request failed")) : null; + return { data, error, isLoading, isFetching }; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function waitForLifecycle( + lifecycle: AsyncLifecycle, +): Promise { + const data = lifecycle.awaitResolve(); + const failure = lifecycle.awaitReject().then((error: unknown) => Promise.reject(asError(error))); + return Promise.race([data, failure]); +} + +function registerQuery(id: string, key: string, scope: QueryScope, store: ApiStore): void { + const current = queries.get(id); + if (current) { + current.subscribers += 1; + return; + } + queries.set(id, { key, scope, store, subscribers: 1 }); +} + +function unregisterQuery(id: string): void { + const current = queries.get(id); + if (!current) { + return; + } + current.subscribers -= 1; + if (current.subscribers === 0) { + queries.delete(id); + } +} + +function sameScope(left: QueryScope, right: QueryScope): boolean { + return left.baseUrl === right.baseUrl && left.profileId === right.profileId; +} + +function affectedQueries( + scope: QueryScope, + affected: readonly string[] | "*", +): Array<[string, LifecycleEntry]> { + const matches: Array<[string, LifecycleEntry]> = []; + for (const entry of queries) { + if (sameScope(entry[1].scope, scope) && (affected === "*" || affected.includes(entry[1].key))) { + matches.push(entry); + } + } + return matches; +} + +export function invalidateQueries(scope: QueryScope, affected: readonly string[] | "*"): void { + for (const [id, entry] of affectedQueries(scope, affected)) { + const lifecycle = findLifecycleById(id); + if (!lifecycle) { + continue; + } + entry.store.dispatch(lifecycle.sync()); + } +} + +export function resetSessionState(): void { + for (const [id, entry] of queries) { + const lifecycle = findLifecycleById(id); + if (lifecycle) { + entry.store.dispatch(lifecycle.subscribe(0)); + entry.store.dispatch(lifecycle.destroy()); + } + } + for (const [id, store] of mutations) { + const lifecycle = findLifecycleById(id); + if (lifecycle) { + store.dispatch(lifecycle.destroy()); + } + } + queries.clear(); + mutations.clear(); + secrets.clear(); + lastErrors.clear(); + pollRefs.clear(); +} + +export function snapshotSecrets(): unknown[] { + return [...secrets.values()]; +} + +function putSecret(id: string, value: unknown): void { + secrets.set(id, value); +} + +function peekSecret(id: string): unknown { + if (!secrets.has(id)) { + throw new Error("Request credentials expired"); + } + return secrets.get(id); +} + +function clearSecret(id: string): void { + secrets.delete(id); +} + +function addPoll(store: ApiStore, id: string, ms: number): void { + const lifecycle = findLifecycleById(id); + if (!lifecycle) { + return; + } + const current = pollRefs.get(id); + if (current) { + current.count += 1; + if (ms < current.ms) { + current.ms = ms; + store.dispatch(lifecycle.subscribe(ms)); + } + return; + } + pollRefs.set(id, { count: 1, ms }); + store.dispatch(lifecycle.subscribe(ms)); +} + +function removePoll(store: ApiStore, id: string): void { + const current = pollRefs.get(id); + if (!current) { + return; + } + current.count -= 1; + if (current.count > 0) { + return; + } + pollRefs.delete(id); + const lifecycle = findLifecycleById(id); + if (lifecycle) { + store.dispatch(lifecycle.subscribe(0)); + } +} + +export type QueryOptions = { + pollIntervalMs?: number; +}; + +export type QueryController = { + id: string; + selector: (state: AsyncableSlice) => AsyncState; + start: () => Promise; + stop: () => void; + refetch: () => Promise; + snapshot: () => QuerySnapshot; +}; + +export function queryController( + store: ApiStore, + host: Host, + definition: QueryDefinition, + options: QueryOptions = {}, +): QueryController { + const scope = scopeFromHost(host); + const id = lifecycleId(scope, definition.key, definition.params); + const lifecycle = asyncLifecycle(id, async (params: Params) => { + try { + const data = await definition.fetch(new OperatorApi(host), params); + lastErrors.delete(id); + return data; + } catch (error) { + lastErrors.set(id, asError(error)); + throw error; + } + }); + + let started = false; + return { + id, + selector: lifecycle.selector, + start() { + if (!started) { + registerQuery(id, definition.key, scope, store); + started = true; + } + const wait = waitForLifecycle(lifecycle); + store.dispatch(lifecycle.sync(definition.params)); + if (options.pollIntervalMs) { + addPoll(store, id, options.pollIntervalMs); + } + return wait; + }, + stop() { + if (!started) { + return; + } + if (options.pollIntervalMs) { + removePoll(store, id); + } + unregisterQuery(id); + started = false; + }, + refetch() { + const wait = waitForLifecycle(lifecycle); + store.dispatch(lifecycle.sync(definition.params)); + return wait; + }, + snapshot() { + return mapQueryState(lifecycle.selector(store.getState()), { enabled: true }, id); + }, + }; +} + +export type MutateCallbacks = { + onSuccess?: (data: Data) => void; + onError?: (error: Error) => void; +}; + +export type MutationController = { + selector: (state: AsyncableSlice) => AsyncState; + mutate: (variables: Variables, callbacks?: MutateCallbacks) => void; + mutateAsync: (variables: Variables) => Promise; + wait: () => Promise; + reset: () => void; + snapshot: () => { data: Data | null; error: Error | null; isPending: boolean }; +}; + +let mutationSeq = 0; + +export function mutationController( + store: ApiStore, + host: Host, + definition: MutationDefinition, +): MutationController { + mutationSeq += 1; + const scope = scopeFromHost(host); + const id = [scope.baseUrl, scope.profileId ?? "_", definition.key, String(mutationSeq)].join( + ID_SEP, + ); + mutations.set(id, store); + const lifecycle = asyncLifecycle( + id, + async (params: Variables | SecretRef) => { + const ref = SECRET_REF in params ? params[SECRET_REF] : undefined; + const variables = (ref === undefined ? params : peekSecret(ref)) as Variables; + try { + const data = await definition.run(new OperatorApi(host), variables); + lastErrors.delete(id); + return data; + } catch (error) { + lastErrors.set(id, asError(error)); + throw error; + } finally { + if (ref !== undefined) { + clearSecret(ref); + } + } + }, + ); + + let inflight: Promise = Promise.resolve(); + let pending = false; + + async function mutateAsync(variables: Variables): Promise { + if (pending) { + throw new Error(`Mutation "${definition.key}" is already pending`); + } + pending = true; + const wait = waitForLifecycle(lifecycle); + if (definition.sensitive) { + const ref = crypto.randomUUID(); + putSecret(ref, variables); + const payload: SecretRef = { [SECRET_REF]: ref }; + store.dispatch(lifecycle.call(payload)); + } else { + store.dispatch(lifecycle.call(variables)); + } + try { + return await wait; + } finally { + pending = false; + invalidateQueries(scope, definition.affected); + } + } + + return { + selector: lifecycle.selector, + mutate(variables, callbacks) { + inflight = mutateAsync(variables) + .then( + (data) => { + callbacks?.onSuccess?.(data); + return data; + }, + (error: unknown) => { + const err = asError(error); + callbacks?.onError?.(err); + }, + ) + .catch(() => undefined); + }, + mutateAsync, + wait() { + return inflight; + }, + reset() { + store.dispatch(lifecycle.reset()); + lastErrors.delete(id); + }, + snapshot() { + const state = lifecycle.selector(store.getState()); + return { + data: state.data, + error: state.status === "error" ? (lastErrors.get(id) ?? new Error(state.error)) : null, + isPending: pending || state.status === "pending", + }; + }, + }; +} + +export type { QueryKey }; diff --git a/ui/src/api/architecture.test.ts b/ui/src/api/architecture.test.ts new file mode 100644 index 00000000..470e0e35 --- /dev/null +++ b/ui/src/api/architecture.test.ts @@ -0,0 +1,73 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, test } from "bun:test"; + +const UI_SRC = join(import.meta.dir, ".."); +const REPO = join(UI_SRC, "..", ".."); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) { + out.push(...walk(path)); + } else { + out.push(path); + } + } + return out; +} + +function isTs(path: string): boolean { + return path.endsWith(".ts") || path.endsWith(".tsx"); +} + +describe("API architecture", () => { + test("components, routes, and contexts do not construct OperatorApi or call fetch", () => { + const files = walk(UI_SRC).filter(isTs); + const violations: string[] = []; + for (const file of files) { + const rel = relative(UI_SRC, file); + if (rel.startsWith("api/") || rel === "api-client.ts" || rel.endsWith(".test.ts")) { + continue; + } + const src = readFileSync(file, "utf8"); + if (/\bnew\s+OperatorApi\b/.test(src)) { + violations.push(`${rel}: constructs OperatorApi`); + } + if (/\bfetch\s*\(/.test(src)) { + violations.push(`${rel}: calls fetch`); + } + if (fromNaiveAsync(src)) { + violations.push(`${rel}: imports NaiveAsync`); + } + } + expect(violations).toEqual([]); + }); + + test("NaiveAsync stays inside ui/src/api", () => { + const webcomponents = walk(join(REPO, "webcomponents", "src")).filter(isTs); + const vscode = walk(join(REPO, "vscode-extension", "src")).filter(isTs); + const hits: string[] = []; + for (const file of [...webcomponents, ...vscode]) { + const src = readFileSync(file, "utf8"); + if (fromNaiveAsync(src) || src.includes("react-redux") || src.includes("naiveAsync")) { + hits.push(relative(REPO, file)); + } + } + expect(hits).toEqual([]); + }); + + test("migrated server responses are not mirrored in component state", () => { + const files = walk(UI_SRC).filter((file) => file.endsWith(".tsx")); + const mirrored = /\[(?:probes|projects|statuses|newSecret),\s*set\w+\]\s*=\s*useState/; + const hits = files + .filter((file) => mirrored.test(readFileSync(file, "utf8"))) + .map((file) => relative(UI_SRC, file)); + expect(hits).toEqual([]); + }); +}); + +function fromNaiveAsync(src: string): boolean { + return /from\s+["']@untra\/naiveasync["']/.test(src); +} diff --git a/ui/src/api/definitions.test.ts b/ui/src/api/definitions.test.ts new file mode 100644 index 00000000..33aaa76c --- /dev/null +++ b/ui/src/api/definitions.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { initializeSetupMutation, revokeSessionMutation } from "./definitions"; +import { QUERY_KEYS } from "./queries"; + +describe("mutation invalidation definitions", () => { + test("revoking a session refreshes the list and current principal", () => { + expect(revokeSessionMutation.affected).toEqual([ + QUERY_KEYS.sessions, + QUERY_KEYS.currentSession, + ]); + }); + + test("setup initialization names its affected query families", () => { + expect(initializeSetupMutation.affected).not.toBe("*"); + expect(initializeSetupMutation.affected).toContain(QUERY_KEYS.setupStatus); + expect(initializeSetupMutation.affected).toContain(QUERY_KEYS.configuration); + expect(initializeSetupMutation.affected).toContain(QUERY_KEYS.issueTypes); + expect(initializeSetupMutation.affected).not.toContain(QUERY_KEYS.currentSession); + }); +}); diff --git a/ui/src/api/definitions.ts b/ui/src/api/definitions.ts new file mode 100644 index 00000000..5fc6d70a --- /dev/null +++ b/ui/src/api/definitions.ts @@ -0,0 +1,533 @@ +import type { CreateAccessKeyRequest } from "@operator/bindings/CreateAccessKeyRequest"; +import type { CreateDelegatorRequest } from "@operator/bindings/CreateDelegatorRequest"; +import type { CreateIssueTypeRequest } from "@operator/bindings/CreateIssueTypeRequest"; +import type { CreateModelServerRequest } from "@operator/bindings/CreateModelServerRequest"; +import type { CreateTicketRequest } from "@operator/bindings/CreateTicketRequest"; +import type { LaunchTicketRequest } from "@operator/bindings/LaunchTicketRequest"; +import type { ListKanbanProjectsRequest } from "@operator/bindings/ListKanbanProjectsRequest"; +import type { ListKanbanStatusesRequest } from "@operator/bindings/ListKanbanStatusesRequest"; +import type { ResetPasswordRequest } from "@operator/bindings/ResetPasswordRequest"; +import type { SetGitSessionEnvRequest } from "@operator/bindings/SetGitSessionEnvRequest"; +import type { SetKanbanSessionEnvRequest } from "@operator/bindings/SetKanbanSessionEnvRequest"; +import type { SetupInitializeRequest } from "@operator/bindings/SetupInitializeRequest"; +import type { TargetDef } from "@operator/bindings/TargetDef"; +import type { UpdateConfigurationRequest } from "@operator/bindings/UpdateConfigurationRequest"; +import type { UpdateIssueTypeRequest } from "@operator/bindings/UpdateIssueTypeRequest"; +import type { ValidateGitTokenRequest } from "@operator/bindings/ValidateGitTokenRequest"; +import type { ValidateKanbanCredentialsRequest } from "@operator/bindings/ValidateKanbanCredentialsRequest"; +import type { WriteGitConfigRequest } from "@operator/bindings/WriteGitConfigRequest"; +import type { WriteKanbanConfigRequest } from "@operator/bindings/WriteKanbanConfigRequest"; +import type { BootstrapSubmitRequest } from "@operator/bindings/BootstrapSubmitRequest"; +import type { OperatorApi } from "../api-client"; +import type { MutationDefinition, QueryDefinition } from "./adapter"; +import { + ISSUE_TYPE_QUERY_KEYS, + LICENSE_QUERY_KEYS, + MODEL_QUERY_KEYS, + PROFILE_QUERY_KEYS, + QUERY_KEYS, + SESSION_QUERY_KEYS, + TICKET_QUERY_KEYS, +} from "./queries"; + +type ApiResult = Awaited>; + +const NONE: Record = {}; + +function read(key: string, fetch: QueryDefinition["fetch"]): QueryDefinition { + return { key, params: NONE, fetch }; +} + +function readWith( + key: string, + params: Params, + fetch: QueryDefinition["fetch"], +): QueryDefinition { + return { key, params, fetch }; +} + +function staticRead( + key: string, + fetch: QueryDefinition["fetch"], +): () => QueryDefinition { + const definition = read(key, fetch); + return () => definition; +} + +function memoizeDefinition( + create: (input: Input) => Definition, +): (input: Input) => Definition { + const definitions = new Map(); + return (input) => { + const existing = definitions.get(input); + if (existing) { + return existing; + } + const definition = create(input); + definitions.set(input, definition); + return definition; + }; +} + +export const profilesQuery = staticRead(QUERY_KEYS.profiles, (api) => api.profiles()); + +export const currentSessionQuery = staticRead(QUERY_KEYS.currentSession, (api) => + api.currentSession(), +); + +export const bootstrapStatusQuery = staticRead(QUERY_KEYS.bootstrapStatus, (api) => + api.bootstrapStatus(), +); + +export const setupStatusQuery = staticRead(QUERY_KEYS.setupStatus, (api) => api.setupStatus()); + +export const setupStepsQuery = staticRead(QUERY_KEYS.setupSteps, (api) => api.setupSteps()); + +export const setupCollectionsQuery = staticRead(QUERY_KEYS.setupCollections, (api) => + api.setupCollections(), +); + +export const integrationsQuery = staticRead(QUERY_KEYS.integrations, (api) => api.integrations()); + +export const healthQuery = staticRead(QUERY_KEYS.health, (api) => api.health()); + +export const statusQuery = staticRead(QUERY_KEYS.status, (api) => api.status()); + +export const sectionsQuery = staticRead(QUERY_KEYS.sections, (api) => api.sections()); + +export const queueStatusQuery = staticRead(QUERY_KEYS.queueStatus, (api) => api.queueStatus()); + +export const kanbanQuery = staticRead(QUERY_KEYS.kanban, (api) => api.kanban()); + +export const activeAgentsQuery = staticRead(QUERY_KEYS.activeAgents, (api) => api.activeAgents()); + +export const agentQuery = memoizeDefinition((agentId: string) => + readWith(QUERY_KEYS.agent, { agentId }, (api, params) => api.getAgent(params.agentId)), +); + +export const configurationQuery = staticRead(QUERY_KEYS.configuration, (api) => + api.getConfiguration(), +); + +export const executionTargetsQuery = staticRead(QUERY_KEYS.executionTargets, (api) => + api.executionTargets(), +); + +export const llmToolsQuery = staticRead(QUERY_KEYS.llmTools, (api) => api.listLlmTools()); + +export const issueTypesQuery = staticRead(QUERY_KEYS.issueTypes, (api) => api.listIssueTypes()); + +export const issueTypeQuery = memoizeDefinition((key: string) => + readWith(QUERY_KEYS.issueType, { key }, (api, params) => api.getIssueType(params.key)), +); + +export const issueTypeDocumentQuery = memoizeDefinition((key: string) => + readWith(QUERY_KEYS.issueTypeDocument, { key }, (api, params) => + api.getIssueTypeDocument(params.key), + ), +); + +export const collectionsQuery = staticRead(QUERY_KEYS.collections, (api) => api.listCollections()); + +export const projectsQuery = staticRead(QUERY_KEYS.projects, (api) => api.listProjects()); + +export const licenseQuery = staticRead(QUERY_KEYS.license, (api) => api.license()); + +export const targetsQuery = staticRead(QUERY_KEYS.targets, (api) => api.targets()); + +export const sessionsQuery = staticRead(QUERY_KEYS.sessions, (api) => api.listSessions()); + +export const accessKeysQuery = staticRead(QUERY_KEYS.accessKeys, (api) => api.listAccessKeys()); + +export const providerKindsQuery = staticRead(QUERY_KEYS.providerKinds, (api) => + api.listProviderKinds(), +); + +export const providerModelsQuery = memoizeDefinition((slug: string) => + readWith(QUERY_KEYS.providerModels, { slug }, (api, params) => api.providerModels(params.slug)), +); + +export const modelServersQuery = staticRead(QUERY_KEYS.modelServers, (api) => + api.listModelServers(), +); + +export const delegatorsQuery = staticRead(QUERY_KEYS.delegators, (api) => api.listDelegators()); + +export const gitProvidersQuery = staticRead(QUERY_KEYS.gitProviders, (api) => api.gitProviders()); + +export const kanbanProvidersQuery = staticRead(QUERY_KEYS.kanbanProviders, (api) => + api.kanbanProviders(), +); + +export const loginMutation: MutationDefinition< + ApiResult<"login">, + { username: string; password: string } +> = { + key: "login", + affected: [], + sensitive: true, + run: (api, variables) => api.login(variables.username, variables.password), +}; + +export const logoutMutation: MutationDefinition, Record> = { + key: "logout", + affected: [], + run: (api) => api.logout(), +}; + +export const bootstrapMutation: MutationDefinition< + ApiResult<"bootstrap">, + BootstrapSubmitRequest +> = { + key: "bootstrap", + affected: [QUERY_KEYS.bootstrapStatus], + sensitive: true, + run: (api, variables) => api.bootstrap(variables), +}; + +export const forgotPasswordMutation: MutationDefinition< + ApiResult<"forgotPassword">, + { username: string } +> = { + key: "forgot-password", + affected: [], + run: (api, variables) => api.forgotPassword(variables.username), +}; + +export const resetPasswordMutation: MutationDefinition< + ApiResult<"resetPassword">, + ResetPasswordRequest +> = { + key: "reset-password", + affected: [], + sensitive: true, + run: (api, variables) => api.resetPassword(variables), +}; + +export const approveDeviceMutation: MutationDefinition< + ApiResult<"approveDevice">, + { userCode: string } +> = { + key: "approve-device", + affected: [...SESSION_QUERY_KEYS], + run: (api, variables) => api.approveDevice(variables.userCode), +}; + +export const createAccessKeyMutation: MutationDefinition< + ApiResult<"createAccessKey">, + CreateAccessKeyRequest +> = { + key: "create-access-key", + affected: [QUERY_KEYS.accessKeys], + run: (api, variables) => api.createAccessKey(variables), +}; + +export const revokeAccessKeyMutation: MutationDefinition< + ApiResult<"revokeAccessKey">, + { id: string } +> = { + key: "revoke-access-key", + affected: [QUERY_KEYS.accessKeys], + run: (api, variables) => api.revokeAccessKey(variables.id), +}; + +export const revokeSessionMutation: MutationDefinition< + ApiResult<"revokeSession">, + { id: string } +> = { + key: "revoke-session", + affected: [QUERY_KEYS.sessions, QUERY_KEYS.currentSession], + run: (api, variables) => api.revokeSession(variables.id), +}; + +export const createProfileMutation: MutationDefinition< + ApiResult<"createProfile">, + { name: string } +> = { + key: "create-profile", + affected: [QUERY_KEYS.profiles], + run: (api, variables) => api.createProfile(variables.name), +}; + +export const renameProfileMutation: MutationDefinition< + ApiResult<"renameProfile">, + { id: string; name: string } +> = { + key: "rename-profile", + affected: [...PROFILE_QUERY_KEYS], + run: (api, variables) => api.renameProfile(variables.id, variables.name), +}; + +export const initializeSetupMutation: MutationDefinition< + ApiResult<"initializeSetup">, + SetupInitializeRequest +> = { + key: "initialize-setup", + affected: [ + QUERY_KEYS.profiles, + QUERY_KEYS.setupStatus, + QUERY_KEYS.setupSteps, + QUERY_KEYS.setupCollections, + QUERY_KEYS.integrations, + QUERY_KEYS.configuration, + QUERY_KEYS.status, + QUERY_KEYS.sections, + QUERY_KEYS.issueTypes, + QUERY_KEYS.collections, + QUERY_KEYS.license, + QUERY_KEYS.targets, + QUERY_KEYS.executionTargets, + QUERY_KEYS.providerKinds, + QUERY_KEYS.modelServers, + QUERY_KEYS.delegators, + ], + run: (api, variables) => api.initializeSetup(variables), +}; + +export const installLicenseMutation: MutationDefinition< + ApiResult<"installLicense">, + { licenseKey: string } +> = { + key: "install-license", + affected: [...LICENSE_QUERY_KEYS], + sensitive: true, + run: (api, variables) => api.installLicense(variables.licenseKey), +}; + +export const removeLicenseMutation: MutationDefinition< + ApiResult<"removeLicense">, + Record +> = { + key: "remove-license", + affected: [...LICENSE_QUERY_KEYS], + run: (api) => api.removeLicense(), +}; + +export const saveTargetMutation: MutationDefinition< + ApiResult<"saveTarget">, + { target: TargetDef; existingName?: string } +> = { + key: "save-target", + affected: [...LICENSE_QUERY_KEYS], + run: (api, variables) => api.saveTarget(variables.target, variables.existingName), +}; + +export const removeTargetMutation: MutationDefinition< + ApiResult<"removeTarget">, + { name: string } +> = { + key: "remove-target", + affected: [...LICENSE_QUERY_KEYS], + run: (api, variables) => api.removeTarget(variables.name), +}; + +export const probeTargetMutation: MutationDefinition, { name: string }> = { + key: "probe-target", + affected: [QUERY_KEYS.targets], + run: (api, variables) => api.probeTarget(variables.name), +}; + +export const pauseQueueMutation: MutationDefinition< + ApiResult<"pauseQueue">, + Record +> = { + key: "pause-queue", + affected: [...TICKET_QUERY_KEYS], + run: (api) => api.pauseQueue(), +}; + +export const resumeQueueMutation: MutationDefinition< + ApiResult<"resumeQueue">, + Record +> = { + key: "resume-queue", + affected: [...TICKET_QUERY_KEYS], + run: (api) => api.resumeQueue(), +}; + +export const syncKanbanMutation: MutationDefinition> = { + key: "sync-kanban", + affected: [...TICKET_QUERY_KEYS], + run: (api) => api.syncKanban(), +}; + +export const createTicketMutation: MutationDefinition< + ApiResult<"createTicket">, + CreateTicketRequest +> = { + key: "create-ticket", + affected: [...TICKET_QUERY_KEYS], + run: (api, variables) => api.createTicket(variables), +}; + +export const launchTicketMutation: MutationDefinition< + ApiResult<"launchTicket">, + { ticketId: string; options: LaunchTicketRequest } +> = { + key: "launch-ticket", + affected: [...TICKET_QUERY_KEYS], + run: (api, variables) => api.launchTicket(variables.ticketId, variables.options), +}; + +export const approveReviewMutation: MutationDefinition = { + key: "approve-review", + affected: [...TICKET_QUERY_KEYS], + run: (api, variables) => api.approveReview(variables.agentId), +}; + +export const rejectReviewMutation: MutationDefinition = { + key: "reject-review", + affected: [...TICKET_QUERY_KEYS], + run: (api, variables) => api.rejectReview(variables.agentId, variables.reason), +}; + +export const focusSessionMutation: MutationDefinition = { + key: "focus-session", + affected: [QUERY_KEYS.activeAgents, QUERY_KEYS.agent], + run: (api, variables) => api.focusSession(variables.agentId), +}; + +export const createIssueTypeMutation: MutationDefinition< + ApiResult<"createIssueType">, + CreateIssueTypeRequest +> = { + key: "create-issue-type", + affected: [...ISSUE_TYPE_QUERY_KEYS], + run: (api, variables) => api.createIssueType(variables), +}; + +export const updateIssueTypeMutation: MutationDefinition< + ApiResult<"updateIssueType">, + { key: string; request: UpdateIssueTypeRequest } +> = { + key: "update-issue-type", + affected: [...ISSUE_TYPE_QUERY_KEYS], + run: (api, variables) => api.updateIssueType(variables.key, variables.request), +}; + +export const deleteIssueTypeMutation: MutationDefinition = { + key: "delete-issue-type", + affected: [...ISSUE_TYPE_QUERY_KEYS], + run: (api, variables) => api.deleteIssueType(variables.key), +}; + +export const activateCollectionMutation: MutationDefinition = { + key: "activate-collection", + affected: [...ISSUE_TYPE_QUERY_KEYS, QUERY_KEYS.status], + run: (api, variables) => api.activateCollection(variables.name), +}; + +export const updateConfigurationMutation: MutationDefinition< + ApiResult<"updateConfiguration">, + UpdateConfigurationRequest +> = { + key: "update-configuration", + affected: [...PROFILE_QUERY_KEYS], + run: (api, variables) => api.updateConfiguration(variables), +}; + +export const createModelServerMutation: MutationDefinition< + ApiResult<"createModelServer">, + CreateModelServerRequest +> = { + key: "create-model-server", + affected: [...MODEL_QUERY_KEYS], + sensitive: true, + run: (api, variables) => api.createModelServer(variables), +}; + +export const createDelegatorMutation: MutationDefinition< + ApiResult<"createDelegator">, + CreateDelegatorRequest +> = { + key: "create-delegator", + affected: [...MODEL_QUERY_KEYS], + run: (api, variables) => api.createDelegator(variables), +}; + +export const updateDelegatorMutation: MutationDefinition< + ApiResult<"updateDelegator">, + { name: string; request: CreateDelegatorRequest } +> = { + key: "update-delegator", + affected: [...MODEL_QUERY_KEYS], + run: (api, variables) => api.updateDelegator(variables.name, variables.request), +}; + +export const validateGitTokenMutation: MutationDefinition< + ApiResult<"validateGitToken">, + ValidateGitTokenRequest +> = { + key: "validate-git-token", + affected: [QUERY_KEYS.gitProviders], + sensitive: true, + run: (api, variables) => api.validateGitToken(variables), +}; + +export const writeGitConfigMutation: MutationDefinition< + ApiResult<"writeGitConfig">, + WriteGitConfigRequest +> = { + key: "write-git-config", + affected: [...PROFILE_QUERY_KEYS, QUERY_KEYS.gitProviders], + run: (api, variables) => api.writeGitConfig(variables), +}; + +export const setGitSessionEnvMutation: MutationDefinition< + ApiResult<"setGitSessionEnv">, + SetGitSessionEnvRequest +> = { + key: "set-git-session-env", + affected: [...PROFILE_QUERY_KEYS], + sensitive: true, + run: (api, variables) => api.setGitSessionEnv(variables), +}; + +export const validateKanbanCredentialsMutation: MutationDefinition< + ApiResult<"validateKanbanCredentials">, + ValidateKanbanCredentialsRequest +> = { + key: "validate-kanban-credentials", + affected: [QUERY_KEYS.kanbanProviders], + sensitive: true, + run: (api, variables) => api.validateKanbanCredentials(variables), +}; + +export const listKanbanProjectsMutation: MutationDefinition< + ApiResult<"listKanbanProjects">, + ListKanbanProjectsRequest +> = { + key: "list-kanban-projects", + affected: [], + sensitive: true, + run: (api, variables) => api.listKanbanProjects(variables), +}; + +export const listKanbanStatusesMutation: MutationDefinition< + ApiResult<"listKanbanStatuses">, + ListKanbanStatusesRequest +> = { + key: "list-kanban-statuses", + affected: [], + sensitive: true, + run: (api, variables) => api.listKanbanStatuses(variables), +}; + +export const writeKanbanConfigMutation: MutationDefinition< + ApiResult<"writeKanbanConfig">, + WriteKanbanConfigRequest +> = { + key: "write-kanban-config", + affected: [...PROFILE_QUERY_KEYS, QUERY_KEYS.kanbanProviders, ...TICKET_QUERY_KEYS], + run: (api, variables) => api.writeKanbanConfig(variables), +}; + +export const setKanbanSessionEnvMutation: MutationDefinition< + ApiResult<"setKanbanSessionEnv">, + SetKanbanSessionEnvRequest +> = { + key: "set-kanban-session-env", + affected: [...PROFILE_QUERY_KEYS], + sensitive: true, + run: (api, variables) => api.setKanbanSessionEnv(variables), +}; diff --git a/ui/src/api/hooks.test.tsx b/ui/src/api/hooks.test.tsx new file mode 100644 index 00000000..b7fbfde8 --- /dev/null +++ b/ui/src/api/hooks.test.tsx @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, jest, test } from "bun:test"; +import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { HostContext, type Host } from "../host"; +import { resetSessionState, type MutationDefinition, type QueryDefinition } from "./adapter"; +import { useApiMutation, useApiQuery } from "./hooks"; +import { ApiProvider } from "./store"; + +function host(profileId?: string): Host { + return { + profileId, + baseUrl: () => "http://operator.test", + openExternal: () => undefined, + browseFolder: () => Promise.resolve(null), + openFile: () => undefined, + }; +} + +function Providers({ children, value = host() }: { children: ReactNode; value?: Host }) { + return ( + + {children} + + ); +} + +afterEach(() => { + cleanup(); + resetSessionState(); + jest.useRealTimers(); +}); + +describe("useApiQuery", () => { + test("starts when enabled and stops reporting loading when disabled", async () => { + let calls = 0; + const definition: QueryDefinition = { + key: "enabled-transition", + params: {}, + fetch: () => Promise.resolve(`value-${++calls}`), + }; + const { result, rerender } = renderHook(({ enabled }) => useApiQuery(definition, { enabled }), { + initialProps: { enabled: false }, + wrapper: Providers, + }); + expect(result.current.isLoading).toBe(false); + expect(calls).toBe(0); + rerender({ enabled: true }); + await waitFor(() => expect(result.current.data).toBe("value-1")); + rerender({ enabled: false }); + expect(result.current.isLoading).toBe(false); + }); + + test("switches to a profile-scoped lifecycle", async () => { + let calls = 0; + const definition: QueryDefinition = { + key: "profile-transition", + params: {}, + fetch: () => Promise.resolve(`value-${++calls}`), + }; + function Probe({ value }: { value: Host }) { + return ( + + + + ); + } + const view = render( + + + , + ); + await screen.findByText("value-1"); + view.rerender( + + + , + ); + await screen.findByText("value-2"); + }); + + test("keeps cached data visible while refetching", async () => { + let release!: (value: string) => void; + let calls = 0; + const definition: QueryDefinition = { + key: "cached-refetch", + params: {}, + fetch: () => { + calls += 1; + return calls === 1 + ? Promise.resolve("cached") + : new Promise((resolve) => { + release = resolve; + }); + }, + }; + const { result } = renderHook(() => useApiQuery(definition), { wrapper: Providers }); + await waitFor(() => expect(result.current.data).toBe("cached")); + let refetch!: Promise; + act(() => { + refetch = result.current.refetch(); + }); + expect(result.current.data).toBe("cached"); + expect(result.current.isFetching).toBe(true); + release("fresh"); + await act(() => refetch); + expect(result.current.data).toBe("fresh"); + }); + + test("cleans up polling on unmount", async () => { + jest.useFakeTimers(); + let calls = 0; + const definition: QueryDefinition = { + key: "poll-cleanup", + params: {}, + fetch: () => Promise.resolve(++calls), + }; + const { unmount } = renderHook(() => useApiQuery(definition, { pollIntervalMs: 10 }), { + wrapper: Providers, + }); + await act(() => Promise.resolve()); + expect(calls).toBe(1); + unmount(); + act(() => { + jest.advanceTimersByTime(50); + }); + await act(() => Promise.resolve()); + expect(calls).toBe(1); + }); +}); + +describe("useApiMutation", () => { + test("reports pending and delivers callbacks", async () => { + let release!: (value: string) => void; + const definition: MutationDefinition = { + key: "pending-callback", + affected: [], + run: (_api, variables) => + new Promise((resolve) => { + release = () => resolve(variables.value); + }), + }; + const success: string[] = []; + const { result } = renderHook(() => useApiMutation(definition), { wrapper: Providers }); + act(() => + result.current.mutate({ value: "done" }, { onSuccess: (data) => success.push(data) }), + ); + expect(result.current.isPending).toBe(true); + await act(() => Promise.resolve(release("done"))); + expect(result.current.isPending).toBe(false); + expect(success).toEqual(["done"]); + }); + + test("rejects duplicate async calls while pending", async () => { + let release!: () => void; + const definition: MutationDefinition> = { + key: "duplicate-hook-call", + affected: [], + run: () => + new Promise((resolve) => { + release = resolve; + }), + }; + const { result } = renderHook(() => useApiMutation(definition), { wrapper: Providers }); + let first!: Promise; + act(() => { + first = result.current.mutateAsync({}); + }); + await expect(result.current.mutateAsync({})).rejects.toThrow("already pending"); + release(); + await act(() => first); + }); + + test("consumes callback-style rejections after delivering the error", async () => { + const definition: MutationDefinition> = { + key: "callback-hook-error", + affected: [], + run: () => Promise.reject(new Error("denied")), + }; + const errors: string[] = []; + const { result } = renderHook(() => useApiMutation(definition), { wrapper: Providers }); + act(() => result.current.mutate({}, { onError: (error) => errors.push(error.message) })); + await waitFor(() => expect(result.current.error?.message).toBe("denied")); + expect(errors).toEqual(["denied"]); + }); +}); + +function Result({ definition }: { definition: QueryDefinition }) { + const result = useApiQuery(definition); + return {result.data ?? "loading"}; +} diff --git a/ui/src/api/hooks.ts b/ui/src/api/hooks.ts new file mode 100644 index 00000000..53fbbd40 --- /dev/null +++ b/ui/src/api/hooks.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { useSelector, useStore } from "react-redux"; +import type { AnyAction, AsyncableSlice } from "@untra/naiveasync"; +import { useHost } from "../host"; +import { + mapQueryState, + mutationController, + queryController, + resetSessionState, + type MutateCallbacks, + type MutationDefinition, + type QueryDefinition, + type QuerySnapshot, +} from "./adapter"; + +export type ApiQueryResult = QuerySnapshot & { + refetch: () => Promise; +}; + +export type ApiMutationResult = { + data: Data | null; + error: Error | null; + isPending: boolean; + mutate: (variables: Variables, callbacks?: MutateCallbacks) => void; + mutateAsync: (variables: Variables) => Promise; + reset: () => void; +}; + +export function useApiQuery>( + definition: QueryDefinition, + options?: { enabled?: boolean; pollIntervalMs?: number }, +): ApiQueryResult { + const store = useStore(); + const host = useHost(); + const enabled = options?.enabled ?? true; + const pollIntervalMs = options?.pollIntervalMs; + const controller = useMemo( + () => queryController(store, host, definition, { pollIntervalMs }), + [definition, host, pollIntervalMs, store], + ); + + useEffect(() => { + if (!enabled) { + return undefined; + } + void controller.start().catch(() => undefined); + return () => { + controller.stop(); + }; + }, [controller, enabled]); + + const state = useSelector((slice: AsyncableSlice) => controller.selector(slice)); + return { + ...mapQueryState(state, { enabled }, controller.id), + refetch: controller.refetch, + }; +} + +export function useApiMutation( + definition: MutationDefinition, +): ApiMutationResult { + const store = useStore(); + const host = useHost(); + const controller = useMemo( + () => mutationController(store, host, definition), + [definition, host, store], + ); + const state = useSelector((slice: AsyncableSlice) => controller.selector(slice)); + const mutate = useCallback( + (variables: Variables, callbacks?: MutateCallbacks) => { + controller.mutate(variables, callbacks); + }, + [controller], + ); + const mutateAsync = useCallback( + (variables: Variables) => controller.mutateAsync(variables), + [controller], + ); + const reset = useCallback(() => { + controller.reset(); + }, [controller]); + return { + data: state.data, + error: + state.status === "error" + ? (controller.snapshot().error ?? new Error(state.error || "Request failed")) + : null, + isPending: state.status === "pending", + mutate, + mutateAsync, + reset, + }; +} + +export function useResetSession(): () => void { + return resetSessionState; +} diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts new file mode 100644 index 00000000..31c65b98 --- /dev/null +++ b/ui/src/api/index.ts @@ -0,0 +1,6 @@ +export { createApiStore, resetSessionState } from "./adapter"; +export type { MutationDefinition, QueryDefinition, QuerySnapshot } from "./adapter"; +export { useApiMutation, useApiQuery, useResetSession } from "./hooks"; +export type { ApiMutationResult, ApiQueryResult } from "./hooks"; +export { ApiProvider } from "./store"; +export { AGENT_POLL_MS, ALL_QUERY_KEYS, QUERY_KEYS, STATUS_POLL_MS } from "./queries"; diff --git a/ui/src/api/queries/index.ts b/ui/src/api/queries/index.ts new file mode 100644 index 00000000..ae8f8fa8 --- /dev/null +++ b/ui/src/api/queries/index.ts @@ -0,0 +1,84 @@ +export const QUERY_KEYS = { + profiles: "profiles", + currentSession: "current-session", + bootstrapStatus: "bootstrap-status", + setupStatus: "setup-status", + setupSteps: "setup-steps", + setupCollections: "setup-collections", + integrations: "integrations", + health: "health", + status: "status", + sections: "sections", + queueStatus: "queue-status", + kanban: "kanban", + activeAgents: "active-agents", + agent: "agent", + configuration: "configuration", + executionTargets: "execution-targets", + llmTools: "llm-tools", + issueTypes: "issue-types", + issueType: "issue-type", + issueTypeDocument: "issue-type-document", + collections: "collections", + projects: "projects", + license: "license", + targets: "targets", + sessions: "sessions", + accessKeys: "access-keys", + providerKinds: "provider-kinds", + providerModels: "provider-models", + modelServers: "model-servers", + delegators: "delegators", + gitProviders: "git-providers", + kanbanProviders: "kanban-providers", +} as const; + +export type QueryKey = (typeof QUERY_KEYS)[keyof typeof QUERY_KEYS]; + +export const STATUS_POLL_MS = 3000; +export const AGENT_POLL_MS = 5000; + +export const TICKET_QUERY_KEYS = [ + QUERY_KEYS.kanban, + QUERY_KEYS.queueStatus, + QUERY_KEYS.activeAgents, + QUERY_KEYS.agent, + QUERY_KEYS.sections, +] as const; + +export const PROFILE_QUERY_KEYS = [ + QUERY_KEYS.profiles, + QUERY_KEYS.setupStatus, + QUERY_KEYS.configuration, + QUERY_KEYS.status, + QUERY_KEYS.sections, +] as const; + +export const LICENSE_QUERY_KEYS = [ + QUERY_KEYS.license, + QUERY_KEYS.targets, + QUERY_KEYS.executionTargets, +] as const; + +export const ISSUE_TYPE_QUERY_KEYS = [ + QUERY_KEYS.issueTypes, + QUERY_KEYS.issueType, + QUERY_KEYS.issueTypeDocument, + QUERY_KEYS.collections, + QUERY_KEYS.status, +] as const; + +export const SESSION_QUERY_KEYS = [ + QUERY_KEYS.currentSession, + QUERY_KEYS.sessions, + QUERY_KEYS.accessKeys, +] as const; + +export const MODEL_QUERY_KEYS = [ + QUERY_KEYS.providerModels, + QUERY_KEYS.modelServers, + QUERY_KEYS.delegators, + QUERY_KEYS.configuration, +] as const; + +export const ALL_QUERY_KEYS = Object.values(QUERY_KEYS); diff --git a/ui/src/api/store.tsx b/ui/src/api/store.tsx new file mode 100644 index 00000000..cbe2735d --- /dev/null +++ b/ui/src/api/store.tsx @@ -0,0 +1,8 @@ +import { useState, type ReactNode } from "react"; +import { Provider } from "react-redux"; +import { createApiStore } from "./adapter"; + +export function ApiProvider({ children }: { children: ReactNode }) { + const [store] = useState(createApiStore); + return {children}; +} diff --git a/ui/src/components/LicensePanel.tsx b/ui/src/components/LicensePanel.tsx index 5c6a0a58..eee0ea8b 100644 --- a/ui/src/components/LicensePanel.tsx +++ b/ui/src/components/LicensePanel.tsx @@ -1,5 +1,7 @@ -import { useCallback, useEffect, useState } from "react"; -import type { LicenseResponse, OperatorApi } from "../api-client"; +import { useCallback, useState } from "react"; +import type { LicenseResponse } from "../api-client"; +import { useApiMutation, useApiQuery } from "../api"; +import { installLicenseMutation, licenseQuery, removeLicenseMutation } from "../api/definitions"; import styles from "./LicensePanel.module.css"; const STATUS_LABELS: Record = { @@ -12,76 +14,55 @@ const STATUS_LABELS: Record = { /// Licence timestamps are i64 seconds, which cross the wire as bigint. const date = (seconds: bigint) => new Date(Number(seconds) * 1000).toLocaleString(); -export function LicensePanel({ - api, - onChange, -}: { - api: OperatorApi; - onChange?: (license: LicenseResponse) => void; -}) { - const [license, setLicense] = useState(null); +export function LicensePanel() { + const query = useApiQuery(licenseQuery()); + const install = useApiMutation(installLicenseMutation); + const remove = useApiMutation(removeLicenseMutation); const [key, setKey] = useState(""); - const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - let active = true; - api - .license() - .then((value) => { - if (active) { - setLicense(value); - onChange?.(value); - } - return undefined; - }) - .catch((cause: unknown) => { - if (active) { - setError(cause instanceof Error ? cause.message : "Could not load license"); - } - }); - return () => { - active = false; - }; - }, [api, onChange]); - - const update = useCallback( - async (remove: boolean) => { - setBusy(true); - setError(null); - try { - await api.refreshCsrf(); - const value = remove ? await api.removeLicense() : await api.installLicense(key.trim()); - setLicense(value); - setKey(""); - onChange?.(value); - } catch (cause) { - setError(cause instanceof Error ? cause.message : "Could not update license"); - } finally { - setBusy(false); - } - }, - [api, key, onChange], - ); + const license = query.data; + const busy = install.isPending || remove.isPending; const onRemove = useCallback(() => { - void update(true); - }, [update]); + setError(null); + remove.mutate( + {}, + { + onError: (cause) => { + setError(cause.message); + }, + }, + ); + }, [remove]); const onSubmit = useCallback( - (event: React.FormEvent) => { + (event: React.SubmitEvent) => { event.preventDefault(); - void update(false); + setError(null); + install.mutate( + { licenseKey: key.trim() }, + { + onSuccess: () => { + setKey(""); + }, + onError: (cause) => { + setError(cause.message); + }, + }, + ); }, - [update], + [install, key], ); + const displayError = error ?? query.error?.message ?? null; + return (

Operator Premium

Premium enables remote targets. Multiple agents on this machine are available free.

- {error && ( + {displayError && (

- {error} + {displayError}

)} {license ? ( diff --git a/ui/src/components/TicketCreatePanel.tsx b/ui/src/components/TicketCreatePanel.tsx index 33254f9a..90c8d2bd 100644 --- a/ui/src/components/TicketCreatePanel.tsx +++ b/ui/src/components/TicketCreatePanel.tsx @@ -1,10 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import { TicketCreateForm } from "@operator/webcomponents"; import type { TicketCreateFormValue } from "@operator/webcomponents"; -import type { IssueTypeSummary } from "@operator/bindings/IssueTypeSummary"; -import type { ProjectSummary } from "@operator/bindings/ProjectSummary"; -import { OperatorApi } from "../api-client"; -import { useHost } from "../host"; +import { useApiMutation, useApiQuery } from "../api"; +import { createTicketMutation, issueTypesQuery, projectsQuery } from "../api/definitions"; import { useRightPanel } from "../right-panel"; import styles from "./TicketCreatePanel.module.css"; @@ -16,83 +14,57 @@ const EMPTY: TicketCreateFormValue = { issueType: "", project: "", summary: "" } * identical to one created anywhere else. */ export function TicketCreatePanel({ onCreated }: { onCreated: () => void }) { - const host = useHost(); const { close } = useRightPanel(); - const [api] = useState(() => new OperatorApi(host)); - const [value, setValue] = useState(EMPTY); - const [issueTypes, setIssueTypes] = useState([]); - const [projects, setProjects] = useState([]); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const createRequest = useRef(0); - - useEffect( - () => () => { - createRequest.current += 1; - }, - [], - ); - - useEffect(() => { - let cancelled = false; - Promise.all([api.listIssueTypes(), api.listProjects()]) - .then(([types, projectList]) => { - if (!cancelled) { - setIssueTypes(types); - setProjects(projectList.filter((project) => project.exists)); - } - return undefined; - }) - .catch((e: Error) => !cancelled && setError(e.message)); - return () => { - cancelled = true; - }; - }, [api]); + const types = useApiQuery(issueTypesQuery()); + const projects = useApiQuery(projectsQuery()); + const create = useApiMutation(createTicketMutation); + const issueTypes = types.data ?? []; + const projectList = (projects.data ?? []).filter((project) => project.exists); + const error = create.error?.message ?? types.error?.message ?? projects.error?.message ?? null; + const loading = types.isLoading || projects.isLoading; + const empty = !loading && !error && (issueTypes.length === 0 || projectList.length === 0); const onSubmit = useCallback(() => { - const request = ++createRequest.current; - setBusy(true); - setError(null); - api - .createTicket({ + create.mutate( + { template: value.issueType, project: value.project, summary: value.summary, values: {}, - }) - .then(() => { - if (request === createRequest.current) { + }, + { + onSuccess: () => { onCreated(); close(); - } - return undefined; - }) - .catch((e: Error) => { - if (request === createRequest.current) { - setError(e.message); - } - }) - .finally(() => { - if (request === createRequest.current) { - setBusy(false); - } - }); - }, [api, close, onCreated, value]); + }, + }, + ); + }, [close, create, onCreated, value]); return (

New ticket

Lands in the TODO queue, ready to launch.

- + {loading ? ( +

Loading issue types and projects…

+ ) : empty ? ( +

+ {issueTypes.length === 0 + ? "No issue types are configured. Add one before creating a ticket." + : "No available projects were found."} +

+ ) : ( + + )}
); } diff --git a/ui/src/components/TicketDetailPanel.tsx b/ui/src/components/TicketDetailPanel.tsx index b2ed6f69..37c75dc5 100644 --- a/ui/src/components/TicketDetailPanel.tsx +++ b/ui/src/components/TicketDetailPanel.tsx @@ -1,16 +1,20 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import type { KanbanTicketCard } from "@operator/bindings/KanbanTicketCard"; -import type { ConfigurationResponse } from "@operator/bindings/ConfigurationResponse"; -import type { DelegatorResponse } from "@operator/bindings/DelegatorResponse"; -import type { LaunchTicketResponse } from "@operator/bindings/LaunchTicketResponse"; import { LaunchForm, TicketDetailView } from "@operator/webcomponents"; import type { LaunchFormValue } from "@operator/webcomponents"; -import { OperatorApi } from "../api-client"; +import { useApiMutation, useApiQuery } from "../api"; +import { + configurationQuery, + delegatorsQuery, + executionTargetsQuery, + focusSessionMutation, + issueTypeDocumentQuery, + launchTicketMutation, +} from "../api/definitions"; import { useHost } from "../host"; import { useRightPanel } from "../right-panel"; import { wrapperSessionLink } from "../session-links"; -import type { IssueType } from "@operator/bindings/IssueType"; import styles from "./TicketDetailPanel.module.css"; /** @@ -24,84 +28,27 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { const host = useHost(); const navigate = useNavigate(); const { close } = useRightPanel(); - const [api] = useState(() => new OperatorApi(host)); - // Launch form state. - const [delegator, setDelegator] = useState(""); // '' = default chain - const [wrapper, setWrapper] = useState(""); // '' = configured default - const [target, setTarget] = useState(""); // '' = delegator's target + const [delegator, setDelegator] = useState(""); + const [wrapper, setWrapper] = useState(""); + const [target, setTarget] = useState(""); const [yolo, setYolo] = useState(false); - - const [config, setConfig] = useState(null); - const [delegators, setDelegators] = useState([]); - const [targets, setTargets] = useState([]); - const [workflow, setWorkflow] = useState(null); - const [workflowError, setWorkflowError] = useState(null); - - const [launching, setLaunching] = useState(false); - const [result, setResult] = useState(null); - const [launchError, setLaunchError] = useState(null); - - // Focus action state (cmux: calls the control-plane focus endpoint). - const [focusBusy, setFocusBusy] = useState(false); - const [focusError, setFocusError] = useState(null); const [focused, setFocused] = useState(false); - const launchRequest = useRef(0); - const focusRequest = useRef(0); - - useEffect( - () => () => { - launchRequest.current += 1; - focusRequest.current += 1; - }, - [], - ); - - // Config (delegator names + the configured control wrapper) for the dropdowns. - useEffect(() => { - let cancelled = false; - Promise.all([api.getConfiguration(), api.listDelegators(), api.executionTargets()]) - .then(([configuration, delegatorResponse, targetResponse]) => { - if (!cancelled) { - setConfig(configuration); - setDelegators(delegatorResponse.delegators); - setTargets( - targetResponse.targets.filter((item) => item.available).map((item) => item.name), - ); - } - return undefined; - }) - .catch(() => !cancelled && setConfig(null)); - return () => { - cancelled = true; - }; - }, [api]); - // The Operator workflow this ticket's issue type defines. Rendered from the - // native document, so this graph matches the one on the docs site exactly. - useEffect(() => { - let cancelled = false; - api - .getIssueTypeDocument(ticket.ticket_type) - .then((doc) => !cancelled && setWorkflow(doc)) - .catch((e) => { - if (!cancelled) { - setWorkflowError(e instanceof Error ? e.message : "Failed to load workflow"); - } - }); - return () => { - cancelled = true; - }; - }, [api, ticket.ticket_type]); + const config = useApiQuery(configurationQuery()); + const delegators = useApiQuery(delegatorsQuery()); + const targets = useApiQuery(executionTargetsQuery()); + const workflow = useApiQuery(issueTypeDocumentQuery(ticket.ticket_type)); + const launch = useApiMutation(launchTicketMutation); + const focus = useApiMutation(focusSessionMutation); - const defaultWrapperLabel = config?.launch.session_wrapper ?? "configured"; + const defaultWrapperLabel = config.data?.launch.session_wrapper ?? "configured"; + const result = launch.data; const onLaunch = useCallback(() => { - const request = ++launchRequest.current; - setLaunching(true); - setLaunchError(null); - api - .launchTicket(ticket.id, { + launch.mutate({ + ticketId: ticket.id, + options: { delegator: delegator || null, provider: null, model: null, @@ -111,51 +58,9 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { retry_reason: null, resume_session_id: null, target: target || null, - }) - .then((response) => { - if (request === launchRequest.current) { - setResult(response); - } - return undefined; - }) - .catch((e) => { - if (request === launchRequest.current) { - setLaunchError(e instanceof Error ? e.message : "Launch failed"); - } - }) - .finally(() => { - if (request === launchRequest.current) { - setLaunching(false); - } - }); - }, [api, delegator, target, ticket.id, wrapper, yolo]); - - const onFocus = useCallback( - (agentId: string) => { - const request = ++focusRequest.current; - setFocusBusy(true); - setFocusError(null); - api - .focusSession(agentId) - .then(() => { - if (request === focusRequest.current) { - setFocused(true); - } - return undefined; - }) - .catch((e) => { - if (request === focusRequest.current) { - setFocusError(e instanceof Error ? e.message : "Focus failed"); - } - }) - .finally(() => { - if (request === focusRequest.current) { - setFocusBusy(false); - } - }); - }, - [api], - ); + }, + }); + }, [delegator, launch, target, ticket.id, wrapper, yolo]); const handleFormChange = useCallback((value: LaunchFormValue) => { setDelegator(value.delegator); @@ -178,14 +83,43 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { } }, [host, link]); const focusSession = useCallback(() => { - if (result) { - onFocus(result.agent_id); + if (!result) { + return; } - }, [onFocus, result]); - // A completed ticket opens read-only: there is nothing left to launch. + focus.mutate( + { agentId: result.agent_id }, + { + onSuccess: () => { + setFocused(true); + }, + }, + ); + }, [focus, result]); const isFinished = ticket.status === "completed"; - const formValue: LaunchFormValue = { delegator, wrapper, target, yolo }; + const launchLoading = config.isLoading || delegators.isLoading || targets.isLoading; + const launchError = + config.error?.message ?? delegators.error?.message ?? targets.error?.message ?? null; + + const launchControls = launchLoading ? ( +
Loading launch configuration…
+ ) : launchError ? ( +
Launch options unavailable: {launchError}
+ ) : ( + item.available) + .map((item) => item.name)} + defaultWrapperLabel={defaultWrapperLabel} + busy={launch.isPending} + error={launch.error?.message ?? null} + onChange={handleFormChange} + onSubmit={onLaunch} + /> + ); + const launchActions = result ? ( <> - {focusError &&
{focusError}
} + {focus.error &&
{focus.error.message}
} )} {link?.kind === "display" && ( @@ -221,27 +155,14 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { return ( - ) - } + launchControls={result || isFinished ? undefined : launchControls} launchedTicketId={result?.ticket_id} launchActions={launchActions} workflow={ - workflowError - ? { status: "error", message: workflowError } - : workflow - ? { status: "ready", data: workflow } + workflow.error + ? { status: "error", message: workflow.error.message } + : workflow.data + ? { status: "ready", data: workflow.data } : { status: "loading", message: "Loading workflow…" } } /> diff --git a/ui/src/components/ticket-panels.test.tsx b/ui/src/components/ticket-panels.test.tsx new file mode 100644 index 00000000..aeeea13c --- /dev/null +++ b/ui/src/components/ticket-panels.test.tsx @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, jest, mock, test } from "bun:test"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { ApiProvider } from "../api"; +import { resetSessionState } from "../api/adapter"; +import { setCsrfToken } from "../api-client"; +import { HostContext, type Host } from "../host"; +import { RightPanelProvider } from "../right-panel"; +import { mockFetch, requestBody, requestPath, restoreFetch } from "../test-fetch"; +import * as webcomponentMocks from "../test-webcomponents"; + +mock.module("@operator/webcomponents", () => webcomponentMocks); + +const { TicketCreatePanel } = await import("./TicketCreatePanel"); +const { TicketDetailPanel } = await import("./TicketDetailPanel"); + +const TEST_HOST: Host = { + baseUrl: () => "http://operator.test", + openExternal: () => undefined, + browseFolder: () => Promise.resolve(null), + openFile: () => undefined, +}; +let createdTickets: string[] = []; + +function recordCreatedTicket(): void { + createdTickets.push("created"); +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function renderPanel(content: ReactNode) { + return render( + + + + {content} + + + , + ); +} + +afterEach(() => { + cleanup(); + resetSessionState(); + setCsrfToken(null); + createdTickets = []; + restoreFetch(); + jest.restoreAllMocks(); +}); + +describe("ticket panels", () => { + test("creates a ticket with the selected type, project, and summary", async () => { + const requests: Array<{ path: string; method: string; body?: string }> = []; + mockFetch((input, init) => { + const path = requestPath(input); + requests.push({ path, method: init?.method ?? "GET", body: requestBody(init) }); + if (path.endsWith("/issuetypes")) { + return Promise.resolve(json([{ key: "TASK", name: "Task" }])); + } + if (path.endsWith("/projects")) { + return Promise.resolve(json([{ name: "operator", path: "/operator", exists: true }])); + } + if (path.endsWith("/tickets")) { + return Promise.resolve(json({ id: "TASK-1", filename: "TASK-1.md", path: "/ticket" })); + } + return Promise.resolve(json({ message: `Unexpected request: ${path}` }, 500)); + }); + setCsrfToken("csrf"); + renderPanel(); + await screen.findByRole("button", { name: "Fill ticket" }); + fireEvent.click(screen.getByRole("button", { name: "Fill ticket" })); + fireEvent.click(screen.getByRole("button", { name: "Submit ticket" })); + await waitFor(() => expect(createdTickets).toEqual(["created"])); + const request = requests.find((item) => item.path.endsWith("/tickets")); + expect(request?.method).toBe("POST"); + expect(JSON.parse(request?.body ?? "{}")).toEqual({ + template: "TASK", + project: "operator", + summary: "Test", + values: {}, + }); + }); + + test("launches a ticket and focuses its cmux session", async () => { + const requests: string[] = []; + mockFetch((input, init) => { + const path = requestPath(input); + requests.push(`${init?.method ?? "GET"} ${path}`); + if (path.endsWith("/configuration")) { + return Promise.resolve(json({ launch: { session_wrapper: "cmux" } })); + } + if (path.endsWith("/delegators")) { + return Promise.resolve(json({ delegators: [], total: 0 })); + } + if (path.endsWith("/execution-targets")) { + return Promise.resolve(json({ targets: [], total: 0 })); + } + if (path.endsWith("/issuetypes/TASK/document")) { + return Promise.resolve(json({ key: "TASK", name: "Task", steps: [] })); + } + if (path.endsWith("/tickets/TASK-1/launch")) { + return Promise.resolve( + json({ + executed_server_side: true, + agent_id: "agent-1", + ticket_id: "TASK-1", + working_directory: "/operator", + command: "", + terminal_name: "op-task-1", + tmux_session_name: "op-task-1", + session_wrapper: "cmux", + session_window_ref: null, + session_context_ref: null, + session_id: "session-1", + worktree_created: false, + branch: null, + }), + ); + } + if (path.endsWith("/agents/agent-1/focus")) { + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve(json({ message: `Unexpected request: ${path}` }, 500)); + }); + setCsrfToken("csrf"); + renderPanel( + , + ); + fireEvent.click(await screen.findByRole("button", { name: "Launch ticket" })); + fireEvent.click(await screen.findByRole("button", { name: "Focus cmux session" })); + await screen.findByRole("button", { name: /Focus cmux session/ }); + expect(requests).toContain("POST /api/v1/tickets/TASK-1/launch"); + expect(requests).toContain("POST /api/v1/agents/agent-1/focus"); + }); +}); diff --git a/ui/src/main.tsx b/ui/src/main.tsx index f8a5fd7b..ed9abedb 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -4,6 +4,7 @@ import { HashRouter, Routes, Route } from "react-router-dom"; import "@vscode/codicons/dist/codicon.css"; import "./index.css"; import { HostContext, createBrowserHost } from "./host"; +import { ApiProvider } from "./api"; import { Layout } from "./Layout"; import { DashboardPage } from "./routes/DashboardPage"; import { ConfigPage } from "./routes/ConfigPage"; @@ -30,41 +31,43 @@ const host = createBrowserHost(); createRoot(document.getElementById("root")!).render( - - - {/* Unauthenticated screens render outside Layout: the shell's own + + + + {/* Unauthenticated screens render outside Layout: the shell's own API calls would 401 for a visitor who cannot yet authenticate. */} - } /> - } /> - } /> - } /> - }> - } /> - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + }> + } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - - - + + + , ); diff --git a/ui/src/naiveasync.d.ts b/ui/src/naiveasync.d.ts new file mode 100644 index 00000000..7eeaee90 --- /dev/null +++ b/ui/src/naiveasync.d.ts @@ -0,0 +1,116 @@ +declare module "@untra/naiveasync" { + import type { Dispatch, Middleware, Reducer } from "redux"; + + export const asyncableEmoji: "🔁"; + + export interface AnyAction { + type: string; + payload?: any; + } + + export type AsyncFunction = (params: Params) => Promise; + export type AsyncableStateStatus = "" | "pending" | "error" | "success"; + export type AsyncState = + | { status: ""; error: ""; params: {}; data: null } + | { + status: "pending"; + error: "" | string; + params: {} | Params; + data: null | Data; + } + | { + status: "error"; + error: "" | string; + params: {} | Params; + data: null | Data; + } + | { status: "success"; error: ""; params: {} | Params; data: Data }; + + export interface AsyncableSlice { + [asyncableEmoji]: Record>; + } + + type AsyncPhase = + | "call" + | "data" + | "error" + | "success" + | "destroy" + | "reset" + | "sync" + | "assign" + | "subscribe"; + + interface AsyncPostmark { + name: string; + phase: AsyncPhase; + trace?: string; + } + + export type AsyncActionCreator = (payload?: Payload) => { + readonly type: string; + readonly postmark: AsyncPostmark; + readonly match: (action: { type: string; payload: any }) => boolean; + }; + + export interface AsyncableOptions { + readonly debounce?: number; + readonly throttle?: number; + readonly timeout?: number; + readonly traceDispatch?: boolean; + readonly dataDepends?: string[]; + } + + export type OnData = + | (() => void) + | ((data: Data) => void) + | ((data: Data, params: Params) => void) + | ((data: Data, params: Params, dispatch: Dispatch) => void); + export type OnError = + | (() => void) + | ((error: string) => void) + | ((error: string, params: Params) => void) + | ((error: string, params: Params, dispatch: Dispatch) => void); + + export interface AsyncLifecycle { + readonly id: string; + readonly operation: AsyncFunction; + readonly selector: (state: AsyncableSlice) => AsyncState; + readonly call: AsyncActionCreator; + readonly sync: AsyncActionCreator; + readonly destroy: AsyncActionCreator; + readonly data: AsyncActionCreator; + readonly error: AsyncActionCreator; + readonly success: AsyncActionCreator; + readonly reset: AsyncActionCreator; + readonly assign: AsyncActionCreator>; + readonly subscribe: AsyncActionCreator; + readonly memoized: (enabled: boolean) => AsyncLifecycle; + readonly throttle: (ms: number) => AsyncLifecycle; + readonly debounce: (ms: number) => AsyncLifecycle; + readonly timeout: (ms: number) => AsyncLifecycle; + readonly retries: ( + retries: number, + callback?: (error: any, retry?: number) => void, + ) => AsyncLifecycle; + readonly onData: (callback: OnData) => AsyncLifecycle; + readonly onError: (callback: OnError) => AsyncLifecycle; + readonly awaitResolve: () => Promise; + readonly awaitReject: () => Promise; + readonly dataDepends: (ids: string[]) => AsyncLifecycle; + readonly resolveData: () => Promise; + readonly rejectError: () => Promise; + readonly options: (options: AsyncableOptions) => AsyncLifecycle; + readonly invalidate: (options?: AsyncableOptions) => AsyncLifecycle; + readonly abortController: (controller: AbortController) => AsyncLifecycle; + } + + export const naiveAsyncMiddleware: Middleware; + export const naiveAsyncReducer: Reducer; + export function asyncLifecycle( + id: string, + operation: AsyncFunction, + options?: AsyncableOptions, + ): AsyncLifecycle; + export function findLifecycleById(id: string): AsyncLifecycle | undefined; +} diff --git a/ui/src/profiles-context.module.css b/ui/src/profiles-context.module.css index c6adb00d..7331ce60 100644 --- a/ui/src/profiles-context.module.css +++ b/ui/src/profiles-context.module.css @@ -1,23 +1,62 @@ .selector { - padding: 0.75rem; - display: flex; - flex-direction: column; - gap: 0.5rem; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.5rem 0.75rem; + align-items: end; + padding: 1rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); } .selector label { + grid-column: 1 / -1; font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.04em; color: var(--text-muted); } .selector select, .selector button { - width: 100%; min-height: 2rem; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + background: var(--surface); +} + +.selector select { + width: 100%; + min-width: 0; + padding: 0 0.625rem; +} + +.selector button { + padding: 0 0.75rem; + color: var(--color-salmon-text); + cursor: pointer; +} + +.selector button:hover { + background: var(--surface-alt); +} + +.refreshError { + grid-column: 1 / -1; + margin: 0; + font-size: 0.75rem; + color: var(--danger); } .gate { padding: 1.5rem; } + +@media (max-width: 640px) { + .selector { + grid-template-columns: 1fr; + } + + .selector label { + grid-column: auto; + } +} diff --git a/ui/src/profiles-context.test.tsx b/ui/src/profiles-context.test.tsx new file mode 100644 index 00000000..9c01dd77 --- /dev/null +++ b/ui/src/profiles-context.test.tsx @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useCallback } from "react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { HostContext, type Host } from "./host"; +import { resetSessionState } from "./api/adapter"; +import { ApiProvider } from "./api/store"; +import { mockFetch, restoreFetch } from "./test-fetch"; +import * as webcomponentMocks from "./test-webcomponents"; + +mock.module("@operator/webcomponents", () => webcomponentMocks); + +const { ProfileSelector, ProfilesProvider, useProfiles } = await import("./profiles-context"); + +const PROFILES = [{ id: "p1", name: "Primary", initialized: true, is_default: true }]; + +const host: Host = { + baseUrl: () => "http://operator.test", + openExternal: () => undefined, + browseFolder: () => Promise.resolve(null), + openFile: () => undefined, +}; + +function json(body: unknown, status = 200): Promise { + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); +} + +function Editor() { + const { refresh } = useProfiles(); + const onRefresh = useCallback(() => { + void refresh().catch(() => undefined); + }, [refresh]); + return ( + <> + + + + + ); +} + +function renderProvider() { + return render( + + + + + }> + } /> + + + + + , + ); +} + +afterEach(() => { + cleanup(); + resetSessionState(); + restoreFetch(); +}); + +describe("ProfilesProvider", () => { + test("gates the app when the initial load fails", async () => { + mockFetch(() => json({ error: "boom" }, 500)); + const view = renderProvider(); + await waitFor(() => + expect(view.container.querySelector('[data-status="error"]')).not.toBeNull(), + ); + expect(screen.queryByLabelText("draft")).toBeNull(); + }); + + test("keeps routes mounted when a background refetch fails", async () => { + let calls = 0; + mockFetch(() => (++calls === 1 ? json(PROFILES) : json({ error: "boom" }, 500))); + renderProvider(); + + const draft = await screen.findByLabelText("draft"); + fireEvent.change(draft, { target: { value: "unsaved edit" } }); + + await act(async () => { + fireEvent.click(screen.getByText("refresh")); + await Promise.resolve(); + }); + + expect(await screen.findByText(/Couldn't refresh configurations/)).toBeTruthy(); + const current = screen.getByLabelText("draft"); + expect(current).toBe(draft); + expect(current.value).toBe("unsaved edit"); + expect(calls).toBe(2); + }); +}); diff --git a/ui/src/profiles-context.tsx b/ui/src/profiles-context.tsx index 5ec0e412..f887679e 100644 --- a/ui/src/profiles-context.tsx +++ b/ui/src/profiles-context.tsx @@ -1,12 +1,15 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { createContext, useCallback, useContext, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { Outlet, useNavigate } from "react-router-dom"; -import { OperatorApi, type ProfileSummary } from "./api-client"; +import type { ProfileSummary } from "./api-client"; import { AsyncState } from "@operator/webcomponents"; import { HostContext, useHost, type Host } from "./host"; +import { useApiMutation, useApiQuery } from "./api"; +import { createProfileMutation, profilesQuery } from "./api/definitions"; import styles from "./profiles-context.module.css"; const SELECTED_PROFILE_KEY = "operator.selected-profile"; +const EMPTY_PROFILES: ProfileSummary[] = []; type ProfilesContextValue = { profiles: ProfileSummary[]; @@ -14,6 +17,7 @@ type ProfilesContextValue = { select: (id: string) => void; create: (name: string) => Promise; refresh: () => Promise; + refreshError: Error | null; }; const ProfilesContext = createContext(null); @@ -43,38 +47,12 @@ function ProfileScope({ profileId, children }: { profileId?: string; children: R } export function ProfilesProvider() { - const host = useHost(); - const api = useMemo(() => new OperatorApi(host), [host]); - const [profiles, setProfiles] = useState([]); + const { data, error, isLoading, refetch } = useApiQuery(profilesQuery()); + const createProfile = useApiMutation(createProfileMutation); + const profiles = data ?? EMPTY_PROFILES; const [selectedId, setSelectedId] = useState(() => localStorage.getItem(SELECTED_PROFILE_KEY)); - const [loaded, setLoaded] = useState(false); - const [error, setError] = useState(null); - - const refresh = useCallback(async () => { - setProfiles(await api.profiles()); - setLoaded(true); - }, [api]); - - useEffect(() => { - let cancelled = false; - api - .profiles() - .then((items) => { - if (!cancelled) { - setProfiles(items); - setLoaded(true); - } - return undefined; - }) - .catch((cause: unknown) => { - if (!cancelled) { - setError(cause instanceof Error ? cause.message : "Could not load configurations"); - } - }); - return () => { - cancelled = true; - }; - }, [api]); + const loaded = !isLoading && data != null; + const refreshError = loaded ? error : null; const selected = profiles.find((profile) => profile.id === selectedId) ?? @@ -89,27 +67,29 @@ export function ProfilesProvider() { const create = useCallback( async (name: string) => { - await api.refreshCsrf(); - const profile = await api.createProfile(name); - setProfiles((items) => [...items, profile]); + const profile = await createProfile.mutateAsync({ name }); select(profile.id); return profile; }, - [api, select], + [createProfile, select], ); + const refresh = useCallback(async () => { + await refetch(); + }, [refetch]); + const value = useMemo( - () => ({ profiles, selected, select, create, refresh }), - [profiles, selected, select, create, refresh], + () => ({ profiles, selected, select, create, refresh, refreshError }), + [profiles, selected, select, create, refresh, refreshError], ); - if (error || !loaded) { + if (!loaded) { return (
value={ error - ? { status: "error", message: error } + ? { status: "error", message: error.message } : { status: "loading", message: "Loading configurations…" } } > @@ -128,7 +108,7 @@ export function ProfilesProvider() { } export function ProfileSelector() { - const { profiles, selected, select } = useProfiles(); + const { profiles, selected, select, refresh, refreshError } = useProfiles(); const navigate = useNavigate(); const onChange = useCallback( @@ -138,7 +118,9 @@ export function ProfileSelector() { return; } select(profile.id); - void navigate(profile.initialized ? "/" : "/onboarding"); + if (!profile.initialized) { + void navigate("/onboarding"); + } }, [profiles, select, navigate], ); @@ -147,9 +129,13 @@ export function ProfileSelector() { void navigate("/onboarding?new=1"); }, [navigate]); + const onRetry = useCallback(() => { + void refresh().catch(() => undefined); + }, [refresh]); + return (
- + @@ -396,8 +363,6 @@ function CreateDelegatorForm({ ))} ) : ( - // Provider not connected (or no models) - fall back to free-text so - // the form still works offline / pre-auth. - -
@@ -601,49 +566,46 @@ function GitFields({ ); } -function DelegatorGitEditor({ - api, - delegator, - onSaved, -}: { - api: OperatorApi; - delegator: DelegatorResponse; - onSaved: () => void; -}) { +function DelegatorGitEditor({ delegator }: { delegator: DelegatorResponse }) { + const update = useApiMutation(updateDelegatorMutation); const [git, setGit] = useState(() => createGitDraft(delegator.git ?? null), ); - const [busy, setBusy] = useState(false); const [error, setError] = useState(""); - const save = async () => { - setBusy(true); + const save = () => { setError(""); - try { - await api.updateDelegator(delegator.name, { + update.mutate( + { name: delegator.name, - llm_tool: delegator.llm_tool, - model: delegator.model, - display_name: delegator.display_name ?? null, - model_properties: delegator.model_properties, - model_server: delegator.model_server ?? null, - launch_config: delegator.launch_config ?? null, - remote_agent: delegator.remote_agent ?? null, - git: serializeGitDraft(git), - }); - onSaved(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to save Git settings"); - } finally { - setBusy(false); - } + request: { + name: delegator.name, + llm_tool: delegator.llm_tool, + model: delegator.model, + display_name: delegator.display_name ?? null, + model_properties: delegator.model_properties, + model_server: delegator.model_server ?? null, + launch_config: delegator.launch_config ?? null, + remote_agent: delegator.remote_agent ?? null, + git: serializeGitDraft(git), + }, + }, + { + onSuccess: (saved) => { + setGit(createGitDraft(saved.git ?? null)); + }, + onError: (cause) => { + setError(cause.message); + }, + }, + ); }; return (
Git settings - + {error &&

{error}

} -
); diff --git a/ui/src/routes/QueuePage.tsx b/ui/src/routes/QueuePage.tsx index 96b155cc..a5124db5 100644 --- a/ui/src/routes/QueuePage.tsx +++ b/ui/src/routes/QueuePage.tsx @@ -1,9 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; import { QueueView } from "@operator/webcomponents"; -import { OperatorApi } from "../api-client"; -import type { KanbanBoardResponse } from "../api-client"; +import { STATUS_POLL_MS, useApiQuery } from "../api"; +import { kanbanQuery } from "../api/definitions"; import type { KanbanTicketCard } from "@operator/bindings/KanbanTicketCard"; -import { useHost } from "../host"; import { useRightPanel } from "../right-panel"; import { CONCEPTS } from "../concepts"; import { TicketDetailPanel } from "../components/TicketDetailPanel"; @@ -11,54 +10,9 @@ import { TicketCreatePanel } from "../components/TicketCreatePanel"; const QUEUE = CONCEPTS.queue; -const POLL_INTERVAL_MS = 3000; - export function QueuePage() { - const host = useHost(); const { open } = useRightPanel(); - const [api] = useState(() => new OperatorApi(host)); - const [board, setBoard] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Guards every state write, so a poll or a post-write refresh that lands - // after unmount is dropped instead of setting state on a dead component. - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - - // Stable, so both the poll and a write can pull the board. - const refresh = useCallback(() => { - api - .kanban() - .then((b) => { - if (mounted.current) { - setBoard(b); - setError(null); - } - return undefined; - }) - .catch((e) => { - if (mounted.current) { - setError(e.message); - } - }) - .finally(() => { - if (mounted.current) { - setLoading(false); - } - }); - }, [api]); - - useEffect(() => { - refresh(); - const timer = setInterval(refresh, POLL_INTERVAL_MS); - return () => clearInterval(timer); - }, [refresh]); + const board = useApiQuery(kanbanQuery(), { pollIntervalMs: STATUS_POLL_MS }); const openTicket = useCallback( (ticket: KanbanTicketCard) => @@ -66,10 +20,11 @@ export function QueuePage() { [open], ); - // A created ticket shows up immediately rather than on the next poll. + const onCreated = useCallback(() => undefined, []); + const createTicket = useCallback( - () => open(, "new-ticket"), - [open, refresh], + () => open(, "new-ticket"), + [open, onCreated], ); return ( @@ -81,14 +36,14 @@ export function QueuePage() { icon: QUEUE.icon, }} board={ - loading + board.isLoading ? { status: "loading", message: "Loading queue..." } - : board - ? { status: "ready", data: board } + : board.data + ? { status: "ready", data: board.data } : { status: "empty", message: "No tickets" } } - error={error} - updatedLabel={board ? new Date(board.last_updated).toLocaleTimeString() : undefined} + error={board.error?.message ?? null} + updatedLabel={board.data ? new Date(board.data.last_updated).toLocaleTimeString() : undefined} onOpenTicket={openTicket} onCreateTicket={createTicket} /> diff --git a/ui/src/routes/RemoteTargetsPage.tsx b/ui/src/routes/RemoteTargetsPage.tsx index b2890d03..4b46f398 100644 --- a/ui/src/routes/RemoteTargetsPage.tsx +++ b/ui/src/routes/RemoteTargetsPage.tsx @@ -1,9 +1,16 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { useNavigate } from "react-router-dom"; import { PremiumPaywall } from "@operator/webcomponents"; import type { TargetDef } from "@operator/bindings/TargetDef"; -import { OperatorApi, type LicenseResponse, type TargetResponse } from "../api-client"; -import { useHost } from "../host"; +import type { TargetResponse } from "../api-client"; +import { useApiMutation, useApiQuery } from "../api"; +import { + licenseQuery, + probeTargetMutation, + removeTargetMutation, + saveTargetMutation, + targetsQuery, +} from "../api/definitions"; import form from "./onboarding/OnboardingPage.module.css"; import styles from "./RemoteTargetsPage.module.css"; @@ -62,69 +69,38 @@ function TargetRow({ target, busy, onProbe, onEdit, onRemove }: TargetRowProps) } export function RemoteTargetsPage() { - const host = useHost(); - const api = useMemo(() => new OperatorApi(host), [host]); const navigate = useNavigate(); - const [targets, setTargets] = useState([]); - const [license, setLicense] = useState(null); + const targetsResult = useApiQuery(targetsQuery()); + const license = useApiQuery(licenseQuery()); + const save = useApiMutation(saveTargetMutation); + const remove = useApiMutation(removeTargetMutation); + const probe = useApiMutation(probeTargetMutation); const [draft, setDraft] = useState(emptyTarget); const [editing, setEditing] = useState(); - const [busy, setBusy] = useState(false); const [message, setMessage] = useState(null); - const refresh = useCallback(async () => { - const [targetResult, licenseResult] = await Promise.all([api.targets(), api.license()]); - setTargets(remoteOnly(targetResult.targets)); - setLicense(licenseResult); - }, [api]); - - useEffect(() => { - let cancelled = false; - Promise.all([api.targets(), api.license()]) - .then(([targetResult, licenseResult]) => { - if (!cancelled) { - setTargets(remoteOnly(targetResult.targets)); - setLicense(licenseResult); - } - return undefined; - }) - .catch((cause: unknown) => { - if (!cancelled) { - setMessage(cause instanceof Error ? cause.message : "Could not load targets"); - } - }); - return () => { - cancelled = true; - }; - }, [api]); - - const act = useCallback( - async (operation: () => Promise, success: string) => { - setBusy(true); - setMessage(null); - try { - await api.refreshCsrf(); - await operation(); - await refresh(); - setMessage(success); - } catch (cause) { - setMessage(cause instanceof Error ? cause.message : "Target operation failed"); - } finally { - setBusy(false); - } - }, - [api, refresh], - ); + const targets = remoteOnly(targetsResult.data?.targets ?? []); + const busy = save.isPending || remove.isPending || probe.isPending; const onProbe = useCallback( (target: TargetResponse) => { - void act(async () => { - const result = await api.probeTarget(target.name); - if (!result.reachable) { - throw new Error(result.message ?? "Target is unreachable"); - } - }, "Target is reachable."); + setMessage(null); + probe.mutate( + { name: target.name }, + { + onSuccess: (result) => { + setMessage( + result.reachable + ? "Target is reachable." + : (result.message ?? "Target is unreachable"), + ); + }, + onError: (cause) => { + setMessage(cause.message); + }, + }, + ); }, - [act, api], + [probe], ); const onEdit = useCallback((target: TargetResponse) => { @@ -140,9 +116,20 @@ export function RemoteTargetsPage() { const onRemove = useCallback( (target: TargetResponse) => { - void act(() => api.removeTarget(target.name), "Target removed."); + setMessage(null); + remove.mutate( + { name: target.name }, + { + onSuccess: () => { + setMessage("Target removed."); + }, + onError: (cause) => { + setMessage(cause.message); + }, + }, + ); }, - [act, api], + [remove], ); const onAddLicense = useCallback(() => { @@ -150,15 +137,24 @@ export function RemoteTargetsPage() { }, [navigate]); const onSubmit = useCallback( - (event: React.FormEvent) => { + (event: React.SubmitEvent) => { event.preventDefault(); - void act(async () => { - await api.saveTarget(draft, editing); - setDraft(emptyTarget()); - setEditing(undefined); - }, "Target saved."); + setMessage(null); + save.mutate( + { target: draft, existingName: editing }, + { + onSuccess: () => { + setDraft(emptyTarget()); + setEditing(undefined); + setMessage("Target saved."); + }, + onError: (cause) => { + setMessage(cause.message); + }, + }, + ); }, - [act, api, draft, editing], + [draft, editing, save], ); const onCancelEdit = useCallback(() => { @@ -166,20 +162,27 @@ export function RemoteTargetsPage() { setDraft(emptyTarget()); }, []); + const statusMessage = message ?? targetsResult.error?.message ?? license.error?.message ?? null; + const loading = targetsResult.isLoading || license.isLoading; + return (

Remote targets Premium

Register SSH hosts and Coder workspaces for delegators to run agents remotely.

- {message && {message}} - {license && !license.premium && ( + {statusMessage && {statusMessage}} + {loading &&

Loading remote targets…

} + {license.data && !license.data.premium && ( )} + {!loading && !targetsResult.error && targets.length === 0 && ( +

No remote targets are registered.

+ )}
    {targets.map((target) => ( ))}
- {license?.premium && ( + {license.data?.premium && (

{editing ? "Edit target" : "Register target"}