From 38a2d37fe098777a378ae91c53f7f74bf4c9e422 Mon Sep 17 00:00:00 2001 From: untra Date: Sat, 19 Sep 2026 15:47:14 -0600 Subject: [PATCH 01/18] tool capability and remote target health supportcatalog feat: add grok CLI and xAI as Alpha LLM tool and model provider Ship grok as a catalog Alpha coding agent (binary grok, implicit xai-api) and xAI as a first-party builtin model server. Detection, launch template (-m, --session-id), remote SSH/tmux with XAI_API_KEY injection, and the compatibility matrix all follow the existing shipped-tool contract. AGENTS.md is now the shared project-discovery marker for every shipped CLI, with CLAUDE.md / CODEX.md / GEMINI.md / GROK.md as vendor backups. Fixes broken relative local docs links. Co-authored-by: Grok 4.6 rename claude.md to agents.md --- CLAUDE.md => AGENTS.md | 22 +- README.md | 5 +- bindings/Delegator.ts | 3 +- bindings/IntegrationCatalogEntryDto.ts | 6 +- bindings/ModelServer.ts | 4 +- bindings/TargetProbeResponse.ts | 7 +- bindings/TargetToolProbe.ts | 3 + docs/_data/navigation.yml | 6 + docs/assets/icons/xai.svg | 1 + docs/getting-started/agents/claude.md | 61 +- docs/getting-started/agents/codex.md | 64 +- docs/getting-started/agents/gemini-cli.md | 71 +- docs/getting-started/agents/grok.md | 56 ++ docs/getting-started/agents/index.md | 1 + docs/getting-started/kanban/github.md | 6 +- .../model-servers/anthropic.md | 4 +- docs/getting-started/model-servers/google.md | 4 +- docs/getting-started/model-servers/index.md | 14 +- docs/getting-started/model-servers/ollama.md | 6 +- docs/getting-started/model-servers/openai.md | 4 +- .../model-servers/openrouter.md | 6 +- docs/getting-started/model-servers/xai.md | 49 ++ docs/llm-tools/index.md | 230 ++++-- docs/maturity/index.md | 39 + docs/schemas/index.md | 4 +- icons/xai.svg | 1 + opr8r/src/cli.rs | 16 + opr8r/src/main.rs | 37 +- opr8r/src/tools.rs | 174 +++++ shared/types.ts | 6 +- src/agents/delegator_resolution.rs | 26 +- src/agents/launcher/llm_command.rs | 95 ++- src/agents/launcher/mod.rs | 20 + src/agents/launcher/prompt.rs | 112 ++- src/agents/launcher/remote.rs | 23 +- src/api/providers/model_server/mod.rs | 32 +- src/api/providers/model_server/probe.rs | 1 + src/config/config_tests.rs | 46 +- src/config/llm_tools.rs | 110 ++- src/docs_gen/integrations.rs | 136 ++++ src/docs_gen/llm_tools.rs | 47 +- src/docs_gen/mod.rs | 9 +- src/integrations/catalog.rs | 46 ++ src/integrations/mod.rs | 2 + src/integrations/support_catalog.rs | 728 ++++++++++++++++++ src/llm/detection.rs | 55 +- src/llm/inventory.rs | 126 +++ src/llm/mod.rs | 3 + src/llm/tool_config.rs | 14 +- src/llm/tools/grok.json | 24 + src/main.rs | 4 +- src/projects.rs | 78 +- src/rest/dto/integrations.rs | 82 ++ src/rest/routes/model_servers.rs | 40 +- src/rest/routes/targets.rs | 52 ++ src/ui/in_progress_panel.rs | 1 + src/ui/sections/llm_section.rs | 13 +- src/ui/sections/modelserver_section.rs | 18 +- src/ui/setup/steps/welcome.rs | 17 +- src/ui/status_panel.rs | 3 +- tests/docs_structure.rs | 58 +- tests/vertical_parity.rs | 66 ++ ui/README.md | 4 +- ui/public/icons/xai.svg | 1 + vscode-extension/src/extension.ts | 8 +- vscode-extension/src/walkthrough.ts | 5 +- .../test/suite/walkthrough.test.ts | 4 + 67 files changed, 2603 insertions(+), 416 deletions(-) rename CLAUDE.md => AGENTS.md (94%) create mode 100644 bindings/TargetToolProbe.ts create mode 100644 docs/assets/icons/xai.svg create mode 100644 docs/getting-started/agents/grok.md create mode 100644 docs/getting-started/model-servers/xai.md create mode 100644 icons/xai.svg create mode 100644 opr8r/src/tools.rs create mode 100644 src/integrations/support_catalog.rs create mode 100644 src/llm/inventory.rs create mode 100644 src/llm/tools/grok.json create mode 100644 ui/public/icons/xai.svg diff --git a/CLAUDE.md b/AGENTS.md similarity index 94% rename from CLAUDE.md rename to AGENTS.md index f6607762..dc7be666 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 @@ -241,7 +241,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 +291,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,14 +313,9 @@ 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 | |---------|-------|------| @@ -330,9 +324,7 @@ it; never re-declare a brand color elsewhere. | 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/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/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/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/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/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/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/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/llm-tools/index.md b/docs/llm-tools/index.md index 49461599..242e792b 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 +# LLM Tools -### Claude Code +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. -The primary LLM tool supported by Operator. +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**, not that an API key works. -### 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 - it +is not merged field-by-field. 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,144 @@ 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 - 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, 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: -### JSON Schema for Structured Output (Temporarily Disabled) +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 -The `jsonSchema` and `jsonSchemaFile` step properties are currently disabled. These properties configure the `--json-schema` flag for Claude Code to enable structured output validation. +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. -**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/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/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/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/main.rs b/opr8r/src/main.rs index 10fa1d76..449b4478 100644 --- a/opr8r/src/main.rs +++ b/opr8r/src/main.rs @@ -4,6 +4,7 @@ mod cli; mod operator_relay; mod output_parser; mod runner; +mod tools; mod transition; use api::{ApiClient, StepCompleteRequest}; @@ -55,17 +56,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/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..4b7bee22 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,7 +65,8 @@ 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())), } } @@ -115,13 +120,13 @@ fn adhoc_model_server_env( .find(|s| s.name == name) .cloned() .or_else(|| { - ["claude", "codex", "gemini"] - .iter() - .map(|t| implicit_model_server_for_tool(t)) + crate::config::implicit_model_servers() + .into_iter() .find(|s| s.name == name) }) .ok_or_else(|| ResolutionError::UnknownModelServer(name.to_string()))?, - None => implicit_model_server_for_tool(tool), + 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 +505,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/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/api/providers/model_server/mod.rs b/src/api/providers/model_server/mod.rs index a055c319..21a6b3e4 100644 --- a/src/api/providers/model_server/mod.rs +++ b/src/api/providers/model_server/mod.rs @@ -109,6 +109,7 @@ pub enum ModelServerKind { AnthropicApi, OpenAiApi, GoogleApi, + XaiApi, Ollama, OpenRouter, OpenAiCompat, @@ -119,10 +120,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 +137,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 +154,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 +173,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 +187,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 +200,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 +216,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 +233,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 +253,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 +276,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 +296,7 @@ impl ModelServerKind { match self { ModelServerKind::AnthropicApi => "ANTHROPIC_BASE_URL", ModelServerKind::OpenAiApi + | ModelServerKind::XaiApi | ModelServerKind::OpenAiCompat | ModelServerKind::Ollama | ModelServerKind::OpenRouter @@ -295,6 +309,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 +333,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 +352,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 +445,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 +486,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..26388146 100644 --- a/src/api/providers/model_server/probe.rs +++ b/src/api/providers/model_server/probe.rs @@ -203,6 +203,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/config/config_tests.rs b/src/config/config_tests.rs index 3b45092a..444de3bf 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" ); - 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"); +} + +#[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" + ); + + 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] diff --git a/src/config/llm_tools.rs b/src/config/llm_tools.rs index 4e2eca72..311591bd 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 { @@ -260,25 +257,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..3eb73f7f 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. -## Supported Tools +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**, not that an API key works. -| 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 | +## Supported Tools +"#, + ); + 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 @@ -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 @@ -277,9 +299,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/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/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..00034eb3 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -10,9 +10,12 @@ //! the system PATH unless a tool opts out via `detection.mode: "always"`. mod detection; +mod inventory; 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/tool_config.rs b/src/llm/tool_config.rs index def1b1c6..13bce73f 100644 --- a/src/llm/tool_config.rs +++ b/src/llm/tool_config.rs @@ -164,6 +164,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 +277,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 +292,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 +307,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 +321,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 +330,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 +339,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..6620c245 100644 --- a/src/main.rs +++ b/src/main.rs @@ -598,9 +598,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." 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/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/targets.rs b/src/rest/routes/targets.rs index bd8d14b4..376fe71d 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 { @@ -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/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/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/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/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/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 22d5c65d..e05d6b41 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -930,9 +930,6 @@ export async function activate(context: vscode.ExtensionContext): Promise // Initialize issue type service (constructor is safe - no network calls) const issueTypeService = new IssueTypeService(outputChannel); - - // Register tree view providers IMMEDIATELY so VS Code never shows - // "no data provider registered" - they start empty and populate async. const statusProvider = new StatusTreeProvider(context); const inProgressProvider = new TicketTreeProvider("in-progress", issueTypeService); const queueProvider = new TicketTreeProvider("queue", issueTypeService); @@ -955,9 +952,8 @@ export async function activate(context: vscode.ExtensionContext): Promise // Handle deep-links from the Operator web UI / control plane: // vscode://untra.operator-terminals/focus-session?name= - // focuses the agent's terminal tab by name. The web UI's launch panel emits - // this link after launching a ticket when the operator control wrapper is VS - // Code, so the user can jump straight to the running agent's terminal. + // focuses the agent's terminal tab by name. The web UI's launch panel emits this link after launching a ticket + // when the operator control wrapper is VS Code, so the user can jump straight to the running agent's terminal. context.subscriptions.push( vscode.window.registerUriHandler({ handleUri(uri: vscode.Uri) { diff --git a/vscode-extension/src/walkthrough.ts b/vscode-extension/src/walkthrough.ts index 8f286b50..d2e3b310 100644 --- a/vscode-extension/src/walkthrough.ts +++ b/vscode-extension/src/walkthrough.ts @@ -77,8 +77,8 @@ export function findEnvVar(keys: readonly string[]): string | undefined { return undefined; } -/** LLM tools to detect */ -export const LLM_TOOLS = ["claude", "codex", "gemini"] as const; +/** LLM tools to detect. Must match `shipped_llm_tools()` binaries in src/config/llm_tools.rs. */ +export const LLM_TOOLS = ["claude", "codex", "gemini", "grok"] as const; /** Minimal detected tool info (mirrors Rust DetectedTool subset) */ export interface DetectedToolResult { @@ -94,6 +94,7 @@ const TOOL_META: Record = { claude: { versionCmd: "claude --version", minVersion: "2.1.0" }, codex: { versionCmd: "codex --version", minVersion: "0.1.0" }, gemini: { versionCmd: "gemini --version", minVersion: "0.1.0" }, + grok: { versionCmd: "grok --version", minVersion: "0.1.0" }, }; function parseVersion(value: string): number[] { diff --git a/vscode-extension/test/suite/walkthrough.test.ts b/vscode-extension/test/suite/walkthrough.test.ts index 7e66bca1..47404cf5 100644 --- a/vscode-extension/test/suite/walkthrough.test.ts +++ b/vscode-extension/test/suite/walkthrough.test.ts @@ -332,5 +332,9 @@ suite("Walkthrough Test Suite", () => { test("includes gemini", () => { assert.ok((LLM_TOOLS as readonly string[]).includes("gemini")); }); + + test("includes grok", () => { + assert.ok((LLM_TOOLS as readonly string[]).includes("grok")); + }); }); }); From 4fa8cd5ad6cb1107a0f8596982799ec5703279f6 Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 20 Sep 2026 05:56:42 -0600 Subject: [PATCH 02/18] layout adjustment --- bindings/SetupStep.ts | 2 +- docs/startup/index.md | 94 +++--- src/startup/steps.rs | 69 +++-- tests/setup_parity.rs | 78 ++++- ui/src/Layout.tsx | 43 ++- ui/src/profiles-context.module.css | 46 ++- ui/src/profiles-context.tsx | 6 +- ui/src/routes/ConfigPage.tsx | 6 + .../onboarding/OnboardingPage.module.css | 5 +- ui/src/routes/onboarding/OnboardingPage.tsx | 272 +++++++++--------- ui/src/routes/onboarding/steps.tsx | 67 ++++- ui/src/routes/onboarding/types.ts | 6 + .../src/components/AppShell.module.css | 49 +++- .../src/components/AppShell.test.tsx | 34 +++ webcomponents/src/components/AppShell.tsx | 34 ++- webcomponents/src/index.ts | 4 +- 16 files changed, 552 insertions(+), 263 deletions(-) create mode 100644 webcomponents/src/components/AppShell.test.tsx 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/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/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/tests/setup_parity.rs b/tests/setup_parity.rs index a03ddfd8..68542ec2 100644 --- a/tests/setup_parity.rs +++ b/tests/setup_parity.rs @@ -15,6 +15,7 @@ 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 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. @@ -266,18 +267,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/ui/src/Layout.tsx b/ui/src/Layout.tsx index e375d958..e17e2f75 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -1,12 +1,12 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, 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"; @@ -17,14 +17,12 @@ 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 { 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( @@ -86,10 +84,32 @@ function RightPanelController() { export function Layout() { const { theme, toggleTheme } = useTheme(); const host = useHost(); + const { selected } = useProfiles(); const navigate = useNavigate(); + const [username, setUsername] = useState(null); const [signingOut, setSigningOut] = useState(false); const [signOutError, setSignOutError] = useState(false); + useEffect(() => { + let active = true; + new OperatorApi(host) + .currentSession() + .then((session) => { + if (active) { + setUsername(session.subject); + } + return undefined; + }) + .catch(() => { + if (active) { + setUsername("Account unavailable"); + } + }); + return () => { + active = false; + }; + }, [host]); + const signOut = useCallback(async () => { setSigningOut(true); setSignOutError(false); @@ -118,12 +138,19 @@ export function Layout() { } groups={ <> - } - footer={} + footer={ + + } panel={} > diff --git a/ui/src/profiles-context.module.css b/ui/src/profiles-context.module.css index c6adb00d..9d3d7ffd 100644 --- a/ui/src/profiles-context.module.css +++ b/ui/src/profiles-context.module.css @@ -1,23 +1,55 @@ .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); } .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.tsx b/ui/src/profiles-context.tsx index 5ec0e412..59f26749 100644 --- a/ui/src/profiles-context.tsx +++ b/ui/src/profiles-context.tsx @@ -138,7 +138,9 @@ export function ProfileSelector() { return; } select(profile.id); - void navigate(profile.initialized ? "/" : "/onboarding"); + if (!profile.initialized) { + void navigate("/onboarding"); + } }, [profiles, select, navigate], ); @@ -149,7 +151,7 @@ export function ProfileSelector() { return (
- + setName(event.target.value)} - /> - Use lowercase letters, numbers, hyphens, and underscores. - - {error &&

{error}

} - - {selected && ( - - )} - - - + ); } -function OnboardingWizard() { +export function OnboardingPage() { const host = useHost(); - const { selected, refresh } = useProfiles(); + const { selected, create, refresh } = useProfiles(); const [params] = useSearchParams(); const navigate = useNavigate(); const [api] = useState(() => new OperatorApi(host)); + // Mount-scoped, not a live read of the query string: the step sync below + // strips `?new=1` as soon as the catalog loads, and a failed create must + // still leave the user creating rather than renaming the selected profile. + const [creating] = useState(() => !selected || params.get("new") === "1"); + const [initialStepParam] = useState(() => params.get("step")); const [status, setStatus] = useState(null); const [steps, setSteps] = useState>>([]); const [integrations, setIntegrations] = useState>>( @@ -117,10 +102,8 @@ function OnboardingWizard() { const [collections, setCollections] = useState>>( [], ); - const [draft, setDraft] = useState(() => emptyDraft(selected?.name ?? "")); - const [currentSlug, setCurrentSlug] = useState( - params.get("step") === "license" ? "license" : "welcome", - ); + const [draft, setDraft] = useState(() => emptyDraft("")); + const [currentSlug, setCurrentSlug] = useState("welcome"); const [exports, setExports] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -138,7 +121,10 @@ function OnboardingWizard() { if (!active) { return undefined; } - if (nextStatus.initialized) { + // While creating, the status describes whichever configuration the + // server defaulted to, so its `initialized` flag says nothing about the + // one being created. + if (nextStatus.initialized && !creating) { void navigate("/", { replace: true }); return undefined; } @@ -146,31 +132,34 @@ function OnboardingWizard() { setSteps(nextSteps); setIntegrations(nextIntegrations); setCollections(nextCollections); - const initial = emptyDraft(selected?.name ?? "", nextStatus.default_acceptance_criteria); - if (selected) { + const initial = emptyDraft( + creating ? "" : (selected?.name ?? ""), + nextStatus.default_acceptance_criteria, + ); + const known = new Set(nextSteps.map((step) => step.slug)); + let saved: SavedDraft | null = null; + if (selected && !creating) { try { - const saved = JSON.parse( + saved = JSON.parse( sessionStorage.getItem(draftStorageKey(selected.id)) ?? "null", - ) as { - version?: number; - draft?: Partial; - currentSlug?: SetupStep; - } | null; - if (saved?.version === WIZARD_DRAFT_VERSION && saved.draft) { - setDraft({ ...initial, ...saved.draft }); - if (saved.currentSlug) { - setCurrentSlug(saved.currentSlug); - } - } else { - setDraft(initial); - } + ) as SavedDraft | null; } catch { sessionStorage.removeItem(draftStorageKey(selected.id)); - setDraft(initial); } - } else { - setDraft(initial); } + setDraft( + saved?.version === WIZARD_DRAFT_VERSION && saved.draft + ? { ...initial, ...saved.draft } + : initial, + ); + const fromUrl = initialStepParam as SetupStep | null; + setCurrentSlug( + fromUrl && known.has(fromUrl) + ? fromUrl + : saved?.currentSlug && known.has(saved.currentSlug) + ? saved.currentSlug + : "welcome", + ); return undefined; }) .catch( @@ -180,27 +169,37 @@ function OnboardingWizard() { return () => { active = false; }; - }, [api, navigate, selected]); + }, [api, creating, initialStepParam, navigate, selected]); + + // The address bar names the step, so a refresh resumes where the user was and + // `?new=1` falls away once the wizard has taken over. + useEffect(() => { + if (status) { + void navigate(`/onboarding?step=${currentSlug}`, { replace: true }); + } + }, [currentSlug, navigate, status]); useEffect(() => { - if (!selected || !status || status.initialized) { + if (!selected || !status || status.initialized || creating) { return; } sessionStorage.setItem( draftStorageKey(selected.id), JSON.stringify({ version: WIZARD_DRAFT_VERSION, draft, currentSlug }), ); - }, [currentSlug, draft, selected, status]); + }, [creating, currentSlug, draft, selected, status]); - const walk = useMemo( - () => - visibleSteps( - steps.map((step) => step.slug), - draft, - ), - [draft, steps], - ); + const slugs = useMemo(() => steps.map((step) => step.slug), [steps]); + const walk = useMemo(() => visibleSteps(slugs, draft), [draft, slugs]); + const rows = useMemo(() => stepRows(slugs, draft), [draft, slugs]); const currentIndex = Math.max(0, walk.indexOf(currentSlug)); + + useEffect(() => { + if (walk.length > 0 && !walk.includes(currentSlug)) { + setCurrentSlug(walk[0]); + } + }, [currentSlug, walk]); + const current = steps.find((step) => step.slug === walk[currentIndex]); const Step = current ? STEP_COMPONENTS[current.slug] : null; @@ -213,15 +212,49 @@ function OnboardingWizard() { [setExports], ); + /** + * Create the configuration the welcome step just named, hand the draft over + * under its id, and point the URL at the next step. `create` selects the new + * profile, which remounts this wizard through `ProfileScope`; both writes + * have to land before that commit for the handoff to survive. + */ + async function createConfiguration(nextSlug: SetupStep): Promise { + setBusy(true); + try { + const profile = await create(draft.configurationName); + sessionStorage.setItem( + draftStorageKey(profile.id), + JSON.stringify({ + version: WIZARD_DRAFT_VERSION, + draft: { ...draft, configurationName: profile.name }, + currentSlug: nextSlug, + }), + ); + void navigate(`/onboarding?step=${nextSlug}`, { replace: true }); + return true; + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not create configuration"); + return false; + } finally { + setBusy(false); + } + } + async function next() { if (!current) { return; } + const nextSlug = walk[Math.min(currentIndex + 1, walk.length - 1)]; if (current.slug === "welcome") { if (!/^[a-z0-9_-]{1,64}$/.test(draft.configurationName)) { setError("Use 1-64 lowercase letters, numbers, hyphens, or underscores."); return; } + if (creating) { + setError(null); + await createConfiguration(nextSlug); + return; + } if (selected && selected.name !== draft.configurationName) { setBusy(true); try { @@ -271,7 +304,7 @@ function OnboardingWizard() { } } setError(null); - setCurrentSlug(walk[Math.min(currentIndex + 1, walk.length - 1)]); + setCurrentSlug(nextSlug); } async function initialize() { @@ -331,29 +364,7 @@ function OnboardingWizard() { return (
- +

@@ -369,6 +380,7 @@ function OnboardingWizard() { status={status} integrations={integrations} collections={collections} + creating={creating} draft={draft} setDraft={setDraft} exports={exports} diff --git a/ui/src/routes/onboarding/steps.tsx b/ui/src/routes/onboarding/steps.tsx index 06c21aa7..50cc3eb1 100644 --- a/ui/src/routes/onboarding/steps.tsx +++ b/ui/src/routes/onboarding/steps.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { KanbanProviderKind } from "@operator/bindings/KanbanProviderKind"; import type { SetupStep } from "@operator/bindings/SetupStep"; -import type { StepComponent, StepProps } from "./types"; +import type { StepComponent, StepProps, StepRow } from "./types"; import { Choice, ChoiceGroup, PremiumPaywall } from "@operator/webcomponents"; import { LicensePanel } from "../../components/LicensePanel"; import type { LicenseResponse } from "../../api-client"; @@ -33,7 +33,7 @@ function ExportBlock({ value }: { value: string }) { ); } -const Welcome: StepComponent = ({ status, draft, setDraft }) => ( +const Welcome: StepComponent = ({ status, creating, draft, setDraft }) => (

Welcome to Operator

We’ll configure this workspace for both the terminal and browser.

@@ -50,17 +50,21 @@ const Welcome: StepComponent = ({ status, draft, setDraft }) => ( /> Use lowercase letters, numbers, hyphens, and underscores. -
-
Configuration
-
{status.config_path}
-
Tickets
-
{status.tickets_path}
-
- {Object.entries(status.projects_by_tool).map(([tool, projects]) => ( -

- {tool}: {projects.join(", ") || "none"} -

- ))} + {!creating && ( + <> +
+
Configuration
+
{status.config_path}
+
Tickets
+
{status.tickets_path}
+
+ {Object.entries(status.projects_by_tool).map(([tool, projects]) => ( +

+ {tool}: {projects.join(", ") || "none"} +

+ ))} + + )} ); @@ -1030,10 +1034,8 @@ export const STEP_COMPONENTS = { "model-server": ModelServer, "git-provider": GitProvider, "collection-source": CollectionSource, - "hosted-collections": HostedCollections, "task-field-config": TaskFieldConfig, "session-wrapper-choice": SessionWrapperChoice, - "execution-target": ExecutionTarget, "worktree-preference": WorktreePreference, "admin-password": AdminPassword, "tmux-onboarding": TmuxOnboarding, @@ -1042,6 +1044,8 @@ export const STEP_COMPONENTS = { "zellij-setup": ZellijSetup, "acceptance-criteria": AcceptanceCriteria, "startup-tickets": StartupTickets, + "hosted-collections": HostedCollections, + "execution-target": ExecutionTarget, confirm: Confirm, } satisfies Record; @@ -1061,3 +1065,36 @@ export function visibleSteps(steps: SetupStep[], draft: StepProps["draft"]): Set (!wrapperSteps.has(step) || step === wrapperStep[draft.wrapper]), ); } + +/** + * Steps a run may skip outright. The catalog gathers them just before `confirm`, + * and the sidebar shows the skipped ones as dimmed, unnumbered rows so that + * answering an earlier question never renumbers the steps already shown. + */ +export const OPTIONAL_STEPS = { + "hosted-collections": "if a hosted collection source is chosen", + "execution-target": "premium · if agents run on remote targets", +} as const satisfies Partial>; + +type OptionalStep = keyof typeof OPTIONAL_STEPS; + +/** + * The sidebar's rows, in catalog order: every step of the walk numbered from 1, + * plus a `number: null` placeholder for each optional step this draft skips. + * Steps no run ever reaches (the admin password, the three unchosen wrappers) + * are left out entirely. + */ +export function stepRows(steps: SetupStep[], draft: StepProps["draft"]): StepRow[] { + const walk = new Set(visibleSteps(steps, draft)); + const rows: StepRow[] = []; + let number = 0; + for (const slug of steps) { + if (walk.has(slug)) { + number += 1; + rows.push({ slug, number }); + } else if (slug in OPTIONAL_STEPS) { + rows.push({ slug, number: null, hint: OPTIONAL_STEPS[slug as OptionalStep] }); + } + } + return rows; +} diff --git a/ui/src/routes/onboarding/types.ts b/ui/src/routes/onboarding/types.ts index 39e6c218..85c533c6 100644 --- a/ui/src/routes/onboarding/types.ts +++ b/ui/src/routes/onboarding/types.ts @@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from "react"; import type { CollectionPreset } from "@operator/bindings/CollectionPreset"; import type { SessionWrapperType } from "@operator/bindings/SessionWrapperType"; import type { SetupExecutionTarget } from "@operator/bindings/SetupExecutionTarget"; +import type { SetupStep } from "@operator/bindings/SetupStep"; import type { IntegrationCatalogEntryDto, OperatorApi, @@ -29,6 +30,8 @@ export type StepProps = { status: SetupStatusResponse; integrations: IntegrationCatalogEntryDto[]; collections: SetupCollectionResponse[]; + /** The configuration does not exist yet, so `status` describes the default one. */ + creating: boolean; draft: WizardDraft; setDraft: Dispatch>; exports: string[]; @@ -36,3 +39,6 @@ export type StepProps = { }; export type StepComponent = (props: StepProps) => React.JSX.Element; + +/** One row of the setup sidebar. `number: null` marks an optional step this draft skips. */ +export type StepRow = { slug: SetupStep; number: number | null; hint?: string }; diff --git a/webcomponents/src/components/AppShell.module.css b/webcomponents/src/components/AppShell.module.css index 711ad910..14fcc831 100644 --- a/webcomponents/src/components/AppShell.module.css +++ b/webcomponents/src/components/AppShell.module.css @@ -74,26 +74,61 @@ margin-bottom: 6px; } -.signOut { +.accountFooter { margin-top: auto; - padding: 8px 12px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.accountRow { + display: flex; + align-items: center; + gap: 10px; +} + +.accountIdentity { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.username, +.configurationName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.username { + color: var(--text); + font-size: 0.8125rem; + font-weight: 600; +} + +.configurationName { + color: var(--text-muted); + font-size: 0.75rem; +} + +.signOut { + flex-shrink: 0; + padding: 6px 9px; color: var(--color-green-l3); background: transparent; border: 1px solid var(--color-green-l3); border-radius: var(--radius); cursor: pointer; + font-size: 0.75rem; } .signOutError { - margin: auto 0 8px; + margin: 0 0 8px; color: var(--danger); font-size: 0.75rem; } -.signOutError + .signOut { - margin-top: 0; -} - .signOut:disabled { opacity: 0.6; cursor: default; diff --git a/webcomponents/src/components/AppShell.test.tsx b/webcomponents/src/components/AppShell.test.tsx new file mode 100644 index 00000000..23255991 --- /dev/null +++ b/webcomponents/src/components/AppShell.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { AccountFooter } from "./AppShell"; + +function handleSignOut() {} + +describe("AccountFooter", () => { + test("renders the account, configuration, and sign-out action", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("admin"); + expect(markup).toContain("Development"); + expect(markup).toContain(">Sign out"); + }); + + test("renders busy and failure states", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Could not sign out."); + expect(markup).toContain("disabled"); + expect(markup).toContain("Signing out…"); + }); +}); diff --git a/webcomponents/src/components/AppShell.tsx b/webcomponents/src/components/AppShell.tsx index 6d2518e1..58e0f2d2 100644 --- a/webcomponents/src/components/AppShell.tsx +++ b/webcomponents/src/components/AppShell.tsx @@ -119,20 +119,38 @@ export function ThemeToggle({ theme, onToggle }: ThemeToggleProps) { ); } -export interface SignOutButtonProps { +export interface AccountFooterProps { + username: string; + configurationName: string; busy?: boolean; failed?: boolean; - onClick: () => void; + onSignOut: () => void; } -export function SignOutButton({ busy, failed, onClick }: SignOutButtonProps) { +export function AccountFooter({ + username, + configurationName, + busy, + failed, + onSignOut, +}: AccountFooterProps) { return ( - <> +
{failed &&

Could not sign out.

} - - +
+
+ + {username} + + + {configurationName} + +
+ +
+
); } diff --git a/webcomponents/src/index.ts b/webcomponents/src/index.ts index ed61f4ed..17f4640f 100644 --- a/webcomponents/src/index.ts +++ b/webcomponents/src/index.ts @@ -4,15 +4,15 @@ export { PremiumPaywall, type PremiumPaywallProps } from "./components/PremiumPa export { AppShell, + AccountFooter, BrandName, NavGroup, NavRow, - SignOutButton, ThemeToggle, + type AccountFooterProps, type AppShellProps, type NavGroupProps, type NavRowProps, - type SignOutButtonProps, type ThemeToggleProps, } from "./components/AppShell"; export { From 56658a35f6e203fda28c9f5a1d06c3d7888f3600 Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 27 Sep 2026 12:40:08 -0600 Subject: [PATCH 03/18] license implementation closer --- .github/workflows/build.yaml | 17 +- Cargo.lock | 374 ++++++++------------- Cargo.toml | 3 +- build.rs | 16 +- docs/cli/index.md | 11 - docs/getting-started/premium/index.md | 32 +- opr8r/Cargo.lock | 400 +++++++++++------------ opr8r/Cargo.toml | 3 +- opr8r/src/api.rs | 11 + src/agents/launcher/coder.rs | 2 +- src/agents/launcher/tests.rs | 2 +- src/api/anthropic.rs | 2 +- src/api/github.rs | 2 +- src/api/providers/ai/anthropic.rs | 2 +- src/api/providers/repo/github.rs | 2 +- src/auth/egress.rs | 2 +- src/env_vars.rs | 37 +-- src/http_client.rs | 48 +++ src/lib.rs | 2 + src/licensing.rs | 450 ++++++++++++++++++-------- src/main.rs | 8 +- src/rest/routes/targets.rs | 2 +- src/rest/server.rs | 2 +- src/services/git_onboarding.rs | 4 +- src/trust_verify.rs | 418 ++++++++++++++++++++++++ testdata/trust-vectors.json | 115 +++++++ tests/licensing_integration.rs | 68 ++-- tests/rest_api_integration.rs | 2 +- tests/trust_vectors.rs | 77 +++++ 29 files changed, 1430 insertions(+), 684 deletions(-) create mode 100644 src/http_client.rs create mode 100644 src/trust_verify.rs create mode 100644 testdata/trust-vectors.json create mode 100644 tests/trust_vectors.rs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f95b23f6..97187937 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -449,19 +449,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/Cargo.lock b/Cargo.lock index 107f24f2..764ee16d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,7 +118,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -129,7 +129,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -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" @@ -1060,7 +1065,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.60.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" @@ -2417,7 +2413,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -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]] @@ -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.60.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" @@ -3925,7 +3853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4111,7 +4039,7 @@ dependencies = [ "parking_lot", "rustix", "signal-hook", - "windows-sys 0.61.2", + "windows-sys 0.60.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" @@ -4611,7 +4529,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 4adac1e9..5df18afd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,7 +75,8 @@ 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"] } # Async traits async-trait = "0.1" 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/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/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/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..772117f3 100644 --- a/opr8r/src/api.rs +++ b/opr8r/src/api.rs @@ -15,6 +15,16 @@ const API_SESSION_FILE: &str = ".tickets/operator/api-session.json"; const API_TOKEN_ENV: &str = "OPERATOR_API_TOKEN"; const PROFILE_ID_ENV: &str = "OPERATOR_PROFILE_ID"; +static INSTALL_CRYPTO_PROVIDER: std::sync::Once = std::sync::Once::new(); + +/// reqwest is built with `rustls-no-provider` to stay on ring; rustls needs a +/// process-default provider installed before the first client is built. +fn ensure_crypto_provider() { + INSTALL_CRYPTO_PROVIDER.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + /// Retry configuration const MAX_RETRIES: u32 = 3; const INITIAL_BACKOFF_MS: u64 = 1000; @@ -224,6 +234,7 @@ impl ApiClient { /// Create a client with an explicit callback credential and, when the /// configuration is known, the id its callbacks must be routed to. pub fn with_profile(base_url: &str, token: Option, profile_id: Option) -> Self { + ensure_crypto_provider(); let client = Client::builder() .timeout(Duration::from_secs(30)) .build() 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/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/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/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/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 "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..7c65f173 --- /dev/null +++ b/src/http_client.rs @@ -0,0 +1,48 @@ +//! 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(); + }); +} + +pub fn client_builder() -> reqwest::ClientBuilder { + ensure_crypto_provider(); + reqwest::Client::builder() +} + +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 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/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/main.rs b/src/main.rs index 6620c245..82ea570a 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)] @@ -977,7 +980,7 @@ 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(); @@ -1454,8 +1457,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/rest/routes/targets.rs b/src/rest/routes/targets.rs index 376fe71d..ab86f052 100644 --- a/src/rest/routes/targets.rs +++ b/src/rest/routes/targets.rs @@ -270,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()))?; 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/services/git_onboarding.rs b/src/services/git_onboarding.rs index 357cd6d6..4d125f2b 100644 --- a/src/services/git_onboarding.rs +++ b/src/services/git_onboarding.rs @@ -49,7 +49,7 @@ 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 +68,7 @@ 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/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/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/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/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/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), + } + } +} From 19139db366dd85b461ad956151db0bcb609544b4 Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 27 Sep 2026 14:36:50 -0600 Subject: [PATCH 04/18] rig wip, auth tests --- Cargo.lock | 24 +- Cargo.toml | 9 + bindings/Config.ts | 7 +- bindings/JudgeAttempt.ts | 7 + bindings/JudgeConfig.ts | 15 + bindings/MultiAgentGroup.ts | 8 +- bindings/MultiModelConfig.ts | 3 +- bindings/MultiPromptConfig.ts | 3 +- bindings/NativeLlmConfig.ts | 14 + docs/configuration/index.md | 2 + docs/getting-started/workflows/index.md | 32 + docs/schemas/index.md | 4 +- docs/schemas/issuetype.md | 4 +- docs/schemas/openapi.json | 44 +- docs/security/index.md | 10 + src/agents/delegator_resolution.rs | 30 +- src/agents/judge.rs | 265 +++ src/agents/mod.rs | 1 + src/agents/sync.rs | 669 ++++++-- src/api/providers/model_server/mod.rs | 14 + src/api/providers/model_server/probe.rs | 9 +- src/config.rs | 5 + src/config/config_tests.rs | 24 + src/config/llm_tools.rs | 28 + src/docs_gen/llm_tools.rs | 20 +- src/llm/mod.rs | 1 + src/llm/native/mod.rs | 171 ++ src/llm/native/rig/client.rs | 195 +++ src/llm/native/rig/mod.rs | 116 ++ src/llm/native/rig/tests.rs | 340 ++++ src/llm/tool_config.rs | 3 +- src/main.rs | 7 +- src/rest/routes/auth.rs | 1497 ++++++++++++++++- src/rest/routes/profiles.rs | 183 ++ src/schemas/issuetype_schema.json | 10 +- src/services/git_onboarding.rs | 6 +- src/state.rs | 95 ++ src/steps/manager.rs | 90 + src/templates/schema.rs | 14 +- src/templates/step_type.rs | 272 +++ testdata/native_llm/anthropic.json | 1 + testdata/native_llm/gemini.json | 1 + testdata/native_llm/ollama.json | 1 + testdata/native_llm/openai.json | 1 + testdata/native_llm/openrouter.json | 1 + testdata/native_llm/xai.json | 1 + vscode-extension/webview-ui/types/defaults.ts | 1 + 47 files changed, 4063 insertions(+), 195 deletions(-) create mode 100644 bindings/JudgeAttempt.ts create mode 100644 bindings/JudgeConfig.ts create mode 100644 bindings/NativeLlmConfig.ts create mode 100644 src/agents/judge.rs create mode 100644 src/llm/native/mod.rs create mode 100644 src/llm/native/rig/client.rs create mode 100644 src/llm/native/rig/mod.rs create mode 100644 src/llm/native/rig/tests.rs create mode 100644 testdata/native_llm/anthropic.json create mode 100644 testdata/native_llm/gemini.json create mode 100644 testdata/native_llm/ollama.json create mode 100644 testdata/native_llm/openai.json create mode 100644 testdata/native_llm/openrouter.json create mode 100644 testdata/native_llm/xai.json diff --git a/Cargo.lock b/Cargo.lock index 764ee16d..87c0f69a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,7 +118,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -129,7 +129,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1065,7 +1065,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1179,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]] @@ -2413,7 +2413,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3385,7 +3385,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3441,7 +3441,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3853,7 +3853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4017,7 +4017,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4039,7 +4039,7 @@ dependencies = [ "parking_lot", "rustix", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4529,7 +4529,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4916,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 5df18afd..1d4879c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,11 @@ sha2 = "0.11" 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/. +# Iced 2026-09-27: re-enable together with the `native-llm` feature below once +# further Rig capabilities justify the ~7% (3.1 MiB) release binary growth. +# rig-core = { version = "=0.42.0", default-features = false, optional = true } + # Async traits async-trait = "0.1" @@ -115,14 +120,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 iced (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 } diff --git a/bindings/Config.ts b/bindings/Config.ts index 118a5c30..1355c579 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,8 @@ mcp: McpConfig, /** * Agent Client Protocol (ACP) agent configuration */ -acp: AcpConfig, }; +acp: AcpConfig, +/** + * In-daemon LLM calls (judge); requires the `native-llm` build feature + */ +native_llm: NativeLlmConfig, }; 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/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..798a497e 100644 --- a/bindings/MultiModelConfig.ts +++ b/bindings/MultiModelConfig.ts @@ -19,7 +19,8 @@ voting_strategy: VotingStrategy, */ share_answers: boolean, /** - * Prompt for the voting round (Handlebars, receives {{ answers }} array) + * Instruction for the judge that picks the winner (Handlebars, rendered + * with the ticket context; candidates are appended automatically) */ 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/docs/configuration/index.md b/docs/configuration/index.md index aee53590..86eefe7c 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -330,6 +330,8 @@ external_servers = [] stdio_advertised = true max_concurrent_sessions = 8 +[native_llm] + ``` ## Configuration Files 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/schemas/index.md b/docs/schemas/index.md index c1098a09..34374733 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..4e8a3078 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 for the judge that picks the winner (Handlebars, rendered with the ticket context; candidates are appended automatically) | | `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 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/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/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index 4b7bee22..10bbee8b 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -70,6 +70,24 @@ pub(crate) fn resolve_model_server_for_delegator( } } +/// 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. @@ -114,17 +132,7 @@ 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(|| { - crate::config::implicit_model_servers() - .into_iter() - .find(|s| s.name == name) - }) - .ok_or_else(|| ResolutionError::UnknownModelServer(name.to_string()))?, + Some(name) => resolve_model_server_by_name(config, name)?, None => implicit_model_server_for_tool(tool) .ok_or_else(|| ResolutionError::UnknownLlmTool(tool.to_string()))?, }; 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/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/providers/model_server/mod.rs b/src/api/providers/model_server/mod.rs index 21a6b3e4..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 diff --git a/src/api/providers/model_server/probe.rs b/src/api/providers/model_server/probe.rs index 26388146..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 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 444de3bf..2122ebe2 100644 --- a/src/config/config_tests.rs +++ b/src/config/config_tests.rs @@ -867,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 311591bd..3aca104e 100644 --- a/src/config/llm_tools.rs +++ b/src/config/llm_tools.rs @@ -234,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 diff --git a/src/docs_gen/llm_tools.rs b/src/docs_gen/llm_tools.rs index 3eb73f7f..f04a8d84 100644 --- a/src/docs_gen/llm_tools.rs +++ b/src/docs_gen/llm_tools.rs @@ -50,9 +50,9 @@ impl DocGenerator for LlmToolsDocGenerator { content.push_str( 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. - -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**, not that an API key works. +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 @@ -89,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. @@ -127,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 @@ -248,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 diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 00034eb3..6e1ca0cf 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -11,6 +11,7 @@ mod detection; mod inventory; +pub mod native; pub mod skill_deployer; pub mod tool_config; 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 13bce73f..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}; diff --git a/src/main.rs b/src/main.rs index 82ea570a..cf059836 100644 --- a/src/main.rs +++ b/src/main.rs @@ -980,7 +980,12 @@ async fn cmd_auth(config: &Config, action: AuthAction) -> Result<()> { "new_password": password, }); - let response = crate::http_client::client_builder().build()?.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(); diff --git a/src/rest/routes/auth.rs b/src/rest/routes/auth.rs index c658f4e7..192a5f6f 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,1429 @@ 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 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()); + + 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}"); + let 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/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/schemas/issuetype_schema.json b/src/schemas/issuetype_schema.json index f30062a5..90b67470 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 for the judge that picks the winner (Handlebars, rendered\nwith the ticket context; candidates are appended automatically)", "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]`) picks the winner; falls back to the\nvoting strategy's deterministic rule when no judge is available", "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]`) picks the best; falls back to the\nfirst variation when no judge is available", "type": "string", "const": "model_choice" }, diff --git a/src/services/git_onboarding.rs b/src/services/git_onboarding.rs index 4d125f2b..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 = crate::http_client::blocking_client_builder().build()? + 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 = crate::http_client::blocking_client_builder().build()? + 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/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..c36dcb85 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, 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/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/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts index 88787015..bc18d4a9 100644 --- a/vscode-extension/webview-ui/types/defaults.ts +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -149,6 +149,7 @@ const DEFAULT_CONFIG: Config = { default_delegator: null, max_concurrent_sessions: 4, }, + native_llm: { judge: null }, }; export const DEFAULT_WEBVIEW_CONFIG: WebviewConfig = { From f14414138cf2d5e240ab1474ff0b94b3b003b442 Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 27 Sep 2026 15:04:17 -0600 Subject: [PATCH 05/18] regenerate bindings --- .githooks/pre-push | 17 - Cargo.toml | 5 +- bindings/Config.ts | 3 +- bindings/MultiModelConfig.ts | 3 +- src/app/agents.rs | 79 ++-- src/app/keyboard.rs | 71 ++-- src/app/tests.rs | 603 ++++++++++++++++++++++++++-- src/ui/dialogs/kanban_onboarding.rs | 5 +- 8 files changed, 684 insertions(+), 102 deletions(-) delete mode 100755 .githooks/pre-push 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/Cargo.toml b/Cargo.toml index 1d4879c6..fc6ac801 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,8 +79,7 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls-no-p rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } # Native (in-daemon) LLM calls via Rig; isolated in src/llm/native/rig/. -# Iced 2026-09-27: re-enable together with the `native-llm` feature below once -# further Rig capabilities justify the ~7% (3.1 MiB) release binary growth. +# 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 @@ -130,7 +129,7 @@ flate2 = "1" [lints.rust] unsafe_code = "deny" -# `native-llm` is iced (see rig-core above); its cfg gates stay in the source. +# `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] diff --git a/bindings/Config.ts b/bindings/Config.ts index 1355c579..9881497e 100644 --- a/bindings/Config.ts +++ b/bindings/Config.ts @@ -73,6 +73,7 @@ mcp: McpConfig, */ acp: AcpConfig, /** - * In-daemon LLM calls (judge); requires the `native-llm` build feature + * 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/MultiModelConfig.ts b/bindings/MultiModelConfig.ts index 798a497e..5eff73af 100644 --- a/bindings/MultiModelConfig.ts +++ b/bindings/MultiModelConfig.ts @@ -19,8 +19,7 @@ voting_strategy: VotingStrategy, */ share_answers: boolean, /** - * Instruction for the judge that picks the winner (Handlebars, rendered - * with the ticket context; candidates are appended automatically) + * Instruction prompt for the judge that picks the winner (Handlebars, rendered with the ticket context) */ voting_prompt?: string | null, /** 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/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 { From e1526c10947e4f59051621eff9b92e27c2f0d152 Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 27 Sep 2026 15:55:10 -0600 Subject: [PATCH 06/18] defaultClient --- clippy.toml | 6 ++ opr8r/src/api.rs | 2 +- src/api/providers/kanban/github_projects.rs | 2 +- src/api/providers/kanban/jira.rs | 2 +- src/api/providers/kanban/linear.rs | 2 +- src/http_client.rs | 14 +++++ src/notifications/webhook_integration.rs | 2 +- ui/src/routes/onboarding/OnboardingPage.tsx | 9 +-- .../stories/components/AppShell.stories.tsx | 59 ++++++++++++++++--- 9 files changed, 79 insertions(+), 19 deletions(-) diff --git a/clippy.toml b/clippy.toml index d0d0c5a2..9c18c31f 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", reason = "use crate::http_client::blocking_client_builder() so the rustls provider is installed" }, + { path = "reqwest::blocking::Client::builder", reason = "use crate::http_client::blocking_client_builder() so the rustls provider is installed" }, +] diff --git a/opr8r/src/api.rs b/opr8r/src/api.rs index 772117f3..b8863aea 100644 --- a/opr8r/src/api.rs +++ b/opr8r/src/api.rs @@ -235,7 +235,7 @@ impl ApiClient { /// configuration is known, the id its callbacks must be routed to. pub fn with_profile(base_url: &str, token: Option, profile_id: Option) -> Self { ensure_crypto_provider(); - let client = Client::builder() + let client = crate::http_client::default_client() .timeout(Duration::from_secs(30)) .build() .expect("Failed to create HTTP client"); 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/http_client.rs b/src/http_client.rs index 7c65f173..5e58b45c 100644 --- a/src/http_client.rs +++ b/src/http_client.rs @@ -15,11 +15,19 @@ fn ensure_crypto_provider() { }); } +#[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() @@ -39,6 +47,12 @@ mod tests { 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()); 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/ui/src/routes/onboarding/OnboardingPage.tsx b/ui/src/routes/onboarding/OnboardingPage.tsx index 40564ace..3d63544a 100644 --- a/ui/src/routes/onboarding/OnboardingPage.tsx +++ b/ui/src/routes/onboarding/OnboardingPage.tsx @@ -192,14 +192,11 @@ export function OnboardingPage() { const slugs = useMemo(() => steps.map((step) => step.slug), [steps]); const walk = useMemo(() => visibleSteps(slugs, draft), [draft, slugs]); const rows = useMemo(() => stepRows(slugs, draft), [draft, slugs]); + if (walk.length > 0 && !walk.includes(currentSlug)) { + setCurrentSlug(walk[0]); + } const currentIndex = Math.max(0, walk.indexOf(currentSlug)); - useEffect(() => { - if (walk.length > 0 && !walk.includes(currentSlug)) { - setCurrentSlug(walk[0]); - } - }, [currentSlug, walk]); - const current = steps.find((step) => step.slug === walk[currentIndex]); const Step = current ? STEP_COMPONENTS[current.slug] : null; diff --git a/webcomponents/stories/components/AppShell.stories.tsx b/webcomponents/stories/components/AppShell.stories.tsx index 1845d611..d2384925 100644 --- a/webcomponents/stories/components/AppShell.stories.tsx +++ b/webcomponents/stories/components/AppShell.stories.tsx @@ -1,15 +1,17 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { + AccountFooter, AppShell, BrandName, NavGroup, NavRow, - SignOutButton, ThemeToggle, } from "../../src/components/AppShell"; import { PageHeader } from "../../src/components/PageHeader"; const NOOP = () => undefined; +const STORY_USERNAME = "operator"; +const STORY_CONFIGURATION = "default"; const STATUS_ROWS = [ { label: "Model Providers", icon: "server", health: "green" }, @@ -26,7 +28,10 @@ const PAGE_ROWS = [ /** Stories render plain anchors; the SPA supplies a react-router NavLink here. */ const anchor = (isActive: boolean) => - (content: React.ReactNode, className: (state: { isActive: boolean }) => string) => ( + ( + content: React.ReactNode, + className: (state: { isActive: boolean }) => string, + ) => ( {content} @@ -43,7 +48,11 @@ function Groups({ lockedReason }: { lockedReason?: string }) { icon={row.icon} health={row.health} disabledReason={row.label === "Kanban" ? lockedReason : undefined} - renderLink={row.label === "Kanban" && lockedReason ? undefined : anchor(index === 0)} + renderLink={ + row.label === "Kanban" && lockedReason + ? undefined + : anchor(index === 0) + } /> ))} @@ -51,7 +60,11 @@ function Groups({ lockedReason }: { lockedReason?: string }) { {PAGE_ROWS.map((row) => (
  • - +
  • ))}
    @@ -70,7 +83,13 @@ const meta = { ), groups: , - footer: , + footer: ( + + ), children: ( }, + args: { + groups: , + }, }; -export const SigningOut: Story = { args: { footer: } }; +export const SigningOut: Story = { + args: { + footer: ( + + ), + }, +}; -export const SignOutFailed: Story = { args: { footer: } }; +export const SignOutFailed: Story = { + args: { + footer: ( + + ), + }, +}; export const DarkTheme: Story = { args: { From c3f7d6d76c28d610eddee182a8eeba109d81a4e4 Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 27 Sep 2026 16:47:00 -0600 Subject: [PATCH 07/18] fixes --- clippy.toml | 4 ++-- opr8r/src/api.rs | 16 ++-------------- opr8r/src/http_client.rs | 33 +++++++++++++++++++++++++++++++++ opr8r/src/main.rs | 1 + 4 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 opr8r/src/http_client.rs diff --git a/clippy.toml b/clippy.toml index 9c18c31f..8cbba9d2 100644 --- a/clippy.toml +++ b/clippy.toml @@ -6,6 +6,6 @@ doc-valid-idents = ["GitHub", "GitLab", "macOS", "OpenAPI", "OAuth", "TypeScript 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", reason = "use crate::http_client::blocking_client_builder() so the rustls provider is installed" }, - { path = "reqwest::blocking::Client::builder", reason = "use crate::http_client::blocking_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/opr8r/src/api.rs b/opr8r/src/api.rs index b8863aea..9665c732 100644 --- a/opr8r/src/api.rs +++ b/opr8r/src/api.rs @@ -15,16 +15,6 @@ const API_SESSION_FILE: &str = ".tickets/operator/api-session.json"; const API_TOKEN_ENV: &str = "OPERATOR_API_TOKEN"; const PROFILE_ID_ENV: &str = "OPERATOR_PROFILE_ID"; -static INSTALL_CRYPTO_PROVIDER: std::sync::Once = std::sync::Once::new(); - -/// reqwest is built with `rustls-no-provider` to stay on ring; rustls needs a -/// process-default provider installed before the first client is built. -fn ensure_crypto_provider() { - INSTALL_CRYPTO_PROVIDER.call_once(|| { - let _ = rustls::crypto::ring::default_provider().install_default(); - }); -} - /// Retry configuration const MAX_RETRIES: u32 = 3; const INITIAL_BACKOFF_MS: u64 = 1000; @@ -231,11 +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 { - ensure_crypto_provider(); - let client = crate::http_client::default_client() + let client = crate::http_client::client_builder() .timeout(Duration::from_secs(30)) .build() .expect("Failed to create HTTP client"); 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 449b4478..9180d684 100644 --- a/opr8r/src/main.rs +++ b/opr8r/src/main.rs @@ -1,5 +1,6 @@ mod api; mod cli; +mod http_client; #[cfg(unix)] mod operator_relay; mod output_parser; From 77e61340dafc8d427653ce6a712db1e262fe4624 Mon Sep 17 00:00:00 2001 From: untra Date: Sun, 27 Sep 2026 22:49:59 -0600 Subject: [PATCH 08/18] new linter who dis --- .../stories/components/AppShell.stories.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/webcomponents/stories/components/AppShell.stories.tsx b/webcomponents/stories/components/AppShell.stories.tsx index d2384925..c62bcfac 100644 --- a/webcomponents/stories/components/AppShell.stories.tsx +++ b/webcomponents/stories/components/AppShell.stories.tsx @@ -28,10 +28,7 @@ const PAGE_ROWS = [ /** Stories render plain anchors; the SPA supplies a react-router NavLink here. */ const anchor = (isActive: boolean) => - ( - content: React.ReactNode, - className: (state: { isActive: boolean }) => string, - ) => ( + (content: React.ReactNode, className: (state: { isActive: boolean }) => string) => ( {content} @@ -48,11 +45,7 @@ function Groups({ lockedReason }: { lockedReason?: string }) { icon={row.icon} health={row.health} disabledReason={row.label === "Kanban" ? lockedReason : undefined} - renderLink={ - row.label === "Kanban" && lockedReason - ? undefined - : anchor(index === 0) - } + renderLink={row.label === "Kanban" && lockedReason ? undefined : anchor(index === 0)} /> ))} @@ -60,11 +53,7 @@ function Groups({ lockedReason }: { lockedReason?: string }) { {PAGE_ROWS.map((row) => (
  • - +
  • ))}
    From 41236232a26b064a7dd7dde67ae28cb3c05f4373 Mon Sep 17 00:00:00 2001 From: untra Date: Mon, 28 Sep 2026 10:28:37 -0600 Subject: [PATCH 09/18] approaching correct --- docs/llm-tools/index.md | 20 +++++++++----------- docs/llms.txt | 2 +- docs/schemas/index.md | 4 ++-- docs/schemas/issuetype.md | 2 +- scripts/cicdprep.sh | 2 +- src/docs_gen/schema_index.rs | 4 ++-- src/schemas/issuetype_schema.json | 6 +++--- 7 files changed, 19 insertions(+), 21 deletions(-) diff --git a/docs/llm-tools/index.md b/docs/llm-tools/index.md index 242e792b..53179805 100644 --- a/docs/llm-tools/index.md +++ b/docs/llm-tools/index.md @@ -8,9 +8,9 @@ layout: doc # 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. - -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**, not that an API key works. +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 @@ -49,8 +49,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. @@ -87,8 +87,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 @@ -208,10 +207,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 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/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 4e8a3078..144a23b0 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 | Instruction for the judge that picks the winner (Handlebars, rendered with the ticket context; candidates are appended automatically) | +| `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 diff --git a/scripts/cicdprep.sh b/scripts/cicdprep.sh index 09b53846..a2b312f6 100755 --- a/scripts/cicdprep.sh +++ b/scripts/cicdprep.sh @@ -237,7 +237,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/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/schemas/issuetype_schema.json b/src/schemas/issuetype_schema.json index 90b67470..984598f8 100644 --- a/src/schemas/issuetype_schema.json +++ b/src/schemas/issuetype_schema.json @@ -1195,7 +1195,7 @@ "default": true }, "voting_prompt": { - "description": "Instruction for the judge that picks the winner (Handlebars, rendered\nwith the ticket context; candidates are appended automatically)", + "description": "Instruction prompt for the judge that picks the winner (Handlebars, rendered with the ticket context)", "type": [ "string", "null" @@ -1237,7 +1237,7 @@ "description": "How the voting round is executed in multi-model steps", "oneOf": [ { - "description": "An LLM judge (`[native_llm.judge]`) picks the winner; falls back to the\nvoting strategy's deterministic rule when no judge is available", + "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" }, @@ -1289,7 +1289,7 @@ "description": "Selection strategy for multi-prompt steps", "oneOf": [ { - "description": "An LLM judge (`[native_llm.judge]`) picks the best; falls back to the\nfirst variation when no judge is available", + "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" }, From d6af6d85fb679b123e9d2f24ae75549e5fddcf09 Mon Sep 17 00:00:00 2001 From: untra Date: Wed, 30 Sep 2026 08:30:12 -0600 Subject: [PATCH 10/18] agnt plugin updates --- AGENTS.md | 2 +- Cargo.toml | 2 +- Makefile | 1 + agnt-plugin/README.md | 37 ++- agnt-plugin/lib/operator-client.js | 150 ++++++++++-- agnt-plugin/lib/operator-client.test.js | 265 +++++++++++++++++++++ agnt-plugin/manifest.json | 60 +++++ agnt-plugin/run-step.js | 8 +- bindings/ItemSource.ts | 4 +- deny.toml | 4 +- docs/configuration/index.md | 16 +- docs/getting-started/integrations/agnt.md | 86 ++++--- docs/getting-started/integrations/index.md | 3 +- docs/schemas/issuetype.md | 4 +- src/templates/schema.rs | 4 +- src/workflow_gen/agnt.rs | 16 +- src/workflow_gen/export.rs | 6 +- 17 files changed, 567 insertions(+), 101 deletions(-) create mode 100644 agnt-plugin/lib/operator-client.test.js diff --git a/AGENTS.md b/AGENTS.md index dc7be666..d896668c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -321,7 +321,7 @@ Operator presents one brand (terracotta + cornflower + cream over a green scale) |---------|-------|------| | 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. diff --git a/Cargo.toml b/Cargo.toml index fc6ac801..dce006cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -177,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..6dd9f1a1 100644 --- a/Makefile +++ b/Makefile @@ -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: 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/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/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/configuration/index.md b/docs/configuration/index.md index 86eefe7c..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") | 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/schemas/issuetype.md b/docs/schemas/issuetype.md index 144a23b0..6fc5a0fe 100644 --- a/docs/schemas/issuetype.md +++ b/docs/schemas/issuetype.md @@ -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/src/templates/schema.rs b/src/templates/schema.rs index c36dcb85..72e2905d 100644 --- a/src/templates/schema.rs +++ b/src/templates/schema.rs @@ -633,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/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, From b0d37e32769f7ff8c8b905a910661490041fe6a4 Mon Sep 17 00:00:00 2001 From: untra Date: Wed, 30 Sep 2026 15:29:41 -0600 Subject: [PATCH 11/18] ui testing fixing fighting, mcp vscode-extension upgrade --- .github/workflows/build.yaml | 4 + docs/getting-started/ides/cursor.md | 2 + docs/getting-started/ides/vscode.md | 21 +- docs/security/authentication.md | 4 + package.json | 1 + src/auth/schema.rs | 5 + src/auth/store.rs | 73 ++- src/rest/routes/auth.rs | 54 ++- ui/bun.lock | 9 + ui/package.json | 2 + ui/src/Layout.tsx | 4 +- ui/src/api-client.test.ts | 157 +++++++ ui/src/api-client.ts | 112 ++++- ui/src/components/LicensePanel.tsx | 1 - ui/src/profiles-context.tsx | 1 - ui/src/routes/DevicePage.tsx | 10 +- ui/src/routes/RemoteTargetsPage.tsx | 3 +- ui/src/routes/SecurityPage.tsx | 8 +- ui/src/routes/onboarding/OnboardingPage.tsx | 10 +- vscode-extension/CHANGELOG.md | 12 + vscode-extension/README.md | 3 +- vscode-extension/package-lock.json | 2 +- vscode-extension/package.json | 2 +- vscode-extension/src/config-panel.ts | 127 ++++- vscode-extension/src/mcp-connect.ts | 346 ++++++++++++-- .../src/sections/connections-section.ts | 29 +- vscode-extension/src/serial-queue.ts | 16 + .../test/suite/config-panel.test.ts | 118 ++++- .../test/suite/manifest-parity.test.ts | 13 +- .../test/suite/mcp-connect.test.ts | 440 +++++++++++++++++- .../test/suite/serial-queue.test.ts | 45 ++ .../test/suite/status-provider.test.ts | 30 ++ vscode-extension/tsconfig.webview.json | 2 +- vscode-extension/webview-ui/App.tsx | 239 +++------- .../webview-ui/components/ConfigPage.tsx | 6 +- .../components/kanban/ProjectRow.tsx | 16 +- .../components/kanban/ProviderCard.tsx | 70 ++- .../components/primitives/index.tsx | 19 +- .../sections/CodingAgentsSection.tsx | 65 +-- .../sections/GitRepositoriesSection.tsx | 19 +- .../sections/KanbanProvidersSection.tsx | 88 ++-- .../sections/ModelProvidersSection.tsx | 30 +- .../sections/PrimaryConfigSection.tsx | 10 +- .../webview-ui/hooks/useDraftField.ts | 62 +++ .../webview-ui/hooks/useStableKeys.ts | 12 + .../webview-ui/state/applyUpdate.test.ts | 206 ++++++++ .../webview-ui/state/applyUpdate.ts | 141 ++++++ .../webview-ui/state/configSync.test.ts | 58 +++ .../webview-ui/state/configSync.ts | 42 ++ .../webview-ui/state/draftField.test.ts | 30 ++ .../webview-ui/state/draftField.ts | 28 ++ .../webview-ui/state/stableKeys.test.ts | 44 ++ .../webview-ui/state/stableKeys.ts | 35 ++ vscode-extension/webview-ui/types/messages.ts | 30 +- 54 files changed, 2373 insertions(+), 543 deletions(-) create mode 100644 ui/src/api-client.test.ts create mode 100644 vscode-extension/src/serial-queue.ts create mode 100644 vscode-extension/test/suite/serial-queue.test.ts create mode 100644 vscode-extension/webview-ui/hooks/useDraftField.ts create mode 100644 vscode-extension/webview-ui/hooks/useStableKeys.ts create mode 100644 vscode-extension/webview-ui/state/applyUpdate.test.ts create mode 100644 vscode-extension/webview-ui/state/applyUpdate.ts create mode 100644 vscode-extension/webview-ui/state/configSync.test.ts create mode 100644 vscode-extension/webview-ui/state/configSync.ts create mode 100644 vscode-extension/webview-ui/state/draftField.test.ts create mode 100644 vscode-extension/webview-ui/state/draftField.ts create mode 100644 vscode-extension/webview-ui/state/stableKeys.test.ts create mode 100644 vscode-extension/webview-ui/state/stableKeys.ts diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 97187937..a7c3a772 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -92,6 +92,9 @@ 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/* @@ -100,6 +103,7 @@ jobs: 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)" 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/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/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/src/auth/schema.rs b/src/auth/schema.rs index 0dc5ef05..cee644e9 100644 --- a/src/auth/schema.rs +++ b/src/auth/schema.rs @@ -138,6 +138,11 @@ const MIGRATIONS: &[&str] = &[ retry_after TEXT ); "#, + // v2 - one-previous-token CSRF grace window, so rotating the token from + // one tab does not break mutations already in flight from another. + r" + ALTER TABLE session ADD COLUMN csrf_prev_hash TEXT; + ", ]; /// Apply any migrations the database has not seen. diff --git a/src/auth/store.rs b/src/auth/store.rs index be8652f5..67f8faed 100644 --- a/src/auth/store.rs +++ b/src/auth/store.rs @@ -469,32 +469,42 @@ impl AuthStore { }) } - /// Whether `csrf` matches the session's stored CSRF hash. + /// Whether `csrf` matches the session's current or immediately previous + /// CSRF hash. pub fn verify_csrf(&self, session_id: &str, csrf: &str) -> 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/rest/routes/auth.rs b/src/rest/routes/auth.rs index 192a5f6f..f60797d7 100644 --- a/src/rest/routes/auth.rs +++ b/src/rest/routes/auth.rs @@ -1493,12 +1493,29 @@ mod tests { .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, @@ -2251,22 +2268,27 @@ mod tests { .unwrap() .is_none()); - 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}"); - let rotated = parse::(value).csrf_token; - assert_ne!(rotated, csrf); + // 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() diff --git a/ui/bun.lock b/ui/bun.lock index 9635dc90..8a95f295 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -11,6 +11,7 @@ "react-router-dom": "^7.6.1", }, "devDependencies": { + "@types/bun": "^1.4.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", @@ -180,8 +181,12 @@ "@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=="], @@ -194,6 +199,8 @@ "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=="], + "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=="], @@ -260,6 +267,8 @@ "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=="], "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=="], diff --git a/ui/package.json b/ui/package.json index aa623c77..0a1794ce 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,6 +8,7 @@ "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", + "test": "bun test", "lint": "cd .. && bun run lint:ui" }, "dependencies": { @@ -17,6 +18,7 @@ "react-router-dom": "^7.6.1" }, "devDependencies": { + "@types/bun": "^1.4.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/ui/src/Layout.tsx b/ui/src/Layout.tsx index e17e2f75..d289a7c1 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -15,7 +15,7 @@ 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 { OperatorApi } from "./api-client"; import { useHost } from "./host"; import { useProfiles } from "./profiles-context"; @@ -115,9 +115,7 @@ export function Layout() { setSignOutError(false); try { const api = new OperatorApi(host); - await api.refreshCsrf(); await api.logout(); - setCsrfToken(null); void navigate("/login", { replace: true }); } catch { setSignOutError(true); 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/components/LicensePanel.tsx b/ui/src/components/LicensePanel.tsx index 5c6a0a58..741d5e78 100644 --- a/ui/src/components/LicensePanel.tsx +++ b/ui/src/components/LicensePanel.tsx @@ -49,7 +49,6 @@ export function LicensePanel({ setBusy(true); setError(null); try { - await api.refreshCsrf(); const value = remove ? await api.removeLicense() : await api.installLicense(key.trim()); setLicense(value); setKey(""); diff --git a/ui/src/profiles-context.tsx b/ui/src/profiles-context.tsx index 59f26749..58526a81 100644 --- a/ui/src/profiles-context.tsx +++ b/ui/src/profiles-context.tsx @@ -89,7 +89,6 @@ export function ProfilesProvider() { const create = useCallback( async (name: string) => { - await api.refreshCsrf(); const profile = await api.createProfile(name); setProfiles((items) => [...items, profile]); select(profile.id); diff --git a/ui/src/routes/DevicePage.tsx b/ui/src/routes/DevicePage.tsx index b185e910..f1415ca5 100644 --- a/ui/src/routes/DevicePage.tsx +++ b/ui/src/routes/DevicePage.tsx @@ -4,7 +4,7 @@ // asking, and approves. It renders inside the authenticated Layout on purpose: // approving a device grants a credential, so it requires an admin session. -import { useEffect, useState, useCallback } from "react"; +import { useState, useCallback } from "react"; import { useSearchParams } from "react-router-dom"; import { useHost } from "../host"; import { OperatorApi, ApiError } from "../api-client"; @@ -18,14 +18,6 @@ export function DevicePage() { const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - // A CSRF token is required to approve, and a page load (or a fresh tab - // opened by the IDE) has none in memory yet. - useEffect(() => { - new OperatorApi(host).refreshCsrf().catch(() => { - /* An unauthenticated visitor is redirected to login by the request layer. */ - }); - }, [host]); - const submit = useCallback( async (event: React.SubmitEvent) => { event.preventDefault(); diff --git a/ui/src/routes/RemoteTargetsPage.tsx b/ui/src/routes/RemoteTargetsPage.tsx index b2890d03..b2a2bca3 100644 --- a/ui/src/routes/RemoteTargetsPage.tsx +++ b/ui/src/routes/RemoteTargetsPage.tsx @@ -102,7 +102,6 @@ export function RemoteTargetsPage() { setBusy(true); setMessage(null); try { - await api.refreshCsrf(); await operation(); await refresh(); setMessage(success); @@ -112,7 +111,7 @@ export function RemoteTargetsPage() { setBusy(false); } }, - [api, refresh], + [refresh], ); const onProbe = useCallback( diff --git a/ui/src/routes/SecurityPage.tsx b/ui/src/routes/SecurityPage.tsx index 55f3fb9a..53e9cba0 100644 --- a/ui/src/routes/SecurityPage.tsx +++ b/ui/src/routes/SecurityPage.tsx @@ -35,9 +35,6 @@ export function SecurityPage() { const load = useCallback(async () => { const api = new OperatorApi(host); try { - // A page load leaves no CSRF token in memory even though the session - // cookie survived, so re-issue one before any mutation is possible. - await api.refreshCsrf().catch(() => undefined); const [s, k] = await Promise.all([api.listSessions(), api.listAccessKeys()]); setSessions(s); setKeys(k); @@ -50,10 +47,7 @@ export function SecurityPage() { useEffect(() => { let cancelled = false; const api = new OperatorApi(host); - void api - .refreshCsrf() - .catch(() => undefined) - .then(() => Promise.all([api.listSessions(), api.listAccessKeys()])) + void Promise.all([api.listSessions(), api.listAccessKeys()]) .then(([s, k]) => { if (!cancelled) { setSessions(s); diff --git a/ui/src/routes/onboarding/OnboardingPage.tsx b/ui/src/routes/onboarding/OnboardingPage.tsx index 3d63544a..04a928c9 100644 --- a/ui/src/routes/onboarding/OnboardingPage.tsx +++ b/ui/src/routes/onboarding/OnboardingPage.tsx @@ -110,14 +110,8 @@ export function OnboardingPage() { useEffect(() => { let active = true; - Promise.all([ - api.refreshCsrf(), - api.setupStatus(), - api.setupSteps(), - api.integrations(), - api.setupCollections(), - ]) - .then(([, nextStatus, nextSteps, nextIntegrations, nextCollections]) => { + Promise.all([api.setupStatus(), api.setupSteps(), api.integrations(), api.setupCollections()]) + .then(([nextStatus, nextSteps, nextIntegrations, nextCollections]) => { if (!active) { return undefined; } diff --git a/vscode-extension/CHANGELOG.md b/vscode-extension/CHANGELOG.md index a28465a9..dabaaacb 100644 --- a/vscode-extension/CHANGELOG.md +++ b/vscode-extension/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to **Operator! Terminals** VS Code extension will be documen The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Operator: Connect MCP Server writes workspace-root `.mcp.json` (`mcpServers.operator`) so VS Code 1.140 Agent Host and Copilot CLI see Operator. +- Optional Copilot Global write to `$COPILOT_HOME/mcp-config.json` or `~/.copilot/mcp-config.json`. +- Status MCP row lists which MCP files contain `operator`. + +### Changed + +- Minimum VS Code version is 1.140.0. + ## [0.1.16] - 2026-01-24 ### Added diff --git a/vscode-extension/README.md b/vscode-extension/README.md index 2a43b327..b4780a25 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -50,6 +50,7 @@ This extension also runs in [Cursor](https://www.cursor.com). Install it from th - **Operator: Show Server Status** - Display server status and terminal count - **Operator: Sign In** - Authorize this editor with a remote Operator daemon through the browser (a local daemon needs no sign-in) - **Operator: Sign Out** - Forget the stored credential for the current daemon +- **Operator: Connect MCP Server** - Register Operator in workspace MCP settings, workspace `.mcp.json`, and optionally Copilot Global ## API Endpoints @@ -69,7 +70,7 @@ The extension exposes a local HTTP API for Operator to manage terminals: ## Requirements -- VS Code 1.85.0 or later +- VS Code 1.140.0 or later - Operator CLI (for full functionality) ## Development diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index 818dffa5..edec08a9 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -43,7 +43,7 @@ "webpack-cli": "^7.2.1" }, "engines": { - "vscode": "^1.120.0" + "vscode": "^1.140.0" } }, "node_modules/@azu/format-text": { diff --git a/vscode-extension/package.json b/vscode-extension/package.json index c9bb50d4..8049edb1 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -15,7 +15,7 @@ }, "license": "MIT", "engines": { - "vscode": "^1.120.0" + "vscode": "^1.140.0" }, "categories": [ "Other" diff --git a/vscode-extension/src/config-panel.ts b/vscode-extension/src/config-panel.ts index d9755d57..ff472a5c 100644 --- a/vscode-extension/src/config-panel.ts +++ b/vscode-extension/src/config-panel.ts @@ -16,6 +16,7 @@ import { detectInstalledLlmTools } from "./walkthrough"; import { getConfigDir, getResolvedConfigPath, resolveWorkingDirectory } from "./config-paths"; import { OperatorApiClient, discoverApiUrl } from "./api-client"; import type { CreateDelegatorRequest } from "./generated"; +import { SerialQueue } from "./serial-queue"; /** Message types from the webview */ interface WebviewMessage { @@ -23,6 +24,22 @@ interface WebviewMessage { [key: string]: unknown; } +/** + * Messages that read or write config.toml. They run through one queue so a + * write's snapshot can never be overtaken by an older read-modify-write. + */ +const CONFIG_MESSAGE_TYPES: ReadonlySet = new Set([ + "ready", + "getConfig", + "updateConfig", + "detectLlmTools", +]); + +interface NavigationRequest { + section: string; + prefill?: Record; +} + /** WebviewConfig shape matching the webview types */ interface WebviewConfig { config_path: string; @@ -37,6 +54,11 @@ export class ConfigPanel { private readonly _panel: vscode.WebviewPanel; private readonly _extensionUri: vscode.Uri; private _disposables: vscode.Disposable[] = []; + private readonly _configQueue = new SerialQueue(); + /** Rev of the last `updateConfig` processed; stamped on every snapshot sent. */ + private _lastRev = 0; + private _webviewReady = false; + private _pendingNavigation: NavigationRequest | undefined; private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) { this._panel = panel; @@ -47,7 +69,7 @@ export class ConfigPanel { this._panel.onDidDispose(() => this._dispose(), null, this._disposables); this._panel.webview.onDidReceiveMessage( - (msg: WebviewMessage) => this._handleMessage(msg), + (msg: WebviewMessage) => this._dispatch(msg), null, this._disposables, ); @@ -78,17 +100,50 @@ export class ConfigPanel { ConfigPanel.currentPanel = new ConfigPanel(panel, extensionUri); } - /** Send a navigation message to the webview to scroll to a section */ + /** + * Send a navigation message to the webview to scroll to a section. A + * freshly created panel isn't listening yet, so the request is held until + * the webview reports `ready`. + */ public static navigateTo(section: string, prefill?: Record): void { - if (ConfigPanel.currentPanel) { - void ConfigPanel.currentPanel._panel.webview.postMessage({ - type: "navigateTo", - section, - prefill, - }); + const panel = ConfigPanel.currentPanel; + if (!panel) { + return; + } + if (panel._webviewReady) { + panel._postNavigation({ section, prefill }); + } else { + panel._pendingNavigation = { section, prefill }; } } + private _postNavigation(request: NavigationRequest): void { + void this._panel.webview.postMessage({ type: "navigateTo", ...request }); + } + + private _flushPendingNavigation(): void { + const pending = this._pendingNavigation; + this._pendingNavigation = undefined; + if (pending) { + this._postNavigation(pending); + } + } + + private _dispatch(message: WebviewMessage): void { + if (!CONFIG_MESSAGE_TYPES.has(message.type)) { + void this._handleMessage(message); + return; + } + this._configQueue + .run(() => this._handleMessage(message)) + .catch((err: unknown) => { + void this._panel.webview.postMessage({ + type: "configError", + error: err instanceof Error ? err.message : "Config operation failed", + }); + }); + } + private _getHtmlContent(): string { const webview = this._panel.webview; @@ -123,8 +178,11 @@ export class ConfigPanel { private async _handleMessage(message: WebviewMessage): Promise { switch (message.type) { case "ready": - // Webview is ready, send config + // A (re)loaded webview starts counting revs from zero. + this._lastRev = 0; + this._webviewReady = true; await this._sendConfig(); + this._flushPendingNavigation(); break; case "getConfig": @@ -132,7 +190,15 @@ export class ConfigPanel { break; case "updateConfig": - await this._updateConfig(message.section as string, message.key as string, message.value); + if (typeof message.rev === "number") { + this._lastRev = message.rev; + } + await this._updateConfig( + message.section as string, + message.key as string, + message.value, + typeof message.instanceKey === "string" ? message.instanceKey : undefined, + ); break; case "browseFile": { @@ -302,11 +368,13 @@ export class ConfigPanel { void this._panel.webview.postMessage({ type: "llmToolsDetected", config, + rev: this._lastRev, }); } catch { // If we can't read config, just send tool names for compatibility void this._panel.webview.postMessage({ type: "llmToolsDetected", + rev: this._lastRev, config: { config_path: configPath || "", working_directory: resolveWorkingDirectory(), @@ -534,6 +602,7 @@ export class ConfigPanel { void this._panel.webview.postMessage({ type: "configLoaded", config, + rev: this._lastRev, }); } catch (err) { void this._panel.webview.postMessage({ @@ -543,20 +612,31 @@ export class ConfigPanel { } } - /** Apply a field update to config.toml and send updated config back */ - private async _updateConfig(section: string, key: string, value: unknown): Promise { + /** + * Apply a field update to config.toml and send updated config back. The + * error carries the rev so the webview rolls back its optimistic state. + */ + private async _updateConfig( + section: string, + key: string, + value: unknown, + instanceKey?: string, + ): Promise { + const rev = this._lastRev; try { - await writeConfigField(section, key, value); + await writeConfigField(section, key, value, instanceKey); const config = await readConfig(); void this._panel.webview.postMessage({ type: "configUpdated", config, + rev, }); } catch (err) { void this._panel.webview.postMessage({ type: "configError", error: err instanceof Error ? err.message : "Failed to update config", + rev, }); } } @@ -628,6 +708,7 @@ const KANBAN_PROJECT_LEVEL_KEYS = new Set([ * * @param kanban the `[kanban]` table from the parsed config * @param slug provider slug (`jira`, `linear`, `github`, …) + * @param instanceKey provider map key identifying the instance to update * @param key field key emitted by the webview (`enabled`, `api_key_env`, * the instance-key field, `project_key`, a project-level key, or * a `projects..` path) @@ -635,6 +716,7 @@ const KANBAN_PROJECT_LEVEL_KEYS = new Set([ export function applyKanbanProviderField( kanban: TomlConfig, slug: string, + instanceKey: string | undefined, key: string, value: unknown, ): void { @@ -642,6 +724,9 @@ export function applyKanbanProviderField( if (!meta) { throw new Error(`Unknown kanban provider: ${slug}`); } + if (!instanceKey) { + throw new Error(`Kanban provider ${slug} update is missing an instance identifier`); + } if (!kanban[slug]) { kanban[slug] = {}; @@ -649,14 +734,18 @@ export function applyKanbanProviderField( const providerMap = kanban[slug] as TomlConfig; const instanceKeys = Object.keys(providerMap); - const instanceKey = instanceKeys[0] ?? meta.defaultInstanceKey; + if (instanceKeys.length > 0 && !Object.hasOwn(providerMap, instanceKey)) { + throw new Error(`Kanban provider ${slug} instance "${instanceKey}" was not found`); + } if (!providerMap[instanceKey]) { providerMap[instanceKey] = {}; } const ws = providerMap[instanceKey] as TomlConfig; if (key === meta.instanceKeyField && typeof value === "string" && value !== instanceKey) { - // Rename the instance key (e.g. the Jira domain / GitHub owner) + if (Object.hasOwn(providerMap, value)) { + throw new Error(`Kanban provider ${slug} instance "${value}" already exists`); + } const existing = providerMap[instanceKey]; delete providerMap[instanceKey]; providerMap[value] = existing; @@ -741,7 +830,12 @@ async function readConfig(): Promise { } /** Write a single field update to config.toml */ -async function writeConfigField(section: string, key: string, value: unknown): Promise { +async function writeConfigField( + section: string, + key: string, + value: unknown, + instanceKey?: string, +): Promise { const configPath = getResolvedConfigPath(); if (!configPath) { throw new Error("No working directory configured"); @@ -774,6 +868,7 @@ async function writeConfigField(section: string, key: string, value: unknown): P applyKanbanProviderField( parsed.kanban as TomlConfig, section.slice("kanban.".length), + instanceKey, key, value, ); diff --git a/vscode-extension/src/mcp-connect.ts b/vscode-extension/src/mcp-connect.ts index 0c47f158..3f3aee07 100644 --- a/vscode-extension/src/mcp-connect.ts +++ b/vscode-extension/src/mcp-connect.ts @@ -6,10 +6,9 @@ * the host IDE: * * - **VS Code (and other Code OSS forks without a special MCP path)** - - * writes a workspace-scope `mcp.servers.operator` entry via - * `vscode.workspace.getConfiguration('mcp').update('servers', ...)`. When - * the operator descriptor advertises stdio, the entry uses the stdio shape; - * otherwise it falls back to SSE (preserves existing behavior). + * writes workspace-scope `mcp.servers.operator` (`.vscode/mcp.json`) and, + * when stdio is advertised, workspace-root `.mcp.json` (`mcpServers.operator`). + * Offers Copilot Global as a follow-up. SSE stays on workspace settings only. * * - **Cursor** - writes a user-scope entry to `~/.cursor/mcp.json` under * `mcpServers.operator`. Cursor's MCP UI surfaces this user-scope config, @@ -53,6 +52,8 @@ export interface McpDescriptorResponse { /** Host IDE branches the extension knows how to register MCP servers in. */ export type HostApp = "cursor" | "vscode" | "other"; +export const COPILOT_GLOBAL_ACTION = "Also write Copilot Global"; + /** * Indirection layer for the small pieces of platform state that need to be * stubbed across function boundaries in tests. Sinon stubs cannot intercept @@ -76,6 +77,22 @@ export const _testable = { cursorMcpConfigPath(): string { return path.join(os.homedir(), ".cursor", "mcp.json"); }, + /** Workspace-root portable MCP file (`/.mcp.json`). */ + workspacePortableMcpPath(): string | undefined { + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) { + return undefined; + } + return path.join(folder.uri.fsPath, ".mcp.json"); + }, + /** Copilot Global portable MCP file. */ + copilotGlobalConfigPath(): string { + const home = process.env.COPILOT_HOME; + if (home && home.length > 0) { + return path.join(home, "mcp-config.json"); + } + return path.join(os.homedir(), ".copilot", "mcp-config.json"); + }, }; /** Detect which IDE the extension is running inside. */ @@ -95,6 +112,115 @@ export function cursorMcpConfigPath(): string { return _testable.cursorMcpConfigPath(); } +/** Public accessor for the workspace-root portable MCP path. */ +export function workspacePortableMcpPath(): string | undefined { + return _testable.workspacePortableMcpPath(); +} + +/** Public accessor for the Copilot Global MCP config path. */ +export function copilotGlobalConfigPath(): string { + return _testable.copilotGlobalConfigPath(); +} + +/** + * Register Operator in Copilot Global (`$COPILOT_HOME/mcp-config.json` or + * `~/.copilot/mcp-config.json`). Stdio-only. + */ +export async function registerInCopilotGlobalConfig( + descriptor: McpDescriptorResponse, + configPath?: string, +): Promise { + if (!descriptor.stdio) { + return { ok: false, path: "", reason: "no-stdio" }; + } + const target = configPath ?? _testable.copilotGlobalConfigPath(); + return writeMcpServersOperatorEntry(target, descriptor.stdio); +} + +export type PortableRegisterSkip = { + ok: false; + path: string; + reason: "no-stdio" | "no-workspace"; +}; + +/** + * Register Operator in the workspace-root portable `.mcp.json`. + * Stdio-only: skip when the descriptor has no stdio or no workspace folder. + */ +export async function registerInVscodeWorkspacePortableConfig( + descriptor: McpDescriptorResponse, + configPath?: string, +): Promise { + if (!descriptor.stdio) { + return { ok: false, path: "", reason: "no-stdio" }; + } + const target = configPath ?? _testable.workspacePortableMcpPath(); + if (!target) { + return { ok: false, path: "", reason: "no-workspace" }; + } + return writeMcpServersOperatorEntry(target, descriptor.stdio); +} + +export type McpServersWriteResult = + | { ok: true; path: string } + | { ok: false; path: string; reason: "parse" | "io"; message: string }; + +/** + * Merge `mcpServers.operator` into a portable MCP file (Cursor, workspace + * `.mcp.json`, Copilot Global). Preserves sibling servers and other top-level + * keys. Does not write `type` or `url`. + */ +export async function writeMcpServersOperatorEntry( + configPath: string, + stdio: StdioCommand, +): Promise { + await fs.mkdir(path.dirname(configPath), { recursive: true }); + + let existing: Record = {}; + try { + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed !== null && typeof parsed === "object") { + existing = parsed as Record; + } + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code !== "ENOENT") { + return { + ok: false, + path: configPath, + reason: "parse", + message: e.message, + }; + } + } + + const existingServers = existing.mcpServers; + const mcpServers = + existingServers && typeof existingServers === "object" + ? { ...(existingServers as Record) } + : {}; + + mcpServers.operator = { + command: stdio.command, + args: stdio.args, + cwd: stdio.cwd, + }; + + try { + await fs.writeFile( + configPath, + `${JSON.stringify({ ...existing, mcpServers }, null, 2)}\n`, + "utf-8", + ); + } catch (err: unknown) { + const e = err as Error; + return { ok: false, path: configPath, reason: "io", message: e.message }; + } + + return { ok: true, path: configPath }; +} + /** * Fetch the MCP descriptor from the Operator API. * @@ -132,6 +258,128 @@ export function isMcpServerRegistered(): boolean { return "operator" in servers; } +export interface McpRegistrationPresence { + vscodeWorkspace: boolean; + workspacePortable: boolean; + copilotGlobal: boolean; + cursor: boolean; +} + +export function isVscodeWorkspaceStdio(): boolean | undefined { + const servers = + vscode.workspace.getConfiguration("mcp").get>("servers") ?? {}; + const entry = servers.operator; + if (entry === null || typeof entry !== "object") { + return undefined; + } + const type = (entry as Record).type; + if (type === "stdio") { + return true; + } + if (type === "sse") { + return false; + } + return undefined; +} + +async function fileHasOperator(configPath: string | undefined): Promise { + if (!configPath) { + return false; + } + try { + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed === null || typeof parsed !== "object") { + return false; + } + const servers = (parsed as Record).mcpServers; + if (servers === null || typeof servers !== "object") { + return false; + } + return "operator" in (servers as Record); + } catch { + return false; + } +} + +export async function inspectMcpRegistration(): Promise { + return { + vscodeWorkspace: isMcpServerRegistered(), + workspacePortable: await fileHasOperator(_testable.workspacePortableMcpPath()), + copilotGlobal: await fileHasOperator(_testable.copilotGlobalConfigPath()), + cursor: await fileHasOperator(_testable.cursorMcpConfigPath()), + }; +} + +export function mcpStatusDescription( + presence: McpRegistrationPresence, + host: HostApp, + workspaceEntryIsStdio: boolean | undefined, +): { connected: boolean; description: string; tooltip: string } { + if (host === "cursor") { + if (presence.cursor) { + return { + connected: true, + description: "~/.cursor/mcp.json", + tooltip: "Operator MCP is registered in Cursor user MCP config.", + }; + } + return { + connected: false, + description: "Connect", + tooltip: "Connect Operator as MCP server in Cursor", + }; + } + + const connected = + presence.vscodeWorkspace || presence.workspacePortable || presence.copilotGlobal; + if (!connected) { + return { + connected: false, + description: "Connect", + tooltip: "Connect Operator as MCP server in VS Code", + }; + } + + if (presence.copilotGlobal && !presence.vscodeWorkspace && !presence.workspacePortable) { + return { + connected: true, + description: "Copilot Global", + tooltip: "Operator MCP is registered in Copilot Global.", + }; + } + + const tokens: string[] = []; + const tooltipParts: string[] = []; + if (presence.vscodeWorkspace) { + if (workspaceEntryIsStdio === false) { + tokens.push("editor (SSE)"); + tooltipParts.push( + "SSE in workspace settings. Portable files skipped because stdio is not advertised.", + ); + } else { + tokens.push("editor"); + tooltipParts.push( + "Registered in workspace MCP settings. Copilot CLI does not read .vscode/mcp.json.", + ); + } + } + if (presence.workspacePortable) { + tokens.push(".mcp.json"); + tooltipParts.push("Agent Host / Copilot CLI can see workspace .mcp.json."); + } + if (presence.copilotGlobal) { + tokens.push("Copilot"); + tooltipParts.push("Also in Copilot Global."); + } + + return { + connected: true, + description: tokens.join(" · "), + tooltip: tooltipParts.join(" "), + }; +} + /** * Build the workspace-scope server entry for VS Code's `mcp.servers`, * preferring the stdio transport when the descriptor advertises it. @@ -166,14 +414,6 @@ export async function registerInVscodeWorkspaceConfig( servers["operator"] = buildVscodeServerEntry(descriptor); await mcpConfig.update("servers", servers, vscode.ConfigurationTarget.Workspace); - - const transport = descriptor.stdio ? "stdio" : "sse"; - const detail = descriptor.stdio - ? `${descriptor.stdio.command} ${descriptor.stdio.args.join(" ")}` - : descriptor.transport_url; - void vscode.window.showInformationMessage( - `Operator MCP server registered (${transport}: ${detail})`, - ); } /** @@ -205,41 +445,21 @@ export async function registerInCursorUserConfig( return; } - await fs.mkdir(path.dirname(configPath), { recursive: true }); - - let existing: Record = {}; - try { - const raw = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(raw) as unknown; - if (parsed !== null && typeof parsed === "object") { - existing = parsed as Record; - } - } catch (err: unknown) { - const e = err as NodeJS.ErrnoException; - if (e.code !== "ENOENT") { + const result = await writeMcpServersOperatorEntry(configPath, descriptor.stdio); + if (!result.ok) { + if (result.reason === "parse") { void vscode.window.showErrorMessage( - `Could not parse existing ${configPath}: ${e.message}. ` + + `Could not parse existing ${configPath}: ${result.message}. ` + "Please fix or remove the file and retry.", ); - return; + } else { + void vscode.window.showErrorMessage( + `Could not write ${configPath}: ${result.message}`, + ); } + return; } - const existingServers = existing.mcpServers; - const mcpServers = - existingServers && typeof existingServers === "object" - ? { ...(existingServers as Record) } - : {}; - - mcpServers["operator"] = { - command: descriptor.stdio.command, - args: descriptor.stdio.args, - cwd: descriptor.stdio.cwd, - }; - - const merged = { ...existing, mcpServers }; - await fs.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8"); - void vscode.window.showInformationMessage( `Operator MCP server registered in ${configPath} (stdio). ` + "You may need to restart Cursor or toggle the server in " + @@ -250,9 +470,9 @@ export async function registerInCursorUserConfig( /** * Connect Operator as an MCP server in the host IDE. * - * Discovers the running API, fetches the descriptor, then dispatches to - * either the Cursor user-scope path (`~/.cursor/mcp.json`) or the VS Code - * workspace-scope path (`mcp.servers`) based on the detected host. + * Cursor writes `~/.cursor/mcp.json`. VS Code keeps workspace `mcp.servers` + * and, when stdio is advertised, also writes workspace-root `.mcp.json`. + * Copilot Global is offered as a follow-up action. */ export async function connectMcpServer(ticketsDir: string | undefined): Promise { try { @@ -269,8 +489,44 @@ export async function connectMcpServer(ticketsDir: string | undefined): Promise< if (detectHostApp() === "cursor") { await registerInCursorUserConfig(descriptor); + return; + } + + await registerInVscodeWorkspaceConfig(descriptor); + const portable = await registerInVscodeWorkspacePortableConfig(descriptor); + + const lines: string[] = []; + const transport = descriptor.stdio ? "stdio" : "sse"; + lines.push(`Operator MCP server registered in workspace settings (${transport}).`); + if (portable.ok) { + lines.push( + `Also wrote ${portable.path}. This path is machine-local; do not commit .mcp.json.`, + ); + } else if (descriptor.stdio) { + lines.push( + "Copilot CLI does not read .vscode/mcp.json. Open a folder or write Copilot Global.", + ); + } + + if (descriptor.stdio) { + const choice = await vscode.window.showInformationMessage( + lines.join(" "), + COPILOT_GLOBAL_ACTION, + ); + if (choice === COPILOT_GLOBAL_ACTION) { + const globalResult = await registerInCopilotGlobalConfig(descriptor); + if (globalResult.ok) { + void vscode.window.showInformationMessage( + `Operator MCP registered in ${globalResult.path} (Copilot Global).`, + ); + } else if ("message" in globalResult) { + void vscode.window.showErrorMessage( + `Could not write ${globalResult.path}: ${globalResult.message}`, + ); + } + } } else { - await registerInVscodeWorkspaceConfig(descriptor); + void vscode.window.showInformationMessage(lines.join(" ")); } } catch (err) { const message = err instanceof Error ? err.message : "Failed to connect MCP server"; diff --git a/vscode-extension/src/sections/connections-section.ts b/vscode-extension/src/sections/connections-section.ts index 56999834..7019a7e3 100644 --- a/vscode-extension/src/sections/connections-section.ts +++ b/vscode-extension/src/sections/connections-section.ts @@ -9,7 +9,8 @@ import type { ApiSessionInfo } from "../api-client"; import { discoverApiUrl, AuthRequiredError, OperatorApiClient } from "../api-client"; import { SIGN_IN_COMMAND_TITLE } from "../auth/errors"; import { getOperatorPath, getOperatorVersion } from "../operator-binary"; -import { isMcpServerRegistered } from "../mcp-connect"; +import * as mcpConnect from "../mcp-connect"; +import type { McpRegistrationPresence } from "../mcp-connect"; export class ConnectionsSection implements StatusSection { readonly sectionId: SectionId = "connections"; @@ -19,7 +20,13 @@ export class ConnectionsSection implements StatusSection { private apiStatus: ApiStatus = { connected: false }; private operatorVersion: string | undefined; private localDirectoryName: string | undefined; - private mcpRegistered = false; + private mcpPresence: McpRegistrationPresence = { + vscodeWorkspace: false, + workspacePortable: false, + copilotGlobal: false, + cursor: false, + }; + private workspaceStdio: boolean | undefined; private wrapperType = "vscode"; private webUiAvailable = false; @@ -57,7 +64,8 @@ export class ConnectionsSection implements StatusSection { this.checkApiStatus(ctx), this.checkWrapperType(ctx), ]); - this.mcpRegistered = isMcpServerRegistered(); + this.mcpPresence = await mcpConnect.inspectMcpRegistration(); + this.workspaceStdio = mcpConnect.isVscodeWorkspaceStdio(); } private async checkWebhookStatus(ctx: SectionContext): Promise { @@ -399,13 +407,18 @@ export class ConnectionsSection implements StatusSection { }); // 5. MCP Connection + const mcpStatus = mcpConnect.mcpStatusDescription( + this.mcpPresence, + mcpConnect.detectHostApp(), + this.workspaceStdio, + ); let mcpItem: StatusItem; - if (this.mcpRegistered) { + if (mcpStatus.connected) { mcpItem = new StatusItem({ label: "MCP", - description: "Connected", + description: mcpStatus.description, icon: "pass", - tooltip: "Operator MCP server is registered in workspace settings", + tooltip: mcpStatus.tooltip, command: this.apiStatus.connected ? { command: "operator.connectMcpServer", @@ -417,9 +430,9 @@ export class ConnectionsSection implements StatusSection { } else if (this.apiStatus.connected) { mcpItem = new StatusItem({ label: "MCP", - description: "Connect", + description: mcpStatus.description, icon: "plug", - tooltip: "Connect Operator as MCP server in VS Code", + tooltip: mcpStatus.tooltip, command: { command: "operator.connectMcpServer", title: "Connect MCP Server", diff --git a/vscode-extension/src/serial-queue.ts b/vscode-extension/src/serial-queue.ts new file mode 100644 index 00000000..e0d6a02b --- /dev/null +++ b/vscode-extension/src/serial-queue.ts @@ -0,0 +1,16 @@ +/** + * Runs async tasks one at a time in submission order. A task that throws + * rejects only its own promise; later tasks still run. + */ +export class SerialQueue { + private _tail: Promise = Promise.resolve(); + + run(task: () => Promise): Promise { + const result = this._tail.then(task); + this._tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} diff --git a/vscode-extension/test/suite/config-panel.test.ts b/vscode-extension/test/suite/config-panel.test.ts index ace49b5b..42c572fe 100644 --- a/vscode-extension/test/suite/config-panel.test.ts +++ b/vscode-extension/test/suite/config-panel.test.ts @@ -94,8 +94,8 @@ suite("Config Panel Kanban Providers", () => { test(`${slug}: scalar field writes under the default instance key`, () => { const kanban: Record = {}; - applyKanbanProviderField(kanban, slug, "enabled", true); - applyKanbanProviderField(kanban, slug, "api_key_env", "MY_TOKEN"); + applyKanbanProviderField(kanban, slug, meta.defaultInstanceKey, "enabled", true); + applyKanbanProviderField(kanban, slug, meta.defaultInstanceKey, "api_key_env", "MY_TOKEN"); const providerMap = kanban[slug] as Record; assert.ok(providerMap, `Expected kanban.${slug} table to exist`); @@ -107,8 +107,14 @@ suite("Config Panel Kanban Providers", () => { test(`${slug}: instance-key field renames the provider map key`, () => { const kanban: Record = {}; - applyKanbanProviderField(kanban, slug, "enabled", true); - applyKanbanProviderField(kanban, slug, meta.instanceKeyField, "renamed-instance"); + applyKanbanProviderField(kanban, slug, meta.defaultInstanceKey, "enabled", true); + applyKanbanProviderField( + kanban, + slug, + meta.defaultInstanceKey, + meta.instanceKeyField, + "renamed-instance", + ); const providerMap = kanban[slug] as Record; assert.ok( @@ -123,7 +129,13 @@ suite("Config Panel Kanban Providers", () => { test(`${slug}: project-scoped field writes into a project sub-table`, () => { const kanban: Record = {}; - applyKanbanProviderField(kanban, slug, "projects.PROJ.sync_user_id", "user-123"); + applyKanbanProviderField( + kanban, + slug, + meta.defaultInstanceKey, + "projects.PROJ.sync_user_id", + "user-123", + ); const providerMap = kanban[slug] as Record; const instance = providerMap[meta.defaultInstanceKey] as Record; @@ -138,7 +150,13 @@ suite("Config Panel Kanban Providers", () => { // Explicit projects..status_mapping path (ProjectRow write path) const kanban: Record = {}; - applyKanbanProviderField(kanban, slug, "projects.PROJ.status_mapping", mapping); + applyKanbanProviderField( + kanban, + slug, + meta.defaultInstanceKey, + "projects.PROJ.status_mapping", + mapping, + ); let providerMap = kanban[slug] as Record; let instance = providerMap[meta.defaultInstanceKey] as Record; let proj = (instance.projects as Record).PROJ as Record; @@ -146,7 +164,7 @@ suite("Config Panel Kanban Providers", () => { // Shorthand project-level key (single-project config forms) const kanban2: Record = {}; - applyKanbanProviderField(kanban2, slug, "status_mapping", mapping); + applyKanbanProviderField(kanban2, slug, meta.defaultInstanceKey, "status_mapping", mapping); providerMap = kanban2[slug] as Record; instance = providerMap[meta.defaultInstanceKey] as Record; const projects = instance.projects as Record; @@ -154,13 +172,97 @@ suite("Config Panel Kanban Providers", () => { proj = projects[firstKey] as Record; assert.deepStrictEqual(proj.status_mapping, mapping); }); + + test(`${slug}: updates and renames only the selected instance`, () => { + const secondInstanceKey = "second-instance"; + const renamedInstanceKey = "renamed-second-instance"; + const firstInstance = { enabled: true, projects: {} }; + const secondInstance = { enabled: false, projects: {} }; + const kanban: Record = { + [slug]: { + [meta.defaultInstanceKey]: firstInstance, + [secondInstanceKey]: secondInstance, + }, + }; + + applyKanbanProviderField(kanban, slug, secondInstanceKey, "enabled", true); + applyKanbanProviderField( + kanban, + slug, + secondInstanceKey, + "projects.PROJ.sync_user_id", + "user-456", + ); + applyKanbanProviderField( + kanban, + slug, + secondInstanceKey, + meta.instanceKeyField, + renamedInstanceKey, + ); + + const providerMap = kanban[slug] as Record; + assert.deepStrictEqual(providerMap[meta.defaultInstanceKey], firstInstance); + assert.ok(!Object.prototype.hasOwnProperty.call(providerMap, secondInstanceKey)); + const renamed = providerMap[renamedInstanceKey] as Record; + assert.strictEqual(renamed.enabled, true); + const projects = renamed.projects as Record; + const project = projects.PROJ as Record; + assert.strictEqual(project.sync_user_id, "user-456"); + }); } }); test("unknown provider slug throws rather than silently dropping the write", () => { assert.throws( - () => applyKanbanProviderField({}, "notaprovider", "enabled", true), + () => applyKanbanProviderField({}, "notaprovider", "instance", "enabled", true), /Unknown kanban provider/, ); }); + + test("missing instance identifier throws rather than selecting the first instance", () => { + assert.throws( + () => applyKanbanProviderField({}, "jira", undefined, "enabled", true), + /instance identifier/, + ); + }); + + test("missing instance target throws rather than creating a sibling entry", () => { + const kanban = { + jira: { + "first.atlassian.net": { enabled: true, projects: {} }, + }, + }; + assert.throws( + () => applyKanbanProviderField(kanban, "jira", "missing.atlassian.net", "enabled", false), + /was not found/, + ); + assert.deepStrictEqual(Object.keys(kanban.jira), ["first.atlassian.net"]); + }); + + test("instance rename collision throws without overwriting either instance", () => { + const first = { enabled: true, projects: {} }; + const second = { enabled: false, projects: {} }; + const kanban = { + jira: { + "first.atlassian.net": first, + "second.atlassian.net": second, + }, + }; + assert.throws( + () => + applyKanbanProviderField( + kanban, + "jira", + "second.atlassian.net", + "domain", + "first.atlassian.net", + ), + /already exists/, + ); + assert.deepStrictEqual(kanban.jira, { + "first.atlassian.net": first, + "second.atlassian.net": second, + }); + }); }); diff --git a/vscode-extension/test/suite/manifest-parity.test.ts b/vscode-extension/test/suite/manifest-parity.test.ts index 94700354..ed1a9e81 100644 --- a/vscode-extension/test/suite/manifest-parity.test.ts +++ b/vscode-extension/test/suite/manifest-parity.test.ts @@ -25,26 +25,25 @@ suite("Manifest Parity Tests", () => { }); // ----------------------------------------------------------------------- - // engines.vscode must be >= 1.93 for terminal shell execution APIs + // engines.vscode must be >= 1.140 for portable MCP / Agent Host files // ----------------------------------------------------------------------- - test("engines.vscode floor is at least 1.93 for shell execution APIs", () => { + test("engines.vscode floor is at least 1.140 for portable MCP / Agent Host files", () => { const enginesVscode = packageJson.engines?.vscode; assert.ok(enginesVscode, "engines.vscode must be defined"); - // Extract the minimum version number from the semver range (e.g. "^1.93.0" -> "1.93.0") + // Extract the minimum version number from the semver range (e.g. "^1.140.0" -> "1.140.0") const match = enginesVscode.match(/(\d+)\.(\d+)/); assert.ok(match, `Could not parse version from engines.vscode: ${enginesVscode}`); const major = Number.parseInt(match[1]!, 10); const minor = Number.parseInt(match[2]!, 10); - // onDidStartTerminalShellExecution was added in 1.93 - const meetsMinimum = major > 1 || (major === 1 && minor >= 93); + const meetsMinimum = major > 1 || (major === 1 && minor >= 140); assert.ok( meetsMinimum, - `engines.vscode "${enginesVscode}" is below 1.93 - TerminalManager uses ` + - `onDidStartTerminalShellExecution/onDidEndTerminalShellExecution which require VS Code 1.93+`, + `engines.vscode "${enginesVscode}" is below 1.140 - Connect MCP writes ` + + `workspace .mcp.json and Copilot Global files that VS Code 1.140 Agent Host reads`, ); }); diff --git a/vscode-extension/test/suite/mcp-connect.test.ts b/vscode-extension/test/suite/mcp-connect.test.ts index 53d30784..55044cfb 100644 --- a/vscode-extension/test/suite/mcp-connect.test.ts +++ b/vscode-extension/test/suite/mcp-connect.test.ts @@ -23,7 +23,14 @@ import { detectHostApp, registerInCursorUserConfig, registerInVscodeWorkspaceConfig, + registerInVscodeWorkspacePortableConfig, + registerInCopilotGlobalConfig, + copilotGlobalConfigPath, connectMcpServer, + writeMcpServersOperatorEntry, + COPILOT_GLOBAL_ACTION, + inspectMcpRegistration, + mcpStatusDescription, _testable, } from "../../src/mcp-connect"; @@ -169,6 +176,87 @@ suite("MCP Connect Test Suite", () => { }); }); + suite("writeMcpServersOperatorEntry()", () => { + let tmpDir: string; + let configPath: string; + + setup(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "op-mcp-servers-")); + configPath = path.join(tmpDir, ".mcp.json"); + }); + + teardown(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + const stdio = { + command: "/usr/local/bin/operator", + args: ["mcp"], + cwd: "/Users/dev/work", + }; + + test("creates parent directory and writes mcpServers.operator", async () => { + const result = await writeMcpServersOperatorEntry(configPath, stdio); + assert.deepStrictEqual(result, { ok: true, path: configPath }); + const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + mcpServers: { operator: { command: string; args: string[]; cwd: string } }; + }; + assert.deepStrictEqual(parsed.mcpServers.operator, stdio); + }); + + test("copies descriptor args including --profile", async () => { + const profileStdio = { + command: "/usr/local/bin/operator", + args: ["--profile", "work", "mcp"], + cwd: "/Users/dev/work", + }; + await writeMcpServersOperatorEntry(configPath, profileStdio); + const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + mcpServers: { operator: { args: string[] } }; + }; + assert.deepStrictEqual(parsed.mcpServers.operator.args, ["--profile", "work", "mcp"]); + }); + + test("preserves sibling mcpServers entries and other top-level keys", async () => { + await fs.writeFile( + configPath, + JSON.stringify({ + customKey: { foo: "bar" }, + mcpServers: { other: { command: "/usr/bin/other", args: [] } }, + }), + "utf-8", + ); + await writeMcpServersOperatorEntry(configPath, stdio); + const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + customKey: { foo: string }; + mcpServers: Record; + }; + assert.deepStrictEqual(parsed.customKey, { foo: "bar" }); + assert.ok(parsed.mcpServers.other); + assert.ok(parsed.mcpServers.operator); + }); + + test("does not write type or url fields", async () => { + await writeMcpServersOperatorEntry(configPath, stdio); + const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + mcpServers: { operator: Record }; + }; + assert.strictEqual(parsed.mcpServers.operator.type, undefined); + assert.strictEqual(parsed.mcpServers.operator.url, undefined); + }); + + test("returns parse failure and leaves malformed JSON untouched", async () => { + await fs.writeFile(configPath, "{not valid json", "utf-8"); + const result = await writeMcpServersOperatorEntry(configPath, stdio); + assert.strictEqual(result.ok, false); + if (result.ok) { + return; + } + assert.strictEqual(result.reason, "parse"); + assert.strictEqual(await fs.readFile(configPath, "utf-8"), "{not valid json"); + }); + }); + suite("registerInCursorUserConfig()", () => { let tmpDir: string; let cursorConfigPath: string; @@ -323,7 +411,7 @@ suite("MCP Connect Test Suite", () => { args: ["mcp"], cwd: "/Users/dev/work", }); - assert.ok(infoStub.calledOnce); + assert.ok(infoStub.notCalled, "workspace register defers toasts to connectMcpServer"); }); test("writes sse entry when descriptor.stdio is absent", async () => { @@ -352,6 +440,137 @@ suite("MCP Connect Test Suite", () => { }); }); + suite("registerInVscodeWorkspacePortableConfig()", () => { + let tmpDir: string; + let configPath: string; + + setup(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "op-mcp-ws-")); + configPath = path.join(tmpDir, ".mcp.json"); + }); + + teardown(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + test("writes mcpServers.operator from descriptor.stdio", async () => { + const descriptor = await loadFixture("mcp-descriptor-response-stdio.json"); + const result = await registerInVscodeWorkspacePortableConfig(descriptor, configPath); + assert.strictEqual(result.ok, true); + const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + mcpServers: { operator: { command: string } }; + }; + assert.strictEqual(parsed.mcpServers.operator.command, "/usr/local/bin/operator"); + }); + + test("skips write when descriptor has no stdio", async () => { + const descriptor = await loadFixture("mcp-descriptor-response.json"); + const result = await registerInVscodeWorkspacePortableConfig(descriptor, configPath); + assert.strictEqual(result.ok, false); + if (result.ok) { + return; + } + assert.strictEqual(result.reason, "no-stdio"); + await assert.rejects(() => fs.access(configPath)); + }); + + test("skips write when workspace path is missing", async () => { + sinon.stub(_testable, "workspacePortableMcpPath").returns(undefined); + const descriptor = await loadFixture("mcp-descriptor-response-stdio.json"); + const result = await registerInVscodeWorkspacePortableConfig(descriptor, undefined); + assert.strictEqual(result.ok, false); + if (result.ok) { + return; + } + assert.strictEqual(result.reason, "no-workspace"); + }); + + test("uses _testable.workspacePortableMcpPath when configPath is omitted", async () => { + const pathStub = sinon.stub(_testable, "workspacePortableMcpPath").returns(undefined); + try { + const descriptor = await loadFixture("mcp-descriptor-response-stdio.json"); + const result = await registerInVscodeWorkspacePortableConfig(descriptor); + assert.strictEqual(result.ok, false); + if (result.ok) { + return; + } + assert.strictEqual(result.reason, "no-workspace"); + assert.ok(pathStub.calledOnce); + } finally { + pathStub.restore(); + } + }); + }); + + suite("copilotGlobalConfigPath()", () => { + test("uses COPILOT_HOME when set", () => { + const prev = process.env.COPILOT_HOME; + process.env.COPILOT_HOME = "/tmp/copilot-home-test"; + try { + assert.strictEqual( + copilotGlobalConfigPath(), + path.join("/tmp/copilot-home-test", "mcp-config.json"), + ); + } finally { + if (prev === undefined) { + delete process.env.COPILOT_HOME; + } else { + process.env.COPILOT_HOME = prev; + } + } + }); + + test("falls back to ~/.copilot/mcp-config.json", () => { + const prev = process.env.COPILOT_HOME; + delete process.env.COPILOT_HOME; + try { + assert.strictEqual( + copilotGlobalConfigPath(), + path.join(os.homedir(), ".copilot", "mcp-config.json"), + ); + } finally { + if (prev !== undefined) { + process.env.COPILOT_HOME = prev; + } + } + }); + }); + + suite("registerInCopilotGlobalConfig()", () => { + let tmpDir: string; + let configPath: string; + + setup(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "op-copilot-mcp-")); + configPath = path.join(tmpDir, "mcp-config.json"); + }); + + teardown(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + test("writes mcpServers.operator when stdio is present", async () => { + const descriptor = await loadFixture("mcp-descriptor-response-stdio.json"); + const result = await registerInCopilotGlobalConfig(descriptor, configPath); + assert.strictEqual(result.ok, true); + const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + mcpServers: { operator: { cwd: string } }; + }; + assert.strictEqual(parsed.mcpServers.operator.cwd, "/Users/dev/work"); + }); + + test("skips write when descriptor has no stdio", async () => { + const descriptor = await loadFixture("mcp-descriptor-response.json"); + const result = await registerInCopilotGlobalConfig(descriptor, configPath); + assert.strictEqual(result.ok, false); + if (result.ok) { + return; + } + assert.strictEqual(result.reason, "no-stdio"); + await assert.rejects(() => fs.access(configPath)); + }); + }); + suite("connectMcpServer() dispatch", () => { let detectHostStub: sinon.SinonStub; let discoverApiUrlStub: sinon.SinonStub; @@ -373,21 +592,99 @@ suite("MCP Connect Test Suite", () => { sinon.stub(vscode.window, "showErrorMessage"); }); - test("VS Code host writes to workspace mcp.servers", async () => { + test("VS Code host writes workspace mcp.servers and .mcp.json when stdio is present", async () => { detectHostStub.returns("Visual Studio Code"); - const descriptor = await loadFixture("mcp-descriptor-response-stdio.json"); - fetchStub.resolves(new Response(JSON.stringify(descriptor), { status: 200 })); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "op-connect-ws-")); + const portablePath = path.join(tmpDir, ".mcp.json"); + sinon.stub(_testable, "workspacePortableMcpPath").returns(portablePath); + try { + const descriptor = await loadFixture("mcp-descriptor-response-stdio.json"); + fetchStub.resolves(new Response(JSON.stringify(descriptor), { status: 200 })); + + await connectMcpServer(undefined); + + assert.ok(configUpdateStub.calledOnce, "legacy mcp.servers write"); + const written = configUpdateStub.firstCall.args[1] as Record< + string, + Record | undefined + >; + assert.ok(written.operator, "operator entry should be written"); + assert.strictEqual(written.operator.type, "stdio"); + const parsed = JSON.parse(await fs.readFile(portablePath, "utf-8")) as { + mcpServers: { operator: { command: string } }; + }; + assert.strictEqual(parsed.mcpServers.operator.command, "/usr/local/bin/operator"); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + test("VS Code host does not write .mcp.json when stdio is absent", async () => { + detectHostStub.returns("Visual Studio Code"); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "op-connect-sse-")); + const portablePath = path.join(tmpDir, ".mcp.json"); + sinon.stub(_testable, "workspacePortableMcpPath").returns(portablePath); + try { + const descriptor = await loadFixture("mcp-descriptor-response.json"); + fetchStub.resolves(new Response(JSON.stringify(descriptor), { status: 200 })); + + await connectMcpServer(undefined); + + assert.ok(configUpdateStub.calledOnce); + const written = configUpdateStub.firstCall.args[1] as Record< + string, + { type: string } | undefined + >; + assert.strictEqual(written.operator?.type, "sse"); + await assert.rejects(() => fs.access(portablePath)); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + test("VS Code host writes Copilot Global when the user accepts the follow-up", async () => { + detectHostStub.returns("Visual Studio Code"); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "op-connect-global-")); + const portablePath = path.join(tmpDir, ".mcp.json"); + const globalPath = path.join(tmpDir, "mcp-config.json"); + sinon.stub(_testable, "workspacePortableMcpPath").returns(portablePath); + sinon.stub(_testable, "copilotGlobalConfigPath").returns(globalPath); + const infoStub = vscode.window.showInformationMessage as unknown as sinon.SinonStub; + infoStub.resolves(COPILOT_GLOBAL_ACTION); + try { + const descriptor = await loadFixture("mcp-descriptor-response-stdio.json"); + fetchStub.resolves(new Response(JSON.stringify(descriptor), { status: 200 })); + + await connectMcpServer(undefined); + + const parsed = JSON.parse(await fs.readFile(globalPath, "utf-8")) as { + mcpServers: { operator: { command: string } }; + }; + assert.strictEqual(parsed.mcpServers.operator.command, "/usr/local/bin/operator"); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); - await connectMcpServer(undefined); + test("VS Code host does not write Copilot Global when the user dismisses the toast", async () => { + detectHostStub.returns("Visual Studio Code"); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "op-connect-dismiss-")); + const portablePath = path.join(tmpDir, ".mcp.json"); + const globalPath = path.join(tmpDir, "mcp-config.json"); + sinon.stub(_testable, "workspacePortableMcpPath").returns(portablePath); + sinon.stub(_testable, "copilotGlobalConfigPath").returns(globalPath); + const infoStub = vscode.window.showInformationMessage as unknown as sinon.SinonStub; + infoStub.resolves(undefined); + try { + const descriptor = await loadFixture("mcp-descriptor-response-stdio.json"); + fetchStub.resolves(new Response(JSON.stringify(descriptor), { status: 200 })); - assert.ok(discoverApiUrlStub.calledOnce); - assert.ok(configUpdateStub.calledOnce, "VS Code path should write workspace config"); - const written = configUpdateStub.firstCall.args[1] as Record< - string, - Record | undefined - >; - assert.ok(written.operator, "operator entry should be written"); - assert.strictEqual(written.operator.type, "stdio"); + await connectMcpServer(undefined); + + await assert.rejects(() => fs.access(globalPath)); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } }); test("Cursor host does NOT write to workspace mcp.servers", async () => { @@ -416,6 +713,123 @@ suite("MCP Connect Test Suite", () => { } }); }); + + suite("inspectMcpRegistration() and mcpStatusDescription()", () => { + test("mcpStatusDescription vscode workspace-only stdio", () => { + const { connected, description, tooltip } = mcpStatusDescription( + { + vscodeWorkspace: true, + workspacePortable: false, + copilotGlobal: false, + cursor: false, + }, + "vscode", + true, + ); + assert.strictEqual(connected, true); + assert.strictEqual(description, "editor"); + assert.ok(tooltip.includes("Copilot CLI")); + }); + + test("mcpStatusDescription vscode SSE-only", () => { + const { description } = mcpStatusDescription( + { + vscodeWorkspace: true, + workspacePortable: false, + copilotGlobal: false, + cursor: false, + }, + "vscode", + false, + ); + assert.strictEqual(description, "editor (SSE)"); + }); + + test("mcpStatusDescription vscode editor plus portable plus global", () => { + const { description } = mcpStatusDescription( + { + vscodeWorkspace: true, + workspacePortable: true, + copilotGlobal: true, + cursor: false, + }, + "vscode", + true, + ); + assert.strictEqual(description, "editor · .mcp.json · Copilot"); + }); + + test("mcpStatusDescription cursor ignores workspace settings", () => { + const { connected, description } = mcpStatusDescription( + { + vscodeWorkspace: true, + workspacePortable: false, + copilotGlobal: false, + cursor: false, + }, + "cursor", + true, + ); + assert.strictEqual(connected, false); + assert.strictEqual(description, "Connect"); + }); + + test("mcpStatusDescription cursor file present", () => { + const { connected, description } = mcpStatusDescription( + { + vscodeWorkspace: false, + workspacePortable: false, + copilotGlobal: false, + cursor: true, + }, + "cursor", + true, + ); + assert.strictEqual(connected, true); + assert.strictEqual(description, "~/.cursor/mcp.json"); + }); + + test("mcpStatusDescription Copilot Global only", () => { + const { connected, description } = mcpStatusDescription( + { + vscodeWorkspace: false, + workspacePortable: false, + copilotGlobal: true, + cursor: false, + }, + "vscode", + undefined, + ); + assert.strictEqual(connected, true); + assert.strictEqual(description, "Copilot Global"); + }); + + test("inspectMcpRegistration reads stubbed portable files", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "op-inspect-")); + const portablePath = path.join(tmpDir, ".mcp.json"); + const globalPath = path.join(tmpDir, "mcp-config.json"); + await fs.writeFile( + portablePath, + JSON.stringify({ mcpServers: { operator: { command: "x", args: ["mcp"], cwd: "/" } } }), + "utf-8", + ); + sinon.stub(_testable, "workspacePortableMcpPath").returns(portablePath); + sinon.stub(_testable, "copilotGlobalConfigPath").returns(globalPath); + sinon.stub(_testable, "cursorMcpConfigPath").returns(path.join(tmpDir, "cursor.json")); + sinon.stub(vscode.workspace, "getConfiguration").returns({ + get: () => ({ operator: { type: "stdio" } }), + } as unknown as vscode.WorkspaceConfiguration); + try { + const presence = await inspectMcpRegistration(); + assert.strictEqual(presence.vscodeWorkspace, true); + assert.strictEqual(presence.workspacePortable, true); + assert.strictEqual(presence.copilotGlobal, false); + assert.strictEqual(presence.cursor, false); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + }); }); // Silence unused-import warning - `mcpConnect` is imported for documentation diff --git a/vscode-extension/test/suite/serial-queue.test.ts b/vscode-extension/test/suite/serial-queue.test.ts new file mode 100644 index 00000000..451c69e7 --- /dev/null +++ b/vscode-extension/test/suite/serial-queue.test.ts @@ -0,0 +1,45 @@ +import * as assert from "node:assert"; +import { SerialQueue } from "../../src/serial-queue"; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve: () => void = () => undefined; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +suite("SerialQueue", () => { + test("runs tasks one at a time in submission order", async () => { + const queue = new SerialQueue(); + const order: string[] = []; + const gate = deferred(); + + const first = queue.run(async () => { + order.push("first:start"); + await gate.promise; + order.push("first:end"); + }); + const second = queue.run(async () => { + order.push("second"); + return Promise.resolve(); + }); + + await Promise.resolve(); + assert.deepStrictEqual(order, ["first:start"]); + gate.resolve(); + await Promise.all([first, second]); + assert.deepStrictEqual(order, ["first:start", "first:end", "second"]); + }); + + test("a throwing task rejects its own promise without blocking later tasks", async () => { + const queue = new SerialQueue(); + const failure = new Error("write failed"); + + const failed = queue.run(() => Promise.reject(failure)); + const after = queue.run(() => Promise.resolve("ran")); + + await assert.rejects(failed, failure); + assert.strictEqual(await after, "ran"); + }); +}); diff --git a/vscode-extension/test/suite/status-provider.test.ts b/vscode-extension/test/suite/status-provider.test.ts index be25b8db..f3aec2a2 100644 --- a/vscode-extension/test/suite/status-provider.test.ts +++ b/vscode-extension/test/suite/status-provider.test.ts @@ -18,6 +18,7 @@ import * as configPaths from "../../src/config-paths"; import * as walkthrough from "../../src/walkthrough"; import * as operatorBinary from "../../src/operator-binary"; import * as apiClient from "../../src/api-client"; +import * as mcpConnect from "../../src/mcp-connect"; const EMPTY_STUB = {}; @@ -295,6 +296,35 @@ suite("Status Provider Test Suite", () => { }); }); + suite("MCP status row", () => { + test("describes editor registration instead of a boolean Connected", async () => { + const mockContext = createMockContext(sandbox, "/fake/working-dir"); + sandbox.stub(configPaths, "configFileExists").resolves(true); + sandbox.stub(configPaths, "getResolvedConfigPath").returns(""); + sandbox.stub(configPaths, "resolveWorkingDirectory").returns("/fake/working-dir"); + sandbox.stub(mcpConnect, "inspectMcpRegistration").resolves({ + vscodeWorkspace: true, + workspacePortable: false, + copilotGlobal: false, + cursor: false, + }); + sandbox.stub(mcpConnect, "isVscodeWorkspaceStdio").returns(true); + sandbox.stub(mcpConnect, "detectHostApp").returns("vscode"); + + const provider = new StatusTreeProvider(mockContext); + await provider.setTicketsDir(tempDir); + + const sections = provider.getChildren(); + const connections = findChild(sections, "Connections"); + assert.ok(connections, "Should have Connections section"); + + const children = provider.getChildren(connections); + const mcp = findChild(children, "MCP"); + assert.ok(mcp, "Should have MCP item"); + assert.strictEqual(mcp.description, "editor"); + }); + }); + suite("progressive disclosure", () => { test("tier 0: only Configuration when config not ready", async () => { const mockContext = createMockContext(sandbox); diff --git a/vscode-extension/tsconfig.webview.json b/vscode-extension/tsconfig.webview.json index cf703537..36b7064c 100644 --- a/vscode-extension/tsconfig.webview.json +++ b/vscode-extension/tsconfig.webview.json @@ -13,5 +13,5 @@ "sourceMap": true }, "include": ["webview-ui/**/*", "src/generated/**/*"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "webview-ui/**/*.test.ts"] } diff --git a/vscode-extension/webview-ui/App.tsx b/vscode-extension/webview-ui/App.tsx index e529304a..bbdcad45 100644 --- a/vscode-extension/webview-ui/App.tsx +++ b/vscode-extension/webview-ui/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useCallback, useRef } from "react"; import { Alert, Spinner } from "./components/primitives"; import { ConfigPage } from "./components/ConfigPage"; import { postMessage, onMessage } from "./vscodeApi"; @@ -11,10 +11,23 @@ import type { IssueTypeSummary, CollectionResponse, ExternalIssueTypeSummary, + NavigationPrefill, } from "./types/messages"; -import type { JiraConfig } from "../src/generated/JiraConfig"; -import type { LinearConfig } from "../src/generated/LinearConfig"; -import type { ProjectSyncConfig } from "../src/generated/ProjectSyncConfig"; +import { applyUpdate } from "./state/applyUpdate"; +import { + configErrorFrom, + errorAfterSnapshot, + isStaleSnapshot, + toWireValue, + type ConfigErrorState, +} from "./state/configSync"; + +interface NavigationRequest { + section: string; + prefill?: NavigationPrefill; +} + +const CREATE_DELEGATOR_ACTION = "createDelegator"; function browseFolder(field: string): void { postMessage({ type: "browseFolder", field }); @@ -46,7 +59,7 @@ function openOperatorUi(route: "issuetypes" | "projects"): void { export function App() { const [config, setConfig] = useState(null); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [jiraResult, setJiraResult] = useState(null); const [linearResult, setLinearResult] = useState(null); const [validatingJira, setValidatingJira] = useState(false); @@ -58,17 +71,27 @@ export function App() { Map >(new Map()); const [kanbanStatuses, setKanbanStatuses] = useState>(new Map()); + const [navigation, setNavigation] = useState(null); + const lastSentRev = useRef(0); + const hasConfig = config !== null; + const delegatorPrefill = + navigation?.prefill?.action === CREATE_DELEGATOR_ACTION ? navigation.prefill : undefined; useEffect(() => { const cleanup = onMessage((msg: ExtensionToWebviewMessage) => { switch (msg.type) { case "configLoaded": case "configUpdated": - setConfig(mergeWithDefaults(msg.config)); - setError(null); + setError((prev) => errorAfterSnapshot(prev, msg.type)); + if (!isStaleSnapshot(msg.rev, lastSentRev.current)) { + setConfig(mergeWithDefaults(msg.config)); + } break; case "configError": - setError(msg.error); + setError(configErrorFrom(msg.error, msg.rev)); + if (msg.rev !== undefined) { + postMessage({ type: "getConfig" }); + } break; case "browseResult": setConfig((prev) => { @@ -90,7 +113,12 @@ export function App() { setValidatingLinear(false); break; case "llmToolsDetected": - setConfig(mergeWithDefaults(msg.config)); + if (!isStaleSnapshot(msg.rev, lastSentRev.current)) { + setConfig(mergeWithDefaults(msg.config)); + } + break; + case "navigateTo": + setNavigation({ section: msg.section, prefill: msg.prefill }); break; case "apiHealthResult": setApiReachable(msg.reachable); @@ -154,17 +182,27 @@ export function App() { return cleanup; }, []); - const handleUpdate = useCallback( - (section: string, key: string, value: unknown) => { - postMessage({ type: "updateConfig", section, key, value }); + useEffect(() => { + if (hasConfig && navigation) { + document + .getElementById(navigation.section) + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }, [hasConfig, navigation]); - // Optimistic update for responsiveness - setConfig((prev) => { - if (!prev) { - return prev; - } - return applyUpdate(prev, section, key, value); + const handleUpdate = useCallback( + (section: string, key: string, value: unknown, instanceKey?: string) => { + lastSentRev.current += 1; + const rev = lastSentRev.current; + postMessage({ + type: "updateConfig", + section, + key, + value: toWireValue(value), + rev, + instanceKey, }); + setConfig((prev) => (prev ? applyUpdate(prev, section, key, value, instanceKey) : prev)); }, [setConfig], ); @@ -191,7 +229,7 @@ export function App() { <> {error && ( - {error} + {error.message} )} {config ? ( @@ -216,6 +254,7 @@ export function App() { kanbanStatuses={kanbanStatuses} onGetKanbanStatuses={getKanbanStatuses} onOpenOperatorUi={openOperatorUi} + delegatorPrefill={delegatorPrefill} /> ) : (
    >(target: T, source: T): T { } return result as T; } - -const DEFAULT_JIRA: JiraConfig = { - enabled: false, - api_key_env: "OPERATOR_JIRA_API_KEY", - email: "", - projects: {}, -}; -const DEFAULT_LINEAR: LinearConfig = { - enabled: false, - api_key_env: "OPERATOR_LINEAR_API_KEY", - projects: {}, -}; -const DEFAULT_PROJECT_SYNC: ProjectSyncConfig = { - sync_user_id: "", - status_mapping: {}, - collection_name: null, - type_mappings: {}, - bidirectional: false, -}; - -/** Apply an update to the config object by section/key path */ -function applyUpdate( - config: WebviewConfig, - section: string, - key: string, - value: unknown, -): WebviewConfig { - const next = { ...config, config: { ...config.config } }; - - switch (section) { - case "primary": - if (key === "working_directory") { - next.working_directory = value as string; - } - break; - - case "agents": { - const updated = { ...next.config.agents }; - (updated as Record)[key] = value; - next.config.agents = updated; - break; - } - - case "sessions": { - const updated = { ...next.config.sessions }; - (updated as Record)[key] = value; - next.config.sessions = updated; - break; - } - - case "kanban.jira": { - const jiraMap = { ...next.config.kanban.jira }; - const domains = Object.keys(jiraMap); - const domain = domains[0] ?? "your-org.atlassian.net"; - const ws: JiraConfig = { ...(jiraMap[domain] ?? DEFAULT_JIRA) }; - - if (key === "enabled" || key === "email" || key === "api_key_env") { - (ws as Record)[key] = value; - jiraMap[domain] = ws; - } else if (key === "domain" && typeof value === "string" && value !== domain) { - delete jiraMap[domain]; - jiraMap[value] = ws; - } else if ( - key === "project_key" || - key === "status_mapping" || - key === "collection_name" || - key === "sync_user_id" || - key === "type_mappings" - ) { - const projects = { ...ws.projects }; - const pKeys = Object.keys(projects); - const pKey = pKeys[0] ?? "default"; - if (key === "project_key") { - const oldProject = projects[pKey] ?? DEFAULT_PROJECT_SYNC; - delete projects[pKey]; - projects[value as string] = oldProject; - } else { - const existing = { ...(projects[pKey] ?? DEFAULT_PROJECT_SYNC) }; - (existing as Record)[key] = value; - projects[pKey] = existing; - } - ws.projects = projects; - jiraMap[domain] = ws; - } else if (key.startsWith("projects.")) { - // Multi-project writes: projects.{projectKey}.{field} - const parts = key.split("."); - if (parts.length >= 3) { - const pKey = parts[1]; - const field = parts.slice(2).join("."); - const projects = { ...ws.projects }; - const existing = { ...(projects[pKey] ?? DEFAULT_PROJECT_SYNC) }; - (existing as Record)[field] = value; - projects[pKey] = existing; - ws.projects = projects; - jiraMap[domain] = ws; - } - } - next.config.kanban = { ...next.config.kanban, jira: jiraMap }; - break; - } - - case "kanban.linear": { - const linearMap = { ...next.config.kanban.linear }; - const teams = Object.keys(linearMap); - const teamId = teams[0] ?? "default-team"; - const ws: LinearConfig = { ...(linearMap[teamId] ?? DEFAULT_LINEAR) }; - - if (key === "enabled" || key === "api_key_env") { - (ws as Record)[key] = value; - linearMap[teamId] = ws; - } else if (key === "team_id" && typeof value === "string" && value !== teamId) { - delete linearMap[teamId]; - linearMap[value] = ws; - } else if ( - key === "status_mapping" || - key === "collection_name" || - key === "sync_user_id" || - key === "type_mappings" - ) { - const projects = { ...ws.projects }; - const pKeys = Object.keys(projects); - const pKey = pKeys[0] ?? "default"; - const existing = { ...(projects[pKey] ?? DEFAULT_PROJECT_SYNC) }; - (existing as Record)[key] = value; - projects[pKey] = existing; - ws.projects = projects; - linearMap[teamId] = ws; - } else if (key.startsWith("projects.")) { - // Multi-project writes: projects.{projectKey}.{field} - const parts = key.split("."); - if (parts.length >= 3) { - const pKey = parts[1]; - const field = parts.slice(2).join("."); - const projects = { ...ws.projects }; - const existing = { ...(projects[pKey] ?? DEFAULT_PROJECT_SYNC) }; - (existing as Record)[field] = value; - projects[pKey] = existing; - ws.projects = projects; - linearMap[teamId] = ws; - } - } - next.config.kanban = { ...next.config.kanban, linear: linearMap }; - break; - } - - case "git": { - const updated = { ...next.config.git }; - (updated as Record)[key] = value; - next.config.git = updated; - break; - } - - case "git.github": { - const github = { ...next.config.git.github }; - (github as Record)[key] = value; - next.config.git = { ...next.config.git, github }; - break; - } - } - - return next; -} diff --git a/vscode-extension/webview-ui/components/ConfigPage.tsx b/vscode-extension/webview-ui/components/ConfigPage.tsx index 4c7bd894..b7952d9c 100644 --- a/vscode-extension/webview-ui/components/ConfigPage.tsx +++ b/vscode-extension/webview-ui/components/ConfigPage.tsx @@ -15,11 +15,12 @@ import type { IssueTypeSummary, CollectionResponse, ExternalIssueTypeSummary, + NavigationPrefill, } from "../types/messages"; interface ConfigPageProps { config: WebviewConfig; - onUpdate: (section: string, key: string, value: unknown) => void; + onUpdate: (section: string, key: string, value: unknown, instanceKey?: string) => void; onBrowseFolder: (field: string) => void; onOpenFile: (filePath: string) => void; onStartSetup: () => void; @@ -38,6 +39,7 @@ interface ConfigPageProps { kanbanStatuses: Map; onGetKanbanStatuses: (provider: string, projectKey: string) => void; onOpenOperatorUi: (route: "issuetypes" | "projects") => void; + delegatorPrefill?: NavigationPrefill; } export function ConfigPage({ @@ -61,6 +63,7 @@ export function ConfigPage({ kanbanStatuses, onGetKanbanStatuses, onOpenOperatorUi, + delegatorPrefill, }: ConfigPageProps) { const scrollRef = useRef(null); const hasWorkDir = Boolean(config.working_directory); @@ -142,6 +145,7 @@ export function ConfigPage({ t.name)} apiReachable={apiReachable} + delegatorPrefill={delegatorPrefill} /> void; + onUpdate: (key: string, value: unknown) => void; onGetExternalIssueTypes: (provider: string, domain: string, projectKey: string) => void; onGetKanbanStatuses: (provider: string, projectKey: string) => void; onViewIssueType: () => void; - sectionKey: string; } const OPERATOR_STATES = [ @@ -47,7 +46,6 @@ export function ProjectRow({ onGetExternalIssueTypes, onGetKanbanStatuses, onViewIssueType, - sectionKey, }: ProjectRowProps) { const [expanded, setExpanded] = useState(false); @@ -72,9 +70,9 @@ export function ProjectRow({ } else { newMappings[externalName] = operatorKey; } - onUpdate(sectionKey, `projects.${projectKey}.type_mappings`, newMappings); + onUpdate(`projects.${projectKey}.type_mappings`, newMappings); }, - [onUpdate, project.type_mappings, projectKey, sectionKey], + [onUpdate, project.type_mappings, projectKey], ); const handleStatusMappingChange = useCallback( @@ -85,9 +83,9 @@ export function ProjectRow({ } else { next[field] = column; } - onUpdate(sectionKey, `projects.${projectKey}.status_mapping`, next); + onUpdate(`projects.${projectKey}.status_mapping`, next); }, - [onUpdate, projectKey, sectionKey, statusMapping], + [onUpdate, projectKey, statusMapping], ); /** Discovered columns plus the currently-mapped value (so a stale mapping stays visible). */ @@ -102,8 +100,8 @@ export function ProjectRow({ const toggleExpanded = useCallback(() => setExpanded((current) => !current), [setExpanded]); const handleCollectionChange = useCallback( (event: React.ChangeEvent) => - onUpdate(sectionKey, `projects.${projectKey}.collection_name`, event.target.value), - [onUpdate, projectKey, sectionKey], + onUpdate(`projects.${projectKey}.collection_name`, event.target.value), + [onUpdate, projectKey], ); return ( diff --git a/vscode-extension/webview-ui/components/kanban/ProviderCard.tsx b/vscode-extension/webview-ui/components/kanban/ProviderCard.tsx index 098431df..c18c6263 100644 --- a/vscode-extension/webview-ui/components/kanban/ProviderCard.tsx +++ b/vscode-extension/webview-ui/components/kanban/ProviderCard.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useState } from "react"; import { Alert, Button, Card, CardContent, Chip, Spinner, TextInput, Toggle } from "../primitives"; import { ProjectRow } from "./ProjectRow"; +import { useDraftField } from "../../hooks/useDraftField"; import type { JiraConfig } from "../../../src/generated/JiraConfig"; import type { LinearConfig } from "../../../src/generated/LinearConfig"; import type { @@ -15,7 +16,7 @@ interface ProviderCardProps { type: "jira" | "linear"; domain: string; config: JiraConfig | LinearConfig; - onUpdate: (section: string, key: string, value: unknown) => void; + onUpdate: (section: string, key: string, value: unknown, instanceKey?: string) => void; onValidate: (...args: string[]) => void; validationResult: JiraValidationInfo | LinearValidationInfo | null; validating: boolean; @@ -56,28 +57,31 @@ export function ProviderCard({ const isConnected = validationResult?.valid === true; const projectCount = projectEntries.length; + const updateProvider = useCallback( + (key: string, value: unknown) => onUpdate(sectionKey, key, value, domain), + [domain, onUpdate, sectionKey], + ); const handleEnabledChange = useCallback( - (event: React.ChangeEvent) => - onUpdate(sectionKey, "enabled", event.target.checked), - [onUpdate, sectionKey], + (event: React.ChangeEvent) => updateProvider("enabled", event.target.checked), + [updateProvider], ); const showCredentialFields = useCallback(() => setShowCredentials(true), [setShowCredentials]); const hideCredentialFields = useCallback(() => setShowCredentials(false), [setShowCredentials]); - const handleDomainChange = useCallback( - (event: React.ChangeEvent) => - onUpdate(sectionKey, isJira ? "domain" : "team_id", event.target.value), - [isJira, onUpdate, sectionKey], + const commitDomain = useCallback( + (next: string) => updateProvider(isJira ? "domain" : "team_id", next), + [isJira, updateProvider], ); - const handleEmailChange = useCallback( - (event: React.ChangeEvent) => - onUpdate(sectionKey, "email", event.target.value), - [onUpdate, sectionKey], + const commitEmail = useCallback( + (next: string) => updateProvider("email", next), + [updateProvider], ); - const handleApiKeyEnvChange = useCallback( - (event: React.ChangeEvent) => - onUpdate(sectionKey, "api_key_env", event.target.value), - [onUpdate, sectionKey], + const commitApiKeyEnv = useCallback( + (next: string) => updateProvider("api_key_env", next), + [updateProvider], ); + const domainDraft = useDraftField(domain, commitDomain); + const emailDraft = useDraftField(jiraConfig?.email ?? "", commitEmail); + const apiKeyEnvDraft = useDraftField(config.api_key_env, commitApiKeyEnv); const handleApiTokenChange = useCallback( (event: React.ChangeEvent) => setApiToken(event.target.value), [setApiToken], @@ -90,8 +94,8 @@ export function ProviderCard({ } }, [apiToken, domain, isJira, jiraConfig?.email, onValidate]); const handleAddProject = useCallback( - (key: string) => onUpdate(sectionKey, `projects.${key}.collection_name`, ""), - [onUpdate, sectionKey], + (key: string) => updateProvider(`projects.${key}.collection_name`, ""), + [updateProvider], ); return ( @@ -142,40 +146,23 @@ export function ProviderCard({ <> - + ) : ( <> - - + + )} @@ -233,11 +220,10 @@ export function ProviderCard({ issueTypes={issueTypes} externalTypes={externalIssueTypes.get(`${type}/${key}`)} statuses={kanbanStatuses.get(`${type}/${key}`)} - onUpdate={onUpdate} + onUpdate={updateProvider} onGetExternalIssueTypes={onGetExternalIssueTypes} onGetKanbanStatuses={onGetKanbanStatuses} onViewIssueType={onViewIssueType} - sectionKey={sectionKey} /> )) )} diff --git a/vscode-extension/webview-ui/components/primitives/index.tsx b/vscode-extension/webview-ui/components/primitives/index.tsx index 2ac069e4..788ca02b 100644 --- a/vscode-extension/webview-ui/components/primitives/index.tsx +++ b/vscode-extension/webview-ui/components/primitives/index.tsx @@ -16,38 +16,29 @@ function cx(...parts: Array): string { return parts.filter(Boolean).join(" "); } -export interface ButtonProps extends CommonProps { +export interface ButtonProps + extends CommonProps, Omit, "type"> { variant?: "contained" | "outlined" | "text"; accent?: "sage"; size?: "small" | "medium"; - disabled?: boolean; - title?: string; - onClick?: React.MouseEventHandler; - children?: ReactNode; } export function Button({ variant = "text", accent, size = "medium", - disabled, - title, - onClick, - children, className, - style, + children, + ...rest }: ButtonProps) { return ( diff --git a/vscode-extension/webview-ui/components/sections/CodingAgentsSection.tsx b/vscode-extension/webview-ui/components/sections/CodingAgentsSection.tsx index cdeb7113..25e74cff 100644 --- a/vscode-extension/webview-ui/components/sections/CodingAgentsSection.tsx +++ b/vscode-extension/webview-ui/components/sections/CodingAgentsSection.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from "react"; import { Button, Chip } from "../primitives"; import { SectionHeader } from "../SectionHeader"; +import { useDraftField } from "../../hooks/useDraftField"; import type { AgentsConfig } from "../../../src/generated/AgentsConfig"; import type { LlmToolsConfig } from "../../../src/generated/LlmToolsConfig"; @@ -12,21 +13,37 @@ interface NumberFieldProps { min: number; max: number; helperText: string; - onChange: React.ChangeEventHandler; + onCommit: (value: number) => void; } -function NumberField({ label, value, min, max, helperText, onChange }: NumberFieldProps) { +function parseBoundedInteger(text: string, min: number, max: number): number | null { + const trimmed = text.trim(); + const n = Number(trimmed); + if (trimmed === "" || !Number.isInteger(n) || n < min || n > max) { + return null; + } + return n; +} + +function NumberField({ label, value, min, max, helperText, onCommit }: NumberFieldProps) { + const isValid = useCallback( + (next: string) => parseBoundedInteger(next, min, max) !== null, + [max, min], + ); + const commit = useCallback( + (next: string) => { + const n = parseBoundedInteger(next, min, max); + if (n !== null) { + onCommit(n); + } + }, + [max, min, onCommit], + ); + const draft = useDraftField(String(value), commit, isValid); return ( ); @@ -50,24 +67,20 @@ export function CodingAgentsSection({ const stepTimeout = Number(agents.step_timeout); const silenceThreshold = Number(agents.silence_threshold); const detected = llm_tools.detected; - const handleMaxParallelChange = useCallback( - (event: React.ChangeEvent) => - onUpdate("agents", "max_parallel", Number.parseInt(event.target.value, 10) || 1), + const commitMaxParallel = useCallback( + (n: number) => onUpdate("agents", "max_parallel", n), [onUpdate], ); - const handleGenerationTimeoutChange = useCallback( - (event: React.ChangeEvent) => - onUpdate("agents", "generation_timeout_secs", Number.parseInt(event.target.value, 10) || 300), + const commitGenerationTimeout = useCallback( + (n: number) => onUpdate("agents", "generation_timeout_secs", BigInt(n)), [onUpdate], ); - const handleStepTimeoutChange = useCallback( - (event: React.ChangeEvent) => - onUpdate("agents", "step_timeout", Number.parseInt(event.target.value, 10) || 1800), + const commitStepTimeout = useCallback( + (n: number) => onUpdate("agents", "step_timeout", BigInt(n)), [onUpdate], ); - const handleSilenceThresholdChange = useCallback( - (event: React.ChangeEvent) => - onUpdate("agents", "silence_threshold", Number.parseInt(event.target.value, 10) || 30), + const commitSilenceThreshold = useCallback( + (n: number) => onUpdate("agents", "silence_threshold", BigInt(n)), [onUpdate], ); @@ -121,7 +134,7 @@ export function CodingAgentsSection({ value={maxParallel} min={1} max={16} - onChange={handleMaxParallelChange} + onCommit={commitMaxParallel} helperText="Maximum number of agents running simultaneously" /> @@ -130,7 +143,7 @@ export function CodingAgentsSection({ value={generationTimeout} min={30} max={3600} - onChange={handleGenerationTimeoutChange} + onCommit={commitGenerationTimeout} helperText="Timeout for each agent generation step" /> @@ -139,7 +152,7 @@ export function CodingAgentsSection({ value={stepTimeout} min={60} max={7200} - onChange={handleStepTimeoutChange} + onCommit={commitStepTimeout} helperText="Maximum seconds a step can run before timing out" /> @@ -148,7 +161,7 @@ export function CodingAgentsSection({ value={silenceThreshold} min={5} max={300} - onChange={handleSilenceThresholdChange} + onCommit={commitSilenceThreshold} helperText="Seconds of silence before considering agent awaiting input" />
    diff --git a/vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx b/vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx index 51891d60..0ff3dc21 100644 --- a/vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx +++ b/vscode-extension/webview-ui/components/sections/GitRepositoriesSection.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from "react"; import { TextInput, SelectInput, Toggle } from "../primitives"; import { SectionHeader } from "../SectionHeader"; +import { useDraftField } from "../../hooks/useDraftField"; import type { GitConfig } from "../../../src/generated/GitConfig"; interface GitRepositoriesSectionProps { @@ -24,16 +25,16 @@ export function GitRepositoriesSection({ git, onUpdate }: GitRepositoriesSection onUpdate("git.github", "enabled", event.target.checked), [onUpdate], ); - const handleGithubTokenChange = useCallback( - (event: React.ChangeEvent) => - onUpdate("git.github", "token_env", event.target.value), + const commitGithubToken = useCallback( + (next: string) => onUpdate("git.github", "token_env", next), [onUpdate], ); - const handleBranchFormatChange = useCallback( - (event: React.ChangeEvent) => - onUpdate("git", "branch_format", event.target.value), + const commitBranchFormat = useCallback( + (next: string) => onUpdate("git", "branch_format", next), [onUpdate], ); + const githubTokenDraft = useDraftField(githubTokenEnv, commitGithubToken); + const branchFormatDraft = useDraftField(branchFormat, commitBranchFormat); const handleWorktreesChange = useCallback( (event: React.ChangeEvent) => onUpdate("git", "use_worktrees", event.target.checked), @@ -68,8 +69,7 @@ export function GitRepositoriesSection({ git, onUpdate }: GitRepositoriesSection diff --git a/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx b/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx index f3e112a6..b717f123 100644 --- a/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx +++ b/vscode-extension/webview-ui/components/sections/KanbanProvidersSection.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from "react"; import { SectionHeader } from "../SectionHeader"; import { LinkOutCard } from "../LinkOutCard"; import { ProviderCard } from "../kanban/ProviderCard"; +import { useStableKeys } from "../../hooks/useStableKeys"; import type { JiraValidationInfo, LinearValidationInfo, @@ -15,7 +16,7 @@ import type { LinearConfig } from "../../../src/generated/LinearConfig"; interface KanbanProvidersSectionProps { kanban: KanbanConfig; - onUpdate: (section: string, key: string, value: unknown) => void; + onUpdate: (section: string, key: string, value: unknown, instanceKey?: string) => void; onValidateJira: (domain: string, email: string, apiToken: string) => void; onValidateLinear: (apiKey: string) => void; jiraResult: JiraValidationInfo | null; @@ -43,6 +44,18 @@ const DEFAULT_LINEAR: LinearConfig = { api_key_env: "OPERATOR_LINEAR_API_KEY", projects: {}, }; +const DEFAULT_JIRA_DOMAIN = "your-org.atlassian.net"; +const DEFAULT_LINEAR_TEAM = "default-team"; + +/** Render a placeholder card under the default instance key until one is configured. */ +function entriesOrDefault( + map: Record | undefined, + defaultKey: string, + defaultValue: W, +): Array<[string, W]> { + const entries = Object.entries(map ?? {}); + return entries.length > 0 ? entries : [[defaultKey, defaultValue]]; +} export function KanbanProvidersSection({ kanban, @@ -62,15 +75,10 @@ export function KanbanProvidersSection({ onGetKanbanStatuses, onOpenOperatorUi, }: KanbanProvidersSectionProps) { - // Iterate all Jira domains - const jiraEntries = Object.entries(kanban.jira ?? {}); - const hasJira = jiraEntries.length > 0; - const defaultJiraDomain = "your-org.atlassian.net"; - - // Iterate all Linear workspaces - const linearEntries = Object.entries(kanban.linear ?? {}); - const hasLinear = linearEntries.length > 0; - const defaultLinearTeam = "default-team"; + const jiraEntries = entriesOrDefault(kanban.jira, DEFAULT_JIRA_DOMAIN, DEFAULT_JIRA); + const linearEntries = entriesOrDefault(kanban.linear, DEFAULT_LINEAR_TEAM, DEFAULT_LINEAR); + const jiraIds = useStableKeys(jiraEntries.map(([domain]) => domain)); + const linearIds = useStableKeys(linearEntries.map(([teamId]) => teamId)); // Viewing an issue type now links out to the hosted Operator UI. const handleViewIssueType = useCallback(() => { @@ -90,32 +98,12 @@ export function KanbanProvidersSection({

    - {/* Jira providers */} - {hasJira ? ( - jiraEntries.map(([domain, config]) => ( - - )) - ) : ( + {jiraEntries.map(([domain, config]) => ( - )} + ))} - {/* Linear providers */} - {hasLinear ? ( - linearEntries.map(([teamId, config]) => ( - - )) - ) : ( + {linearEntries.map(([teamId, config]) => ( - )} + ))}
    {/* Issue types & collections now live in the hosted Operator UI */} diff --git a/vscode-extension/webview-ui/components/sections/ModelProvidersSection.tsx b/vscode-extension/webview-ui/components/sections/ModelProvidersSection.tsx index 4874c043..794f94bb 100644 --- a/vscode-extension/webview-ui/components/sections/ModelProvidersSection.tsx +++ b/vscode-extension/webview-ui/components/sections/ModelProvidersSection.tsx @@ -7,6 +7,7 @@ import type { ModelServerKindEntry, ModelServerModelsResponse, DelegatorResponse, + NavigationPrefill, } from "../../types/messages"; const BRAND_ICONS = new Set(["anthropic", "google", "ollama", "openrouter"]); @@ -14,6 +15,7 @@ const BRAND_ICONS = new Set(["anthropic", "google", "ollama", "openrouter"]); interface ModelProvidersSectionProps { detectedTools: string[]; apiReachable: boolean; + delegatorPrefill?: NavigationPrefill; } type ProbeMap = Record; @@ -46,7 +48,11 @@ function DismissableAlert({ ); } -export function ModelProvidersSection({ detectedTools, apiReachable }: ModelProvidersSectionProps) { +export function ModelProvidersSection({ + detectedTools, + apiReachable, + delegatorPrefill, +}: ModelProvidersSectionProps) { const [kinds, setKinds] = useState([]); const [probes, setProbes] = useState({}); const [delegators, setDelegators] = useState([]); @@ -104,6 +110,7 @@ export function ModelProvidersSection({ detectedTools, apiReachable }: ModelProv case "kanbanStatusesLoaded": case "linearValidationResult": case "llmToolsDetected": + case "navigateTo": case "projectsError": case "projectsLoaded": break; @@ -148,7 +155,12 @@ export function ModelProvidersSection({ detectedTools, apiReachable }: ModelProv - +

    Delegators

    {delegators.length === 0 ? ( @@ -236,15 +248,25 @@ function CreateDelegatorForm({ kinds, probes, detectedTools, + prefill, }: { kinds: ModelServerKindEntry[]; probes: ProbeMap; detectedTools: string[]; + prefill?: NavigationPrefill; }) { - const [tool, setTool] = useState(""); + const [tool, setTool] = useState(prefill?.tool ?? ""); const [provider, setProvider] = useState(""); - const [model, setModel] = useState(""); + const [model, setModel] = useState(prefill?.model ?? ""); const [name, setName] = useState(""); + const [appliedPrefill, setAppliedPrefill] = useState(prefill); + if (prefill !== appliedPrefill) { + setAppliedPrefill(prefill); + if (prefill) { + setTool(prefill.tool ?? ""); + setModel(prefill.model ?? ""); + } + } const selectedTool = tool || detectedTools[0] || ""; diff --git a/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx b/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx index fe8786a6..431f280f 100644 --- a/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx +++ b/vscode-extension/webview-ui/components/sections/PrimaryConfigSection.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from "react"; import { Button, TextInput, SelectInput } from "../primitives"; import { SectionHeader } from "../SectionHeader"; import { OperatorBrand } from "../OperatorBrand"; +import { useDraftField } from "../../hooks/useDraftField"; interface PrimaryConfigSectionProps { working_directory: string; @@ -16,11 +17,11 @@ export function PrimaryConfigSection({ onUpdate, onBrowseFolder, }: PrimaryConfigSectionProps) { - const handleWorkingDirectoryChange = useCallback( - (event: React.ChangeEvent) => - onUpdate("primary", "working_directory", event.target.value), + const commitWorkingDirectory = useCallback( + (next: string) => onUpdate("primary", "working_directory", next), [onUpdate], ); + const workingDirectoryDraft = useDraftField(working_directory, commitWorkingDirectory); const handleBrowseFolder = useCallback( () => onBrowseFolder("workingDirectory"), [onBrowseFolder], @@ -47,8 +48,7 @@ export function PrimaryConfigSection({
    diff --git a/vscode-extension/webview-ui/hooks/useDraftField.ts b/vscode-extension/webview-ui/hooks/useDraftField.ts new file mode 100644 index 00000000..495ceab0 --- /dev/null +++ b/vscode-extension/webview-ui/hooks/useDraftField.ts @@ -0,0 +1,62 @@ +import type React from "react"; +import { useCallback, useState } from "react"; +import { initialDraft, isDirty, syncDraft, withDraft } from "../state/draftField"; + +const COMMIT_KEY = "Enter"; + +export interface DraftFieldProps { + value: string; + onChange: React.ChangeEventHandler; + onBlur: React.FocusEventHandler; + onKeyDown: React.KeyboardEventHandler; +} + +const acceptAll = (): boolean => true; + +/** + * Local edit buffer for a config field: keystrokes stay in the webview and + * `commit` runs once on blur or Enter, instead of a host round-trip per key. + * A draft failing `isValid` is discarded and the committed value restored. + */ +export function useDraftField( + value: string, + commit: (next: string) => void, + isValid: (next: string) => boolean = acceptAll, +): DraftFieldProps { + const [stored, setStored] = useState(() => initialDraft(value)); + const state = syncDraft(stored, value); + if (state !== stored) { + setStored(state); + } + + const onChange = useCallback( + (event: React.ChangeEvent) => { + const draft = event.target.value; + setStored((prev) => withDraft(syncDraft(prev, value), draft)); + }, + [value], + ); + + const flush = useCallback(() => { + if (!isDirty(state)) { + return; + } + if (isValid(state.draft)) { + commit(state.draft); + setStored(initialDraft(state.draft)); + } else { + setStored((prev) => withDraft(prev, prev.synced)); + } + }, [commit, isValid, state]); + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === COMMIT_KEY) { + flush(); + } + }, + [flush], + ); + + return { value: state.draft, onChange, onBlur: flush, onKeyDown }; +} diff --git a/vscode-extension/webview-ui/hooks/useStableKeys.ts b/vscode-extension/webview-ui/hooks/useStableKeys.ts new file mode 100644 index 00000000..4c3e05e4 --- /dev/null +++ b/vscode-extension/webview-ui/hooks/useStableKeys.ts @@ -0,0 +1,12 @@ +import { useState } from "react"; +import { EMPTY_STABLE_KEYS, reconcileStableKeys } from "../state/stableKeys"; + +/** Render keys that stay stable when a map key is renamed in place. */ +export function useStableKeys(keys: readonly string[]): ReadonlyMap { + const [stored, setStored] = useState(() => reconcileStableKeys(EMPTY_STABLE_KEYS, keys)); + const state = reconcileStableKeys(stored, keys); + if (state !== stored) { + setStored(state); + } + return state.ids; +} diff --git a/vscode-extension/webview-ui/state/applyUpdate.test.ts b/vscode-extension/webview-ui/state/applyUpdate.test.ts new file mode 100644 index 00000000..9c6bb51d --- /dev/null +++ b/vscode-extension/webview-ui/state/applyUpdate.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test"; +import { applyUpdate } from "./applyUpdate"; +import { DEFAULT_WEBVIEW_CONFIG } from "../types/defaults"; +import type { WebviewConfig } from "../types/messages"; +import type { JiraConfig } from "../../src/generated/JiraConfig"; +import type { LinearConfig } from "../../src/generated/LinearConfig"; + +const JIRA_SECTION = "kanban.jira"; +const LINEAR_SECTION = "kanban.linear"; +const OLD_DOMAIN = "old.atlassian.net"; +const NEW_DOMAIN = "new.atlassian.net"; +const SECOND_DOMAIN = "second.atlassian.net"; +const OLD_TEAM = "team-old"; +const NEW_TEAM = "team-new"; +const SECOND_TEAM = "team-second"; +const PROJECT = "PROJ"; + +const JIRA_WORKSPACE: JiraConfig = { + enabled: true, + api_key_env: "JIRA_KEY", + email: "me@example.com", + projects: { + [PROJECT]: { + sync_user_id: "u1", + status_mapping: { todo: "To Do" }, + collection_name: "dev", + type_mappings: {}, + bidirectional: false, + }, + }, +}; + +const LINEAR_WORKSPACE: LinearConfig = { + enabled: true, + api_key_env: "LINEAR_KEY", + projects: {}, +}; + +const SECOND_JIRA_WORKSPACE: JiraConfig = { + enabled: true, + api_key_env: "SECOND_JIRA_KEY", + email: "second@example.com", + projects: {}, +}; + +const SECOND_LINEAR_WORKSPACE: LinearConfig = { + enabled: true, + api_key_env: "SECOND_LINEAR_KEY", + projects: {}, +}; + +function withKanban(): WebviewConfig { + return { + ...DEFAULT_WEBVIEW_CONFIG, + config: { + ...DEFAULT_WEBVIEW_CONFIG.config, + kanban: { + ...DEFAULT_WEBVIEW_CONFIG.config.kanban, + jira: { [OLD_DOMAIN]: JIRA_WORKSPACE }, + linear: { [OLD_TEAM]: LINEAR_WORKSPACE }, + }, + }, + }; +} + +function withMultipleKanban(): WebviewConfig { + const config = withKanban(); + return { + ...config, + config: { + ...config.config, + kanban: { + ...config.config.kanban, + jira: { + ...config.config.kanban.jira, + [SECOND_DOMAIN]: SECOND_JIRA_WORKSPACE, + }, + linear: { + ...config.config.kanban.linear, + [SECOND_TEAM]: SECOND_LINEAR_WORKSPACE, + }, + }, + }, + }; +} + +describe("applyUpdate", () => { + test("projects.. updates only that project field", () => { + const next = applyUpdate( + withKanban(), + JIRA_SECTION, + `projects.${PROJECT}.collection_name`, + "ops", + OLD_DOMAIN, + ); + const project = next.config.kanban.jira[OLD_DOMAIN]?.projects[PROJECT]; + expect(project?.collection_name).toBe("ops"); + expect(project?.sync_user_id).toBe("u1"); + expect(project?.status_mapping).toEqual({ todo: "To Do" }); + }); + + test("projects.. creates a missing project from defaults", () => { + const next = applyUpdate( + withKanban(), + LINEAR_SECTION, + "projects.ENG.collection_name", + "", + OLD_TEAM, + ); + expect(next.config.kanban.linear[OLD_TEAM]?.projects.ENG?.collection_name).toBe(""); + expect(next.config.kanban.linear[OLD_TEAM]?.api_key_env).toBe("LINEAR_KEY"); + }); + + test("jira domain rename preserves the workspace entry", () => { + const next = applyUpdate(withKanban(), JIRA_SECTION, "domain", NEW_DOMAIN, OLD_DOMAIN); + expect(Object.keys(next.config.kanban.jira)).toEqual([NEW_DOMAIN]); + expect(next.config.kanban.jira[NEW_DOMAIN]).toEqual(JIRA_WORKSPACE); + }); + + test("linear team_id rename preserves the workspace entry", () => { + const next = applyUpdate(withKanban(), LINEAR_SECTION, "team_id", NEW_TEAM, OLD_TEAM); + expect(Object.keys(next.config.kanban.linear)).toEqual([NEW_TEAM]); + expect(next.config.kanban.linear[NEW_TEAM]).toEqual(LINEAR_WORKSPACE); + }); + + test("instance scalar edits only the selected workspace without mutating the input", () => { + const input = withMultipleKanban(); + const next = applyUpdate(input, JIRA_SECTION, "email", "updated@example.com", SECOND_DOMAIN); + expect(next.config.kanban.jira[OLD_DOMAIN]?.email).toBe("me@example.com"); + expect(next.config.kanban.jira[SECOND_DOMAIN]?.email).toBe("updated@example.com"); + expect(input.config.kanban.jira[OLD_DOMAIN]?.email).toBe("me@example.com"); + expect(input.config.kanban.jira[SECOND_DOMAIN]?.email).toBe("second@example.com"); + }); + + test("project field edits only the selected workspace", () => { + const next = applyUpdate( + withMultipleKanban(), + LINEAR_SECTION, + "projects.ENG.collection_name", + "engineering", + SECOND_TEAM, + ); + expect(next.config.kanban.linear[OLD_TEAM]?.projects.ENG).toBeUndefined(); + expect(next.config.kanban.linear[SECOND_TEAM]?.projects.ENG?.collection_name).toBe( + "engineering", + ); + }); + + test("jira domain rename moves only the selected workspace", () => { + const next = applyUpdate( + withMultipleKanban(), + JIRA_SECTION, + "domain", + NEW_DOMAIN, + SECOND_DOMAIN, + ); + expect(next.config.kanban.jira[OLD_DOMAIN]).toEqual(JIRA_WORKSPACE); + expect(next.config.kanban.jira[SECOND_DOMAIN]).toBeUndefined(); + expect(next.config.kanban.jira[NEW_DOMAIN]).toEqual(SECOND_JIRA_WORKSPACE); + }); + + test("linear team rename moves only the selected workspace", () => { + const next = applyUpdate( + withMultipleKanban(), + LINEAR_SECTION, + "team_id", + NEW_TEAM, + SECOND_TEAM, + ); + expect(next.config.kanban.linear[OLD_TEAM]).toEqual(LINEAR_WORKSPACE); + expect(next.config.kanban.linear[SECOND_TEAM]).toBeUndefined(); + expect(next.config.kanban.linear[NEW_TEAM]).toEqual(SECOND_LINEAR_WORKSPACE); + }); + + test("missing and colliding instance targets do not overwrite existing workspaces", () => { + const input = withMultipleKanban(); + const missingTarget = applyUpdate( + input, + JIRA_SECTION, + "email", + "wrong@example.com", + "missing.atlassian.net", + ); + const collision = applyUpdate(input, JIRA_SECTION, "domain", OLD_DOMAIN, SECOND_DOMAIN); + expect(missingTarget.config.kanban.jira).toEqual(input.config.kanban.jira); + expect(collision.config.kanban.jira).toEqual(input.config.kanban.jira); + }); + + test("bigint fields stay bigint", () => { + const next = applyUpdate(withKanban(), "agents", "step_timeout", BigInt(900)); + expect(typeof next.config.agents.step_timeout).toBe("bigint"); + expect(next.config.agents.step_timeout).toBe(BigInt(900)); + expect(typeof next.config.agents.generation_timeout_secs).toBe("bigint"); + }); + + test("primary working_directory updates the wrapper field", () => { + const next = applyUpdate(withKanban(), "primary", "working_directory", "/repos"); + expect(next.working_directory).toBe("/repos"); + }); + + test("git.github merges into the nested github table", () => { + const next = applyUpdate(withKanban(), "git.github", "token_env", "GH"); + expect(next.config.git.github.token_env).toBe("GH"); + expect(next.config.git.github.enabled).toBe(DEFAULT_WEBVIEW_CONFIG.config.git.github.enabled); + }); +}); diff --git a/vscode-extension/webview-ui/state/applyUpdate.ts b/vscode-extension/webview-ui/state/applyUpdate.ts new file mode 100644 index 00000000..641707cf --- /dev/null +++ b/vscode-extension/webview-ui/state/applyUpdate.ts @@ -0,0 +1,141 @@ +import type { WebviewConfig } from "../types/messages"; +import type { JiraConfig } from "../../src/generated/JiraConfig"; +import type { LinearConfig } from "../../src/generated/LinearConfig"; +import type { ProjectSyncConfig } from "../../src/generated/ProjectSyncConfig"; + +const DEFAULT_JIRA: JiraConfig = { + enabled: false, + api_key_env: "OPERATOR_JIRA_API_KEY", + email: "", + projects: {}, +}; +const DEFAULT_LINEAR: LinearConfig = { + enabled: false, + api_key_env: "OPERATOR_LINEAR_API_KEY", + projects: {}, +}; +const DEFAULT_PROJECT_SYNC: ProjectSyncConfig = { + sync_user_id: "", + status_mapping: {}, + collection_name: null, + type_mappings: {}, + bidirectional: false, +}; + +const PROJECT_PATH_PREFIX = "projects."; +const PROJECT_PATH_MIN_PARTS = 3; + +interface KanbanProviderShape { + instanceKeyField: string; + defaultWorkspace: W; +} + +const JIRA_SHAPE: KanbanProviderShape = { + instanceKeyField: "domain", + defaultWorkspace: DEFAULT_JIRA, +}; +const LINEAR_SHAPE: KanbanProviderShape = { + instanceKeyField: "team_id", + defaultWorkspace: DEFAULT_LINEAR, +}; + +type KanbanWorkspace = { projects: Record }; + +function applyKanbanUpdate( + source: Record, + shape: KanbanProviderShape, + key: string, + value: unknown, + instanceKey: string | undefined, +): Record { + const map = { ...source }; + if (!instanceKey) { + return map; + } + const hasInstances = Object.keys(map).length > 0; + if (hasInstances && !Object.hasOwn(map, instanceKey)) { + return map; + } + const workspace: W = { ...(map[instanceKey] ?? shape.defaultWorkspace) }; + + if (key === shape.instanceKeyField) { + if (typeof value === "string" && value !== instanceKey) { + if (Object.hasOwn(map, value)) { + return map; + } + delete map[instanceKey]; + map[value] = workspace; + } + return map; + } + + if (key.startsWith(PROJECT_PATH_PREFIX)) { + const parts = key.split("."); + const projectKey = parts[1]; + if (parts.length < PROJECT_PATH_MIN_PARTS || !projectKey) { + return map; + } + const field = parts.slice(2).join("."); + const project = { ...(workspace.projects[projectKey] ?? DEFAULT_PROJECT_SYNC) }; + (project as Record)[field] = value; + workspace.projects = { ...workspace.projects, [projectKey]: project }; + } else { + (workspace as Record)[key] = value; + } + map[instanceKey] = workspace; + return map; +} + +/** Apply an update to the config object by section/key path. Pure: never mutates `config`. */ +export function applyUpdate( + config: WebviewConfig, + section: string, + key: string, + value: unknown, + instanceKey?: string, +): WebviewConfig { + const next = { ...config, config: { ...config.config } }; + + switch (section) { + case "primary": + if (key === "working_directory") { + next.working_directory = value as string; + } + break; + + case "agents": + next.config.agents = { ...next.config.agents, [key]: value }; + break; + + case "sessions": + next.config.sessions = { ...next.config.sessions, [key]: value }; + break; + + case "kanban.jira": + next.config.kanban = { + ...next.config.kanban, + jira: applyKanbanUpdate(next.config.kanban.jira, JIRA_SHAPE, key, value, instanceKey), + }; + break; + + case "kanban.linear": + next.config.kanban = { + ...next.config.kanban, + linear: applyKanbanUpdate(next.config.kanban.linear, LINEAR_SHAPE, key, value, instanceKey), + }; + break; + + case "git": + next.config.git = { ...next.config.git, [key]: value }; + break; + + case "git.github": + next.config.git = { + ...next.config.git, + github: { ...next.config.git.github, [key]: value }, + }; + break; + } + + return next; +} diff --git a/vscode-extension/webview-ui/state/configSync.test.ts b/vscode-extension/webview-ui/state/configSync.test.ts new file mode 100644 index 00000000..3348a63d --- /dev/null +++ b/vscode-extension/webview-ui/state/configSync.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { configErrorFrom, errorAfterSnapshot, isStaleSnapshot, toWireValue } from "./configSync"; + +describe("isStaleSnapshot", () => { + test("a snapshot older than the last sent rev is stale", () => { + expect(isStaleSnapshot(2, 3)).toBe(true); + }); + + test("a snapshot at or after the last sent rev is current", () => { + expect(isStaleSnapshot(3, 3)).toBe(false); + expect(isStaleSnapshot(4, 3)).toBe(false); + }); + + test("a snapshot without a rev is never stale", () => { + expect(isStaleSnapshot(undefined, 3)).toBe(false); + }); +}); + +describe("toWireValue", () => { + test("bigint becomes a JSON-safe number", () => { + const wire = toWireValue(BigInt(300)); + expect(wire).toBe(300); + expect(() => JSON.stringify({ wire })).not.toThrow(); + }); + + test("other values pass through unchanged", () => { + const obj = { a: 1 }; + expect(toWireValue(obj)).toBe(obj); + expect(toWireValue("x")).toBe("x"); + }); +}); + +describe("config error lifecycle", () => { + const writeError = configErrorFrom("disk full", 4); + const loadError = configErrorFrom("parse failed", undefined); + + test("an error with a rev is a write error, without one a load error", () => { + expect(writeError).toEqual({ message: "disk full", source: "write" }); + expect(loadError).toEqual({ message: "parse failed", source: "load" }); + }); + + test("the rollback configLoaded keeps the write error visible", () => { + expect(errorAfterSnapshot(writeError, "configLoaded")).toBe(writeError); + }); + + test("a successful configLoaded clears a load error", () => { + expect(errorAfterSnapshot(loadError, "configLoaded")).toBeNull(); + }); + + test("a successful write clears any error", () => { + expect(errorAfterSnapshot(writeError, "configUpdated")).toBeNull(); + expect(errorAfterSnapshot(loadError, "configUpdated")).toBeNull(); + }); + + test("no error stays no error", () => { + expect(errorAfterSnapshot(null, "configLoaded")).toBeNull(); + }); +}); diff --git a/vscode-extension/webview-ui/state/configSync.ts b/vscode-extension/webview-ui/state/configSync.ts new file mode 100644 index 00000000..366b9648 --- /dev/null +++ b/vscode-extension/webview-ui/state/configSync.ts @@ -0,0 +1,42 @@ +/** + * A config snapshot is stale when it was produced before the webview's most + * recent `updateConfig`; applying it would clobber newer optimistic edits. + * Snapshots without a rev (e.g. from older hosts) are always applied. + */ +export function isStaleSnapshot(snapshotRev: number | undefined, lastSentRev: number): boolean { + return snapshotRev !== undefined && snapshotRev < lastSentRev; +} + +/** Webview→host messages are JSON-serialized, which cannot encode bigint. */ +export function toWireValue(value: unknown): unknown { + return typeof value === "bigint" ? Number(value) : value; +} + +export type ConfigErrorSource = "write" | "load"; + +export interface ConfigErrorState { + readonly message: string; + readonly source: ConfigErrorSource; +} + +export type ConfigSnapshotType = "configLoaded" | "configUpdated"; + +/** Host errors stamped with a rev came from a failed `updateConfig`. */ +export function configErrorFrom(message: string, rev: number | undefined): ConfigErrorState { + return { message, source: rev === undefined ? "load" : "write" }; +} + +/** + * Only a successful write clears a write error; the `configLoaded` that rolls + * back a failed write must leave it visible. A snapshot's arrival proves the + * host succeeded, so this applies whether or not the snapshot itself is stale. + */ +export function errorAfterSnapshot( + error: ConfigErrorState | null, + snapshot: ConfigSnapshotType, +): ConfigErrorState | null { + if (error === null || snapshot === "configUpdated" || error.source === "load") { + return null; + } + return error; +} diff --git a/vscode-extension/webview-ui/state/draftField.test.ts b/vscode-extension/webview-ui/state/draftField.test.ts new file mode 100644 index 00000000..884a3cc2 --- /dev/null +++ b/vscode-extension/webview-ui/state/draftField.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { initialDraft, syncDraft, withDraft } from "./draftField"; + +describe("syncDraft", () => { + test("follows an external value change while the draft is clean", () => { + const state = syncDraft(initialDraft("a"), "b"); + expect(state).toEqual({ draft: "b", synced: "b" }); + }); + + test("keeps in-progress edits when the external value changes", () => { + const editing = withDraft(initialDraft("a"), "typed"); + expect(syncDraft(editing, "b")).toEqual({ draft: "typed", synced: "b" }); + }); + + test("returns the same state when the value is unchanged", () => { + const state = withDraft(initialDraft("a"), "typed"); + expect(syncDraft(state, "a")).toBe(state); + }); + + test("rolls back to the external value after a committed edit is reverted", () => { + const committed = syncDraft(withDraft(initialDraft("old"), "new"), "new"); + expect(committed).toEqual({ draft: "new", synced: "new" }); + expect(syncDraft(committed, "old")).toEqual({ draft: "old", synced: "old" }); + }); + + test("a committed draft resyncs to a normalized external value", () => { + const committed = initialDraft("0300"); + expect(syncDraft(committed, "300")).toEqual({ draft: "300", synced: "300" }); + }); +}); diff --git a/vscode-extension/webview-ui/state/draftField.ts b/vscode-extension/webview-ui/state/draftField.ts new file mode 100644 index 00000000..73d1ee16 --- /dev/null +++ b/vscode-extension/webview-ui/state/draftField.ts @@ -0,0 +1,28 @@ +/** `synced` is the last external value seen; the draft is dirty while it differs. */ +export interface DraftState { + readonly draft: string; + readonly synced: string; +} + +export function initialDraft(value: string): DraftState { + return { draft: value, synced: value }; +} + +export function withDraft(state: DraftState, draft: string): DraftState { + return { ...state, draft }; +} + +export function isDirty(state: DraftState): boolean { + return state.draft !== state.synced; +} + +/** + * Reconcile a new external value: a clean draft follows it (including a + * rollback), a dirty draft keeps the user's in-progress text. + */ +export function syncDraft(state: DraftState, value: string): DraftState { + if (value === state.synced) { + return state; + } + return { synced: value, draft: isDirty(state) ? state.draft : value }; +} diff --git a/vscode-extension/webview-ui/state/stableKeys.test.ts b/vscode-extension/webview-ui/state/stableKeys.test.ts new file mode 100644 index 00000000..1c0d3517 --- /dev/null +++ b/vscode-extension/webview-ui/state/stableKeys.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { EMPTY_STABLE_KEYS, reconcileStableKeys } from "./stableKeys"; + +describe("reconcileStableKeys", () => { + test("assigns a fresh id per key", () => { + const state = reconcileStableKeys(EMPTY_STABLE_KEYS, ["a", "b"]); + expect(state.ids.get("a")).not.toBe(state.ids.get("b")); + }); + + test("returns the same state when keys are unchanged", () => { + const state = reconcileStableKeys(EMPTY_STABLE_KEYS, ["a"]); + expect(reconcileStableKeys(state, ["a"])).toBe(state); + }); + + test("a single rename keeps the original id", () => { + const state = reconcileStableKeys(EMPTY_STABLE_KEYS, ["old.atlassian.net"]); + const renamed = reconcileStableKeys(state, ["new.atlassian.net"]); + expect(renamed.ids.get("new.atlassian.net")).toBe(state.ids.get("old.atlassian.net")); + expect(renamed.ids.has("old.atlassian.net")).toBe(false); + }); + + test("repeated renames keep the original id", () => { + let state = reconcileStableKeys(EMPTY_STABLE_KEYS, ["a"]); + const original = state.ids.get("a"); + for (const key of ["ab", "abc", "abcd"]) { + state = reconcileStableKeys(state, [key]); + } + expect(state.ids.get("abcd")).toBe(original); + }); + + test("an added key alongside existing ones gets a new id", () => { + const state = reconcileStableKeys(EMPTY_STABLE_KEYS, ["a"]); + const next = reconcileStableKeys(state, ["a", "b"]); + expect(next.ids.get("a")).toBe(state.ids.get("a")); + expect(next.ids.get("b")).not.toBe(state.ids.get("a")); + }); + + test("ids are never reused after removal", () => { + const state = reconcileStableKeys(EMPTY_STABLE_KEYS, ["a", "b"]); + const removed = reconcileStableKeys(state, ["a"]); + const added = reconcileStableKeys(removed, ["a", "c"]); + expect(added.ids.get("c")).not.toBe(state.ids.get("b")); + }); +}); diff --git a/vscode-extension/webview-ui/state/stableKeys.ts b/vscode-extension/webview-ui/state/stableKeys.ts new file mode 100644 index 00000000..7fc747dc --- /dev/null +++ b/vscode-extension/webview-ui/state/stableKeys.ts @@ -0,0 +1,35 @@ +export interface StableKeyState { + readonly ids: ReadonlyMap; + readonly nextSeq: number; +} + +export const EMPTY_STABLE_KEYS: StableKeyState = { ids: new Map(), nextSeq: 0 }; + +/** + * Map each key to an id that survives renames: when exactly one key vanished + * and exactly one appeared, the new key inherits the old id. Returns `state` + * itself when nothing changed so callers can detect a no-op by identity. + */ +export function reconcileStableKeys( + state: StableKeyState, + keys: readonly string[], +): StableKeyState { + const added = keys.filter((key) => !state.ids.has(key)); + const removed = [...state.ids.keys()].filter((key) => !keys.includes(key)); + if (added.length === 0 && removed.length === 0) { + return state; + } + + const [renamedFrom] = removed; + const [renamedTo] = added; + const isRename = added.length === 1 && removed.length === 1 && renamedFrom !== undefined; + + const ids = new Map(); + let nextSeq = state.nextSeq; + for (const key of keys) { + const inherited = isRename && key === renamedTo ? state.ids.get(renamedFrom) : undefined; + const id = state.ids.get(key) ?? inherited ?? String(nextSeq++); + ids.set(key, id); + } + return { ids, nextSeq }; +} diff --git a/vscode-extension/webview-ui/types/messages.ts b/vscode-extension/webview-ui/types/messages.ts index 428f23fe..a8e2f5b1 100644 --- a/vscode-extension/webview-ui/types/messages.ts +++ b/vscode-extension/webview-ui/types/messages.ts @@ -49,11 +49,28 @@ export interface ProjectSummary { commands: string[]; } +/** + * Optional payload carried by `navigateTo`. Only `createDelegator` exists + * today (sent by the `operator.createDelegator` command). + */ +export interface NavigationPrefill { + action?: string; + tool?: string; + model?: string; +} + /** Messages from the webview to the extension host */ export type WebviewToExtensionMessage = | { type: "ready" } | { type: "getConfig" } - | { type: "updateConfig"; section: string; key: string; value: unknown } + | { + type: "updateConfig"; + section: string; + key: string; + value: unknown; + rev: number; + instanceKey?: string; + } | { type: "browseFile"; field: string } | { type: "browseFolder"; field: string } | { type: "validateJira"; domain: string; email: string; apiToken: string } @@ -83,13 +100,13 @@ export type WebviewToExtensionMessage = /** Messages from the extension host to the webview */ export type ExtensionToWebviewMessage = - | { type: "configLoaded"; config: WebviewConfig } - | { type: "configUpdated"; config: WebviewConfig } - | { type: "configError"; error: string } + | { type: "configLoaded"; config: WebviewConfig; rev: number } + | { type: "configUpdated"; config: WebviewConfig; rev: number } + | { type: "configError"; error: string; rev?: number } | { type: "browseResult"; field: string; path: string } | { type: "jiraValidationResult"; result: JiraValidationInfo } | { type: "linearValidationResult"; result: LinearValidationInfo } - | { type: "llmToolsDetected"; config: WebviewConfig } + | { type: "llmToolsDetected"; config: WebviewConfig; rev: number } | { type: "apiHealthResult"; reachable: boolean } | { type: "projectsLoaded"; projects: ProjectSummary[] } | { type: "projectsError"; error: string } @@ -116,7 +133,8 @@ export type ExtensionToWebviewMessage = | { type: "modelProvidersLoaded"; kinds: ModelServerKindEntry[]; delegators: DelegatorResponse[] } | { type: "providerProbed"; slug: string; result: ModelServerModelsResponse } | { type: "delegatorCreated"; name: string } - | { type: "modelProvidersError"; error: string }; + | { type: "modelProvidersError"; error: string } + | { type: "navigateTo"; section: string; prefill?: NavigationPrefill }; export interface JiraValidationInfo { valid: boolean; From 4e332acb783231e8e1e47f449e95faa9397946aa Mon Sep 17 00:00:00 2001 From: untra Date: Wed, 30 Sep 2026 17:06:47 -0600 Subject: [PATCH 12/18] naiveasync wassup, githooks removal, more frontend cleanup --- .github/workflows/build.yaml | 2 +- AGENTS.md | 6 +- Makefile | 7 +- scripts/cicdprep.sh | 1 - tests/ui_packaging.rs | 5 + ui/bun.lock | 64 +++ ui/bunfig.toml | 2 + ui/naiveasyncplan.md | 56 ++ ui/package.json | 10 +- ui/src/Layout.tsx | 44 +- ui/src/WorkspaceGate.tsx | 23 +- ui/src/api/adapter.test.ts | 535 ++++++++++++++++++ ui/src/api/adapter.ts | 406 +++++++++++++ ui/src/api/architecture.test.ts | 74 +++ ui/src/api/definitions.test.ts | 20 + ui/src/api/definitions.ts | 533 +++++++++++++++++ ui/src/api/hooks.test.tsx | 187 ++++++ ui/src/api/hooks.ts | 97 ++++ ui/src/api/index.ts | 6 + ui/src/api/queries/index.ts | 84 +++ ui/src/api/store.tsx | 8 + ui/src/components/LicensePanel.tsx | 94 ++- ui/src/components/TicketCreatePanel.tsx | 106 ++-- ui/src/components/TicketDetailPanel.tsx | 217 +++---- ui/src/components/ticket-panels.test.tsx | 184 ++++++ ui/src/main.tsx | 67 +-- ui/src/naiveasync.d.ts | 116 ++++ ui/src/profiles-context.tsx | 53 +- ui/src/routes/AgentDetailPage.tsx | 34 +- ui/src/routes/ConfigPage.tsx | 69 +-- ui/src/routes/DashboardPage.tsx | 72 +-- ui/src/routes/DevicePage.tsx | 17 +- ui/src/routes/ForgotPasswordPage.tsx | 16 +- ui/src/routes/IssueTypesPage.tsx | 94 +-- ui/src/routes/LicensePage.tsx | 7 +- ui/src/routes/LoginPage.tsx | 66 ++- ui/src/routes/ModelProvidersPage.tsx | 340 +++++------ ui/src/routes/QueuePage.tsx | 71 +-- ui/src/routes/RemoteTargetsPage.tsx | 144 ++--- ui/src/routes/ResetPasswordPage.tsx | 27 +- ui/src/routes/SecurityPage.module.css | 10 + ui/src/routes/SecurityPage.tsx | 350 +++++++----- ui/src/routes/SetupPage.tsx | 68 ++- ui/src/routes/auth-pages.test.tsx | 191 +++++++ ui/src/routes/onboarding/OnboardingPage.tsx | 184 +++--- ui/src/routes/onboarding/steps.tsx | 209 +++---- ui/src/routes/onboarding/types.ts | 2 - ui/src/routes/security-page.test.tsx | 112 ++++ ui/src/sections-context.tsx | 42 +- ui/src/test-dom.ts | 3 + ui/tsconfig.json | 2 +- vscode-extension/src/mcp-connect.ts | 4 +- .../test/suite/mcp-connect.test.ts | 5 +- .../src/views/DashboardView.module.css | 5 + webcomponents/src/views/DashboardView.tsx | 3 + 55 files changed, 3725 insertions(+), 1429 deletions(-) create mode 100644 ui/bunfig.toml create mode 100644 ui/naiveasyncplan.md create mode 100644 ui/src/api/adapter.test.ts create mode 100644 ui/src/api/adapter.ts create mode 100644 ui/src/api/architecture.test.ts create mode 100644 ui/src/api/definitions.test.ts create mode 100644 ui/src/api/definitions.ts create mode 100644 ui/src/api/hooks.test.tsx create mode 100644 ui/src/api/hooks.ts create mode 100644 ui/src/api/index.ts create mode 100644 ui/src/api/queries/index.ts create mode 100644 ui/src/api/store.tsx create mode 100644 ui/src/components/ticket-panels.test.tsx create mode 100644 ui/src/naiveasync.d.ts create mode 100644 ui/src/routes/auth-pages.test.tsx create mode 100644 ui/src/routes/security-page.test.tsx create mode 100644 ui/src/test-dom.ts diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a7c3a772..6e50c67e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -96,7 +96,7 @@ jobs: 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: | diff --git a/AGENTS.md b/AGENTS.md index d896668c..fa2b72ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,11 +79,9 @@ excluded by `.oxfmtrc.json` / `.oxlintrc.jsonc` and must never be reformatted. 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: +the expectation before opening a PR. -```bash -make install-hooks # sets core.hooksPath=.githooks -``` +./scripts/cicdprep.sh 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). diff --git a/Makefile b/Makefile index 6dd9f1a1..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 @@ -164,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/scripts/cicdprep.sh b/scripts/cicdprep.sh index a2b312f6..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/)'; } 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/ui/bun.lock b/ui/bun.lock index 8a95f295..b845bc6d 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -5,16 +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", }, @@ -53,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=="], @@ -111,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=="], @@ -173,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=="], @@ -191,14 +209,30 @@ "@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=="], @@ -211,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=="], @@ -223,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=="], @@ -243,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=="], @@ -265,14 +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 0a1794ce..9ccfb7e4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,16 +12,24 @@ "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/src/Layout.tsx b/ui/src/Layout.tsx index d289a7c1..1d5ad06e 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { AppShell, @@ -15,8 +15,8 @@ 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 } from "./api-client"; -import { useHost } from "./host"; +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 @@ -83,46 +83,24 @@ function RightPanelController() { export function Layout() { const { theme, toggleTheme } = useTheme(); - const host = useHost(); const { selected } = useProfiles(); const navigate = useNavigate(); - const [username, setUsername] = useState(null); - 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); - useEffect(() => { - let active = true; - new OperatorApi(host) - .currentSession() - .then((session) => { - if (active) { - setUsername(session.subject); - } - return undefined; - }) - .catch(() => { - if (active) { - setUsername("Account unavailable"); - } - }); - return () => { - active = false; - }; - }, [host]); - const signOut = useCallback(async () => { - setSigningOut(true); setSignOutError(false); try { - const api = new OperatorApi(host); - await api.logout(); + await logout.mutateAsync({}); + resetSession(); void navigate("/login", { replace: true }); } catch { setSignOutError(true); - } finally { - setSigningOut(false); } - }, [host, navigate]); + }, [logout, navigate, resetSession]); return ( @@ -144,7 +122,7 @@ export function Layout() { 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/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..bd8ac876 --- /dev/null +++ b/ui/src/api/architecture.test.ts @@ -0,0 +1,74 @@ +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..aa2e4437 --- /dev/null +++ b/ui/src/api/hooks.test.tsx @@ -0,0 +1,187 @@ +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(async () => undefined); + expect(calls).toBe(1); + unmount(); + act(() => jest.advanceTimersByTime(50)); + await act(async () => undefined); + 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(async () => 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 741d5e78..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,75 +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 { - 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..5a538376 --- /dev/null +++ b/ui/src/components/ticket-panels.test.tsx @@ -0,0 +1,184 @@ +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"; + +mock.module("@operator/webcomponents", () => ({ + TicketCreateForm: ({ + onChange, + onSubmit, + }: { + onChange: (value: { issueType: string; project: string; summary: string }) => void; + onSubmit: () => void; + }) => ( + <> + + + + ), + TicketDetailView: ({ + launchControls, + launchActions, + }: { + launchControls?: ReactNode; + launchActions?: ReactNode; + }) => ( +
    + {launchControls} + {launchActions} +
    + ), + LaunchForm: ({ onSubmit }: { onSubmit: () => void }) => ( + + ), +})); + +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, +}; + +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); + 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 }> = []; + globalThis.fetch = jest.fn((input, init) => { + const path = new URL(String(input)).pathname; + requests.push({ path, method: init?.method ?? "GET", body: init?.body?.toString() }); + 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)); + }) as typeof fetch; + setCsrfToken("csrf"); + const created: string[] = []; + renderPanel( created.push("created")} />); + 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(created).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[] = []; + globalThis.fetch = jest.fn((input, init) => { + const path = new URL(String(input)).pathname; + 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)); + }) as typeof fetch; + 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.tsx b/ui/src/profiles-context.tsx index 58526a81..e8e00f5b 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[]; @@ -43,38 +46,11 @@ 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 selected = profiles.find((profile) => profile.id === selectedId) ?? @@ -89,14 +65,17 @@ export function ProfilesProvider() { const create = useCallback( async (name: string) => { - 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], @@ -108,7 +87,7 @@ export function ProfilesProvider() { value={ error - ? { status: "error", message: error } + ? { status: "error", message: error.message } : { status: "loading", message: "Loading configurations…" } } > diff --git a/ui/src/routes/AgentDetailPage.tsx b/ui/src/routes/AgentDetailPage.tsx index 228b9559..02cde885 100644 --- a/ui/src/routes/AgentDetailPage.tsx +++ b/ui/src/routes/AgentDetailPage.tsx @@ -1,34 +1,18 @@ -import { useEffect, useState } from "react"; import { useParams, Link } from "react-router-dom"; -import { OperatorApi } from "../api-client"; -import type { AgentDetailResponse } from "../api-client"; -import { useHost } from "../host"; +import { AGENT_POLL_MS, useApiQuery } from "../api"; +import { agentQuery } from "../api/definitions"; import styles from "./AgentDetailPage.module.css"; export function AgentDetailPage() { const { id } = useParams<{ id: string }>(); - const host = useHost(); - const [api] = useState(() => new OperatorApi(host)); - const [agent, setAgent] = useState(null); - const [error, setError] = useState(null); + const query = useApiQuery(agentQuery(id ?? ""), { + enabled: Boolean(id), + pollIntervalMs: AGENT_POLL_MS, + }); + const agent = query.data; - useEffect(() => { - if (!id) { - return undefined; - } - const load = () => { - api - .getAgent(id) - .then(setAgent) - .catch((e) => setError(e.message)); - }; - load(); - const interval = setInterval(load, 5000); - return () => clearInterval(interval); - }, [api, id]); - - if (error) { - return
    Error: {error}
    ; + if (query.error) { + return
    Error: {query.error.message}
    ; } if (!agent) { return
    Loading...
    ; diff --git a/ui/src/routes/ConfigPage.tsx b/ui/src/routes/ConfigPage.tsx index a555fa02..3a54a073 100644 --- a/ui/src/routes/ConfigPage.tsx +++ b/ui/src/routes/ConfigPage.tsx @@ -1,7 +1,10 @@ -import { useEffect, useState } from "react"; -import { OperatorApi } from "../api-client"; -import type { StatusResponse, CollectionResponse, ProjectSummary } from "../api-client"; -import { useHost } from "../host"; +import { useApiMutation, useApiQuery } from "../api"; +import { + activateCollectionMutation, + collectionsQuery, + projectsQuery, + statusQuery, +} from "../api/definitions"; import { CONCEPTS } from "../concepts"; import { PageHeader } from "../components/PageHeader"; import { ProfileSelector } from "../profiles-context"; @@ -10,32 +13,20 @@ import styles from "./ConfigPage.module.css"; const CONFIG = CONCEPTS.config; export function ConfigPage() { - const host = useHost(); - const [api] = useState(() => new OperatorApi(host)); - const [status, setStatus] = useState(null); - const [collections, setCollections] = useState([]); - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const status = useApiQuery(statusQuery()); + const collections = useApiQuery(collectionsQuery()); + const projects = useApiQuery(projectsQuery()); + const activate = useApiMutation(activateCollectionMutation); + const loading = status.isLoading || collections.isLoading || projects.isLoading; + const error = + status.error?.message ?? + collections.error?.message ?? + projects.error?.message ?? + activate.error?.message ?? + null; - useEffect(() => { - Promise.all([ - api.status().then(setStatus), - api.listCollections().then(setCollections), - api.listProjects().then(setProjects), - ]) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); - }, [api]); - - const handleActivateCollection = async (name: string) => { - try { - await api.activateCollection(name); - const updated = await api.listCollections(); - setCollections(updated); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to activate collection"); - } + const handleActivateCollection = (name: string) => { + activate.mutate({ name }); }; if (loading) { @@ -58,29 +49,29 @@ export function ConfigPage() {
    - {status && ( + {status.data && (

    Status

    Version - {status.version} + {status.data.version} Issue Types - {status.issuetype_count} + {status.data.issuetype_count} Collections - {status.collection_count} + {status.data.collection_count} Active Collection - {status.active_collection} + {status.data.active_collection}
    )}

    Collections

    - {collections.length === 0 ? ( + {(collections.data ?? []).length === 0 ? (

    No collections configured.

    ) : (
    - {collections.map((c) => ( + {(collections.data ?? []).map((c) => (
    -

    Projects ({projects.length})

    - {projects.length === 0 ? ( +

    Projects ({(projects.data ?? []).length})

    + {(projects.data ?? []).length === 0 ? (

    No projects discovered.

    ) : ( @@ -127,7 +118,7 @@ export function ConfigPage() { - {projects.map((p) => ( + {(projects.data ?? []).map((p) => ( diff --git a/ui/src/routes/DashboardPage.tsx b/ui/src/routes/DashboardPage.tsx index 3045d479..6b2b4029 100644 --- a/ui/src/routes/DashboardPage.tsx +++ b/ui/src/routes/DashboardPage.tsx @@ -1,9 +1,8 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; import { Link } from "react-router-dom"; import { DashboardView } from "@operator/webcomponents"; -import { OperatorApi } from "../api-client"; -import type { HealthResponse, QueueStatusResponse, KanbanBoardResponse } from "../api-client"; -import { useHost } from "../host"; +import { STATUS_POLL_MS, useApiQuery } from "../api"; +import { healthQuery, kanbanQuery, queueStatusQuery } from "../api/definitions"; import { CONCEPTS } from "../concepts"; import type { KanbanTicketCard } from "@operator/bindings/KanbanTicketCard"; import { useRightPanel } from "../right-panel"; @@ -11,58 +10,24 @@ import { TicketDetailPanel } from "../components/TicketDetailPanel"; const DASHBOARD = CONCEPTS.dashboard; -const POLL_INTERVAL_MS = 3000; - export function DashboardPage() { - const host = useHost(); const { open } = useRightPanel(); - const [api] = useState(() => new OperatorApi(host)); - const [health, setHealth] = useState(null); - const [queue, setQueue] = useState(null); - const [board, setBoard] = useState(null); - const [error, setError] = useState(null); + const board = useApiQuery(kanbanQuery(), { pollIntervalMs: STATUS_POLL_MS }); + const queue = useApiQuery(queueStatusQuery(), { pollIntervalMs: STATUS_POLL_MS }); + const health = useApiQuery(healthQuery(), { pollIntervalMs: STATUS_POLL_MS }); + const error = [ + board.error && `Board: ${board.error.message}`, + queue.error && `Queue: ${queue.error.message}`, + health.error && `Health: ${health.error.message}`, + ] + .filter(Boolean) + .join(" "); const openTicket = useCallback( (ticket: KanbanTicketCard) => open(, ticket.id), [open], ); - useEffect(() => { - let cancelled = false; - - const refresh = () => { - api - .kanban() - .then((b) => { - if (!cancelled) { - setBoard(b); - setError(null); - } - return undefined; - }) - .catch((e) => { - if (!cancelled) { - setError(e.message); - } - }); - api - .queueStatus() - .then((q) => !cancelled && setQueue(q)) - .catch(() => {}); - api - .health() - .then((h) => !cancelled && setHealth(h)) - .catch(() => {}); - }; - - refresh(); - const timer = setInterval(refresh, POLL_INTERVAL_MS); - return () => { - cancelled = true; - clearInterval(timer); - }; - }, [api]); - return ( View all sections →} onOpenTicket={openTicket} /> diff --git a/ui/src/routes/DevicePage.tsx b/ui/src/routes/DevicePage.tsx index f1415ca5..e3b89271 100644 --- a/ui/src/routes/DevicePage.tsx +++ b/ui/src/routes/DevicePage.tsx @@ -6,25 +6,24 @@ import { useState, useCallback } from "react"; import { useSearchParams } from "react-router-dom"; -import { useHost } from "../host"; -import { OperatorApi, ApiError } from "../api-client"; +import { ApiError } from "../api-client"; +import { useApiMutation } from "../api"; +import { approveDeviceMutation } from "../api/definitions"; import { AuthCard, AuthField, AuthSubmit } from "@operator/webcomponents"; export function DevicePage() { - const host = useHost(); const [params] = useSearchParams(); const [userCode, setUserCode] = useState(params.get("user_code") ?? ""); const [approved, setApproved] = useState(null); const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); + const approve = useApiMutation(approveDeviceMutation); const submit = useCallback( async (event: React.SubmitEvent) => { event.preventDefault(); setError(null); - setBusy(true); try { - const res = await new OperatorApi(host).approveDevice(userCode.trim()); + const res = await approve.mutateAsync({ userCode: userCode.trim() }); setApproved(res.client_id); } catch (e) { setError( @@ -32,11 +31,9 @@ export function DevicePage() { ? "That code is unknown or has expired. Start the connection again from your editor." : "Approval failed.", ); - } finally { - setBusy(false); } }, - [host, userCode], + [approve, userCode], ); const onCodeChange = useCallback((value: string) => setUserCode(value.toUpperCase()), []); @@ -59,7 +56,7 @@ export function DevicePage() { error={error} onSubmit={submit} actions={ - + Approve } diff --git a/ui/src/routes/ForgotPasswordPage.tsx b/ui/src/routes/ForgotPasswordPage.tsx index 3f4a9786..1bb469eb 100644 --- a/ui/src/routes/ForgotPasswordPage.tsx +++ b/ui/src/routes/ForgotPasswordPage.tsx @@ -1,31 +1,27 @@ import { useState, useCallback } from "react"; import { Link } from "react-router-dom"; -import { useHost } from "../host"; -import { OperatorApi } from "../api-client"; +import { useApiMutation } from "../api"; +import { forgotPasswordMutation } from "../api/definitions"; import { MAX_USERNAME_LENGTH } from "../auth-constraints"; import { AuthCard, AuthField, AuthSubmit } from "@operator/webcomponents"; export function ForgotPasswordPage() { - const host = useHost(); const [username, setUsername] = useState(""); const [message, setMessage] = useState(null); - const [busy, setBusy] = useState(false); + const forgot = useApiMutation(forgotPasswordMutation); const submit = useCallback( async (event: React.SubmitEvent) => { event.preventDefault(); - setBusy(true); try { - const response = await new OperatorApi(host).forgotPassword(username); + const response = await forgot.mutateAsync({ username }); setMessage(response.message); } catch { setMessage( "Recovery instructions are unavailable. Ask the server administrator to run `operator auth reset-admin-password` locally.", ); - } finally { - setBusy(false); } }, - [host, username], + [forgot, username], ); return ( @@ -35,7 +31,7 @@ export function ForgotPasswordPage() { notice={message} onSubmit={submit} actions={ - + Get recovery instructions } diff --git a/ui/src/routes/IssueTypesPage.tsx b/ui/src/routes/IssueTypesPage.tsx index c1c8cb3a..c23f7b78 100644 --- a/ui/src/routes/IssueTypesPage.tsx +++ b/ui/src/routes/IssueTypesPage.tsx @@ -1,84 +1,26 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import { IssueTypesView } from "@operator/webcomponents"; -import { OperatorApi } from "../api-client"; -import type { IssueTypeSummary, IssueTypeResponse } from "../api-client"; -import { useHost } from "../host"; +import { useApiQuery } from "../api"; +import { issueTypeDocumentQuery, issueTypeQuery, issueTypesQuery } from "../api/definitions"; import { CONCEPTS } from "../concepts"; -import type { IssueType } from "@operator/bindings/IssueType"; const ISSUE_TYPES = CONCEPTS.issuetypes; export function IssueTypesPage() { - const host = useHost(); - const [api] = useState(() => new OperatorApi(host)); - const [issueTypes, setIssueTypes] = useState([]); - const [selected, setSelected] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [selectedKey, setSelectedKey] = useState(null); const [view, setView] = useState<"steps" | "graph">("steps"); - const [document_, setDocument] = useState(null); - const selectionRequest = useRef(0); + const list = useApiQuery(issueTypesQuery()); + const detail = useApiQuery(issueTypeQuery(selectedKey ?? ""), { enabled: Boolean(selectedKey) }); + const document = useApiQuery(issueTypeDocumentQuery(selectedKey ?? ""), { + enabled: view === "graph" && Boolean(selectedKey), + }); + const issueTypes = list.data ?? []; + const selected = detail.data; + const error = list.error?.message ?? detail.error?.message ?? document.error?.message ?? null; - useEffect(() => { - api - .listIssueTypes() - .then(setIssueTypes) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); - }, [api]); - - // Lazily fetch the native Operator workflow document when the graph opens. - // Same bytes the docs site renders, so the two graphs cannot disagree. - useEffect(() => { - if (view !== "graph" || !selected) { - return undefined; - } - if (document_?.key === selected.key) { - return undefined; - } - let cancelled = false; - api - .getIssueTypeDocument(selected.key) - .then((doc) => { - if (!cancelled) { - setDocument(doc); - } - return undefined; - }) - .catch((e) => { - if (!cancelled) { - setError(e instanceof Error ? e.message : "Failed to load workflow"); - } - }); - return () => { - cancelled = true; - }; - }, [api, view, selected, document_]); - - const handleSelect = useCallback( - async (key: string) => { - const request = ++selectionRequest.current; - try { - const detail = await api.getIssueType(key); - if (request === selectionRequest.current) { - setSelected(detail); - setError(null); - } - } catch (e) { - if (request === selectionRequest.current) { - setError(e instanceof Error ? e.message : "Failed to load issue type"); - } - } - }, - [api], - ); - - const selectIssueType = useCallback( - (key: string) => { - void handleSelect(key); - }, - [handleSelect], - ); + const selectIssueType = useCallback((key: string) => { + setSelectedKey(key); + }, []); return ( 0 ? { status: "ready", data: issueTypes } @@ -97,8 +39,8 @@ export function IssueTypesPage() { } selected={selected} workflow={ - document_ && selected && document_.key === selected.key - ? { status: "ready", data: document_ } + document.data && selected && document.data.key === selected.key + ? { status: "ready", data: document.data } : { status: "loading", message: "Loading workflow graph…" } } mode={view} diff --git a/ui/src/routes/LicensePage.tsx b/ui/src/routes/LicensePage.tsx index 3c0900b5..e78e5b28 100644 --- a/ui/src/routes/LicensePage.tsx +++ b/ui/src/routes/LicensePage.tsx @@ -1,16 +1,11 @@ -import { useMemo } from "react"; -import { OperatorApi } from "../api-client"; -import { useHost } from "../host"; import { LicensePanel } from "../components/LicensePanel"; import styles from "./LicensePage.module.css"; export function LicensePage() { - const host = useHost(); - const api = useMemo(() => new OperatorApi(host), [host]); return (

    License

    - +
    ); } diff --git a/ui/src/routes/LoginPage.tsx b/ui/src/routes/LoginPage.tsx index 0595406e..1637b675 100644 --- a/ui/src/routes/LoginPage.tsx +++ b/ui/src/routes/LoginPage.tsx @@ -2,47 +2,33 @@ // has no sections to show in the sidebar, and every API call the shell makes // would 401. -import { useEffect, useState, useCallback } from "react"; -import { Link, useNavigate } from "react-router-dom"; -import { useHost } from "../host"; -import { OperatorApi, ApiError } from "../api-client"; +import { useState, useCallback } from "react"; +import { Link, Navigate, useNavigate } from "react-router-dom"; +import { ApiError } from "../api-client"; +import { useApiMutation, useApiQuery, useResetSession } from "../api"; +import { bootstrapStatusQuery, loginMutation, setupStatusQuery } from "../api/definitions"; import { MAX_PASSWORD_LENGTH, MAX_USERNAME_LENGTH } from "../auth-constraints"; import { AuthCard, AuthField, AuthSubmit } from "@operator/webcomponents"; export function LoginPage() { - const host = useHost(); const navigate = useNavigate(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - - // A server with no admin account yet needs setup, not login. - useEffect(() => { - const api = new OperatorApi(host); - api - .bootstrapStatus() - .then((status) => { - if (status.state !== "complete") { - void navigate("/setup", { replace: true }); - } - return undefined; - }) - .catch(() => { - /* Unreachable server: let the login attempt report it. */ - }); - }, [host, navigate]); + const bootstrap = useApiQuery(bootstrapStatusQuery()); + const login = useApiMutation(loginMutation); + const setup = useApiQuery(setupStatusQuery(), { enabled: false }); + const resetSession = useResetSession(); const submit = useCallback( async (event: React.SubmitEvent) => { event.preventDefault(); setError(null); - setBusy(true); try { - const api = new OperatorApi(host); - await api.login(username, password); - const setup = await api.setupStatus(); - void navigate(setup.initialized ? "/" : "/onboarding", { replace: true }); + await login.mutateAsync({ username, password }); + const status = await setup.refetch(); + resetSession(); + void navigate(status.initialized ? "/" : "/onboarding", { replace: true }); } catch (e) { const status = e instanceof ApiError ? e.status : 0; setError( @@ -50,13 +36,27 @@ export function LoginPage() { ? "Too many attempts. Wait a moment and try again." : "Incorrect username or password.", ); - } finally { - setBusy(false); } }, - [host, username, password, navigate], + [login, username, password, navigate, resetSession, setup], ); + if (bootstrap.data && bootstrap.data.state !== "complete") { + return ; + } + + if (bootstrap.isLoading) { + return Checking server status…; + } + + if (bootstrap.error) { + return ( + + The Operator server status could not be loaded. Check the server and reload this page. + + ); + } + return ( + Sign in } diff --git a/ui/src/routes/ModelProvidersPage.tsx b/ui/src/routes/ModelProvidersPage.tsx index de63f30a..71422d70 100644 --- a/ui/src/routes/ModelProvidersPage.tsx +++ b/ui/src/routes/ModelProvidersPage.tsx @@ -3,16 +3,23 @@ // one's connection state (a live /models probe), and lets you create a delegator // by picking a connected provider + one of its live models. -import { useCallback, useEffect, useMemo, useState } from "react"; -import { OperatorApi } from "../api-client"; +import { useCallback, useMemo, useState } from "react"; import type { ModelServerKindEntry, ModelServerModelsResponse, - LlmToolsResponse, DelegatorResponse, } from "../api-client"; import type { GitExecutionConfig } from "@operator/bindings/GitExecutionConfig"; -import { useHost } from "../host"; +import { useApiMutation, useApiQuery } from "../api"; +import { + createDelegatorMutation, + createModelServerMutation, + delegatorsQuery, + llmToolsQuery, + providerKindsQuery, + providerModelsQuery, + updateDelegatorMutation, +} from "../api/definitions"; import { CONCEPTS } from "../concepts"; import { PageHeader } from "../components/PageHeader"; import { BrandIcon } from "../components/BrandIcon"; @@ -20,9 +27,7 @@ import { ConceptIcon } from "../components/ConceptIcon"; import styles from "./ModelProvidersPage.module.css"; const CONCEPT = CONCEPTS["model-servers"]; - -/** Live connection probe per provider slug. `undefined` = still loading. */ -type ProbeMap = Record; +const EMPTY_KINDS: ModelServerKindEntry[] = []; /** A detected llm tool offered in the delegator form; unhealthy ones can't launch. */ type DetectedToolOption = { name: string; healthOk: boolean }; @@ -51,93 +56,48 @@ function serializeGitDraft(draft: GitExecutionDraft | null): GitExecutionConfig } export function ModelProvidersPage() { - const host = useHost(); - const [api] = useState(() => new OperatorApi(host)); - const [kinds, setKinds] = useState([]); - const [probes, setProbes] = useState({}); - const [detectedTools, setDetectedTools] = useState([]); - const [delegators, setDelegators] = useState([]); - const [loading, setLoading] = useState(true); + const kindsQuery = useApiQuery(providerKindsQuery()); + const toolsQuery = useApiQuery(llmToolsQuery()); + const delegatorsQueryResult = useApiQuery(delegatorsQuery()); + const connect = useApiMutation(createModelServerMutation); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); - - const refreshDelegators = useCallback(() => { - api - .listDelegators() - .then((r) => setDelegators(r.delegators)) - .catch(() => { - /* non-fatal */ - }); - }, [api]); - - // Load the catalog + detected tools, then probe each provider for connection. - useEffect(() => { - let cancelled = false; - Promise.all([api.listProviderKinds(), api.listLlmTools()]) - .then(([catalog, tools]: [ModelServerKindEntry[], LlmToolsResponse]) => { - if (!cancelled) { - setKinds(catalog); - setDetectedTools(tools.tools.map((t) => ({ name: t.name, healthOk: t.health_ok }))); - // Probe each provider concurrently; fill the map as results land. - for (const k of catalog) { - api - .providerModels(k.slug) - .then((r) => !cancelled && setProbes((p) => ({ ...p, [k.slug]: r }))) - .catch( - () => - !cancelled && - setProbes((p) => ({ - ...p, - [k.slug]: { - server: k.slug, - reachable: false, - models: [], - error: "probe failed", - }, - })), - ); - } - } - return undefined; - }) - .catch((e) => !cancelled && setError(e instanceof Error ? e.message : "Failed to load")) - .finally(() => !cancelled && setLoading(false)); - return () => { - cancelled = true; - }; - }, [api]); - - useEffect(refreshDelegators, [refreshDelegators]); + const kinds = kindsQuery.data ?? EMPTY_KINDS; + const detectedTools: DetectedToolOption[] = (toolsQuery.data?.tools ?? []).map((t) => ({ + name: t.name, + healthOk: t.health_ok, + })); + const delegators = delegatorsQueryResult.data?.delegators ?? []; + const loading = kindsQuery.isLoading || toolsQuery.isLoading || delegatorsQueryResult.isLoading; const connectGateway = useCallback( - async (kind: ModelServerKindEntry) => { + (kind: ModelServerKindEntry) => { setError(null); - try { - await api.createModelServer({ + connect.mutate( + { name: kind.slug, kind: kind.slug, base_url: kind.default_base_url ?? null, api_key_env: kind.default_api_key_env ?? null, extra_env: {}, display_name: kind.display_name, - }); - setNotice(`Declared "${kind.slug}". Re-probing…`); - const r = await api.providerModels(kind.slug); - setProbes((p) => ({ ...p, [kind.slug]: r })); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to connect provider"); - } + }, + { + onSuccess: () => { + setNotice(`Declared "${kind.slug}". Re-probing…`); + }, + onError: (cause) => { + setError(cause.message); + }, + }, + ); }, - [api], + [connect], ); - const handleDelegatorCreated = useCallback( - (name: string) => { - setNotice(`Created delegator "${name}".`); - refreshDelegators(); - }, - [refreshDelegators], - ); + const handleDelegatorCreated = useCallback((name: string) => { + setNotice(`Created delegator "${name}".`); + }, []); const firstParty = useMemo(() => kinds.filter((k) => k.category === "first-party"), [kinds]); const gateways = useMemo(() => kinds.filter((k) => k.category === "gateway"), [kinds]); @@ -155,28 +115,34 @@ export function ModelProvidersPage() { icon={CONCEPT.icon} /> - {error &&
    {error}
    } + {(error ?? + kindsQuery.error?.message ?? + toolsQuery.error?.message ?? + delegatorsQueryResult.error?.message) && ( +
    + {error ?? + kindsQuery.error?.message ?? + toolsQuery.error?.message ?? + delegatorsQueryResult.error?.message} +
    + )} {notice &&
    {notice}
    } - + ))} @@ -227,14 +188,12 @@ function ProviderGroup({ heading, blurb, kinds, - probes, onConnect, }: { heading: string; blurb: string; kinds: ModelServerKindEntry[]; - probes: ProbeMap; - onConnect: (k: ModelServerKindEntry) => Promise; + onConnect: (k: ModelServerKindEntry) => void; }) { if (kinds.length === 0) { return null; @@ -244,76 +203,85 @@ function ProviderGroup({

    {heading}

    {blurb}

      - {kinds.map((k) => { - const probe = probes[k.slug]; - const conn = connectionLabel(probe); - return ( -
    • - - {k.brand_icon ? : } - - {k.display_name} - {k.description} - - {conn.text} - {conn.state === "disconnected" && k.connectable && !k.is_builtin && ( - - )} - {conn.state === "disconnected" && k.default_api_key_env && ( - set {k.default_api_key_env} - )} - {!k.connectable && ( - - needs base_url - - )} -
    • - ); - })} + {kinds.map((k) => ( + + ))}
    ); } +function ProviderRow({ + kind, + onConnect, +}: { + kind: ModelServerKindEntry; + onConnect: (k: ModelServerKindEntry) => void; +}) { + const query = useApiQuery(providerModelsQuery(kind.slug)); + const probe = query.error + ? { server: kind.slug, reachable: false, models: [], error: "probe failed" } + : (query.data ?? undefined); + const conn = connectionLabel(probe); + return ( +
  • + + {kind.brand_icon ? : } + + {kind.display_name} + {kind.description} + + {conn.text} + {conn.state === "disconnected" && kind.connectable && !kind.is_builtin && ( + + )} + {conn.state === "disconnected" && kind.default_api_key_env && ( + set {kind.default_api_key_env} + )} + {!kind.connectable && ( + + needs base_url + + )} +
  • + ); +} + function CreateDelegatorForm({ - api, kinds, - probes, detectedTools, onCreated, onError, }: { - api: OperatorApi; kinds: ModelServerKindEntry[]; - probes: ProbeMap; detectedTools: DetectedToolOption[]; onCreated: (name: string) => void; onError: (msg: string) => void; }) { + const create = useApiMutation(createDelegatorMutation); const [tool, setTool] = useState(""); const [provider, setProvider] = useState(""); const [model, setModel] = useState(""); const [name, setName] = useState(""); const [git, setGit] = useState(null); - const [submitting, setSubmitting] = useState(false); + const selectedModels = useApiQuery(providerModelsQuery(provider), { enabled: Boolean(provider) }); const preferredTool = detectedTools.find((candidate) => candidate.healthOk) ?? detectedTools[0]; const selectedTool = tool || preferredTool?.name || ""; - const probe = provider ? probes[provider] : undefined; - const liveModels = probe?.reachable ? probe.models : []; + const liveModels = + !selectedModels.error && selectedModels.data?.reachable ? selectedModels.data.models : []; - const submit = async () => { + const submit = () => { if (!selectedTool || !provider || !model) { onError("Pick a tool, a provider, and a model."); return; } - setSubmitting(true); - try { - const delegatorName = name.trim() || `${selectedTool}-${model}`; - await api.createDelegator({ + const delegatorName = name.trim() || `${selectedTool}-${model}`; + create.mutate( + { name: delegatorName, llm_tool: selectedTool, model, @@ -323,15 +291,18 @@ function CreateDelegatorForm({ git: serializeGitDraft(git), launch_config: null, remote_agent: null, - }); - setName(""); - setModel(""); - onCreated(delegatorName); - } catch (e) { - onError(e instanceof Error ? e.message : "Failed to create delegator"); - } finally { - setSubmitting(false); - } + }, + { + onSuccess: () => { + setName(""); + setModel(""); + onCreated(delegatorName); + }, + onError: (cause) => { + onError(cause.message); + }, + }, + ); }; return ( @@ -368,15 +339,11 @@ function CreateDelegatorForm({ className={styles.select} > - {kinds.map((k) => { - const connected = probes[k.slug]?.reachable; - return ( - - ); - })} + {kinds.map((k) => ( + + ))} @@ -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 b2a2bca3..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,68 +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 operation(); - await refresh(); - setMessage(success); - } catch (cause) { - setMessage(cause instanceof Error ? cause.message : "Target operation failed"); - } finally { - setBusy(false); - } - }, - [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) => { @@ -139,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(() => { @@ -149,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(() => { @@ -165,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"}

    {p.project_name} {p.kind ?? "-"}
    - - - - - - - - - {sessions?.sessions.map((s) => ( - - - - - + {sessions.isLoading &&

    Loading browser sessions…

    } + {!sessions.isLoading && !sessions.error && sessionRows.length === 0 && ( +

    No browser sessions were found.

    + )} + {!sessions.isLoading && sessionRows.length > 0 && ( +
    StartedExpiresLast used -
    - {formatDate(s.created_at)} - {s.current && this browser} - {formatDate(s.expires_at)}{formatDate(s.last_used_at)} - -
    + + + + + + - ))} - -
    StartedExpiresLast used
    + + + {visibleSessions.map((s) => ( + + + {formatDate(s.created_at)} + {s.current && this browser} + + {formatDate(s.expires_at)} + {formatDate(s.last_used_at)} + + + + + ))} + + + )} + {sessionRows.length > PAGE_SIZE && ( +
    + + Page {sessionPage + 1} + +
    + )}
    @@ -163,26 +178,32 @@ export function SecurityPage() {

    Editors and other clients authorized through the device flow.

    - - - - - - - - - - - {sessions?.devices.map((d) => ( - - - - - + {sessions.isLoading &&

    Loading connected devices…

    } + {!sessions.isLoading && !sessions.error && deviceRows.length === 0 && ( +

    No connected devices were found.

    + )} + {!sessions.isLoading && deviceRows.length > 0 && ( +
    ClientScopesApprovedLast used
    {d.client_id}{d.scopes.join(", ")}{formatDate(d.created_at)}{formatDate(d.last_used_at)}
    + + + + + + - ))} - -
    ClientScopesApprovedLast used
    + + + {deviceRows.map((d) => ( + + {d.client_id} + {d.scopes.join(", ")} + {formatDate(d.created_at)} + {formatDate(d.last_used_at)} + + ))} + + + )}
    @@ -191,14 +212,14 @@ export function SecurityPage() { For integrations. Grant only the scopes the integration needs; every key expires.

    - {newSecret && ( + {createKey.data?.secret && (
    Copy this now - it is shown once and cannot be retrieved. - {newSecret} + {createKey.data.secret}
    )} - +
    ); diff --git a/ui/src/routes/SetupPage.tsx b/ui/src/routes/SetupPage.tsx index 4dc751d8..1a29762b 100644 --- a/ui/src/routes/SetupPage.tsx +++ b/ui/src/routes/SetupPage.tsx @@ -2,37 +2,27 @@ // account; once bootstrap completes this redirects to login, so it cannot be // used to re-claim the account. -import { useEffect, useState, useCallback } from "react"; -import { useNavigate } from "react-router-dom"; -import { useHost } from "../host"; -import { OperatorApi, ApiError } from "../api-client"; +import { useState, useCallback } from "react"; +import { Navigate, useNavigate } from "react-router-dom"; +import { ApiError } from "../api-client"; +import { useApiMutation, useApiQuery, useResetSession } from "../api"; +import { bootstrapMutation, bootstrapStatusQuery, loginMutation } from "../api/definitions"; import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from "../auth-constraints"; import { AuthCard, AuthField, AuthSubmit } from "@operator/webcomponents"; export function SetupPage() { - const host = useHost(); const navigate = useNavigate(); - const [needsTemporary, setNeedsTemporary] = useState(false); const [temporary, setTemporary] = useState(""); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); + const bootstrap = useApiQuery(bootstrapStatusQuery()); + const submitBootstrap = useApiMutation(bootstrapMutation); + const login = useApiMutation(loginMutation); + const resetSession = useResetSession(); + const needsTemporary = bootstrap.data?.requires_temporary_password ?? false; - useEffect(() => { - const api = new OperatorApi(host); - api - .bootstrapStatus() - .then((status) => { - if (status.state === "complete") { - void navigate("/login", { replace: true }); - } else { - setNeedsTemporary(status.requires_temporary_password); - } - return undefined; - }) - .catch(() => setError("Cannot reach the Operator server.")); - }, [host, navigate]); + const displayError = error ?? (bootstrap.error ? "Cannot reach the Operator server." : null); const tooShort = password.length > 0 && password.length < MIN_PASSWORD_LENGTH; const mismatch = confirm.length > 0 && password !== confirm; @@ -45,15 +35,13 @@ export function SetupPage() { async (event: React.SubmitEvent) => { event.preventDefault(); setError(null); - setBusy(true); try { - const api = new OperatorApi(host); - const result = await api.bootstrap({ + const result = await submitBootstrap.mutateAsync({ temporary_password: needsTemporary ? temporary : null, new_password: password, }); - // Bootstrap creates the account but does not sign you in. - await api.login(result.username, password); + await login.mutateAsync({ username: result.username, password }); + resetSession(); void navigate("/onboarding", { replace: true }); } catch (e) { if (e instanceof ApiError && e.status === 409) { @@ -65,18 +53,32 @@ export function SetupPage() { } else { setError(e instanceof ApiError ? e.message : "Setup failed."); } - } finally { - setBusy(false); } }, - [host, needsTemporary, temporary, password, navigate], + [needsTemporary, temporary, password, navigate, submitBootstrap, login, resetSession], ); + if (bootstrap.data?.state === "complete") { + return ; + } + + if (bootstrap.isLoading) { + return Checking server status…; + } + + if (bootstrap.error) { + return ( + + The Operator server status could not be loaded. Check the server and reload this page. + + ); + } + return ( + Create admin account } diff --git a/ui/src/routes/auth-pages.test.tsx b/ui/src/routes/auth-pages.test.tsx new file mode 100644 index 00000000..5163ddaf --- /dev/null +++ b/ui/src/routes/auth-pages.test.tsx @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, jest, mock, test } from "bun:test"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ChangeEvent, FormEvent, ReactNode } from "react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { ApiProvider } from "../api"; +import { resetSessionState } from "../api/adapter"; +import { setCsrfToken } from "../api-client"; +import { HostContext, type Host } from "../host"; + +mock.module("@operator/webcomponents", () => ({ + AuthCard: ({ + title, + error, + children, + actions, + onSubmit, + }: { + title: string; + error?: string | null; + children: ReactNode; + actions?: ReactNode; + onSubmit?: (event: FormEvent) => void; + }) => ( +
    +

    {title}

    + {error &&

    {error}

    } + {children} + {actions} +
    + ), + AuthField: ({ + label, + value, + onChange, + type, + }: { + label: string; + value: string; + onChange: (value: string) => void; + type?: string; + }) => ( + + ), + AuthSubmit: ({ children, disabled }: { children: ReactNode; disabled?: boolean }) => ( + + ), +})); + +const { LoginPage } = await import("./LoginPage"); +const { SetupPage } = await import("./SetupPage"); + +const TEST_HOST: Host = { + baseUrl: () => "http://operator.test", + openExternal: () => undefined, + browseFolder: () => Promise.resolve(null), + openFile: () => undefined, +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function renderAuth(initialPath: string) { + return render( + + + + + } /> + } /> + Onboarding destination

    } /> + Workspace destination

    } /> +
    +
    +
    +
    , + ); +} + +afterEach(() => { + cleanup(); + resetSessionState(); + setCsrfToken(null); + jest.restoreAllMocks(); +}); + +describe("authentication routes", () => { + test("login redirects an uninitialized server to setup", async () => { + globalThis.fetch = jest.fn(() => + Promise.resolve(json({ state: "uninitialized", requires_temporary_password: false })), + ) as typeof fetch; + renderAuth("/login"); + expect(await screen.findByRole("heading", { name: "Set up Operator" })).toBeTruthy(); + }); + + test("login maps rate limiting to an actionable message", async () => { + globalThis.fetch = jest.fn((input, init) => { + const path = new URL(String(input)).pathname; + if (path.endsWith("/bootstrap") && init?.method !== "POST") { + return Promise.resolve(json({ state: "complete", requires_temporary_password: false })); + } + return Promise.resolve(json({ message: "slow down" }, 429)); + }) as typeof fetch; + renderAuth("/login"); + await screen.findByRole("heading", { name: "Sign in to Operator" }); + fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "bad password" } }); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + expect(await screen.findByText("Too many attempts. Wait a moment and try again.")).toBeTruthy(); + }); + + test("successful login routes an unfinished workspace to onboarding", async () => { + globalThis.fetch = jest.fn((input, init) => { + const path = new URL(String(input)).pathname; + if (path.endsWith("/bootstrap")) { + return Promise.resolve(json({ state: "complete", requires_temporary_password: false })); + } + if (path.endsWith("/login") && init?.method === "POST") { + return Promise.resolve( + json({ scopes: ["admin"], expires_at: "2030-01-01T00:00:00Z", csrf_token: "csrf" }), + ); + } + if (path.endsWith("/setup/status")) { + return Promise.resolve( + json({ + initialized: false, + admin_configured: true, + config_path: "operator.toml", + tickets_path: ".tickets", + projects_by_tool: {}, + default_acceptance_criteria: "", + }), + ); + } + return Promise.resolve(json({ message: `Unexpected request: ${path}` }, 500)); + }) as typeof fetch; + renderAuth("/login"); + await screen.findByRole("heading", { name: "Sign in to Operator" }); + fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } }); + fireEvent.change(screen.getByLabelText("Password"), { target: { value: "valid password" } }); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + expect(await screen.findByText("Onboarding destination")).toBeTruthy(); + }); + + test("setup redirects complete servers and maps a rejected temporary password", async () => { + let complete = true; + globalThis.fetch = jest.fn((input, init) => { + const path = new URL(String(input)).pathname; + if (path.endsWith("/bootstrap") && init?.method !== "POST") { + return Promise.resolve( + json({ + state: complete ? "complete" : "awaiting_password", + requires_temporary_password: !complete, + }), + ); + } + return Promise.resolve(json({ message: "invalid bootstrap password" }, 401)); + }) as typeof fetch; + const first = renderAuth("/setup"); + expect(await screen.findByRole("heading", { name: "Sign in to Operator" })).toBeTruthy(); + first.unmount(); + resetSessionState(); + complete = false; + renderAuth("/setup"); + await screen.findByRole("heading", { name: "Set up Operator" }); + fireEvent.change(screen.getByLabelText("Temporary password"), { + target: { value: "temporary" }, + }); + fireEvent.change(screen.getByLabelText("New admin password"), { + target: { value: "a sufficiently long password" }, + }); + fireEvent.change(screen.getByLabelText("Confirm password"), { + target: { value: "a sufficiently long password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create admin account" })); + expect(await screen.findByText("The temporary password is incorrect.")).toBeTruthy(); + }); +}); diff --git a/ui/src/routes/onboarding/OnboardingPage.tsx b/ui/src/routes/onboarding/OnboardingPage.tsx index 04a928c9..ce3d9b2e 100644 --- a/ui/src/routes/onboarding/OnboardingPage.tsx +++ b/ui/src/routes/onboarding/OnboardingPage.tsx @@ -1,8 +1,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import type { SetupStatusResponse, SetupStepResponse } from "../../api-client"; -import { ApiError, OperatorApi } from "../../api-client"; -import { useHost } from "../../host"; +import { Navigate, useNavigate, useSearchParams } from "react-router-dom"; +import { ApiError, type SetupStepResponse } from "../../api-client"; +import { useApiMutation, useApiQuery } from "../../api"; +import { + initializeSetupMutation, + integrationsQuery, + licenseQuery, + renameProfileMutation, + setupCollectionsQuery, + setupStatusQuery, + setupStepsQuery, +} from "../../api/definitions"; import { STEP_COMPONENTS, stepRows, visibleSteps } from "./steps"; import type { SetupStep } from "@operator/bindings/SetupStep"; import type { StepRow, WizardDraft } from "./types"; @@ -11,6 +19,7 @@ import styles from "./OnboardingPage.module.css"; const WIZARD_DRAFT_VERSION = 1; const WIZARD_DRAFT_PREFIX = "operator.onboarding-draft"; +const EMPTY_STEPS: SetupStepResponse[] = []; type SavedDraft = { version?: number; @@ -50,7 +59,7 @@ function SetupProgress({ currentSlug, }: { rows: StepRow[]; - steps: SetupStepResponse[]; + steps: { slug: SetupStep; name: string }[]; currentSlug: SetupStep; }) { const currentRow = rows.findIndex((row) => row.slug === currentSlug); @@ -84,94 +93,71 @@ function SetupProgress({ } export function OnboardingPage() { - const host = useHost(); const { selected, create, refresh } = useProfiles(); const [params] = useSearchParams(); const navigate = useNavigate(); - const [api] = useState(() => new OperatorApi(host)); - // Mount-scoped, not a live read of the query string: the step sync below - // strips `?new=1` as soon as the catalog loads, and a failed create must - // still leave the user creating rather than renaming the selected profile. + const statusQuery = useApiQuery(setupStatusQuery()); + const stepsQuery = useApiQuery(setupStepsQuery()); + const integrationsQueryResult = useApiQuery(integrationsQuery()); + const collectionsQueryResult = useApiQuery(setupCollectionsQuery()); + const license = useApiQuery(licenseQuery(), { enabled: false }); + const rename = useApiMutation(renameProfileMutation); + const initialize = useApiMutation(initializeSetupMutation); const [creating] = useState(() => !selected || params.get("new") === "1"); const [initialStepParam] = useState(() => params.get("step")); - const [status, setStatus] = useState(null); - const [steps, setSteps] = useState>>([]); - const [integrations, setIntegrations] = useState>>( - [], - ); - const [collections, setCollections] = useState>>( - [], - ); const [draft, setDraft] = useState(() => emptyDraft("")); const [currentSlug, setCurrentSlug] = useState("welcome"); const [exports, setExports] = useState([]); const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); + const [creatingBusy, setCreatingBusy] = useState(false); + const [hydrated, setHydrated] = useState(false); - useEffect(() => { - let active = true; - Promise.all([api.setupStatus(), api.setupSteps(), api.integrations(), api.setupCollections()]) - .then(([nextStatus, nextSteps, nextIntegrations, nextCollections]) => { - if (!active) { - return undefined; - } - // While creating, the status describes whichever configuration the - // server defaulted to, so its `initialized` flag says nothing about the - // one being created. - if (nextStatus.initialized && !creating) { - void navigate("/", { replace: true }); - return undefined; - } - setStatus(nextStatus); - setSteps(nextSteps); - setIntegrations(nextIntegrations); - setCollections(nextCollections); - const initial = emptyDraft( - creating ? "" : (selected?.name ?? ""), - nextStatus.default_acceptance_criteria, - ); - const known = new Set(nextSteps.map((step) => step.slug)); - let saved: SavedDraft | null = null; - if (selected && !creating) { - try { - saved = JSON.parse( - sessionStorage.getItem(draftStorageKey(selected.id)) ?? "null", - ) as SavedDraft | null; - } catch { - sessionStorage.removeItem(draftStorageKey(selected.id)); - } - } - setDraft( - saved?.version === WIZARD_DRAFT_VERSION && saved.draft - ? { ...initial, ...saved.draft } - : initial, - ); - const fromUrl = initialStepParam as SetupStep | null; - setCurrentSlug( - fromUrl && known.has(fromUrl) - ? fromUrl - : saved?.currentSlug && known.has(saved.currentSlug) - ? saved.currentSlug - : "welcome", - ); - return undefined; - }) - .catch( - (cause: unknown) => - active && setError(cause instanceof Error ? cause.message : "Could not load setup"), - ); - return () => { - active = false; - }; - }, [api, creating, initialStepParam, navigate, selected]); + const status = statusQuery.data; + const steps = stepsQuery.data ?? EMPTY_STEPS; + const integrations = integrationsQueryResult.data ?? []; + const collections = collectionsQueryResult.data ?? []; + const busy = creatingBusy || rename.isPending || initialize.isPending; + const nextStatus = statusQuery.data; + const nextSteps = stepsQuery.data; + + if (!hydrated && nextStatus && nextSteps && !(nextStatus.initialized && !creating)) { + const initial = emptyDraft( + creating ? "" : (selected?.name ?? ""), + nextStatus.default_acceptance_criteria, + ); + const known = new Set(nextSteps.map((step) => step.slug)); + let saved: SavedDraft | null = null; + if (selected && !creating) { + try { + saved = JSON.parse( + sessionStorage.getItem(draftStorageKey(selected.id)) ?? "null", + ) as SavedDraft | null; + } catch { + sessionStorage.removeItem(draftStorageKey(selected.id)); + } + } + setHydrated(true); + setDraft( + saved?.version === WIZARD_DRAFT_VERSION && saved.draft + ? { ...initial, ...saved.draft } + : initial, + ); + const fromUrl = initialStepParam as SetupStep | null; + setCurrentSlug( + fromUrl && known.has(fromUrl) + ? fromUrl + : saved?.currentSlug && known.has(saved.currentSlug) + ? saved.currentSlug + : "welcome", + ); + } - // The address bar names the step, so a refresh resumes where the user was and - // `?new=1` falls away once the wizard has taken over. useEffect(() => { - if (status) { - void navigate(`/onboarding?step=${currentSlug}`, { replace: true }); + if (!status || (status.initialized && !creating)) { + return; } - }, [currentSlug, navigate, status]); + void navigate(`/onboarding?step=${currentSlug}`, { replace: true }); + }, [creating, currentSlug, navigate, status]); useEffect(() => { if (!selected || !status || status.initialized || creating) { @@ -210,7 +196,7 @@ export function OnboardingPage() { * have to land before that commit for the handoff to survive. */ async function createConfiguration(nextSlug: SetupStep): Promise { - setBusy(true); + setCreatingBusy(true); try { const profile = await create(draft.configurationName); sessionStorage.setItem( @@ -227,7 +213,7 @@ export function OnboardingPage() { setError(cause instanceof Error ? cause.message : "Could not create configuration"); return false; } finally { - setBusy(false); + setCreatingBusy(false); } } @@ -247,22 +233,19 @@ export function OnboardingPage() { return; } if (selected && selected.name !== draft.configurationName) { - setBusy(true); try { - await api.renameProfile(selected.id, draft.configurationName); + await rename.mutateAsync({ id: selected.id, name: draft.configurationName }); await refresh(); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not rename configuration"); return; - } finally { - setBusy(false); } } } if (current.slug === "execution-mode" && draft.executionMode === "remote") { try { - const license = await api.license(); - if (!license.premium) { + const licenseValue = await license.refetch(); + if (!licenseValue.premium) { setError("A valid Premium license is required for remote targets."); return; } @@ -298,8 +281,7 @@ export function OnboardingPage() { setCurrentSlug(nextSlug); } - async function initialize() { - setBusy(true); + async function onInitialize() { setError(null); try { const executionTarget: WizardDraft["executionTarget"] = @@ -311,7 +293,7 @@ export function OnboardingPage() { ), } : draft.executionTarget; - await api.initializeSetup({ + await initialize.mutateAsync({ preset: draft.preset, task_fields: draft.taskFields, wrapper: draft.wrapper, @@ -340,8 +322,6 @@ export function OnboardingPage() { ? cause.message : "Initialization failed", ); - } finally { - setBusy(false); } } @@ -349,8 +329,21 @@ export function OnboardingPage() { void next(); }; + const loadError = + statusQuery.error?.message ?? + stepsQuery.error?.message ?? + integrationsQueryResult.error?.message ?? + collectionsQueryResult.error?.message ?? + null; + + if (statusQuery.data?.initialized && !creating) { + return ; + } + if (!status || !current || !Step) { - return
    {error ?? "Loading workspace setup…"}
    ; + return ( +
    {error ?? loadError ?? "Loading workspace setup…"}
    + ); } return ( @@ -367,7 +360,6 @@ export function OnboardingPage() { {error &&
    {error}
    }
    {currentIndex === walk.length - 1 ? ( - ) : ( diff --git a/ui/src/routes/onboarding/steps.tsx b/ui/src/routes/onboarding/steps.tsx index 50cc3eb1..7d1f5702 100644 --- a/ui/src/routes/onboarding/steps.tsx +++ b/ui/src/routes/onboarding/steps.tsx @@ -1,10 +1,25 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import type { KanbanProviderKind } from "@operator/bindings/KanbanProviderKind"; import type { SetupStep } from "@operator/bindings/SetupStep"; import type { StepComponent, StepProps, StepRow } from "./types"; import { Choice, ChoiceGroup, PremiumPaywall } from "@operator/webcomponents"; import { LicensePanel } from "../../components/LicensePanel"; -import type { LicenseResponse } from "../../api-client"; +import { useApiMutation, useApiQuery } from "../../api"; +import { + gitProvidersQuery, + kanbanProvidersQuery, + licenseQuery, + listKanbanProjectsMutation, + listKanbanStatusesMutation, + providerKindsQuery, + providerModelsQuery, + setGitSessionEnvMutation, + setKanbanSessionEnvMutation, + validateGitTokenMutation, + validateKanbanCredentialsMutation, + writeGitConfigMutation, + writeKanbanConfigMutation, +} from "../../api/definitions"; import styles from "./OnboardingPage.module.css"; const TASK_FIELDS = ["priority", "points", "user_story"] as const; @@ -68,39 +83,12 @@ const Welcome: StepComponent = ({ status, creating, draft, setDraft }) => ( ); -const License: StepComponent = ({ api, setDraft }) => { - const onChange = useCallback( - (license: LicenseResponse) => setDraft((current) => ({ ...current, premium: license.premium })), - [setDraft], - ); - return ; -}; +const License: StepComponent = () => ; -const ExecutionMode: StepComponent = ({ api, draft, setDraft }) => { - const [license, setLicense] = useState(null); +const ExecutionMode: StepComponent = ({ draft, setDraft }) => { + const licenseQueryResult = useApiQuery(licenseQuery()); const [showLicense, setShowLicense] = useState(false); - const changed = useCallback( - (value: LicenseResponse) => { - setLicense(value); - setDraft((current) => ({ ...current, premium: value.premium })); - }, - [setDraft], - ); - useEffect(() => { - let active = true; - api - .license() - .then((value) => { - if (active) { - changed(value); - } - return undefined; - }) - .catch(() => {}); - return () => { - active = false; - }; - }, [api, changed]); + const license = licenseQueryResult.data; const selectLocal = useCallback(() => { setDraft((current) => ({ @@ -150,38 +138,47 @@ const ExecutionMode: StepComponent = ({ api, draft, setDraft }) => { onAddLicense={addLicense} /> )} - {showLicense && } + {showLicense && } ); }; -function KanbanInfo({ api, addExport }: StepProps) { - const [providers, setProviders] = useState>>([]); +function KanbanInfo({ addExport }: StepProps) { + const providersQuery = useApiQuery(kanbanProvidersQuery()); + const validateCredentials = useApiMutation(validateKanbanCredentialsMutation); + const listProjects = useApiMutation(listKanbanProjectsMutation); + const listStatuses = useApiMutation(listKanbanStatusesMutation); + const writeConfig = useApiMutation(writeKanbanConfigMutation); + const setSessionEnv = useApiMutation(setKanbanSessionEnvMutation); const [provider, setProvider] = useState(""); const [domain, setDomain] = useState(""); const [email, setEmail] = useState(""); const [token, setToken] = useState(""); const [rootPath, setRootPath] = useState(""); const [instance, setInstance] = useState("default"); - const [projects, setProjects] = useState<{ id: string; key: string; name: string }[]>([]); const [projectKey, setProjectKey] = useState(""); - const [statuses, setStatuses] = useState([]); const [mapping, setMapping] = useState({ todo: "", doing: "", done: "" }); const [syncUserId, setSyncUserId] = useState(""); const [workspaceKey, setWorkspaceKey] = useState(""); const [message, setMessage] = useState(null); - const [busy, setBusy] = useState(false); - - useEffect(() => { - void api - .kanbanProviders() - .then(setProviders) - .catch((error: Error) => setMessage(error.message)); - }, [api]); + const providers = providersQuery.data ?? []; + const projects = listProjects.data?.projects ?? []; + const statuses = listStatuses.data?.statuses ?? []; + const busy = + validateCredentials.isPending || + listProjects.isPending || + listStatuses.isPending || + writeConfig.isPending || + setSessionEnv.isPending; const selectProvider = useCallback( - (slug: string) => setProvider(KANBAN_KINDS.find((kind) => kind === slug) ?? ""), - [setProvider], + (slug: string) => { + setProvider(KANBAN_KINDS.find((kind) => kind === slug) ?? ""); + setProjectKey(""); + listProjects.reset(); + listStatuses.reset(); + }, + [listProjects, listStatuses], ); // The catalog carries one more provider than `KANBAN_KINDS`: the built-in board @@ -200,10 +197,9 @@ function KanbanInfo({ api, addExport }: StepProps) { if (!provider) { return; } - setBusy(true); setMessage(null); try { - const validation = await api.validateKanbanCredentials(credentials()); + const validation = await validateCredentials.mutateAsync(credentials()); if (!validation.valid) { throw new Error(validation.error ?? "Credentials were rejected"); } @@ -215,7 +211,7 @@ function KanbanInfo({ api, addExport }: StepProps) { ); setWorkspaceKey(validation.github?.user_login ?? ""); if (provider === "openspec") { - await api.writeKanbanConfig({ + await writeConfig.mutateAsync({ provider, openspec: { instance, root_path: rootPath, project: null }, jira: null, @@ -225,13 +221,10 @@ function KanbanInfo({ api, addExport }: StepProps) { setMessage("OpenSpec connected."); return; } - const listed = await api.listKanbanProjects(credentials()); - setProjects(listed.projects); + await listProjects.mutateAsync(credentials()); setMessage("Credentials validated. Choose a project."); } catch (error) { setMessage(error instanceof Error ? error.message : "Connection failed"); - } finally { - setBusy(false); } } @@ -241,8 +234,7 @@ function KanbanInfo({ api, addExport }: StepProps) { return; } try { - const result = await api.listKanbanStatuses({ ...credentials(), project_key: value }); - setStatuses(result.statuses); + const result = await listStatuses.mutateAsync({ ...credentials(), project_key: value }); setMapping({ todo: result.statuses[0] ?? "", doing: result.statuses[1] ?? "", @@ -257,7 +249,6 @@ function KanbanInfo({ api, addExport }: StepProps) { if (!provider || !projectKey) { return; } - setBusy(true); try { const env = provider === "jira" @@ -267,13 +258,13 @@ function KanbanInfo({ api, addExport }: StepProps) { : "OPERATOR_GITHUB_TOKEN"; const status_mapping = mapping; if (provider === "jira") { - const envResult = await api.setKanbanSessionEnv({ + const envResult = await setSessionEnv.mutateAsync({ provider, jira: { domain, email, api_token: token, api_key_env: env }, linear: null, github: null, }); - await api.writeKanbanConfig({ + await writeConfig.mutateAsync({ provider, jira: { domain, @@ -289,14 +280,14 @@ function KanbanInfo({ api, addExport }: StepProps) { }); addExport(envResult.shell_export_block); } else if (provider === "linear") { - const envResult = await api.setKanbanSessionEnv({ + const envResult = await setSessionEnv.mutateAsync({ provider, linear: { api_key: token, api_key_env: env }, jira: null, github: null, }); const selected = projects.find((item) => item.key === projectKey); - await api.writeKanbanConfig({ + await writeConfig.mutateAsync({ provider, linear: { workspace_key: selected?.id ?? projectKey, @@ -311,7 +302,7 @@ function KanbanInfo({ api, addExport }: StepProps) { }); addExport(envResult.shell_export_block); } else if (provider === "github") { - const envResult = await api.setKanbanSessionEnv({ + const envResult = await setSessionEnv.mutateAsync({ provider, github: { token, api_key_env: env }, jira: null, @@ -319,7 +310,7 @@ function KanbanInfo({ api, addExport }: StepProps) { }); const selected = projects.find((item) => item.key === projectKey); const owner = selected?.name.split("/#", 1)[0] ?? workspaceKey; - await api.writeKanbanConfig({ + await writeConfig.mutateAsync({ provider, github: { owner, @@ -338,8 +329,6 @@ function KanbanInfo({ api, addExport }: StepProps) { setMessage("Kanban provider connected."); } catch (error) { setMessage(error instanceof Error ? error.message : "Could not save provider"); - } finally { - setBusy(false); } } @@ -456,52 +445,33 @@ function KanbanInfo({ api, addExport }: StepProps) { )} - {message &&

    {message}

    } + {(message ?? providersQuery.error?.message) && ( +

    {message ?? providersQuery.error?.message}

    + )}
    )} ); } -function ModelServer({ api, integrations, draft, setDraft }: StepProps) { +function ProbeLabel({ slug }: { slug: string }) { + const { data, error, isLoading } = useApiQuery(providerModelsQuery(slug)); + if (data?.reachable) { + return `${data.models.length} models`; + } + if (isLoading) { + return "checking…"; + } + return data?.error ?? (error ? "unreachable" : "checking…"); +} + +function ModelServer({ integrations, draft, setDraft }: StepProps) { const entries = useMemo( () => integrations.filter((entry) => entry.vertical === "model"), [integrations], ); - const [kinds, setKinds] = useState>>([]); - const [probes, setProbes] = useState>({}); - useEffect(() => { - let active = true; - void api.listProviderKinds().then((result) => { - if (active) { - setKinds(result); - } - return undefined; - }); - for (const entry of entries) { - void api - .providerModels(entry.slug) - .then((result) => { - if (active) { - setProbes((current) => ({ - ...current, - [entry.slug]: result.reachable - ? `${result.models.length} models` - : (result.error ?? "unreachable"), - })); - } - return undefined; - }) - .catch(() => { - if (active) { - setProbes((current) => ({ ...current, [entry.slug]: "unreachable" })); - } - }); - } - return () => { - active = false; - }; - }, [api, entries]); + const kindsQuery = useApiQuery(providerKindsQuery()); + const kinds = kindsQuery.data ?? []; const keyExports = draft.modelServers.flatMap((slug) => { const env = kinds.find((kind) => kind.slug === slug)?.default_api_key_env; return env ? [`export ${env}=""`] : []; @@ -511,7 +481,7 @@ function ModelServer({ api, integrations, draft, setDraft }: StepProps) { setDraft((current) => ({ ...current, modelServers: current.modelServers.includes(provider) - ? current.modelServers.filter((slug) => slug !== provider) + ? current.modelServers.filter((item) => item !== provider) : [...current.modelServers, provider], })), [setDraft], @@ -532,7 +502,9 @@ function ModelServer({ api, integrations, draft, setDraft }: StepProps) { onSelect={toggleProvider} > {entry.label} - {probes[entry.slug] ?? "checking…"} + + + ))} @@ -541,38 +513,35 @@ function ModelServer({ api, integrations, draft, setDraft }: StepProps) { ); } -function GitProvider({ api, addExport }: StepProps) { - const [providers, setProviders] = useState>>([]); +function GitProvider({ addExport }: StepProps) { + const providersQuery = useApiQuery(gitProvidersQuery()); + const validateToken = useApiMutation(validateGitTokenMutation); + const writeConfig = useApiMutation(writeGitConfigMutation); + const setSessionEnv = useApiMutation(setGitSessionEnvMutation); const [selected, setSelected] = useState(""); const [token, setToken] = useState(""); const [message, setMessage] = useState(null); - const [busy, setBusy] = useState(false); - useEffect(() => { - void api - .gitProviders() - .then(setProviders) - .catch((error: Error) => setMessage(error.message)); - }, [api]); + const providers = providersQuery.data ?? []; + const busy = validateToken.isPending || writeConfig.isPending || setSessionEnv.isPending; const provider = providers.find((item) => item.slug === selected); async function save() { if (!provider) { return; } - setBusy(true); setMessage(null); try { if (provider.state !== "authenticated") { - const validation = await api.validateGitToken({ provider: provider.slug, token }); + const validation = await validateToken.mutateAsync({ provider: provider.slug, token }); if (!validation.valid) { throw new Error(validation.error ?? "Token was rejected"); } } - const config = await api.writeGitConfig({ + const config = await writeConfig.mutateAsync({ provider: provider.slug, token_env: provider.token_env, }); if (token) { - const env = await api.setGitSessionEnv({ provider: provider.slug, token }); + const env = await setSessionEnv.mutateAsync({ provider: provider.slug, token }); addExport(env.shell_export_block); } else { addExport(config.shell_export_block); @@ -581,8 +550,6 @@ function GitProvider({ api, addExport }: StepProps) { setMessage(`Connected ${provider.label}${config.username ? ` as ${config.username}` : ""}.`); } catch (error) { setMessage(error instanceof Error ? error.message : "Could not connect provider"); - } finally { - setBusy(false); } } return ( @@ -628,7 +595,9 @@ function GitProvider({ api, addExport }: StepProps) { > Connect - {message &&

    {message}

    } + {(message ?? providersQuery.error?.message) && ( +

    {message ?? providersQuery.error?.message}

    + )}
    )} diff --git a/ui/src/routes/onboarding/types.ts b/ui/src/routes/onboarding/types.ts index 85c533c6..12ddae6e 100644 --- a/ui/src/routes/onboarding/types.ts +++ b/ui/src/routes/onboarding/types.ts @@ -5,7 +5,6 @@ import type { SetupExecutionTarget } from "@operator/bindings/SetupExecutionTarg import type { SetupStep } from "@operator/bindings/SetupStep"; import type { IntegrationCatalogEntryDto, - OperatorApi, SetupCollectionResponse, SetupStatusResponse, } from "../../api-client"; @@ -26,7 +25,6 @@ export type WizardDraft = { }; export type StepProps = { - api: OperatorApi; status: SetupStatusResponse; integrations: IntegrationCatalogEntryDto[]; collections: SetupCollectionResponse[]; diff --git a/ui/src/routes/security-page.test.tsx b/ui/src/routes/security-page.test.tsx new file mode 100644 index 00000000..9c182b51 --- /dev/null +++ b/ui/src/routes/security-page.test.tsx @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, jest, mock, test } from "bun:test"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ApiProvider } from "../api"; +import { resetSessionState } from "../api/adapter"; +import { setCsrfToken } from "../api-client"; +import { HostContext, type Host } from "../host"; + +mock.module("@operator/webcomponents", () => ({})); + +const { SecurityPage } = await import("./SecurityPage"); + +const TEST_HOST: Host = { + baseUrl: () => "http://operator.test", + openExternal: () => undefined, + browseFolder: () => Promise.resolve(null), + openFile: () => undefined, +}; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +afterEach(() => { + cleanup(); + resetSessionState(); + setCsrfToken(null); + jest.restoreAllMocks(); +}); + +describe("SecurityPage", () => { + test("creates and revokes credentials through mutation lifecycles", async () => { + const requests: string[] = []; + globalThis.fetch = jest.fn((input, init) => { + const path = new URL(String(input)).pathname; + const method = init?.method ?? "GET"; + requests.push(`${method} ${path}`); + if (path.endsWith("/auth/sessions") && method === "GET") { + return Promise.resolve( + json({ + sessions: [ + { + id: "session-1", + created_at: "2026-09-30T00:00:00Z", + expires_at: "2026-10-01T00:00:00Z", + current: false, + }, + ], + devices: [], + }), + ); + } + if (path.endsWith("/auth/keys") && method === "GET") { + return Promise.resolve( + json({ + keys: [ + { + id: "key-1", + name: "existing", + scopes: ["read"], + created_at: "2026-09-30T00:00:00Z", + expires_at: "2026-10-01T00:00:00Z", + }, + ], + }), + ); + } + if (path.endsWith("/auth/keys") && method === "POST") { + return Promise.resolve( + json({ + key: { + id: "key-2", + name: "ci", + scopes: ["read"], + created_at: "2026-09-30T00:00:00Z", + expires_at: "2026-10-01T00:00:00Z", + }, + secret: "operator-secret-once", + }), + ); + } + if (method === "DELETE" && path.includes("/auth/sessions/")) { + return Promise.resolve(json({ message: "logged out" })); + } + if (method === "DELETE" && path.includes("/auth/keys/")) { + return Promise.resolve(json({ id: "key-1", revoked_at: "2026-09-30T01:00:00Z" })); + } + return Promise.resolve(json({ message: `Unexpected request: ${path}` })); + }) as typeof fetch; + setCsrfToken("csrf"); + render( + + + + + , + ); + await screen.findByText("existing"); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "ci" } }); + fireEvent.click(screen.getByRole("button", { name: "Create key" })); + expect(await screen.findByText("operator-secret-once")).toBeTruthy(); + const revokeButtons = screen.getAllByRole("button", { name: "Revoke" }); + fireEvent.click(revokeButtons[0]); + fireEvent.click(revokeButtons[1]); + await waitFor(() => { + expect(requests).toContain("DELETE /api/v1/auth/sessions/session-1"); + expect(requests).toContain("DELETE /api/v1/auth/keys/key-1"); + }); + }); +}); diff --git a/ui/src/sections-context.tsx b/ui/src/sections-context.tsx index 672e9aa2..edeabe3e 100644 --- a/ui/src/sections-context.tsx +++ b/ui/src/sections-context.tsx @@ -3,13 +3,11 @@ // one place (mounted once by Layout) keeps the sidebar and every section page in // sync off a single 3s timer instead of N drifting ones. -import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { createContext, useContext, useMemo } from "react"; import type { ReactNode } from "react"; -import { OperatorApi } from "./api-client"; import type { SectionDto } from "./api-client"; -import { useHost } from "./host"; - -const POLL_INTERVAL_MS = 3000; +import { STATUS_POLL_MS, useApiQuery } from "./api"; +import { sectionsQuery } from "./api/definitions"; interface SectionsState { sections: SectionDto[] | null; @@ -19,38 +17,8 @@ interface SectionsState { const SectionsContext = createContext({ sections: null, error: null }); export function SectionsProvider({ children }: { children: ReactNode }) { - const host = useHost(); - const [api] = useState(() => new OperatorApi(host)); - const [sections, setSections] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - const refresh = () => { - api - .sections() - .then((s) => { - if (!cancelled) { - setSections(s); - setError(null); - } - return undefined; - }) - .catch((e) => { - if (!cancelled) { - setError(e.message); - } - }); - }; - refresh(); - const timer = setInterval(refresh, POLL_INTERVAL_MS); - return () => { - cancelled = true; - clearInterval(timer); - }; - }, [api]); - - const value = useMemo(() => ({ sections, error }), [sections, error]); + const { data, error } = useApiQuery(sectionsQuery(), { pollIntervalMs: STATUS_POLL_MS }); + const value = useMemo(() => ({ sections: data, error: error?.message ?? null }), [data, error]); return {children}; } diff --git a/ui/src/test-dom.ts b/ui/src/test-dom.ts new file mode 100644 index 00000000..7f712d02 --- /dev/null +++ b/ui/src/test-dom.ts @@ -0,0 +1,3 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +GlobalRegistrator.register(); diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 45100a96..e2ca5d7a 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", diff --git a/vscode-extension/src/mcp-connect.ts b/vscode-extension/src/mcp-connect.ts index 3f3aee07..a729552b 100644 --- a/vscode-extension/src/mcp-connect.ts +++ b/vscode-extension/src/mcp-connect.ts @@ -453,9 +453,7 @@ export async function registerInCursorUserConfig( "Please fix or remove the file and retry.", ); } else { - void vscode.window.showErrorMessage( - `Could not write ${configPath}: ${result.message}`, - ); + void vscode.window.showErrorMessage(`Could not write ${configPath}: ${result.message}`); } return; } diff --git a/vscode-extension/test/suite/mcp-connect.test.ts b/vscode-extension/test/suite/mcp-connect.test.ts index 55044cfb..72fca817 100644 --- a/vscode-extension/test/suite/mcp-connect.test.ts +++ b/vscode-extension/test/suite/mcp-connect.test.ts @@ -573,15 +573,12 @@ suite("MCP Connect Test Suite", () => { suite("connectMcpServer() dispatch", () => { let detectHostStub: sinon.SinonStub; - let discoverApiUrlStub: sinon.SinonStub; let configUpdateStub: sinon.SinonStub; let getStub: sinon.SinonStub; setup(() => { detectHostStub = sinon.stub(_testable, "rawAppName"); - discoverApiUrlStub = sinon - .stub(apiClient, "discoverApiUrl") - .resolves("http://localhost:7008"); + sinon.stub(apiClient, "discoverApiUrl").resolves("http://localhost:7008"); configUpdateStub = sinon.stub().resolves(); getStub = sinon.stub().returns({}); sinon.stub(vscode.workspace, "getConfiguration").returns({ diff --git a/webcomponents/src/views/DashboardView.module.css b/webcomponents/src/views/DashboardView.module.css index 0d23c0c0..000b87da 100644 --- a/webcomponents/src/views/DashboardView.module.css +++ b/webcomponents/src/views/DashboardView.module.css @@ -22,6 +22,11 @@ background: var(--danger-bg); border-radius: var(--radius-lg); } +.loading { + margin-bottom: 1rem; + color: var(--text-muted); + font-size: 0.875rem; +} .statusBanner { padding: 0.25rem 0.75rem; color: var(--success); diff --git a/webcomponents/src/views/DashboardView.tsx b/webcomponents/src/views/DashboardView.tsx index bb387f2c..15ba78ad 100644 --- a/webcomponents/src/views/DashboardView.tsx +++ b/webcomponents/src/views/DashboardView.tsx @@ -12,6 +12,7 @@ export interface DashboardViewProps { health: HealthResponse | null; queue: QueueStatusResponse | null; board: KanbanBoardResponse | null; + loading?: boolean; error?: string | null; updatedLabel?: string; statusLink: ReactNode; @@ -23,6 +24,7 @@ export function DashboardView({ health, queue, board, + loading, error, updatedLabel, statusLink, @@ -46,6 +48,7 @@ export function DashboardView({ {statusLink}
    {error &&
    API: {error}
    } + {loading &&
    Loading dashboard…
    }
    {metrics.map(([label, value]) => (
    From 7cac50a5a401d75db9cc1b33948584d0f2385c4d Mon Sep 17 00:00:00 2001 From: untra Date: Wed, 30 Sep 2026 17:11:45 -0600 Subject: [PATCH 13/18] no more githooks plz --- AGENTS.md | 3 +- ui/src/components/ticket-panels.test.tsx | 40 +---- ui/src/routes/auth-pages.test.tsx | 51 +------ ui/src/routes/model-providers-page.test.tsx | 134 +++++++++++++++++ ui/src/routes/onboarding/steps.test.tsx | 131 +++++++++++++++++ ui/src/routes/security-page.test.tsx | 4 +- ui/src/test-webcomponents.tsx | 137 ++++++++++++++++++ vscode-extension/src/mcp-connect.ts | 4 +- .../test/suite/mcp-connect.test.ts | 5 +- 9 files changed, 415 insertions(+), 94 deletions(-) create mode 100644 ui/src/routes/model-providers-page.test.tsx create mode 100644 ui/src/routes/onboarding/steps.test.tsx create mode 100644 ui/src/test-webcomponents.tsx diff --git a/AGENTS.md b/AGENTS.md index fa2b72ef..64358105 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,9 +79,8 @@ excluded by `.oxfmtrc.json` / `.oxlintrc.jsonc` and must never be reformatted. 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. +the expectation before opening a PR: -./scripts/cicdprep.sh 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). diff --git a/ui/src/components/ticket-panels.test.tsx b/ui/src/components/ticket-panels.test.tsx index 5a538376..d6d7a41d 100644 --- a/ui/src/components/ticket-panels.test.tsx +++ b/ui/src/components/ticket-panels.test.tsx @@ -7,45 +7,9 @@ import { resetSessionState } from "../api/adapter"; import { setCsrfToken } from "../api-client"; import { HostContext, type Host } from "../host"; import { RightPanelProvider } from "../right-panel"; +import * as webcomponentMocks from "../test-webcomponents"; -mock.module("@operator/webcomponents", () => ({ - TicketCreateForm: ({ - onChange, - onSubmit, - }: { - onChange: (value: { issueType: string; project: string; summary: string }) => void; - onSubmit: () => void; - }) => ( - <> - - - - ), - TicketDetailView: ({ - launchControls, - launchActions, - }: { - launchControls?: ReactNode; - launchActions?: ReactNode; - }) => ( -
    - {launchControls} - {launchActions} -
    - ), - LaunchForm: ({ onSubmit }: { onSubmit: () => void }) => ( - - ), -})); +mock.module("@operator/webcomponents", () => webcomponentMocks); const { TicketCreatePanel } = await import("./TicketCreatePanel"); const { TicketDetailPanel } = await import("./TicketDetailPanel"); diff --git a/ui/src/routes/auth-pages.test.tsx b/ui/src/routes/auth-pages.test.tsx index 5163ddaf..2ac3d398 100644 --- a/ui/src/routes/auth-pages.test.tsx +++ b/ui/src/routes/auth-pages.test.tsx @@ -1,60 +1,13 @@ import { afterEach, describe, expect, jest, mock, test } from "bun:test"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type { ChangeEvent, FormEvent, ReactNode } from "react"; import { MemoryRouter, Route, Routes } 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 * as webcomponentMocks from "../test-webcomponents"; -mock.module("@operator/webcomponents", () => ({ - AuthCard: ({ - title, - error, - children, - actions, - onSubmit, - }: { - title: string; - error?: string | null; - children: ReactNode; - actions?: ReactNode; - onSubmit?: (event: FormEvent) => void; - }) => ( -
    -

    {title}

    - {error &&

    {error}

    } - {children} - {actions} -
    - ), - AuthField: ({ - label, - value, - onChange, - type, - }: { - label: string; - value: string; - onChange: (value: string) => void; - type?: string; - }) => ( - - ), - AuthSubmit: ({ children, disabled }: { children: ReactNode; disabled?: boolean }) => ( - - ), -})); +mock.module("@operator/webcomponents", () => webcomponentMocks); const { LoginPage } = await import("./LoginPage"); const { SetupPage } = await import("./SetupPage"); diff --git a/ui/src/routes/model-providers-page.test.tsx b/ui/src/routes/model-providers-page.test.tsx new file mode 100644 index 00000000..9bde90a8 --- /dev/null +++ b/ui/src/routes/model-providers-page.test.tsx @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, jest, mock, test } from "bun:test"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ApiProvider } from "../api"; +import { resetSessionState } from "../api/adapter"; +import { setCsrfToken } from "../api-client"; +import { HostContext, type Host } from "../host"; +import * as webcomponentMocks from "../test-webcomponents"; + +mock.module("@operator/webcomponents", () => webcomponentMocks); + +const { ModelProvidersPage } = await import("./ModelProvidersPage"); + +const TEST_HOST: Host = { + baseUrl: () => "http://operator.test", + openExternal: () => undefined, + browseFolder: () => Promise.resolve(null), + openFile: () => undefined, +}; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +afterEach(() => { + cleanup(); + resetSessionState(); + setCsrfToken(null); + jest.restoreAllMocks(); +}); + +describe("ModelProvidersPage", () => { + test("creates and updates delegators", async () => { + const requests: Array<{ path: string; method: string }> = []; + const delegator = { + name: "claude-gpt", + llm_tool: "claude", + model: "gpt-test", + display_name: null, + model_properties: {}, + model_server: "openai", + launch_config: null, + remote_agent: null, + git: null, + }; + globalThis.fetch = jest.fn((input, init) => { + const path = new URL(String(input)).pathname; + const method = init?.method ?? "GET"; + requests.push({ path, method }); + if (path.endsWith("/model-servers/kinds")) { + return Promise.resolve( + json([ + { + slug: "openai", + display_name: "OpenAI", + description: "OpenAI models", + setup_url: "https://example.test", + icon: "sparkle", + is_builtin: true, + category: "first-party", + category_label: "First-party", + brand_icon: null, + default_base_url: "https://api.example.test", + default_api_key_env: "OPENAI_API_KEY", + connectable: true, + }, + ]), + ); + } + if (path.endsWith("/llm-tools")) { + return Promise.resolve( + json({ + tools: [ + { + name: "claude", + version: "1", + min_version: null, + version_ok: true, + model_aliases: [], + capabilities: {}, + health_ok: true, + }, + ], + total: 1, + }), + ); + } + if (path.endsWith("/model-servers/kinds/openai/models")) { + return Promise.resolve( + json({ + server: "openai", + reachable: true, + models: [{ id: "gpt-test", display_name: "GPT Test" }], + error: null, + }), + ); + } + if (path.endsWith("/delegators") && method === "GET") { + return Promise.resolve(json({ delegators: [delegator], total: 1 })); + } + if (path.endsWith("/delegators") && method === "POST") { + return Promise.resolve(json(delegator)); + } + if (path.endsWith("/delegators/claude-gpt") && method === "PUT") { + return Promise.resolve(json(delegator)); + } + return Promise.resolve(json({ message: `Unexpected request: ${path}` })); + }) as typeof fetch; + setCsrfToken("csrf"); + render( + + + + + , + ); + await screen.findByRole("heading", { name: "Model Providers" }); + fireEvent.change(screen.getByLabelText("Provider"), { target: { value: "openai" } }); + await waitFor(() => expect(screen.getByLabelText("Model")).toBeTruthy()); + fireEvent.change(screen.getByLabelText("Model"), { target: { value: "gpt-test" } }); + fireEvent.click(screen.getByRole("button", { name: "Create delegator" })); + await screen.findByText('Created delegator "claude-gpt-test".'); + fireEvent.click(screen.getByRole("button", { name: "Save Git settings" })); + await waitFor(() => { + expect(requests).toContainEqual({ path: "/api/v1/delegators", method: "POST" }); + expect(requests).toContainEqual({ + path: "/api/v1/delegators/claude-gpt", + method: "PUT", + }); + }); + }); +}); diff --git a/ui/src/routes/onboarding/steps.test.tsx b/ui/src/routes/onboarding/steps.test.tsx new file mode 100644 index 00000000..87b4aa3d --- /dev/null +++ b/ui/src/routes/onboarding/steps.test.tsx @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, jest, mock, test } from "bun:test"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ApiProvider } from "../../api"; +import { resetSessionState } from "../../api/adapter"; +import { setCsrfToken } from "../../api-client"; +import { HostContext, type Host } from "../../host"; +import * as webcomponentMocks from "../../test-webcomponents"; +import type { WizardDraft } from "./types"; + +mock.module("@operator/webcomponents", () => webcomponentMocks); + +const { STEP_COMPONENTS } = await import("./steps"); + +const TEST_HOST: Host = { + baseUrl: () => "http://operator.test", + openExternal: () => undefined, + browseFolder: () => Promise.resolve(null), + openFile: () => undefined, +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +afterEach(() => { + cleanup(); + resetSessionState(); + setCsrfToken(null); + jest.restoreAllMocks(); +}); + +describe("onboarding integration steps", () => { + test("keeps chained mutations separate when the final write fails", async () => { + const requests: string[] = []; + globalThis.fetch = jest.fn((input, init) => { + const path = new URL(String(input)).pathname; + const method = init?.method ?? "GET"; + requests.push(`${method} ${path}`); + if (path.endsWith("/kanban/providers")) { + return Promise.resolve( + json([ + { + slug: "github", + display_name: "GitHub Projects", + description: "GitHub board", + setup_url: "https://example.test", + icon: "github", + configured: false, + }, + ]), + ); + } + if (path.endsWith("/kanban/validate")) { + return Promise.resolve( + json({ valid: true, github: { user_id: "42", user_login: "octocat" } }), + ); + } + if (path.endsWith("/kanban/projects")) { + return Promise.resolve( + json({ projects: [{ id: "PVT_1", key: "project-1", name: "octocat/#1" }] }), + ); + } + if (path.endsWith("/kanban/statuses")) { + return Promise.resolve(json({ statuses: ["Todo", "Doing", "Done"] })); + } + if (path.endsWith("/kanban/session-env")) { + return Promise.resolve(json({ shell_export_block: "export OPERATOR_GITHUB_TOKEN=..." })); + } + if (path.endsWith("/kanban/config")) { + return Promise.resolve(json({ message: "configuration write failed" }, 500)); + } + return Promise.resolve(json({ message: `Unexpected request: ${path}` }, 500)); + }) as typeof fetch; + setCsrfToken("csrf"); + const exports: string[] = []; + const draft: WizardDraft = { + configurationName: "default", + executionMode: "local", + premium: false, + preset: "simple", + taskFields: [], + wrapper: "tmux", + executionTarget: { kind: "local" }, + coderParameters: [], + useWorktrees: false, + acceptanceCriteria: "", + modelServers: [], + hostedCollectionIds: [], + }; + const KanbanInfo = STEP_COMPONENTS["kanban-info"]; + render( + + + undefined} + exports={exports} + addExport={(value) => exports.push(value)} + /> + + , + ); + fireEvent.click(await screen.findByRole("button", { name: /GitHub Projects/ })); + fireEvent.change(screen.getByLabelText("API token"), { target: { value: "github-token" } }); + fireEvent.click(screen.getByRole("button", { name: "Validate and connect" })); + await screen.findByText("Credentials validated. Choose a project."); + fireEvent.change(screen.getByLabelText("Project"), { target: { value: "project-1" } }); + await screen.findByLabelText("todo"); + fireEvent.click(screen.getByRole("button", { name: "Save provider" })); + expect(await screen.findByText("configuration write failed")).toBeTruthy(); + expect(exports).toEqual([]); + await waitFor(() => { + expect(requests).toContain("POST /api/v1/kanban/session-env"); + expect(requests).toContain("PUT /api/v1/kanban/config"); + }); + }); +}); diff --git a/ui/src/routes/security-page.test.tsx b/ui/src/routes/security-page.test.tsx index 9c182b51..58e8ef11 100644 --- a/ui/src/routes/security-page.test.tsx +++ b/ui/src/routes/security-page.test.tsx @@ -1,12 +1,10 @@ -import { afterEach, describe, expect, jest, mock, test } from "bun:test"; +import { afterEach, describe, expect, jest, test } from "bun:test"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { ApiProvider } from "../api"; import { resetSessionState } from "../api/adapter"; import { setCsrfToken } from "../api-client"; import { HostContext, type Host } from "../host"; -mock.module("@operator/webcomponents", () => ({})); - const { SecurityPage } = await import("./SecurityPage"); const TEST_HOST: Host = { diff --git a/ui/src/test-webcomponents.tsx b/ui/src/test-webcomponents.tsx new file mode 100644 index 00000000..5bd3f2c0 --- /dev/null +++ b/ui/src/test-webcomponents.tsx @@ -0,0 +1,137 @@ +import type { ChangeEvent, FormEvent, ReactNode } from "react"; + +export function AuthCard({ + title, + error, + children, + actions, + onSubmit, +}: { + title: string; + error?: string | null; + children: ReactNode; + actions?: ReactNode; + onSubmit?: (event: FormEvent) => void; +}) { + return ( +
    +

    {title}

    + {error &&

    {error}

    } + {children} + {actions} +
    + ); +} + +export function AuthField({ + label, + value, + onChange, + type, +}: { + label: string; + value: string; + onChange: (value: string) => void; + type?: string; +}) { + return ( + + ); +} + +export function AuthSubmit({ children, disabled }: { children: ReactNode; disabled?: boolean }) { + return ( + + ); +} + +export function TicketCreateForm({ + onChange, + onSubmit, +}: { + onChange: (value: { issueType: string; project: string; summary: string }) => void; + onSubmit: () => void; +}) { + return ( + <> + + + + ); +} + +export function TicketDetailView({ + launchControls, + launchActions, +}: { + launchControls?: ReactNode; + launchActions?: ReactNode; +}) { + return ( +
    + {launchControls} + {launchActions} +
    + ); +} + +export function LaunchForm({ onSubmit }: { onSubmit: () => void }) { + return ( + + ); +} + +export function PageHeader({ title }: { title: string }) { + return

    {title}

    ; +} + +export function BrandIcon() { + return ; +} + +export function ConceptIcon() { + return ; +} + +export function ChoiceGroup({ children }: { children: ReactNode }) { + return
    {children}
    ; +} + +export function Choice({ + children, + value, + onSelect, +}: { + children: ReactNode; + value: string; + onSelect: (value: string) => void; +}) { + return ( + + ); +} + +export function PremiumPaywall() { + return
    Premium required
    ; +} diff --git a/vscode-extension/src/mcp-connect.ts b/vscode-extension/src/mcp-connect.ts index a729552b..3f3aee07 100644 --- a/vscode-extension/src/mcp-connect.ts +++ b/vscode-extension/src/mcp-connect.ts @@ -453,7 +453,9 @@ export async function registerInCursorUserConfig( "Please fix or remove the file and retry.", ); } else { - void vscode.window.showErrorMessage(`Could not write ${configPath}: ${result.message}`); + void vscode.window.showErrorMessage( + `Could not write ${configPath}: ${result.message}`, + ); } return; } diff --git a/vscode-extension/test/suite/mcp-connect.test.ts b/vscode-extension/test/suite/mcp-connect.test.ts index 72fca817..55044cfb 100644 --- a/vscode-extension/test/suite/mcp-connect.test.ts +++ b/vscode-extension/test/suite/mcp-connect.test.ts @@ -573,12 +573,15 @@ suite("MCP Connect Test Suite", () => { suite("connectMcpServer() dispatch", () => { let detectHostStub: sinon.SinonStub; + let discoverApiUrlStub: sinon.SinonStub; let configUpdateStub: sinon.SinonStub; let getStub: sinon.SinonStub; setup(() => { detectHostStub = sinon.stub(_testable, "rawAppName"); - sinon.stub(apiClient, "discoverApiUrl").resolves("http://localhost:7008"); + discoverApiUrlStub = sinon + .stub(apiClient, "discoverApiUrl") + .resolves("http://localhost:7008"); configUpdateStub = sinon.stub().resolves(); getStub = sinon.stub().returns({}); sinon.stub(vscode.workspace, "getConfiguration").returns({ From 924d29cccfca7ee5b72a2113ada530fc851910ec Mon Sep 17 00:00:00 2001 From: untra Date: Wed, 30 Sep 2026 17:13:28 -0600 Subject: [PATCH 14/18] plz no more githooks --- AGENTS.md | 5 ----- ui/src/api/architecture.test.ts | 3 +-- ui/src/api/hooks.test.tsx | 19 ++++++++++--------- ui/src/components/ticket-panels.test.tsx | 10 ++++++---- ui/src/routes/auth-pages.test.tsx | 18 ++++++++++-------- ui/src/routes/model-providers-page.test.tsx | 6 ++++-- ui/src/routes/onboarding/steps.test.tsx | 6 ++++-- ui/src/routes/security-page.test.tsx | 6 ++++-- ui/src/test-fetch.ts | 15 +++++++++++++++ vscode-extension/src/mcp-connect.ts | 4 +--- .../test/suite/mcp-connect.test.ts | 5 +---- 11 files changed, 56 insertions(+), 41 deletions(-) create mode 100644 ui/src/test-fetch.ts diff --git a/AGENTS.md b/AGENTS.md index 64358105..f0765677 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,11 +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: - - 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 diff --git a/ui/src/api/architecture.test.ts b/ui/src/api/architecture.test.ts index bd8ac876..470e0e35 100644 --- a/ui/src/api/architecture.test.ts +++ b/ui/src/api/architecture.test.ts @@ -60,8 +60,7 @@ describe("API architecture", () => { 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 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)); diff --git a/ui/src/api/hooks.test.tsx b/ui/src/api/hooks.test.tsx index aa2e4437..e746d046 100644 --- a/ui/src/api/hooks.test.tsx +++ b/ui/src/api/hooks.test.tsx @@ -38,10 +38,10 @@ describe("useApiQuery", () => { params: {}, fetch: () => Promise.resolve(`value-${++calls}`), }; - const { result, rerender } = renderHook( - ({ enabled }) => useApiQuery(definition, { enabled }), - { initialProps: { enabled: false }, wrapper: Providers }, - ); + 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 }); @@ -114,10 +114,9 @@ describe("useApiQuery", () => { params: {}, fetch: () => Promise.resolve(++calls), }; - const { unmount } = renderHook( - () => useApiQuery(definition, { pollIntervalMs: 10 }), - { wrapper: Providers }, - ); + const { unmount } = renderHook(() => useApiQuery(definition, { pollIntervalMs: 10 }), { + wrapper: Providers, + }); await act(async () => undefined); expect(calls).toBe(1); unmount(); @@ -140,7 +139,9 @@ describe("useApiMutation", () => { }; const success: string[] = []; const { result } = renderHook(() => useApiMutation(definition), { wrapper: Providers }); - act(() => result.current.mutate({ value: "done" }, { onSuccess: (data) => success.push(data) })); + act(() => + result.current.mutate({ value: "done" }, { onSuccess: (data) => success.push(data) }), + ); expect(result.current.isPending).toBe(true); await act(async () => release("done")); expect(result.current.isPending).toBe(false); diff --git a/ui/src/components/ticket-panels.test.tsx b/ui/src/components/ticket-panels.test.tsx index d6d7a41d..e90c06bc 100644 --- a/ui/src/components/ticket-panels.test.tsx +++ b/ui/src/components/ticket-panels.test.tsx @@ -7,6 +7,7 @@ import { resetSessionState } from "../api/adapter"; import { setCsrfToken } from "../api-client"; import { HostContext, type Host } from "../host"; import { RightPanelProvider } from "../right-panel"; +import { mockFetch, restoreFetch } from "../test-fetch"; import * as webcomponentMocks from "../test-webcomponents"; mock.module("@operator/webcomponents", () => webcomponentMocks); @@ -44,13 +45,14 @@ afterEach(() => { cleanup(); resetSessionState(); setCsrfToken(null); + 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 }> = []; - globalThis.fetch = jest.fn((input, init) => { + mockFetch((input, init) => { const path = new URL(String(input)).pathname; requests.push({ path, method: init?.method ?? "GET", body: init?.body?.toString() }); if (path.endsWith("/issuetypes")) { @@ -63,7 +65,7 @@ describe("ticket panels", () => { return Promise.resolve(json({ id: "TASK-1", filename: "TASK-1.md", path: "/ticket" })); } return Promise.resolve(json({ message: `Unexpected request: ${path}` }, 500)); - }) as typeof fetch; + }); setCsrfToken("csrf"); const created: string[] = []; renderPanel( created.push("created")} />); @@ -83,7 +85,7 @@ describe("ticket panels", () => { test("launches a ticket and focuses its cmux session", async () => { const requests: string[] = []; - globalThis.fetch = jest.fn((input, init) => { + mockFetch((input, init) => { const path = new URL(String(input)).pathname; requests.push(`${init?.method ?? "GET"} ${path}`); if (path.endsWith("/configuration")) { @@ -121,7 +123,7 @@ describe("ticket panels", () => { return Promise.resolve(new Response(null, { status: 204 })); } return Promise.resolve(json({ message: `Unexpected request: ${path}` }, 500)); - }) as typeof fetch; + }); setCsrfToken("csrf"); renderPanel( webcomponentMocks); @@ -47,26 +48,27 @@ afterEach(() => { cleanup(); resetSessionState(); setCsrfToken(null); + restoreFetch(); jest.restoreAllMocks(); }); describe("authentication routes", () => { test("login redirects an uninitialized server to setup", async () => { - globalThis.fetch = jest.fn(() => + mockFetch(() => Promise.resolve(json({ state: "uninitialized", requires_temporary_password: false })), - ) as typeof fetch; + ); renderAuth("/login"); expect(await screen.findByRole("heading", { name: "Set up Operator" })).toBeTruthy(); }); test("login maps rate limiting to an actionable message", async () => { - globalThis.fetch = jest.fn((input, init) => { + mockFetch((input, init) => { const path = new URL(String(input)).pathname; if (path.endsWith("/bootstrap") && init?.method !== "POST") { return Promise.resolve(json({ state: "complete", requires_temporary_password: false })); } return Promise.resolve(json({ message: "slow down" }, 429)); - }) as typeof fetch; + }); renderAuth("/login"); await screen.findByRole("heading", { name: "Sign in to Operator" }); fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } }); @@ -76,7 +78,7 @@ describe("authentication routes", () => { }); test("successful login routes an unfinished workspace to onboarding", async () => { - globalThis.fetch = jest.fn((input, init) => { + mockFetch((input, init) => { const path = new URL(String(input)).pathname; if (path.endsWith("/bootstrap")) { return Promise.resolve(json({ state: "complete", requires_temporary_password: false })); @@ -99,7 +101,7 @@ describe("authentication routes", () => { ); } return Promise.resolve(json({ message: `Unexpected request: ${path}` }, 500)); - }) as typeof fetch; + }); renderAuth("/login"); await screen.findByRole("heading", { name: "Sign in to Operator" }); fireEvent.change(screen.getByLabelText("Username"), { target: { value: "operator" } }); @@ -110,7 +112,7 @@ describe("authentication routes", () => { test("setup redirects complete servers and maps a rejected temporary password", async () => { let complete = true; - globalThis.fetch = jest.fn((input, init) => { + mockFetch((input, init) => { const path = new URL(String(input)).pathname; if (path.endsWith("/bootstrap") && init?.method !== "POST") { return Promise.resolve( @@ -121,7 +123,7 @@ describe("authentication routes", () => { ); } return Promise.resolve(json({ message: "invalid bootstrap password" }, 401)); - }) as typeof fetch; + }); const first = renderAuth("/setup"); expect(await screen.findByRole("heading", { name: "Sign in to Operator" })).toBeTruthy(); first.unmount(); diff --git a/ui/src/routes/model-providers-page.test.tsx b/ui/src/routes/model-providers-page.test.tsx index 9bde90a8..f27011d0 100644 --- a/ui/src/routes/model-providers-page.test.tsx +++ b/ui/src/routes/model-providers-page.test.tsx @@ -4,6 +4,7 @@ import { ApiProvider } from "../api"; import { resetSessionState } from "../api/adapter"; import { setCsrfToken } from "../api-client"; import { HostContext, type Host } from "../host"; +import { mockFetch, restoreFetch } from "../test-fetch"; import * as webcomponentMocks from "../test-webcomponents"; mock.module("@operator/webcomponents", () => webcomponentMocks); @@ -28,6 +29,7 @@ afterEach(() => { cleanup(); resetSessionState(); setCsrfToken(null); + restoreFetch(); jest.restoreAllMocks(); }); @@ -45,7 +47,7 @@ describe("ModelProvidersPage", () => { remote_agent: null, git: null, }; - globalThis.fetch = jest.fn((input, init) => { + mockFetch((input, init) => { const path = new URL(String(input)).pathname; const method = init?.method ?? "GET"; requests.push({ path, method }); @@ -107,7 +109,7 @@ describe("ModelProvidersPage", () => { return Promise.resolve(json(delegator)); } return Promise.resolve(json({ message: `Unexpected request: ${path}` })); - }) as typeof fetch; + }); setCsrfToken("csrf"); render( diff --git a/ui/src/routes/onboarding/steps.test.tsx b/ui/src/routes/onboarding/steps.test.tsx index 87b4aa3d..a0515edf 100644 --- a/ui/src/routes/onboarding/steps.test.tsx +++ b/ui/src/routes/onboarding/steps.test.tsx @@ -4,6 +4,7 @@ import { ApiProvider } from "../../api"; import { resetSessionState } from "../../api/adapter"; import { setCsrfToken } from "../../api-client"; import { HostContext, type Host } from "../../host"; +import { mockFetch, restoreFetch } from "../../test-fetch"; import * as webcomponentMocks from "../../test-webcomponents"; import type { WizardDraft } from "./types"; @@ -29,13 +30,14 @@ afterEach(() => { cleanup(); resetSessionState(); setCsrfToken(null); + restoreFetch(); jest.restoreAllMocks(); }); describe("onboarding integration steps", () => { test("keeps chained mutations separate when the final write fails", async () => { const requests: string[] = []; - globalThis.fetch = jest.fn((input, init) => { + mockFetch((input, init) => { const path = new URL(String(input)).pathname; const method = init?.method ?? "GET"; requests.push(`${method} ${path}`); @@ -73,7 +75,7 @@ describe("onboarding integration steps", () => { return Promise.resolve(json({ message: "configuration write failed" }, 500)); } return Promise.resolve(json({ message: `Unexpected request: ${path}` }, 500)); - }) as typeof fetch; + }); setCsrfToken("csrf"); const exports: string[] = []; const draft: WizardDraft = { diff --git a/ui/src/routes/security-page.test.tsx b/ui/src/routes/security-page.test.tsx index 58e8ef11..9278cffa 100644 --- a/ui/src/routes/security-page.test.tsx +++ b/ui/src/routes/security-page.test.tsx @@ -4,6 +4,7 @@ import { ApiProvider } from "../api"; import { resetSessionState } from "../api/adapter"; import { setCsrfToken } from "../api-client"; import { HostContext, type Host } from "../host"; +import { mockFetch, restoreFetch } from "../test-fetch"; const { SecurityPage } = await import("./SecurityPage"); @@ -25,13 +26,14 @@ afterEach(() => { cleanup(); resetSessionState(); setCsrfToken(null); + restoreFetch(); jest.restoreAllMocks(); }); describe("SecurityPage", () => { test("creates and revokes credentials through mutation lifecycles", async () => { const requests: string[] = []; - globalThis.fetch = jest.fn((input, init) => { + mockFetch((input, init) => { const path = new URL(String(input)).pathname; const method = init?.method ?? "GET"; requests.push(`${method} ${path}`); @@ -86,7 +88,7 @@ describe("SecurityPage", () => { return Promise.resolve(json({ id: "key-1", revoked_at: "2026-09-30T01:00:00Z" })); } return Promise.resolve(json({ message: `Unexpected request: ${path}` })); - }) as typeof fetch; + }); setCsrfToken("csrf"); render( diff --git a/ui/src/test-fetch.ts b/ui/src/test-fetch.ts new file mode 100644 index 00000000..3384e89f --- /dev/null +++ b/ui/src/test-fetch.ts @@ -0,0 +1,15 @@ +import { jest } from "bun:test"; + +const originalFetch = globalThis.fetch; + +export function mockFetch( + implementation: (input: RequestInfo | URL, init?: RequestInit) => Promise, +) { + const mocked = jest.fn(implementation); + globalThis.fetch = Object.assign(mocked, { preconnect: originalFetch.preconnect }); + return mocked; +} + +export function restoreFetch(): void { + globalThis.fetch = originalFetch; +} diff --git a/vscode-extension/src/mcp-connect.ts b/vscode-extension/src/mcp-connect.ts index 3f3aee07..a729552b 100644 --- a/vscode-extension/src/mcp-connect.ts +++ b/vscode-extension/src/mcp-connect.ts @@ -453,9 +453,7 @@ export async function registerInCursorUserConfig( "Please fix or remove the file and retry.", ); } else { - void vscode.window.showErrorMessage( - `Could not write ${configPath}: ${result.message}`, - ); + void vscode.window.showErrorMessage(`Could not write ${configPath}: ${result.message}`); } return; } diff --git a/vscode-extension/test/suite/mcp-connect.test.ts b/vscode-extension/test/suite/mcp-connect.test.ts index 55044cfb..72fca817 100644 --- a/vscode-extension/test/suite/mcp-connect.test.ts +++ b/vscode-extension/test/suite/mcp-connect.test.ts @@ -573,15 +573,12 @@ suite("MCP Connect Test Suite", () => { suite("connectMcpServer() dispatch", () => { let detectHostStub: sinon.SinonStub; - let discoverApiUrlStub: sinon.SinonStub; let configUpdateStub: sinon.SinonStub; let getStub: sinon.SinonStub; setup(() => { detectHostStub = sinon.stub(_testable, "rawAppName"); - discoverApiUrlStub = sinon - .stub(apiClient, "discoverApiUrl") - .resolves("http://localhost:7008"); + sinon.stub(apiClient, "discoverApiUrl").resolves("http://localhost:7008"); configUpdateStub = sinon.stub().resolves(); getStub = sinon.stub().returns({}); sinon.stub(vscode.workspace, "getConfiguration").returns({ From 04d9c458e579864b308aa4ba597c13c9d355e275 Mon Sep 17 00:00:00 2001 From: untra Date: Wed, 30 Sep 2026 17:20:36 -0600 Subject: [PATCH 15/18] tests for operator mockmodule --- ui/src/api/hooks.test.tsx | 10 +++-- ui/src/components/ticket-panels.test.tsx | 19 +++++---- ui/src/routes/auth-pages.test.tsx | 8 ++-- ui/src/routes/model-providers-page.test.tsx | 4 +- ui/src/routes/onboarding/steps.test.tsx | 43 +++++++++++++-------- ui/src/routes/security-page.test.tsx | 4 +- ui/src/test-fetch.ts | 9 +++++ ui/src/test-webcomponents.tsx | 4 +- 8 files changed, 63 insertions(+), 38 deletions(-) diff --git a/ui/src/api/hooks.test.tsx b/ui/src/api/hooks.test.tsx index e746d046..b7fbfde8 100644 --- a/ui/src/api/hooks.test.tsx +++ b/ui/src/api/hooks.test.tsx @@ -117,11 +117,13 @@ describe("useApiQuery", () => { const { unmount } = renderHook(() => useApiQuery(definition, { pollIntervalMs: 10 }), { wrapper: Providers, }); - await act(async () => undefined); + await act(() => Promise.resolve()); expect(calls).toBe(1); unmount(); - act(() => jest.advanceTimersByTime(50)); - await act(async () => undefined); + act(() => { + jest.advanceTimersByTime(50); + }); + await act(() => Promise.resolve()); expect(calls).toBe(1); }); }); @@ -143,7 +145,7 @@ describe("useApiMutation", () => { result.current.mutate({ value: "done" }, { onSuccess: (data) => success.push(data) }), ); expect(result.current.isPending).toBe(true); - await act(async () => release("done")); + await act(() => Promise.resolve(release("done"))); expect(result.current.isPending).toBe(false); expect(success).toEqual(["done"]); }); diff --git a/ui/src/components/ticket-panels.test.tsx b/ui/src/components/ticket-panels.test.tsx index e90c06bc..aeeea13c 100644 --- a/ui/src/components/ticket-panels.test.tsx +++ b/ui/src/components/ticket-panels.test.tsx @@ -7,7 +7,7 @@ import { resetSessionState } from "../api/adapter"; import { setCsrfToken } from "../api-client"; import { HostContext, type Host } from "../host"; import { RightPanelProvider } from "../right-panel"; -import { mockFetch, restoreFetch } from "../test-fetch"; +import { mockFetch, requestBody, requestPath, restoreFetch } from "../test-fetch"; import * as webcomponentMocks from "../test-webcomponents"; mock.module("@operator/webcomponents", () => webcomponentMocks); @@ -21,6 +21,11 @@ const TEST_HOST: Host = { 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), { @@ -45,6 +50,7 @@ afterEach(() => { cleanup(); resetSessionState(); setCsrfToken(null); + createdTickets = []; restoreFetch(); jest.restoreAllMocks(); }); @@ -53,8 +59,8 @@ 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 = new URL(String(input)).pathname; - requests.push({ path, method: init?.method ?? "GET", body: init?.body?.toString() }); + 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" }])); } @@ -67,12 +73,11 @@ describe("ticket panels", () => { return Promise.resolve(json({ message: `Unexpected request: ${path}` }, 500)); }); setCsrfToken("csrf"); - const created: string[] = []; - renderPanel( created.push("created")} />); + 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(created).toEqual(["created"])); + 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({ @@ -86,7 +91,7 @@ describe("ticket panels", () => { test("launches a ticket and focuses its cmux session", async () => { const requests: string[] = []; mockFetch((input, init) => { - const path = new URL(String(input)).pathname; + const path = requestPath(input); requests.push(`${init?.method ?? "GET"} ${path}`); if (path.endsWith("/configuration")) { return Promise.resolve(json({ launch: { session_wrapper: "cmux" } })); diff --git a/ui/src/routes/auth-pages.test.tsx b/ui/src/routes/auth-pages.test.tsx index 3496ac7d..2d76c524 100644 --- a/ui/src/routes/auth-pages.test.tsx +++ b/ui/src/routes/auth-pages.test.tsx @@ -5,7 +5,7 @@ import { ApiProvider } from "../api"; import { resetSessionState } from "../api/adapter"; import { setCsrfToken } from "../api-client"; import { HostContext, type Host } from "../host"; -import { mockFetch, restoreFetch } from "../test-fetch"; +import { mockFetch, requestPath, restoreFetch } from "../test-fetch"; import * as webcomponentMocks from "../test-webcomponents"; mock.module("@operator/webcomponents", () => webcomponentMocks); @@ -63,7 +63,7 @@ describe("authentication routes", () => { test("login maps rate limiting to an actionable message", async () => { mockFetch((input, init) => { - const path = new URL(String(input)).pathname; + const path = requestPath(input); if (path.endsWith("/bootstrap") && init?.method !== "POST") { return Promise.resolve(json({ state: "complete", requires_temporary_password: false })); } @@ -79,7 +79,7 @@ describe("authentication routes", () => { test("successful login routes an unfinished workspace to onboarding", async () => { mockFetch((input, init) => { - const path = new URL(String(input)).pathname; + const path = requestPath(input); if (path.endsWith("/bootstrap")) { return Promise.resolve(json({ state: "complete", requires_temporary_password: false })); } @@ -113,7 +113,7 @@ describe("authentication routes", () => { test("setup redirects complete servers and maps a rejected temporary password", async () => { let complete = true; mockFetch((input, init) => { - const path = new URL(String(input)).pathname; + const path = requestPath(input); if (path.endsWith("/bootstrap") && init?.method !== "POST") { return Promise.resolve( json({ diff --git a/ui/src/routes/model-providers-page.test.tsx b/ui/src/routes/model-providers-page.test.tsx index f27011d0..3f8b542a 100644 --- a/ui/src/routes/model-providers-page.test.tsx +++ b/ui/src/routes/model-providers-page.test.tsx @@ -4,7 +4,7 @@ import { ApiProvider } from "../api"; import { resetSessionState } from "../api/adapter"; import { setCsrfToken } from "../api-client"; import { HostContext, type Host } from "../host"; -import { mockFetch, restoreFetch } from "../test-fetch"; +import { mockFetch, requestPath, restoreFetch } from "../test-fetch"; import * as webcomponentMocks from "../test-webcomponents"; mock.module("@operator/webcomponents", () => webcomponentMocks); @@ -48,7 +48,7 @@ describe("ModelProvidersPage", () => { git: null, }; mockFetch((input, init) => { - const path = new URL(String(input)).pathname; + const path = requestPath(input); const method = init?.method ?? "GET"; requests.push({ path, method }); if (path.endsWith("/model-servers/kinds")) { diff --git a/ui/src/routes/onboarding/steps.test.tsx b/ui/src/routes/onboarding/steps.test.tsx index a0515edf..ac38632d 100644 --- a/ui/src/routes/onboarding/steps.test.tsx +++ b/ui/src/routes/onboarding/steps.test.tsx @@ -4,7 +4,7 @@ import { ApiProvider } from "../../api"; import { resetSessionState } from "../../api/adapter"; import { setCsrfToken } from "../../api-client"; import { HostContext, type Host } from "../../host"; -import { mockFetch, restoreFetch } from "../../test-fetch"; +import { mockFetch, requestPath, restoreFetch } from "../../test-fetch"; import * as webcomponentMocks from "../../test-webcomponents"; import type { WizardDraft } from "./types"; @@ -18,6 +18,22 @@ const TEST_HOST: Host = { browseFolder: () => Promise.resolve(null), openFile: () => undefined, }; +const EMPTY_VALUES: never[] = []; +const SETUP_STATUS = { + initialized: false, + admin_configured: true, + config_path: "operator.toml", + tickets_path: ".tickets", + projects_by_tool: {}, + default_acceptance_criteria: "", +}; +let recordedExports: string[] = []; + +function ignoreDraftUpdate(): void {} + +function recordExport(value: string): void { + recordedExports.push(value); +} function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -30,6 +46,7 @@ afterEach(() => { cleanup(); resetSessionState(); setCsrfToken(null); + recordedExports = []; restoreFetch(); jest.restoreAllMocks(); }); @@ -38,7 +55,7 @@ describe("onboarding integration steps", () => { test("keeps chained mutations separate when the final write fails", async () => { const requests: string[] = []; mockFetch((input, init) => { - const path = new URL(String(input)).pathname; + const path = requestPath(input); const method = init?.method ?? "GET"; requests.push(`${method} ${path}`); if (path.endsWith("/kanban/providers")) { @@ -77,7 +94,6 @@ describe("onboarding integration steps", () => { return Promise.resolve(json({ message: `Unexpected request: ${path}` }, 500)); }); setCsrfToken("csrf"); - const exports: string[] = []; const draft: WizardDraft = { configurationName: "default", executionMode: "local", @@ -97,21 +113,14 @@ describe("onboarding integration steps", () => { undefined} - exports={exports} - addExport={(value) => exports.push(value)} + setDraft={ignoreDraftUpdate} + exports={recordedExports} + addExport={recordExport} /> , @@ -124,7 +133,7 @@ describe("onboarding integration steps", () => { await screen.findByLabelText("todo"); fireEvent.click(screen.getByRole("button", { name: "Save provider" })); expect(await screen.findByText("configuration write failed")).toBeTruthy(); - expect(exports).toEqual([]); + expect(recordedExports).toEqual([]); await waitFor(() => { expect(requests).toContain("POST /api/v1/kanban/session-env"); expect(requests).toContain("PUT /api/v1/kanban/config"); diff --git a/ui/src/routes/security-page.test.tsx b/ui/src/routes/security-page.test.tsx index 9278cffa..54c3a8f1 100644 --- a/ui/src/routes/security-page.test.tsx +++ b/ui/src/routes/security-page.test.tsx @@ -4,7 +4,7 @@ import { ApiProvider } from "../api"; import { resetSessionState } from "../api/adapter"; import { setCsrfToken } from "../api-client"; import { HostContext, type Host } from "../host"; -import { mockFetch, restoreFetch } from "../test-fetch"; +import { mockFetch, requestPath, restoreFetch } from "../test-fetch"; const { SecurityPage } = await import("./SecurityPage"); @@ -34,7 +34,7 @@ describe("SecurityPage", () => { test("creates and revokes credentials through mutation lifecycles", async () => { const requests: string[] = []; mockFetch((input, init) => { - const path = new URL(String(input)).pathname; + const path = requestPath(input); const method = init?.method ?? "GET"; requests.push(`${method} ${path}`); if (path.endsWith("/auth/sessions") && method === "GET") { diff --git a/ui/src/test-fetch.ts b/ui/src/test-fetch.ts index 3384e89f..f1cf6703 100644 --- a/ui/src/test-fetch.ts +++ b/ui/src/test-fetch.ts @@ -13,3 +13,12 @@ export function mockFetch( export function restoreFetch(): void { globalThis.fetch = originalFetch; } + +export function requestPath(input: RequestInfo | URL): string { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return new URL(url).pathname; +} + +export function requestBody(init?: RequestInit): string | undefined { + return typeof init?.body === "string" ? init.body : undefined; +} diff --git a/ui/src/test-webcomponents.tsx b/ui/src/test-webcomponents.tsx index 5bd3f2c0..282b101c 100644 --- a/ui/src/test-webcomponents.tsx +++ b/ui/src/test-webcomponents.tsx @@ -1,4 +1,4 @@ -import type { ChangeEvent, FormEvent, ReactNode } from "react"; +import type { ChangeEvent, ReactNode, SubmitEvent } from "react"; export function AuthCard({ title, @@ -11,7 +11,7 @@ export function AuthCard({ error?: string | null; children: ReactNode; actions?: ReactNode; - onSubmit?: (event: FormEvent) => void; + onSubmit?: (event: SubmitEvent) => void; }) { return (
    From 3c882daa4c08b2bc540d811ed6da211d4f255afb Mon Sep 17 00:00:00 2001 From: untra Date: Wed, 30 Sep 2026 18:26:31 -0600 Subject: [PATCH 16/18] misc frontend bug fixes redux --- ui/src/profiles-context.module.css | 7 + ui/src/profiles-context.test.tsx | 100 +++++++ ui/src/profiles-context.tsx | 24 +- ui/src/routes/StatusPage.tsx | 9 +- ui/src/routes/onboarding/steps.test.tsx | 164 ++++++++--- ui/src/routes/onboarding/steps.tsx | 22 +- ui/src/routes/status-page.test.tsx | 82 ++++++ ui/src/test-webcomponents.tsx | 14 + vscode-extension/package-lock.json | 272 +++++++++++++++++- vscode-extension/package.json | 3 + vscode-extension/src/config-panel.ts | 2 +- vscode-extension/tsconfig.webview.json | 2 +- vscode-extension/webview-ui/App.tsx | 1 + .../components/kanban/ProviderCard.tsx | 10 +- .../sections/ModelProvidersSection.test.tsx | 106 +++++++ .../sections/ModelProvidersSection.tsx | 66 ++++- .../webview-ui/state/addProject.test.ts | 27 ++ .../webview-ui/state/addProject.ts | 21 ++ vscode-extension/webview-ui/test-dom.ts | 5 + vscode-extension/webview-ui/types/messages.ts | 1 + .../src/components/AppShell.module.css | 23 ++ .../src/components/AppShell.test.tsx | 15 +- webcomponents/src/components/AppShell.tsx | 23 +- .../stories/components/AppShell.stories.tsx | 2 +- 24 files changed, 927 insertions(+), 74 deletions(-) create mode 100644 ui/src/profiles-context.test.tsx create mode 100644 ui/src/routes/status-page.test.tsx create mode 100644 vscode-extension/webview-ui/components/sections/ModelProvidersSection.test.tsx create mode 100644 vscode-extension/webview-ui/state/addProject.test.ts create mode 100644 vscode-extension/webview-ui/state/addProject.ts create mode 100644 vscode-extension/webview-ui/test-dom.ts diff --git a/ui/src/profiles-context.module.css b/ui/src/profiles-context.module.css index 9d3d7ffd..7331ce60 100644 --- a/ui/src/profiles-context.module.css +++ b/ui/src/profiles-context.module.css @@ -40,6 +40,13 @@ background: var(--surface-alt); } +.refreshError { + grid-column: 1 / -1; + margin: 0; + font-size: 0.75rem; + color: var(--danger); +} + .gate { padding: 1.5rem; } 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 e8e00f5b..f887679e 100644 --- a/ui/src/profiles-context.tsx +++ b/ui/src/profiles-context.tsx @@ -17,6 +17,7 @@ type ProfilesContextValue = { select: (id: string) => void; create: (name: string) => Promise; refresh: () => Promise; + refreshError: Error | null; }; const ProfilesContext = createContext(null); @@ -50,7 +51,8 @@ export function ProfilesProvider() { const createProfile = useApiMutation(createProfileMutation); const profiles = data ?? EMPTY_PROFILES; const [selectedId, setSelectedId] = useState(() => localStorage.getItem(SELECTED_PROFILE_KEY)); - const loaded = !isLoading && data !== null; + const loaded = !isLoading && data != null; + const refreshError = loaded ? error : null; const selected = profiles.find((profile) => profile.id === selectedId) ?? @@ -77,11 +79,11 @@ export function ProfilesProvider() { }, [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 (
    @@ -106,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( @@ -127,6 +129,10 @@ export function ProfileSelector() { void navigate("/onboarding?new=1"); }, [navigate]); + const onRetry = useCallback(() => { + void refresh().catch(() => undefined); + }, [refresh]); + return (
    @@ -141,6 +147,14 @@ export function ProfileSelector() { + {refreshError && ( +

    + Couldn't refresh configurations: {refreshError.message}{" "} + +

    + )}
    ); } diff --git a/ui/src/routes/StatusPage.tsx b/ui/src/routes/StatusPage.tsx index 6d9c9eb9..734a862c 100644 --- a/ui/src/routes/StatusPage.tsx +++ b/ui/src/routes/StatusPage.tsx @@ -16,17 +16,20 @@ export function StatusPage() { const [searchParams] = useSearchParams(); const targetSection = searchParams.get("s"); const { sections, error } = useSections(); + const hasTargetSection = Boolean( + targetSection && sections?.some((section) => section.id === targetSection), + ); - // Scroll to a deep-linked section (e.g. /status?s=git) once sections load. + // Scroll to a deep-linked section (e.g. /status?s=git) once that section exists. useEffect(() => { - if (!sections || !targetSection) { + if (!targetSection || !hasTargetSection) { return; } const el = document.getElementById(targetSection); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); } - }, [sections, targetSection]); + }, [hasTargetSection, targetSection]); return (
    diff --git a/ui/src/routes/onboarding/steps.test.tsx b/ui/src/routes/onboarding/steps.test.tsx index ac38632d..870fe175 100644 --- a/ui/src/routes/onboarding/steps.test.tsx +++ b/ui/src/routes/onboarding/steps.test.tsx @@ -4,7 +4,7 @@ import { ApiProvider } from "../../api"; import { resetSessionState } from "../../api/adapter"; import { setCsrfToken } from "../../api-client"; import { HostContext, type Host } from "../../host"; -import { mockFetch, requestPath, restoreFetch } from "../../test-fetch"; +import { mockFetch, requestBody, requestPath, restoreFetch } from "../../test-fetch"; import * as webcomponentMocks from "../../test-webcomponents"; import type { WizardDraft } from "./types"; @@ -42,6 +42,50 @@ function json(body: unknown, status = 200): Response { }); } +const DRAFT: WizardDraft = { + configurationName: "default", + executionMode: "local", + premium: false, + preset: "simple", + taskFields: [], + wrapper: "tmux", + executionTarget: { kind: "local" }, + coderParameters: [], + useWorktrees: false, + acceptanceCriteria: "", + modelServers: [], + hostedCollectionIds: [], +}; + +const GITHUB_PROVIDER = { + slug: "github", + display_name: "GitHub Projects", + description: "GitHub board", + setup_url: "https://example.test", + icon: "github", + configured: false, +}; + +function renderKanbanInfo() { + const KanbanInfo = STEP_COMPONENTS["kanban-info"]; + return render( + + + + + , + ); +} + afterEach(() => { cleanup(); resetSessionState(); @@ -59,18 +103,7 @@ describe("onboarding integration steps", () => { const method = init?.method ?? "GET"; requests.push(`${method} ${path}`); if (path.endsWith("/kanban/providers")) { - return Promise.resolve( - json([ - { - slug: "github", - display_name: "GitHub Projects", - description: "GitHub board", - setup_url: "https://example.test", - icon: "github", - configured: false, - }, - ]), - ); + return Promise.resolve(json([GITHUB_PROVIDER])); } if (path.endsWith("/kanban/validate")) { return Promise.resolve( @@ -94,37 +127,7 @@ describe("onboarding integration steps", () => { return Promise.resolve(json({ message: `Unexpected request: ${path}` }, 500)); }); setCsrfToken("csrf"); - const draft: WizardDraft = { - configurationName: "default", - executionMode: "local", - premium: false, - preset: "simple", - taskFields: [], - wrapper: "tmux", - executionTarget: { kind: "local" }, - coderParameters: [], - useWorktrees: false, - acceptanceCriteria: "", - modelServers: [], - hostedCollectionIds: [], - }; - const KanbanInfo = STEP_COMPONENTS["kanban-info"]; - render( - - - - - , - ); + renderKanbanInfo(); fireEvent.click(await screen.findByRole("button", { name: /GitHub Projects/ })); fireEvent.change(screen.getByLabelText("API token"), { target: { value: "github-token" } }); fireEvent.click(screen.getByRole("button", { name: "Validate and connect" })); @@ -139,4 +142,77 @@ describe("onboarding integration steps", () => { expect(requests).toContain("PUT /api/v1/kanban/config"); }); }); + test("maps columns from the selected project's statuses, not an earlier pick", async () => { + const releases = new Map void>(); + const configBodies: string[] = []; + mockFetch((input, init) => { + const path = requestPath(input); + if (path.endsWith("/kanban/providers")) { + return Promise.resolve(json([GITHUB_PROVIDER])); + } + if (path.endsWith("/kanban/validate")) { + return Promise.resolve( + json({ valid: true, github: { user_id: "42", user_login: "octocat" } }), + ); + } + if (path.endsWith("/kanban/projects")) { + return Promise.resolve( + json({ + projects: [ + { id: "PVT_A", key: "project-a", name: "octocat/#1" }, + { id: "PVT_B", key: "project-b", name: "octocat/#2" }, + ], + }), + ); + } + if (path.endsWith("/kanban/statuses")) { + const projectKey = (JSON.parse(requestBody(init) ?? "{}") as { project_key: string }) + .project_key; + const statuses = + projectKey === "project-a" + ? ["A-todo", "A-doing", "A-done"] + : ["B-todo", "B-doing", "B-done"]; + return new Promise((resolve) => { + releases.set(projectKey, () => resolve(json({ statuses }))); + }); + } + if (path.endsWith("/kanban/session-env")) { + return Promise.resolve(json({ shell_export_block: "export OPERATOR_GITHUB_TOKEN=..." })); + } + if (path.endsWith("/kanban/config")) { + configBodies.push(requestBody(init) ?? ""); + return Promise.resolve(json({ message: "ok" })); + } + return Promise.resolve(json({ message: `Unexpected request: ${path}` }, 500)); + }); + setCsrfToken("csrf"); + renderKanbanInfo(); + fireEvent.click(await screen.findByRole("button", { name: /GitHub Projects/ })); + fireEvent.change(screen.getByLabelText("API token"), { target: { value: "github-token" } }); + fireEvent.click(screen.getByRole("button", { name: "Validate and connect" })); + await screen.findByText("Credentials validated. Choose a project."); + + const project = screen.getByLabelText("Project"); + fireEvent.change(project, { target: { value: "project-a" } }); + await waitFor(() => expect(releases.has("project-a")).toBe(true)); + expect(project.disabled).toBe(true); + + releases.get("project-a")?.(); + await waitFor(() => expect(project.disabled).toBe(false)); + fireEvent.change(project, { target: { value: "project-b" } }); + expect(screen.queryByLabelText("todo")).toBeNull(); + await waitFor(() => expect(releases.has("project-b")).toBe(true)); + releases.get("project-b")?.(); + await waitFor(() => + expect(screen.getByLabelText("todo").value).toBe("B-todo"), + ); + + fireEvent.click(screen.getByRole("button", { name: "Save provider" })); + await waitFor(() => expect(configBodies).toHaveLength(1)); + const saved = JSON.parse(configBodies[0]) as { + github: { project_key: string; status_mapping: { todo: string } }; + }; + expect(saved.github.project_key).toBe("PVT_B"); + expect(saved.github.status_mapping.todo).toBe("B-todo"); + }); }); diff --git a/ui/src/routes/onboarding/steps.tsx b/ui/src/routes/onboarding/steps.tsx index 7d1f5702..551dce84 100644 --- a/ui/src/routes/onboarding/steps.tsx +++ b/ui/src/routes/onboarding/steps.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import type { KanbanProviderKind } from "@operator/bindings/KanbanProviderKind"; import type { SetupStep } from "@operator/bindings/SetupStep"; import type { StepComponent, StepProps, StepRow } from "./types"; @@ -25,6 +25,7 @@ import styles from "./OnboardingPage.module.css"; const TASK_FIELDS = ["priority", "points", "user_story"] as const; const WRAPPERS = ["tmux", "vscode", "cmux", "zellij"] as const; const KANBAN_KINDS = ["jira", "linear", "github", "openspec"] as const; +const EMPTY_STATUS_MAPPING = { todo: "", doing: "", done: "" }; const COLLECTION_SOURCES = [ ["simple", "Simple"], ["dev_kanban", "Development"], @@ -157,7 +158,8 @@ function KanbanInfo({ addExport }: StepProps) { const [rootPath, setRootPath] = useState(""); const [instance, setInstance] = useState("default"); const [projectKey, setProjectKey] = useState(""); - const [mapping, setMapping] = useState({ todo: "", doing: "", done: "" }); + const [mapping, setMapping] = useState(EMPTY_STATUS_MAPPING); + const statusRequest = useRef(0); const [syncUserId, setSyncUserId] = useState(""); const [workspaceKey, setWorkspaceKey] = useState(""); const [message, setMessage] = useState(null); @@ -175,6 +177,8 @@ function KanbanInfo({ addExport }: StepProps) { (slug: string) => { setProvider(KANBAN_KINDS.find((kind) => kind === slug) ?? ""); setProjectKey(""); + setMapping(EMPTY_STATUS_MAPPING); + statusRequest.current += 1; listProjects.reset(); listStatuses.reset(); }, @@ -229,18 +233,26 @@ function KanbanInfo({ addExport }: StepProps) { } async function chooseProject(value: string) { + const request = ++statusRequest.current; setProjectKey(value); + setMapping(EMPTY_STATUS_MAPPING); if (!provider || !value) { return; } try { const result = await listStatuses.mutateAsync({ ...credentials(), project_key: value }); + if (request !== statusRequest.current) { + return; + } setMapping({ todo: result.statuses[0] ?? "", doing: result.statuses[1] ?? "", done: result.statuses.at(-1) ?? "", }); } catch (error) { + if (request !== statusRequest.current) { + return; + } setMessage(error instanceof Error ? error.message : "Could not list statuses"); } } @@ -412,7 +424,11 @@ function KanbanInfo({ addExport }: StepProps) { <>