diff --git a/.claude/commands/build-docs.md b/.claude/commands/build-docs.md index 4cefb6c3..4bcae9c5 100644 --- a/.claude/commands/build-docs.md +++ b/.claude/commands/build-docs.md @@ -13,7 +13,7 @@ Build and serve the `docs/` subproject locally for inspection. Stop immediately Run each step sequentially from the repo root. If any step fails, stop and report the failure clearly. 1. **Install Ruby dependencies**: `cd docs && bundle install` -2. **Build the full site**: `make docs` from the repo root. This runs the whole pipeline in order — ts-rs bindings, the generated reference docs and hosted collection bundle (`cargo run -- docs`), the shared `webcomponents/` bundle, the copy into `docs/assets/js/`, then Jekyll. +2. **Build the full site**: `make docs` from the repo root. This runs the whole pipeline in order - ts-rs bindings, the generated reference docs and hosted collection bundle (`cargo run -- docs`), the shared `webcomponents/` bundle, the copy into `docs/assets/js/`, then Jekyll. 3. **Serve locally**: `cd docs && bundle exec jekyll serve` (run in background so the session remains interactive; serves on port 4000) 4. **Report**: Confirm the site is running at http://localhost:4000. Let the user know it auto-rebuilds on file changes. diff --git a/.claude/commands/new-collection.md b/.claude/commands/new-collection.md index 3cddaaca..6c266167 100644 --- a/.claude/commands/new-collection.md +++ b/.claude/commands/new-collection.md @@ -1,5 +1,5 @@ --- -description: Author a new Operator issuetype collection — a shareable AI workflow +description: Author a new Operator issuetype collection - a shareable AI workflow allowed-tools: Bash, Read, Write, Edit, Glob, Grep --- @@ -9,7 +9,7 @@ Create a new workflow **collection**: a named, versioned bundle of issue types t The mechanics are easy; the design is the hard part. workflows allow agents to apply themselves in a deterministic manner. -## Vocabulary — get this right first +## Vocabulary - get this right first Three terms that are easy to conflate: @@ -25,7 +25,7 @@ Kanban issue types describe how a *team labels* work; a collection describes how the term _"Workflow"_ is overloaded in the AI space. Operator workflows are json described collections of issuetypes. they can export to other workflows, such as `claude workflows` among others. As a result Operator workflows are meant to compose as broad and neutral as possible, with some opinions about structure and behavior. Define your workflows in the context of the work you want to get done. -## Step 1 — Decide what loop you are encoding +## Step 1 - Decide what loop you are encoding Answer these before opening an editor. If you cannot answer them crisply, the collection is not ready to write. @@ -42,7 +42,7 @@ collection is not ready to write. continuing, and what happens on rejection? 5. **When does it stop?** Both success and give-up conditions. -Study the shipped collections before inventing a shape — they are short and +Study the shipped collections before inventing a shape - they are short and each encodes a real, published methodology: ```bash @@ -52,7 +52,7 @@ cat src/collections/ralph_loop/collection.json cat src/collections/dev_kanban/FEAT.json # the canonical multi-step example ``` -## Step 2 — Create the directory +## Step 2 - Create the directory Official/curated collections that ship in the binary live in `src/collections//`. Community contributions live in @@ -65,15 +65,15 @@ to every user.** When in doubt, use `collections/community/`. / ├── collection.json # the manifest ├── icon.svg # Simple Icons-shaped glyph -├── .json # one per issue type — the Operator workflow +├── .json # one per issue type - the Operator workflow └── .md # optional ticket template per issue type ``` `` must match `^[a-z0-9_]{3,64}$` and equal the directory name. -## Step 3 — Write the issue types +## Step 3 - Write the issue types -One `.json` per issue type. `KEY` matches `^[A-Z][A-Z0-9_]{1,15}$` — +One `.json` per issue type. `KEY` matches `^[A-Z][A-Z0-9_]{1,15}$` - **no hyphens**, because the hyphen separates the key from the ticket number in `FEAT-123-project-summary.md`. @@ -88,15 +88,15 @@ Required top-level fields: `key`, `name`, `description`, `mode`, `glyph`, `fields`, `steps`. Also set `"$schema": "../../schemas/issuetype_schema.json"` so editors validate as you type. -- **`mode`** — `autonomous` (launch and monitor; several run in parallel) or +- **`mode`** - `autonomous` (launch and monitor; several run in parallel) or `paired` (needs you in the loop; one at a time). This is a real scheduling constraint, not a hint. Choose `paired` only when a human genuinely must participate throughout. -- **`glyph`** — one character shown in the TUI. Already in use across +- **`glyph`** - one character shown in the TUI. Already in use across collections: `! # % * > ? @ B E F J L P R S T V ~`. Pick something unused and mnemonic. -- **`color`** — one of `cyan`, `green`, `blue`, `magenta`, `yellow`, `red`. -- **`fields`** — the ticket's inputs. Types: `string`, `text`, `enum`, `bool`, +- **`color`** - one of `cyan`, `green`, `blue`, `magenta`, `yellow`, `red`. +- **`fields`** - the ticket's inputs. Types: `string`, `text`, `enum`, `bool`, `date`, `integer`. Use `"auto": "id" | "date" | "branch" | "status"` for values Operator fills in, and mark those `"user_editable": false`. @@ -112,7 +112,7 @@ Steps are where the methodology actually lives. Each step is one agent session. "prompt": "...", // Handlebars over the ticket's fields: {{ summary }} "allowed_tools": ["Read", "Grep"], // least privilege for this step "artifact_patterns": [".tickets/plans/{{ id }}.md"], // files that signal completion - "review_type": "plan", // none|plan|visual|pr — a gate + "review_type": "plan", // none|plan|visual|pr - a gate "on_reject": { "goto_step": "plan", "prompt": "Plan rejected: {{ rejection_reason }}..." }, "next_step": "build" // omit on the final step } @@ -122,7 +122,7 @@ Rules that matter: - **Chain with `next_step`.** Ordering follows the chain from the first step, then appends anything unreached. Do not rely on array order alone. -- **`on_reject.goto_step` is the retry edge** — it may point backwards, and +- **`on_reject.goto_step` is the retry edge** - it may point backwards, and usually should point at the step that can actually fix the problem (a failed PR review goes back to `code`, not to `plan`). - **One step, one job.** A step that plans *and* implements *and* tests gives @@ -132,7 +132,7 @@ Rules that matter: - **Prompts are Handlebars** over the ticket's fields. Reference only fields you actually declared. -Beyond plain `task` steps, these types exist — use them when the shape calls +Beyond plain `task` steps, these types exist - use them when the shape calls for it, not for novelty: `classifier`, `rag`, `delegator`, `mcp`, `multi_model` (fan out, then vote), `multi_prompt`, `matrixed`, `pipeline`. @@ -145,7 +145,7 @@ and Handlebars placeholders. Copy the shape from an existing one: cat src/collections/dev_kanban/FEAT.md ``` -## Step 4 — Write the manifest +## Step 4 - Write the manifest ```jsonc { @@ -178,7 +178,7 @@ cat src/collections/dev_kanban/FEAT.md } ``` -**Do not write `checksum` or `schema_checksum`** — the docs generator computes +**Do not write `checksum` or `schema_checksum`** - the docs generator computes them at publish time. `tier: "community"` additionally requires `author`, `url`, `license`, and `icon_path`. @@ -186,7 +186,7 @@ them at publish time. `tier: "community"` additionally requires `author`, what the catalog page displays, so it is how a reader decides whether to adopt your collection. Write it for them, not for the parser. -## Step 5 — Draw the icon +## Step 5 - Draw the icon A single-path 24×24 glyph. The full rules and rationale are in `docs/design-system/` under "Brand & collection icons"; the short version: @@ -195,10 +195,10 @@ A single-path 24×24 glyph. The full rules and rationale are in Display Name ``` -No `fill`, `stroke`, `width`, or `height` — the icon inherits `currentColor` +No `fill`, `stroke`, `width`, or `height` - the icon inherits `currentColor` and its container's size. The `` must equal the manifest's `name`. -Render it and *look at it* before trusting it — hand-authored path data is easy +Render it and *look at it* before trusting it - hand-authored path data is easy to get subtly wrong, and the test checks shape, not whether the glyph reads: ```bash @@ -210,14 +210,14 @@ magick -background white -density 384 <id>/icon.svg /tmp/icon.png Then open `/tmp/icon.png`. If neither tool is installed, open the SVG in a browser. -## Step 6 — Register it (embedded collections only) +## Step 6 - Register it (embedded collections only) Skip this for `collections/community/`. For `src/collections/<id>/`, add an entry to `EMBEDDED_COLLECTIONS` in `src/collections/mod.rs`, following the -existing entries exactly — `manifest`, `icon_svg`, and one `EmbeddedIssueType` +existing entries exactly - `manifest`, `icon_svg`, and one `EmbeddedIssueType` per key, in the same order as the manifest. -## Step 7 — Validate +## Step 7 - Validate Run these in order and fix anything that fails. Do not skip ahead. @@ -242,8 +242,8 @@ make docs cd docs/_site && python3 -m http.server 4100 ``` -Open `http://localhost:4100/workflows/` — your collection should appear as a -card — then its page, and step through each issue type's graph. **A workflow +Open `http://localhost:4100/workflows/` - your collection should appear as a +card - then its page, and step through each issue type's graph. **A workflow that looks wrong as a graph is wrong.** Disconnected nodes, a reject edge pointing somewhere useless, or a 12-step chain with no gates are all visible at a glance and all worth fixing before shipping. diff --git a/.dockerignore b/.dockerignore index cb062bff..496348ac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,5 @@ * !operator-linux-amd64 !operator-linux-arm64 +!opr8r-linux-amd64 +!opr8r-linux-arm64 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 910075a8..a9d6615d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,10 @@ version: 2 updates: + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + - package-ecosystem: cargo directory: "/" schedule: diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 28d4600a..860874e0 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -104,6 +104,28 @@ jobs: arguments: --all-features command: check + - name: Set up Helm + uses: azure/setup-helm@v5.0.1 + + - name: Lint and render Helm chart + run: | + helm lint charts/operator + helm template operator charts/operator \ + --set ingress.enabled=true \ + --set ingress.host=operator.example.com \ + --set ingress.tls.secretName=operator-tls \ + --set networkPolicy.enabled=true \ + --set bootstrap.existingSecret=operator-bootstrap \ + > operator-chart.yaml + + - name: Scan rendered Helm chart + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: config + scan-ref: operator-chart.yaml + severity: HIGH,CRITICAL + exit-code: '1' + # Compute the next version once, before anything is built, so the compiled # binaries embed the same version that the release tag + Docker tag will use. # The actual file edits + commit + tag still happen in the release job. @@ -474,6 +496,7 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ needs.version.outputs.version }} + commit: ${{ steps.version_commit.outputs.commit }} steps: - uses: actions/checkout@v7 with: @@ -500,6 +523,11 @@ jobs: run: | sed -i 's/^version: .*/version: ${{ needs.version.outputs.version }}/' docs/_config.yml + - name: Update Helm chart versions + run: | + sed -i 's/^version: .*/version: ${{ needs.version.outputs.version }}/' charts/operator/Chart.yaml + sed -i 's/^appVersion: .*/appVersion: "${{ needs.version.outputs.version }}"/' charts/operator/Chart.yaml + - name: Update package.json versions run: | for f in vscode-extension/package.json \ @@ -533,10 +561,12 @@ jobs: "$BIN" docs --only openapi - name: Commit version bump + id: version_commit run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add VERSION Cargo.toml Cargo.lock docs/_config.yml \ + charts/operator/Chart.yaml \ vscode-extension/package.json \ vscode-extension/src/webhook-server.ts \ opr8r/Cargo.toml opr8r/Cargo.lock \ @@ -545,6 +575,7 @@ jobs: docs/schemas/openapi.json git commit -m "chore: bump version to v${{ needs.version.outputs.version }} [skip ci]" git push + echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Create tag run: | @@ -647,6 +678,37 @@ jobs: untra/operator:${{ needs.release.outputs.version }} untra/operator:latest + - name: Scan container image + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: untra/operator:${{ needs.release.outputs.version }} + scanners: vuln + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: '1' + + chart: + needs: [release, docker] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.release.outputs.commit }} + + - name: Set up Helm + uses: azure/setup-helm@v5.0.1 + + - name: Log in to GHCR + run: echo "${{ github.token }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin + + - name: Package and push Helm chart + run: | + helm package charts/operator --destination dist + helm push "dist/operator-${{ needs.release.outputs.version }}.tgz" oci://ghcr.io/untra/charts + deploy-docs: needs: release runs-on: ubuntu-latest diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e04a6605..fe1b559d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,6 +7,8 @@ on: paths: - 'docs/**' - 'src/docs_gen/**' + - 'src/rest/**' + - 'src/auth/**' - 'src/taxonomy/taxonomy.toml' - 'src/templates/*.json' - 'src/collections/**' diff --git a/.github/workflows/integration-tests-matrix.yml b/.github/workflows/integration-tests-matrix.yml index 2d75172e..381cba7a 100644 --- a/.github/workflows/integration-tests-matrix.yml +++ b/.github/workflows/integration-tests-matrix.yml @@ -18,6 +18,7 @@ on: - 'vscode-extension/**' - 'tests/**' - 'scripts/ci/**' + - '.github/workflows/integration-tests-matrix.yml' workflow_dispatch: inputs: run_vscode_tests: @@ -187,8 +188,8 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ matrix.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ matrix.os }}-cargo-test- + key: ${{ matrix.os }}-cargo-${{ github.job }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ matrix.os }}-cargo-${{ github.job }}- - name: Download operator binary uses: actions/download-artifact@v8 @@ -246,8 +247,8 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ matrix.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ matrix.os }}-cargo-test- + key: ${{ matrix.os }}-cargo-${{ github.job }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ matrix.os }}-cargo-${{ github.job }}- - name: Download operator binary uses: actions/download-artifact@v8 @@ -315,8 +316,8 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ matrix.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ matrix.os }}-cargo-test- + key: ${{ matrix.os }}-cargo-${{ github.job }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ matrix.os }}-cargo-${{ github.job }}- - name: Download operator binary uses: actions/download-artifact@v8 @@ -342,18 +343,17 @@ jobs: # VSCODE WRAPPER LAUNCH TESTS (Rust library-level, all platforms) # ============================================================================ + # No `needs: build-operator`: `cargo test` compiles the library from source and + # this suite never executes the release binary, so waiting on it was dead time. test-vscode-wrapper: name: VSCode Wrapper (${{ matrix.os }}) - needs: build-operator if: github.event_name != 'workflow_dispatch' || inputs.run_vscode_wrapper_tests strategy: fail-fast: false matrix: include: - os: ubuntu-latest - artifact: operator-linux-x64 - os: macos-14 - artifact: operator-macos-arm64 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 @@ -368,17 +368,8 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ matrix.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ matrix.os }}-cargo-test- - - - name: Download operator binary - uses: actions/download-artifact@v8 - with: - name: ${{ matrix.artifact }} - path: target/release - - - name: Make binary executable - run: chmod +x target/release/operator + key: ${{ matrix.os }}-cargo-vscode-wrapper-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ matrix.os }}-cargo-vscode-wrapper- - name: Run VS Code wrapper launch tests env: @@ -391,21 +382,15 @@ jobs: test-rest-api: name: REST API (${{ matrix.os }}) - needs: build-operator if: github.event_name != 'workflow_dispatch' || inputs.run_api_tests strategy: fail-fast: false matrix: include: - os: ubuntu-latest - artifact: operator-linux-x64 - os: ubuntu-24.04-arm - artifact: operator-linux-arm64 - os: macos-14 - artifact: operator-macos-arm64 - os: windows-latest - artifact: operator-windows-x64 - extension: .exe runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 @@ -420,18 +405,8 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ matrix.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ matrix.os }}-cargo-test- - - - name: Download operator binary - uses: actions/download-artifact@v8 - with: - name: ${{ matrix.artifact }} - path: target/release - - - name: Make binary executable (Unix) - if: runner.os != 'Windows' - run: chmod +x target/release/operator + key: ${{ matrix.os }}-cargo-rest-api-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ matrix.os }}-cargo-rest-api- - name: Run REST API integration tests env: @@ -444,46 +419,34 @@ jobs: test-opr8r-cli: name: opr8r CLI (${{ matrix.os }}) - needs: [build-operator, build-opr8r] + needs: build-opr8r if: github.event_name != 'workflow_dispatch' || inputs.run_opr8r_tests strategy: fail-fast: false matrix: include: - os: ubuntu-latest - operator_artifact: operator-linux-x64 opr8r_artifact: opr8r-linux-x64 - os: ubuntu-24.04-arm - operator_artifact: operator-linux-arm64 opr8r_artifact: opr8r-linux-arm64 - os: macos-14 - operator_artifact: operator-macos-arm64 opr8r_artifact: opr8r-macos-arm64 - os: windows-latest - operator_artifact: operator-windows-x64 opr8r_artifact: opr8r-windows-x64 extension: .exe runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 - - name: Download operator binary - uses: actions/download-artifact@v8 - with: - name: ${{ matrix.operator_artifact }} - path: target/release - - name: Download opr8r binary uses: actions/download-artifact@v8 with: name: ${{ matrix.opr8r_artifact }} path: opr8r-bin - - name: Make binaries executable (Unix) + - name: Make binary executable (Unix) if: runner.os != 'Windows' - run: | - chmod +x target/release/operator - chmod +x opr8r-bin/opr8r + run: chmod +x opr8r-bin/opr8r - name: Test opr8r --version (Unix) if: runner.os != 'Windows' @@ -509,20 +472,17 @@ jobs: test-vscode-extension: name: VSCode Extension (${{ matrix.os }}) - needs: [build-operator, build-opr8r] + needs: build-opr8r if: github.event_name != 'workflow_dispatch' || inputs.run_vscode_tests strategy: fail-fast: false matrix: include: - os: ubuntu-latest - operator_artifact: operator-linux-x64 opr8r_artifact: opr8r-linux-x64 - os: macos-14 - operator_artifact: operator-macos-arm64 opr8r_artifact: opr8r-macos-arm64 - os: windows-latest - operator_artifact: operator-windows-x64 opr8r_artifact: opr8r-windows-x64 extension: .exe runs-on: ${{ matrix.os }} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 62b7cd49..e5d16972 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -8,9 +8,13 @@ on: - 'src/api/providers/kanban/**' - 'src/api/providers/model_server/**' - 'src/git/**' + - 'src/api/glab_cli.rs' + - 'src/api/gitlab_service.rs' + - 'src/api/pr_service.rs' - 'tests/kanban_integration.rs' - 'tests/model_server_integration.rs' - 'tests/git_integration.rs' + - 'tests/gitprovider_integration.rs' - '.github/workflows/integration-tests.yml' pull_request: branches: @@ -19,9 +23,13 @@ on: - 'src/api/providers/kanban/**' - 'src/api/providers/model_server/**' - 'src/git/**' + - 'src/api/glab_cli.rs' + - 'src/api/gitlab_service.rs' + - 'src/api/pr_service.rs' - 'tests/kanban_integration.rs' - 'tests/model_server_integration.rs' - 'tests/git_integration.rs' + - 'tests/gitprovider_integration.rs' workflow_dispatch: inputs: run_jira: @@ -48,6 +56,10 @@ on: description: 'Run Git push tests (creates/deletes optest/ branches)' type: boolean default: false + run_git_providers: + description: 'Run git-provider (GitHub/GitLab PR service) integration tests' + type: boolean + default: false env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' @@ -179,6 +191,33 @@ jobs: # NOTE: Git push tests disabled in CI - require authenticated push access # Run locally with OPERATOR_GIT_PUSH_ENABLED=true to test push operations + # Git Provider Integration Tests (live PrService against gh/glab) + - name: Install glab + if: github.event_name != 'workflow_dispatch' || inputs.run_git_providers + run: | + GLAB_VERSION="1.55.0" + curl -L "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/glab_${GLAB_VERSION}_linux_amd64.tar.gz" | tar xz + sudo mv bin/glab /usr/local/bin/ + glab --version + + - name: Git provider integration tests + if: >- + (github.event_name != 'workflow_dispatch' || inputs.run_git_providers) && + env.OPERATOR_GITHUB_TOKEN != '' + env: + OPERATOR_GITPROVIDER_TEST_ENABLED: 'true' + OPERATOR_GITHUB_TOKEN: ${{ secrets.OPERATOR_GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.OPERATOR_GITHUB_TOKEN }} + GITLAB_TOKEN: ${{ secrets.OPERATOR_GITLAB_TOKEN }} + GITEA_TOKEN: ${{ secrets.OPERATOR_GITEA_TOKEN }} + OPERATOR_GITPROVIDER_TEST_REPO_GITHUB: ${{ vars.OPERATOR_GITPROVIDER_TEST_REPO_GITHUB || '' }} + OPERATOR_GITPROVIDER_TEST_PR_GITHUB: ${{ vars.OPERATOR_GITPROVIDER_TEST_PR_GITHUB || '' }} + OPERATOR_GITPROVIDER_TEST_REPO_GITLAB: ${{ vars.OPERATOR_GITPROVIDER_TEST_REPO_GITLAB || '' }} + OPERATOR_GITPROVIDER_TEST_PR_GITLAB: ${{ vars.OPERATOR_GITPROVIDER_TEST_PR_GITLAB || '' }} + OPERATOR_GITPROVIDER_TEST_REPO_GITEA: ${{ vars.OPERATOR_GITPROVIDER_TEST_REPO_GITEA || '' }} + OPERATOR_GITPROVIDER_TEST_PR_GITEA: ${{ vars.OPERATOR_GITPROVIDER_TEST_PR_GITEA || '' }} + run: cargo test --locked --test gitprovider_integration -- --nocapture + - name: Cleanup optest branches if: always() run: | diff --git a/.github/workflows/vscode-extension.yaml b/.github/workflows/vscode-extension.yaml index 98c74bac..d66c6b6d 100644 --- a/.github/workflows/vscode-extension.yaml +++ b/.github/workflows/vscode-extension.yaml @@ -6,12 +6,14 @@ on: paths: - 'vscode-extension/**' - 'icons/**' + - 'bindings/**' - '.github/workflows/vscode-extension.yaml' pull_request: branches: [main] paths: - 'vscode-extension/**' - 'icons/**' + - 'bindings/**' workflow_dispatch: inputs: publish: diff --git a/CLAUDE.md b/CLAUDE.md index ed9748ca..c959a31b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,8 @@ ## Code Style Aim for functional software development with a focus on stateless, single responsibility focus. -Minimize use of comments; they should be terse and used judiciously, ideally one sentence tops. +ABSOLUTELY NO UNNECESSARY CODE COMMENTS WITHIN FUNCTIONS OR CONFIGURATION. +Minimize use of comments entirely; they should be terse and used judiciously, ideally one line tops. Data types come from rust; typescript and docs binds are generated from low-level rust types annotated with comments that embed as descriptions into configuration and reference files. Favor falsey defaults ; lets aim not to enforce `default=true` or some other javascript-truthy default value. @@ -273,7 +274,7 @@ All generated files include a header warning: 1. Create a struct implementing `DocGenerator` trait in `src/docs_gen/` 2. Implement `name()`, `source()`, `output_path()`, and `generate()` -3. Register it in `src/docs_gen/mod.rs` `all_generators()` — that one list drives +3. Register it in `src/docs_gen/mod.rs` `all_generators()` - that one list drives the full run, the `--only` filter, and the CLI help text, so there is nothing to add in `src/main.rs` @@ -281,10 +282,10 @@ All generated files include a header warning: 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 +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 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. @@ -292,14 +293,14 @@ it; never re-declare a brand color elsewhere. |---------|-------|------| | 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. | -| 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`. | +| 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. -**Icons.** Every SVG icon follows the Operator icon standard — a single +**Icons.** Every SVG icon follows the Operator icon standard - a single monochrome `<path>` 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/`, diff --git a/Cargo.lock b/Cargo.lock index df7c06de..c9fb7abf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,6 +156,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -395,6 +407,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -425,6 +443,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -980,6 +1007,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1178,6 +1206,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1262,6 +1302,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1484,9 +1530,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -1529,6 +1575,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1537,7 +1592,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1548,7 +1603,16 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", ] [[package]] @@ -1978,6 +2042,21 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -2042,6 +2121,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-clipping" version = "0.3.7" @@ -2330,6 +2420,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2347,6 +2447,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-modular" version = "0.6.4" @@ -2501,9 +2610,11 @@ version = "0.2.7" dependencies = [ "agent-client-protocol", "anyhow", + "argon2", "async-trait", "axum", "backon", + "base64", "chrono", "clap", "config", @@ -2514,6 +2625,7 @@ dependencies = [ "glob", "handlebars", "http-body-util", + "jsonwebtoken", "lazy_static", "mac-notification-sys", "mime_guess", @@ -2521,16 +2633,21 @@ dependencies = [ "notify-rust", "once_cell", "operator-relay", + "password-hash", + "rand 0.9.5", "ratatui", "ratatui-textarea", "regex", "reqwest", + "ring", + "rusqlite", "rust-embed", "schemars 1.2.2", "serde", "serde_json", "serde_yaml", "sha2 0.11.0", + "subtle", "sysinfo", "tempfile", "thiserror 2.0.20", @@ -2543,6 +2660,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "ts-rs", + "url", "utoipa", "utoipa-axum", "utoipa-swagger-ui", @@ -2653,6 +2771,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -2665,6 +2794,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2843,6 +2982,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -2947,6 +3095,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -2958,11 +3116,33 @@ dependencies = [ "rand_core 0.10.1", ] +[[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 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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 = "rand_core" @@ -3247,6 +3427,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.10.0", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-embed" version = "8.12.0" @@ -3694,6 +3888,18 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.20", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -5281,7 +5487,7 @@ checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink", + "hashlink 0.11.1", ] [[package]] @@ -5368,6 +5574,26 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index d1948b11..05ab84a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,9 +92,20 @@ http-body-util = "0.1" utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } utoipa-swagger-ui = { version = "9", features = ["axum"] } -# Agent Client Protocol (ACP) — JSON-RPC 2.0 over stdio for editor integration +# Agent Client Protocol (ACP) JSON-RPC 2.0 over stdio for editor integration agent-client-protocol = "2.0" +# Authentication: local credential store, password hashing, and token signing. +rusqlite = { version = "0.37", features = ["bundled"] } +argon2 = "0.5" +password-hash = { version = "0.5", features = ["getrandom"] } +jsonwebtoken = "9" +ring = "0.17" +rand = "0.9" +base64 = "0.22" +subtle = "2" +url = "2" + # Embedded web UI (behind embed-ui feature flag) rust-embed = { version = "8", optional = true } mime_guess = { version = "2", optional = true } diff --git a/Dockerfile b/Dockerfile index 217a2461..019a1cd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,26 @@ # glibc 2.41 >= the ubuntu-24.04 build runners' 2.39, so the GNU binary runs. -FROM debian:trixie-slim +FROM debian:trixie-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132 + +LABEL org.opencontainers.image.title="Operator" \ + org.opencontainers.image.description="Agent orchestration server and CLI" \ + org.opencontainers.image.url="https://operator.untra.io" \ + org.opencontainers.image.source="https://github.com/untra/operator" \ + org.opencontainers.image.licenses="MIT" # Populated automatically by buildx per target platform (amd64 / arm64). ARG TARGETARCH # Substrate Operator needs to launch agents: git (VCS ops), tmux (session # wrapper), ca-certificates (TLS to LLM/kanban APIs). The LLM CLI (claude / -# codex / gemini) and its auth are supplied by the user via a derived image or -# a mount + env vars -- not baked in here. +# codex / gemini) and its auth are supplied by the user via a derived image or env vars RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates git tmux \ && rm -rf /var/lib/apt/lists/* # CI stages the prebuilt release binaries as {operator,opr8r}-linux-${TARGETARCH} # (the -linux-x86_64 artifacts are renamed to -linux-amd64; arm64 matches). -# Both halves ship: agent sessions launched by the operator server call the -# opr8r client to report step completion for multi-step ticket workflows. -COPY operator-linux-${TARGETARCH} /usr/local/bin/operator -COPY opr8r-linux-${TARGETARCH} /usr/local/bin/opr8r -RUN chmod +x /usr/local/bin/operator /usr/local/bin/opr8r +COPY --chown=0:0 --chmod=0755 operator-linux-${TARGETARCH} /usr/local/bin/operator +COPY --chown=0:0 --chmod=0755 opr8r-linux-${TARGETARCH} /usr/local/bin/opr8r # Fail the multi-arch build (incl. arm64 under QEMU binfmt from # setup-qemu-action) before push if a binary can't execute on this base. @@ -26,15 +28,17 @@ RUN chmod +x /usr/local/bin/operator /usr/local/bin/opr8r RUN ["/usr/local/bin/operator", "--version"] RUN ["/usr/local/bin/opr8r", "--version"] -# Run as an unprivileged user with a writable HOME by default. A compromised -# agent tool then can't act as root against the mounted workspace. -# Debian ships a legacy `operator` system group, so assign the existing `users` -# group rather than letting useradd create a colliding same-name group. -RUN useradd --create-home --uid 1000 --gid users operator +# Run as an unprivileged user with a dedicated uid and gid. +RUN groupdel operator \ + && groupadd -g 10001 operator \ + && useradd -u 10001 -g 10001 -m -d /home/operator operator \ + && mkdir /op \ + && chown operator:operator /op +ENV HOME=/home/operator USER operator # Mount your projects root here: `docker run -v $(pwd):/op:rw ...`. # Operator auto-loads .tickets/operator/config.toml relative to the cwd. -# Created owned by uid 1000, so the default (no-mount) workdir is writable. WORKDIR /op +EXPOSE 7008 ENTRYPOINT ["operator"] diff --git a/Dockerfile.local b/Dockerfile.local new file mode 100644 index 00000000..0ead793e --- /dev/null +++ b/Dockerfile.local @@ -0,0 +1,90 @@ +# --- Stage 1: embedded web UI --------------------------------------------- +# Cargo.toml sets default = ["embed-ui"], and build.rs silently substitutes a +# placeholder index.html when ui/dist is missing, which would produce an image +# with no dashboard. Building the SPA here is required, not optional. +FROM oven/bun:1.3.14 AS web + +# webcomponents' own build script shells out to `npm run copy-types`, which runs +# `node scripts/copy-types.mjs`. The oven/bun image ships bun only; CI gets away +# with `bun run build` because the GitHub runner already has node on PATH. +RUN apt-get update \ + && apt-get install -y --no-install-recommends nodejs npm \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src + +# bindings/ is committed (CI only verifies freshness), so `make bindings` -- +# i.e. a full cargo test run -- is not needed to type the frontend. +COPY bindings/ ./bindings/ + +# webcomponents first: ui/ resolves @operator/webcomponents to +# ../webcomponents/dist/index.js by vite alias, a dist-artifact dependency +# rather than a package dependency, so the order is load-bearing. +COPY webcomponents/ ./webcomponents/ +RUN cd webcomponents && bun install --frozen-lockfile && bun run build + +# ui/src/index.css @imports the brand tokens from the docs site, which is the +# single source of truth for them (docs/design-system/). The SPA build needs +# that one file even though nothing else of docs/ is involved. +COPY docs/assets/css/ ./docs/assets/css/ +COPY ui/ ./ui/ +RUN cd ui && bun install --frozen-lockfile && bun run build + +# --- Stage 2: Rust binaries ----------------------------------------------- +# The full image rather than -slim: rusqlite is features = ["bundled"], so a C +# toolchain is required. +FROM rust:1.95 AS build + +WORKDIR /src +COPY . . +COPY --from=web /src/ui/dist ./ui/dist + +RUN cargo build --release --locked --bin operator + +# opr8r is a separate cargo project with its own Cargo.lock, not a workspace +# member, so it needs its own invocation. +RUN cd opr8r && cargo build --release --locked + +# --- Stage 3: runtime ------------------------------------------------------ +# Mirrors Dockerfile; only the operator/opr8r binary source differs. +# glibc 2.41 >= the ubuntu-24.04 build runners' 2.39, so the GNU binary runs. +FROM debian:trixie-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132 + +LABEL org.opencontainers.image.title="Operator" \ + org.opencontainers.image.description="Agent orchestration server and CLI (local build)" \ + org.opencontainers.image.url="https://operator.untra.io" \ + org.opencontainers.image.source="https://github.com/untra/operator" \ + org.opencontainers.image.licenses="MIT" + +# Substrate Operator needs to launch agents: git (VCS ops), tmux (session +# wrapper), ca-certificates (TLS to LLM/kanban APIs). The LLM CLI (claude / +# codex / gemini) and its auth are supplied by the user via a derived image or +# a mount + env vars -- not baked in here. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git tmux \ + && rm -rf /var/lib/apt/lists/* + +# Both halves ship: agent sessions launched by the operator server call the +# opr8r client to report step completion for multi-step ticket workflows. +COPY --from=build --chown=0:0 --chmod=0755 /src/target/release/operator /usr/local/bin/operator +COPY --from=build --chown=0:0 --chmod=0755 /src/opr8r/target/release/opr8r /usr/local/bin/opr8r + +# Fail the build before use if a binary can't execute on this base. +# --version short-circuits in clap before any config or tmux load. +RUN ["/usr/local/bin/operator", "--version"] +RUN ["/usr/local/bin/opr8r", "--version"] + +# Run as an unprivileged user with a dedicated uid and gid. +RUN groupdel operator \ + && groupadd -g 10001 operator \ + && useradd -u 10001 -g 10001 -m -d /home/operator operator \ + && mkdir /op \ + && chown operator:operator /op +ENV HOME=/home/operator +USER operator + +# Mount your projects root here: `docker run -v $(pwd):/op:rw ...`. +# Operator auto-loads .tickets/operator/config.toml relative to the cwd. +WORKDIR /op +EXPOSE 7008 +ENTRYPOINT ["operator"] diff --git a/Dockerfile.local.dockerignore b/Dockerfile.local.dockerignore new file mode 100644 index 00000000..30bfa45d --- /dev/null +++ b/Dockerfile.local.dockerignore @@ -0,0 +1,16 @@ +# Build context for Dockerfile.local only. +# .dockerignore is an allow-list of the binaries of the CI stages, which would leave this build with an empty context. +.git +target +opr8r/target +**/node_modules +docs/_site +.tickets + +# Rebuilt from source in the web stage; a stale local dist must not leak in. +ui/dist +webcomponents/dist + +# CI-staged release binaries, if a previous CI-style build left them behind. +operator-linux-* +opr8r-linux-* diff --git a/README.md b/README.md index 3c464b49..80b521cb 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Operator! [![GitHub Tag](https://img.shields.io/github/v/tag/untra/operator)](https://github.com/untra/operator/releases) [![codecov](https://codecov.io/gh/untra/operator/branch/main/graph/badge.svg)](https://codecov.io/gh/untra/operator) -**_This Project is currently in alpha, is free to use, and officially promises nothing yet!_** +**_This Project is currently in ALPHA, is free to use, and officially promises nothing yet!_** * **Session** [![tmux](https://img.shields.io/badge/tmux-1BB91F?logo=tmux&logoColor=white)](https://operator.untra.io/getting-started/sessions/tmux/) [![cmux](https://img.shields.io/badge/cmux-333333)](https://operator.untra.io/getting-started/sessions/cmux/) [![Zellij](https://img.shields.io/badge/Zellij-E8590C)](https://operator.untra.io/getting-started/sessions/zellij/) @@ -18,7 +18,7 @@ * **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/) -* **Platform** [![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)](https://operator.untra.io/getting-started/platforms/docker/) [![Coder](https://img.shields.io/badge/Coder-7C71FF?logo=coder&logoColor=white)](https://operator.untra.io/getting-started/platforms/coder/) +* **Platform** [![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)](https://operator.untra.io/getting-started/platforms/docker/) [![Coder](https://img.shields.io/badge/Coder-7C71FF?logo=coder&logoColor=white)](https://operator.untra.io/getting-started/platforms/coder/) [![Kubernetes](https://img.shields.io/badge/Kubernetes-326CE5?logo=kubernetes&logoColor=white)](https://operator.untra.io/getting-started/platforms/kubernetes/) * **Workflow Export Format** [![Claude Workflow](https://img.shields.io/badge/Claude_Workflow-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/workflows/claude/) [![AGNT Workflow](https://img.shields.io/badge/AGNT_Workflow-6E56CF)](https://operator.untra.io/getting-started/workflows/agnt/) @@ -98,7 +98,7 @@ sudo mv operator /usr/local/bin/ ``` ```powershell -# Windows (PowerShell) — use operator-windows-arm64.exe on ARM64 +# Windows (PowerShell) - use operator-windows-arm64.exe on ARM64 Invoke-WebRequest -Uri "https://github.com/untra/operator/releases/latest/download/operator-windows-x86_64.exe" -OutFile "operator.exe" ``` @@ -301,7 +301,7 @@ operator launch --delegator codex-local-qwen operator launch --llm-tool codex --model qwen2.5-coder --model-server ollama-local ``` -**Protocol compatibility.** Codex speaks the OpenAI API — pairing with ollama requires no bridge. Claude and Gemini use their own vendor protocols and require a translating proxy (e.g. `claude-code-router`, `litellm-proxy`) between the CLI and ollama; declare the bridge URL as your `model_server.base_url`. +**Protocol compatibility.** Codex speaks the OpenAI API - pairing with ollama requires no bridge. Claude and Gemini use their own vendor protocols and require a translating proxy (e.g. `claude-code-router`, `litellm-proxy`) between the CLI and ollama; declare the bridge URL as your `model_server.base_url`. -Current release ships the infrastructure — ollama detection and automatic env-var injection on spawn land in the next release. See `docs/getting-started/model-servers/` for the full walkthrough. +Current release ships the infrastructure - ollama detection and automatic env-var injection on spawn land in the next release. See `docs/getting-started/model-servers/` for the full walkthrough. diff --git a/agnt-plugin/README.md b/agnt-plugin/README.md index b5a8a04d..cede4ff9 100644 --- a/agnt-plugin/README.md +++ b/agnt-plugin/README.md @@ -1,14 +1,10 @@ # operator-plugin (AGNT.gg) -An [AGNT.gg](https://agnt.gg) plugin that exposes **Operator!**'s ticket -orchestration as workflow nodes. Drop these nodes into an AGNT workflow to -create tickets, launch coding agents, poll the queue, export workflows, and -raise investigations — all driven by Operator's local REST API. +An [AGNT.gg](https://agnt.gg) plugin that exposes **Operator!**'s ticket orchestration as workflow nodes. +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 the `operator workflow export --format agnt` emitter built into Operator, which emits graphs composed of the `operator-launch-agent` nodes this plugin defines. ## Nodes @@ -79,7 +75,7 @@ curl -X POST http://localhost:3333/api/plugins/reload ## Alternative: the MCP bridge (no plugin) Operator also ships a stdio MCP server exposing ~18 orchestration tools. AGNT -consumes stdio MCP servers natively — register Operator without this plugin via +consumes stdio MCP servers natively - register Operator without this plugin via AGNT's MCP settings: ```json diff --git a/bindings/AccessKeyListResponse.ts b/bindings/AccessKeyListResponse.ts new file mode 100644 index 00000000..7f4e38c5 --- /dev/null +++ b/bindings/AccessKeyListResponse.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AccessKeySummary } from "./AccessKeySummary"; + +/** + * All access keys, active and revoked. + */ +export type AccessKeyListResponse = { +/** + * The keys. + */ +keys: Array<AccessKeySummary>, }; diff --git a/bindings/AccessKeySummary.ts b/bindings/AccessKeySummary.ts new file mode 100644 index 00000000..e3f5caf8 --- /dev/null +++ b/bindings/AccessKeySummary.ts @@ -0,0 +1,35 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Scope } from "./Scope"; + +/** + * Access key metadata. Carries no secret and no hash. + */ +export type AccessKeySummary = { +/** + * Stable identifier, safe to display and to reference for revocation. + */ +id: string, +/** + * Human-readable label. + */ +name: string, +/** + * Scopes granted. + */ +scopes: Array<Scope>, +/** + * When the key was created. + */ +created_at: string, +/** + * When the key expires. + */ +expires_at: string, +/** + * When the key was last exchanged for a token; `None` if never used. + */ +last_used_at?: string | null, +/** + * When the key was revoked; `None` while active. + */ +revoked_at?: string | null, }; diff --git a/bindings/AgentState.ts b/bindings/AgentState.ts index 871bae77..a6c1801b 100644 --- a/bindings/AgentState.ts +++ b/bindings/AgentState.ts @@ -1,7 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GitExecutionConfig } from "./GitExecutionConfig"; import type { StepLaunchContext } from "./StepLaunchContext"; -export type AgentState = { id: string, ticket_id: string, ticket_type: string, project: string, status: string, started_at: string, last_activity: string, last_message: string | null, paired: boolean, +export type AgentState = { +/** + * Non-secret Git configuration captured at launch. + */ +git_context: GitExecutionConfig | null, id: string, ticket_id: string, ticket_type: string, project: string, status: string, started_at: string, last_activity: string, last_message: string | null, paired: boolean, /** * The terminal session name for this agent (for recovery) */ diff --git a/bindings/AgentsConfiguration.ts b/bindings/AgentsConfiguration.ts new file mode 100644 index 00000000..40013855 --- /dev/null +++ b/bindings/AgentsConfiguration.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 AgentsConfiguration = { max_parallel: number, cores_reserved: number, max_agents_per_repo: number, health_check_interval: bigint, generation_timeout_secs: bigint, sync_interval: bigint, step_timeout: bigint, silence_threshold: bigint, }; diff --git a/bindings/AgentsConfigurationPatch.ts b/bindings/AgentsConfigurationPatch.ts new file mode 100644 index 00000000..d685a049 --- /dev/null +++ b/bindings/AgentsConfigurationPatch.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 AgentsConfigurationPatch = { max_parallel: number | null, cores_reserved: number | null, max_agents_per_repo: number | null, health_check_interval: bigint | null, generation_timeout_secs: bigint | null, sync_interval: bigint | null, step_timeout: bigint | null, silence_threshold: bigint | null, }; diff --git a/bindings/BootstrapState.ts b/bindings/BootstrapState.ts new file mode 100644 index 00000000..f6c908ba --- /dev/null +++ b/bindings/BootstrapState.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Where the deployment sits in the one-time admin-creation sequence. + */ +export type BootstrapState = "uninitialized" | "awaiting_password" | "complete"; diff --git a/bindings/BootstrapStatusResponse.ts b/bindings/BootstrapStatusResponse.ts new file mode 100644 index 00000000..1ebeea33 --- /dev/null +++ b/bindings/BootstrapStatusResponse.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BootstrapState } from "./BootstrapState"; + +/** + * Current bootstrap state, readable without authentication so a client can + * route a first-time visitor to setup rather than to login. + */ +export type BootstrapStatusResponse = { +/** + * The state this deployment is in. + */ +state: BootstrapState, +/** + * Whether a temporary password was supplied out of band (a mounted + * bootstrap secret). When true, submission must present it. + */ +requires_temporary_password: boolean, }; diff --git a/bindings/BootstrapSubmitRequest.ts b/bindings/BootstrapSubmitRequest.ts new file mode 100644 index 00000000..52755404 --- /dev/null +++ b/bindings/BootstrapSubmitRequest.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. + +/** + * Claim the admin account and set its password. + */ +export type BootstrapSubmitRequest = { +/** + * The out-of-band temporary password, when + * `requires_temporary_password` is set. Never persisted or logged. + */ +temporary_password?: string | null, +/** + * The admin password to set. Never persisted in plaintext or logged. + */ +new_password: string, }; diff --git a/bindings/BootstrapSubmitResponse.ts b/bindings/BootstrapSubmitResponse.ts new file mode 100644 index 00000000..054bbe70 --- /dev/null +++ b/bindings/BootstrapSubmitResponse.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BootstrapState } from "./BootstrapState"; + +/** + * Result of a successful bootstrap. + */ +export type BootstrapSubmitResponse = { +/** + * The state after submission — `Complete` on success. + */ +state: BootstrapState, }; diff --git a/bindings/ConfigurationResponse.ts b/bindings/ConfigurationResponse.ts new file mode 100644 index 00000000..8fce3ec5 --- /dev/null +++ b/bindings/ConfigurationResponse.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentsConfiguration } from "./AgentsConfiguration"; +import type { LaunchConfiguration } from "./LaunchConfiguration"; +import type { QueueConfiguration } from "./QueueConfiguration"; +import type { UiConfiguration } from "./UiConfiguration"; + +/** + * The deliberately supported, integration-safe configuration surface. + */ +export type ConfigurationResponse = { agents: AgentsConfiguration, queue: QueueConfiguration, ui: UiConfiguration, launch: LaunchConfiguration, }; diff --git a/bindings/CreateAccessKeyRequest.ts b/bindings/CreateAccessKeyRequest.ts new file mode 100644 index 00000000..92b23a04 --- /dev/null +++ b/bindings/CreateAccessKeyRequest.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Scope } from "./Scope"; + +/** + * Create a service access key for an integration. + */ +export type CreateAccessKeyRequest = { +/** + * Human-readable label identifying what holds this key. + */ +name: string, +/** + * Scopes to grant. Only what the integration needs. + */ +scopes: Array<Scope>, +/** + * Days until the key expires. Expiry is mandatory — there is no + * non-expiring key. + */ +expires_in_days: bigint, }; diff --git a/bindings/CreateAccessKeyResponse.ts b/bindings/CreateAccessKeyResponse.ts new file mode 100644 index 00000000..64f89d17 --- /dev/null +++ b/bindings/CreateAccessKeyResponse.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AccessKeySummary } from "./AccessKeySummary"; + +/** + * A newly created access key. **The secret appears here and nowhere else, + * ever** — only its hash is stored, so it cannot be shown again. + */ +export type CreateAccessKeyResponse = { +/** + * Metadata for the created key. + */ +key: AccessKeySummary, +/** + * The key secret, returned exactly once. Store it now; it is unrecoverable. + */ +secret: string, }; diff --git a/bindings/CreateDelegatorFromToolRequest.ts b/bindings/CreateDelegatorFromToolRequest.ts index f4ac8889..2148519b 100644 --- a/bindings/CreateDelegatorFromToolRequest.ts +++ b/bindings/CreateDelegatorFromToolRequest.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DelegatorLaunchConfigDto } from "./DelegatorLaunchConfigDto"; +import type { GitExecutionConfig } from "./GitExecutionConfig"; /** * Request to create a delegator from a detected LLM tool @@ -9,6 +10,10 @@ import type { DelegatorLaunchConfigDto } from "./DelegatorLaunchConfigDto"; * If `model` is omitted, uses the tool's first model alias. */ export type CreateDelegatorFromToolRequest = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Name of the detected tool (e.g., "claude", "codex", "gemini") */ diff --git a/bindings/CreateDelegatorRequest.ts b/bindings/CreateDelegatorRequest.ts index 02a32eff..84b55b9b 100644 --- a/bindings/CreateDelegatorRequest.ts +++ b/bindings/CreateDelegatorRequest.ts @@ -1,11 +1,16 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DelegatorLaunchConfigDto } from "./DelegatorLaunchConfigDto"; +import type { GitExecutionConfig } from "./GitExecutionConfig"; import type { RemoteAgentRef } from "./RemoteAgentRef"; /** * Request to create a new delegator */ export type CreateDelegatorRequest = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Unique name for the delegator */ diff --git a/bindings/CsrfTokenResponse.ts b/bindings/CsrfTokenResponse.ts new file mode 100644 index 00000000..f42f85c4 --- /dev/null +++ b/bindings/CsrfTokenResponse.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A freshly minted CSRF token for the current session. + */ +export type CsrfTokenResponse = { +/** + * Send as the CSRF header on cookie-authenticated mutations. + */ +csrf_token: string, }; diff --git a/bindings/CurrentSessionResponse.ts b/bindings/CurrentSessionResponse.ts new file mode 100644 index 00000000..89846972 --- /dev/null +++ b/bindings/CurrentSessionResponse.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PrincipalKind } from "./PrincipalKind"; +import type { Scope } from "./Scope"; + +/** + * The caller's current authenticated identity. + */ +export type CurrentSessionResponse = { +/** + * Account name — always `admin`, the single human account. + */ +subject: string, +/** + * Scopes this credential holds. + */ +scopes: Array<Scope>, +/** + * How the caller authenticated. + */ +principal_kind: PrincipalKind, +/** + * When this credential expires, if it does. + */ +expires_at?: string | null, }; diff --git a/bindings/Delegator.ts b/bindings/Delegator.ts index cf2f9782..1e862b50 100644 --- a/bindings/Delegator.ts +++ b/bindings/Delegator.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DelegatorLaunchConfig } from "./DelegatorLaunchConfig"; +import type { GitExecutionConfig } from "./GitExecutionConfig"; import type { RemoteAgentRef } from "./RemoteAgentRef"; import type { JsonValue } from "./serde_json/JsonValue"; @@ -10,6 +11,10 @@ import type { JsonValue } from "./serde_json/JsonValue"; * that can be used to launch agents for tickets. */ export type Delegator = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Unique name for this delegator (e.g., "claude-opus-auto") */ diff --git a/bindings/DelegatorResponse.ts b/bindings/DelegatorResponse.ts index 2279cd66..cf975182 100644 --- a/bindings/DelegatorResponse.ts +++ b/bindings/DelegatorResponse.ts @@ -1,11 +1,16 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DelegatorLaunchConfigDto } from "./DelegatorLaunchConfigDto"; +import type { GitExecutionConfig } from "./GitExecutionConfig"; import type { RemoteAgentRef } from "./RemoteAgentRef"; /** * Response for a single delegator */ export type DelegatorResponse = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Unique name */ diff --git a/bindings/DetectedToolSummary.ts b/bindings/DetectedToolSummary.ts new file mode 100644 index 00000000..b1f0c975 --- /dev/null +++ b/bindings/DetectedToolSummary.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. +import type { ToolCapabilitiesSummary } from "./ToolCapabilitiesSummary"; + +/** + * Public view of a detected agent tool without local paths or command flags. + */ +export type DetectedToolSummary = { name: string, version: string, min_version: string | null, version_ok: boolean, model_aliases: Array<string>, capabilities: ToolCapabilitiesSummary, health_ok: boolean, }; diff --git a/bindings/DeviceApprovalRequest.ts b/bindings/DeviceApprovalRequest.ts new file mode 100644 index 00000000..e97176e9 --- /dev/null +++ b/bindings/DeviceApprovalRequest.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Approve a pending device authorization from an authenticated session. + */ +export type DeviceApprovalRequest = { +/** + * The user code shown on the requesting device. + */ +user_code: string, }; diff --git a/bindings/DeviceApprovalResponse.ts b/bindings/DeviceApprovalResponse.ts new file mode 100644 index 00000000..4325fe2a --- /dev/null +++ b/bindings/DeviceApprovalResponse.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Scope } from "./Scope"; + +/** + * Result of approving a device. + */ +export type DeviceApprovalResponse = { +/** + * Client that requested authorization, echoed so the approver can confirm. + */ +client_id: string, +/** + * Scopes granted. + */ +scopes: Array<Scope>, +/** + * Whether approval completed. + */ +approved: boolean, }; diff --git a/bindings/DeviceAuthorizationRequest.ts b/bindings/DeviceAuthorizationRequest.ts new file mode 100644 index 00000000..8869e99e --- /dev/null +++ b/bindings/DeviceAuthorizationRequest.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Scope } from "./Scope"; + +/** + * Begin device authorization for a public client that cannot hold a secret. + */ +export type DeviceAuthorizationRequest = { +/** + * Identifier for the requesting client (e.g. `vscode`). + */ +client_id: string, +/** + * Scopes requested. IDE clients request all four, because such a client + * acts as the human admin. + */ +scopes: Array<Scope>, }; diff --git a/bindings/DeviceAuthorizationResponse.ts b/bindings/DeviceAuthorizationResponse.ts new file mode 100644 index 00000000..92d98107 --- /dev/null +++ b/bindings/DeviceAuthorizationResponse.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * RFC 8628 device authorization response. + */ +export type DeviceAuthorizationResponse = { +/** + * Opaque code the client polls the token endpoint with. Never logged. + */ +device_code: string, +/** + * Short code the human types into the approval screen. + */ +user_code: string, +/** + * Where the human goes to approve. + */ +verification_uri: string, +/** + * `verification_uri` with the user code pre-filled. + */ +verification_uri_complete: string, +/** + * Seconds until the device code expires. + */ +expires_in: bigint, +/** + * Minimum seconds the client must wait between polls. + */ +interval: bigint, }; diff --git a/bindings/DeviceSummary.ts b/bindings/DeviceSummary.ts new file mode 100644 index 00000000..32120dde --- /dev/null +++ b/bindings/DeviceSummary.ts @@ -0,0 +1,35 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Scope } from "./Scope"; + +/** + * A client authorized through the device flow. + */ +export type DeviceSummary = { +/** + * Stable identifier, safe to display and to reference for revocation. + */ +id: string, +/** + * Client identifier supplied at authorization (e.g. `vscode`). + */ +client_id: string, +/** + * Scopes granted to this device. + */ +scopes: Array<Scope>, +/** + * When the device was approved. + */ +created_at: string, +/** + * When the device's refresh credential reaches its absolute deadline. + */ +expires_at: string, +/** + * When the device last refreshed. + */ +last_used_at?: string | null, +/** + * When the device was revoked; `None` while active. + */ +revoked_at?: string | null, }; diff --git a/bindings/ExecutionTargetKind.ts b/bindings/ExecutionTargetKind.ts new file mode 100644 index 00000000..df2044fb --- /dev/null +++ b/bindings/ExecutionTargetKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Execution target transport category. + */ +export type ExecutionTargetKind = "local" | "docker" | "coder" | "ssh"; diff --git a/bindings/ExecutionTargetSummary.ts b/bindings/ExecutionTargetSummary.ts new file mode 100644 index 00000000..b38764a0 --- /dev/null +++ b/bindings/ExecutionTargetSummary.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. +import type { ExecutionTargetKind } from "./ExecutionTargetKind"; + +/** + * A named execution target without connection or credential plumbing. + */ +export type ExecutionTargetSummary = { name: string, display_name: string | null, kind: ExecutionTargetKind, available: boolean, }; diff --git a/bindings/ExecutionTargetsResponse.ts b/bindings/ExecutionTargetsResponse.ts new file mode 100644 index 00000000..a0e0b026 --- /dev/null +++ b/bindings/ExecutionTargetsResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExecutionTargetSummary } from "./ExecutionTargetSummary"; + +export type ExecutionTargetsResponse = { targets: Array<ExecutionTargetSummary>, total: number, }; diff --git a/bindings/ForgejoConfig.ts b/bindings/ForgejoConfig.ts new file mode 100644 index 00000000..cc7c5e1f --- /dev/null +++ b/bindings/ForgejoConfig.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 ForgejoConfig = { enabled: boolean, token_env: string, +/** + * HTTPS host or base URL; defaults to codeberg.org. + */ +host: string | null, wip_prefix: string, }; diff --git a/bindings/GitConfig.ts b/bindings/GitConfig.ts index 25728390..a9b7bd6a 100644 --- a/bindings/GitConfig.ts +++ b/bindings/GitConfig.ts @@ -1,12 +1,19 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ForgejoConfig } from "./ForgejoConfig"; import type { GitHubConfig } from "./GitHubConfig"; +import type { GitIdentityConfig } from "./GitIdentityConfig"; import type { GitLabConfig } from "./GitLabConfig"; import type { GitProviderConfig } from "./GitProviderConfig"; +import type { GiteaConfig } from "./GiteaConfig"; /** * Git provider configuration for PR/MR operations */ export type GitConfig = { +/** + * Default commit identity for delegated work. + */ +identity?: GitIdentityConfig | null, gitea: GiteaConfig, forgejo: ForgejoConfig, /** * Active provider (auto-detected from remote URL if not specified) */ diff --git a/bindings/GitConfigEntry.ts b/bindings/GitConfigEntry.ts new file mode 100644 index 00000000..26a5011d --- /dev/null +++ b/bindings/GitConfigEntry.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 GitConfigEntry = { key: string, value: string, }; diff --git a/bindings/GitCredentialConfig.ts b/bindings/GitCredentialConfig.ts new file mode 100644 index 00000000..17f539e5 --- /dev/null +++ b/bindings/GitCredentialConfig.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Supplied HTTPS credential, bound to a repository; contains no secret value. + */ +export type GitCredentialConfig = { repository_url: string, username: string, token_env: string, }; diff --git a/bindings/GitExecutionConfig.ts b/bindings/GitExecutionConfig.ts new file mode 100644 index 00000000..31ad63f2 --- /dev/null +++ b/bindings/GitExecutionConfig.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GitConfigEntry } from "./GitConfigEntry"; +import type { GitCredentialConfig } from "./GitCredentialConfig"; +import type { GitIdentityConfig } from "./GitIdentityConfig"; + +/** + * Git settings owned by a named delegator. + */ +export type GitExecutionConfig = { identity: GitIdentityConfig | null, credentials: GitCredentialConfig | null, settings: Array<GitConfigEntry>, }; diff --git a/bindings/GitIdentityConfig.ts b/bindings/GitIdentityConfig.ts new file mode 100644 index 00000000..12f8966a --- /dev/null +++ b/bindings/GitIdentityConfig.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Commit identity template for delegated work. + */ +export type GitIdentityConfig = { name: string, email: string, }; diff --git a/bindings/GiteaConfig.ts b/bindings/GiteaConfig.ts new file mode 100644 index 00000000..eca59228 --- /dev/null +++ b/bindings/GiteaConfig.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 GiteaConfig = { enabled: boolean, token_env: string, +/** + * HTTPS host or base URL; defaults to gitea.com. + */ +host: string | null, wip_prefix: string, }; diff --git a/bindings/LaunchConfiguration.ts b/bindings/LaunchConfiguration.ts new file mode 100644 index 00000000..61119331 --- /dev/null +++ b/bindings/LaunchConfiguration.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SessionWrapper } from "./SessionWrapper"; + +export type LaunchConfiguration = { confirm_autonomous: boolean, confirm_paired: boolean, launch_delay_ms: bigint, docker_enabled: boolean, docker_image: string, yolo_enabled: boolean, session_wrapper: SessionWrapper, }; diff --git a/bindings/LaunchConfigurationPatch.ts b/bindings/LaunchConfigurationPatch.ts new file mode 100644 index 00000000..a5beaf91 --- /dev/null +++ b/bindings/LaunchConfigurationPatch.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SessionWrapper } from "./SessionWrapper"; + +export type LaunchConfigurationPatch = { confirm_autonomous: boolean | null, confirm_paired: boolean | null, launch_delay_ms: bigint | null, docker_enabled: boolean | null, docker_image: string | null, yolo_enabled: boolean | null, session_wrapper: SessionWrapper | null, }; diff --git a/bindings/LlmToolsResponse.ts b/bindings/LlmToolsResponse.ts index bcbcdd40..a6c0dc92 100644 --- a/bindings/LlmToolsResponse.ts +++ b/bindings/LlmToolsResponse.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DetectedTool } from "./DetectedTool"; +import type { DetectedToolSummary } from "./DetectedToolSummary"; /** * Response listing detected LLM tools @@ -8,7 +8,7 @@ export type LlmToolsResponse = { /** * Detected CLI tools with model aliases and capabilities */ -tools: Array<DetectedTool>, +tools: Array<DetectedToolSummary>, /** * Total count */ diff --git a/bindings/LoginRequest.ts b/bindings/LoginRequest.ts new file mode 100644 index 00000000..c36a2883 --- /dev/null +++ b/bindings/LoginRequest.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Password login, exchanged for an opaque server-side session cookie. + */ +export type LoginRequest = { +/** + * The admin password. Never persisted in plaintext or logged. + */ +password: string, }; diff --git a/bindings/LoginResponse.ts b/bindings/LoginResponse.ts new file mode 100644 index 00000000..2e02a8ff --- /dev/null +++ b/bindings/LoginResponse.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Scope } from "./Scope"; + +/** + * Successful login. The session itself rides in a `Set-Cookie` header, not in + * this body — a body-borne session identifier would be readable by script. + */ +export type LoginResponse = { +/** + * Scopes the session holds. + */ +scopes: Array<Scope>, +/** + * When the session expires. + */ +expires_at: string, +/** + * CSRF token to send on subsequent cookie-authenticated mutations. + */ +csrf_token: string, }; diff --git a/bindings/LogoutResponse.ts b/bindings/LogoutResponse.ts new file mode 100644 index 00000000..b14a834e --- /dev/null +++ b/bindings/LogoutResponse.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Result of destroying the current session server-side. + */ +export type LogoutResponse = { +/** + * Always true; present so the response has a stable, non-empty shape. + */ +ended: boolean, }; diff --git a/bindings/OAuthErrorCode.ts b/bindings/OAuthErrorCode.ts new file mode 100644 index 00000000..7d953ea4 --- /dev/null +++ b/bindings/OAuthErrorCode.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * OAuth error codes Operator emits. + */ +export type OAuthErrorCode = "authorization_pending" | "slow_down" | "expired_token" | "access_denied" | "invalid_grant" | "invalid_request" | "invalid_client" | "invalid_scope" | "unsupported_grant_type"; diff --git a/bindings/OAuthErrorResponse.ts b/bindings/OAuthErrorResponse.ts new file mode 100644 index 00000000..a32e6392 --- /dev/null +++ b/bindings/OAuthErrorResponse.ts @@ -0,0 +1,21 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { OAuthErrorCode } from "./OAuthErrorCode"; + +/** + * Standardized OAuth error, shaped per RFC 6749 §5.2 so stock clients can + * interpret it — notably `authorization_pending` and `slow_down`, which a + * device-flow client polls against. + */ +export type OAuthErrorResponse = { +/** + * Machine-readable error code. + */ +error: OAuthErrorCode, +/** + * Human-readable explanation. + */ +error_description?: string | null, +/** + * Documentation link. + */ +error_uri?: string | null, }; diff --git a/bindings/PanelNamesConfiguration.ts b/bindings/PanelNamesConfiguration.ts new file mode 100644 index 00000000..b5e0e38f --- /dev/null +++ b/bindings/PanelNamesConfiguration.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 PanelNamesConfiguration = { status: string, queue: string, in_progress: string, completed: string, }; diff --git a/bindings/PanelNamesConfigurationPatch.ts b/bindings/PanelNamesConfigurationPatch.ts new file mode 100644 index 00000000..5f343319 --- /dev/null +++ b/bindings/PanelNamesConfigurationPatch.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 PanelNamesConfigurationPatch = { status: string | null, queue: string | null, in_progress: string | null, completed: string | null, }; diff --git a/bindings/PrincipalKind.ts b/bindings/PrincipalKind.ts new file mode 100644 index 00000000..1909e703 --- /dev/null +++ b/bindings/PrincipalKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What kind of credential authenticated a request. + */ +export type PrincipalKind = "session" | "access_token" | "local_process" | "agent_callback"; diff --git a/bindings/QueueConfiguration.ts b/bindings/QueueConfiguration.ts new file mode 100644 index 00000000..c20ac628 --- /dev/null +++ b/bindings/QueueConfiguration.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 QueueConfiguration = { auto_assign: boolean, priority_order: Array<string>, poll_interval_ms: bigint, }; diff --git a/bindings/QueueConfigurationPatch.ts b/bindings/QueueConfigurationPatch.ts new file mode 100644 index 00000000..7d7145a6 --- /dev/null +++ b/bindings/QueueConfigurationPatch.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 QueueConfigurationPatch = { auto_assign: boolean | null, priority_order: Array<string> | null, poll_interval_ms: bigint | null, }; diff --git a/bindings/RepoInfo.ts b/bindings/RepoInfo.ts index b33e35b1..012125e5 100644 --- a/bindings/RepoInfo.ts +++ b/bindings/RepoInfo.ts @@ -5,6 +5,10 @@ import type { GitProvider } from "./GitProvider"; * Repository info parsed from remote URL (provider-agnostic) */ export type RepoInfo = { +/** + * Repository hostname, retained for routing and monitor isolation. + */ +host: string | null, /** * Git hosting provider */ diff --git a/bindings/RestApiConfig.ts b/bindings/RestApiConfig.ts index 3e7c6682..c80234d5 100644 --- a/bindings/RestApiConfig.ts +++ b/bindings/RestApiConfig.ts @@ -19,6 +19,13 @@ host: string, */ port: number, /** - * CORS allowed origins (empty = allow all) + * CORS allowed origins. Empty means **same-origin only** */ -cors_origins: Array<string>, }; +cors_origins: Array<string>, +/** + * Externally reachable base URL (e.g. `https://operator.example.com`). + * + * OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, + * which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. + */ +public_url: string | null, }; diff --git a/bindings/RevokeAccessKeyResponse.ts b/bindings/RevokeAccessKeyResponse.ts new file mode 100644 index 00000000..28fb76c2 --- /dev/null +++ b/bindings/RevokeAccessKeyResponse.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. + +/** + * Result of revoking an access key. + */ +export type RevokeAccessKeyResponse = { +/** + * The revoked key's identifier. + */ +id: string, +/** + * When revocation took effect. + */ +revoked_at: string, }; diff --git a/bindings/Scope.ts b/bindings/Scope.ts new file mode 100644 index 00000000..6e50e981 --- /dev/null +++ b/bindings/Scope.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A typed authorization scope. + * + * Scopes are **not hierarchical**: `Write` does not imply `Read`. A credential + * is granted each scope it needs explicitly, so an integration's authority is + * legible from its scope list alone rather than requiring the reader to reason + * about implication. + */ +export type Scope = "read" | "write" | "execute" | "admin"; diff --git a/bindings/SessionListResponse.ts b/bindings/SessionListResponse.ts new file mode 100644 index 00000000..d824fc19 --- /dev/null +++ b/bindings/SessionListResponse.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DeviceSummary } from "./DeviceSummary"; +import type { SessionSummary } from "./SessionSummary"; + +/** + * All sessions and devices for the admin account. + */ +export type SessionListResponse = { +/** + * Browser sessions. + */ +sessions: Array<SessionSummary>, +/** + * Device-flow clients. + */ +devices: Array<DeviceSummary>, }; diff --git a/bindings/SessionSummary.ts b/bindings/SessionSummary.ts new file mode 100644 index 00000000..21fedc39 --- /dev/null +++ b/bindings/SessionSummary.ts @@ -0,0 +1,32 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * An active or expired browser session. Carries no session identifier — the + * cookie value is never readable back out, only the session's `id` for + * revocation. + */ +export type SessionSummary = { +/** + * Stable identifier, safe to display and to reference for revocation. + */ +id: string, +/** + * When the session began. + */ +created_at: string, +/** + * When the session expires. + */ +expires_at: string, +/** + * When the session was last used. + */ +last_used_at?: string | null, +/** + * When the session was revoked; `None` while active. + */ +revoked_at?: string | null, +/** + * Whether this is the session making the request. + */ +current: boolean, }; diff --git a/bindings/SessionWrapper.ts b/bindings/SessionWrapper.ts new file mode 100644 index 00000000..5cea6429 --- /dev/null +++ b/bindings/SessionWrapper.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 SessionWrapper = "tmux" | "vscode" | "cmux" | "zellij"; diff --git a/bindings/TokenRequest.ts b/bindings/TokenRequest.ts new file mode 100644 index 00000000..b50e7728 --- /dev/null +++ b/bindings/TokenRequest.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. + +/** + * Token endpoint request. The discriminator makes unrelated credential + * combinations unrepresentable. + */ +export type TokenRequest = { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", device_code: string, client_id: string, } | { "grant_type": "refresh_token", refresh_token: string, client_id: string, } | { "grant_type": "operator:access-key", access_key: string, }; diff --git a/bindings/TokenResponse.ts b/bindings/TokenResponse.ts new file mode 100644 index 00000000..e053feae --- /dev/null +++ b/bindings/TokenResponse.ts @@ -0,0 +1,28 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Scope } from "./Scope"; + +/** + * A newly issued access token, and a refresh token when the grant produces one. + */ +export type TokenResponse = { +/** + * Signed, short-lived bearer token. + */ +access_token: string, +/** + * Always `Bearer`. + */ +token_type: string, +/** + * Seconds until `access_token` expires. + */ +expires_in: bigint, +/** + * Opaque rotating refresh token. Absent for access-key exchange, which is + * re-exercised with the key itself rather than refreshed. + */ +refresh_token?: string | null, +/** + * Scopes the access token carries. + */ +scopes: Array<Scope>, }; diff --git a/bindings/ToolCapabilitiesSummary.ts b/bindings/ToolCapabilitiesSummary.ts new file mode 100644 index 00000000..e3d0dcd5 --- /dev/null +++ b/bindings/ToolCapabilitiesSummary.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 ToolCapabilitiesSummary = { supports_sessions: boolean, supports_headless: boolean, }; diff --git a/bindings/UiConfiguration.ts b/bindings/UiConfiguration.ts new file mode 100644 index 00000000..c6cb7e53 --- /dev/null +++ b/bindings/UiConfiguration.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PanelNamesConfiguration } from "./PanelNamesConfiguration"; + +export type UiConfiguration = { refresh_rate_ms: bigint, completed_history_hours: bigint, summary_max_length: number, panel_names: PanelNamesConfiguration, }; diff --git a/bindings/UiConfigurationPatch.ts b/bindings/UiConfigurationPatch.ts new file mode 100644 index 00000000..54f50cd5 --- /dev/null +++ b/bindings/UiConfigurationPatch.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PanelNamesConfigurationPatch } from "./PanelNamesConfigurationPatch"; + +export type UiConfigurationPatch = { refresh_rate_ms: bigint | null, completed_history_hours: bigint | null, summary_max_length: number | null, panel_names: PanelNamesConfigurationPatch | null, }; diff --git a/bindings/UpdateConfigurationRequest.ts b/bindings/UpdateConfigurationRequest.ts new file mode 100644 index 00000000..a02d53a1 --- /dev/null +++ b/bindings/UpdateConfigurationRequest.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentsConfigurationPatch } from "./AgentsConfigurationPatch"; +import type { LaunchConfigurationPatch } from "./LaunchConfigurationPatch"; +import type { QueueConfigurationPatch } from "./QueueConfigurationPatch"; +import type { UiConfigurationPatch } from "./UiConfigurationPatch"; + +/** + * Field-level patch for the public operational configuration. + */ +export type UpdateConfigurationRequest = { agents: AgentsConfigurationPatch | null, queue: QueueConfigurationPatch | null, ui: UiConfigurationPatch | null, launch: LaunchConfigurationPatch | null, }; diff --git a/bindings/XOperator.ts b/bindings/XOperator.ts index eb325238..1088ce53 100644 --- a/bindings/XOperator.ts +++ b/bindings/XOperator.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DelegatorLaunchConfig } from "./DelegatorLaunchConfig"; +import type { GitExecutionConfig } from "./GitExecutionConfig"; /** * The Operator-namespaced half of an [`AgentProfile`] — the fields a Delegator @@ -7,6 +8,10 @@ import type { DelegatorLaunchConfig } from "./DelegatorLaunchConfig"; * round-trips it losslessly. */ export type XOperator = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Optional display name for UI. */ diff --git a/build.rs b/build.rs index df593449..d169a0eb 100644 --- a/build.rs +++ b/build.rs @@ -18,7 +18,7 @@ fn main() { ) .expect("write placeholder index.html"); println!( - "cargo:warning=ui/dist/index.html is a placeholder — run `cd ui && bun run build` for real UI" + "cargo:warning=ui/dist/index.html is a placeholder - run `cd ui && bun run build` for real UI" ); } @@ -26,7 +26,7 @@ fn main() { let total = walk_dir_size(ui_dist); assert!( total <= 15_728_640, - "UI dist is {}B ({:.1}MB) — exceeds 15MB uncompressed budget", + "UI dist is {}B ({:.1}MB) - exceeds 15MB uncompressed budget", total, total as f64 / 1_048_576.0 ); diff --git a/bump-version.sh b/bump-version.sh index 282cb739..e82e1f6a 100755 --- a/bump-version.sh +++ b/bump-version.sh @@ -40,7 +40,6 @@ TEXT_FILES=( # JSON files: update .version via jq JSON_FILES=( "vscode-extension/package.json" - "backstage-server/package.json" "agnt-plugin/package.json" "agnt-plugin/manifest.json" ) @@ -71,5 +70,17 @@ for f in "${JSON_FILES[@]}"; do fi done +CHART_FILE="charts/operator/Chart.yaml" +if $DRY_RUN; then + echo "[dry-run] would update $CHART_FILE" +else + awk -v version="$NEW" ' + /^version:/ { $0 = "version: " version } + /^appVersion:/ { $0 = "appVersion: \"" version "\"" } + { print } + ' "$CHART_FILE" > "$CHART_FILE.tmp" && mv "$CHART_FILE.tmp" "$CHART_FILE" + echo "Updated $CHART_FILE" +fi + echo "" echo "Done. Version is now $NEW" diff --git a/charts/operator/Chart.yaml b/charts/operator/Chart.yaml new file mode 100644 index 00000000..28d8b8cf --- /dev/null +++ b/charts/operator/Chart.yaml @@ -0,0 +1,10 @@ +apiVersion: v2 +name: operator +description: Run Operator as a single-writer agent orchestration service +type: application +version: 0.2.7 +appVersion: "0.2.7" +kubeVersion: ">=1.25.0-0" +home: https://operator.untra.io +sources: + - https://github.com/untra/operator diff --git a/charts/operator/templates/_helpers.tpl b/charts/operator/templates/_helpers.tpl new file mode 100644 index 00000000..64fe56a0 --- /dev/null +++ b/charts/operator/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{- define "operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "operator.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{ include "operator.selectorLabels" . }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} + +{{- define "operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "operator.serviceAccountName" -}} +{{- default (include "operator.fullname" .) .Values.serviceAccount.name }} +{{- end }} diff --git a/charts/operator/templates/ingress.yaml b/charts/operator/templates/ingress.yaml new file mode 100644 index 00000000..bab53c8d --- /dev/null +++ b/charts/operator/templates/ingress.yaml @@ -0,0 +1,35 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls.secretName }} + tls: + - hosts: + - {{ $.Values.ingress.host | quote }} + secretName: {{ . | quote }} + {{- end }} + rules: + - {{- with .Values.ingress.host }} + host: {{ . | quote }} + {{- end }} + http: + paths: + - path: {{ .Values.ingress.path }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "operator.fullname" . }} + port: + name: http +{{- end }} diff --git a/charts/operator/templates/networkpolicy.yaml b/charts/operator/templates/networkpolicy.yaml new file mode 100644 index 00000000..84ae0206 --- /dev/null +++ b/charts/operator/templates/networkpolicy.yaml @@ -0,0 +1,43 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "operator.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - port: {{ .Values.service.port }} + protocol: TCP + {{- with .Values.networkPolicy.ingress.from }} + from: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if or .Values.networkPolicy.egress.allowDNS .Values.networkPolicy.egress.to }} + egress: + {{- if .Values.networkPolicy.egress.allowDNS }} + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + {{- end }} + {{- with .Values.networkPolicy.egress.to }} + - to: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- else }} + egress: [] + {{- end }} +{{- end }} diff --git a/charts/operator/templates/service.yaml b/charts/operator/templates/service.yaml new file mode 100644 index 00000000..c844d9c1 --- /dev/null +++ b/charts/operator/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "operator.selectorLabels" . | nindent 4 }} diff --git a/charts/operator/templates/serviceaccount.yaml b/charts/operator/templates/serviceaccount.yaml new file mode 100644 index 00000000..f2ece2fe --- /dev/null +++ b/charts/operator/templates/serviceaccount.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "operator.serviceAccountName" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: false diff --git a/charts/operator/templates/statefulset.yaml b/charts/operator/templates/statefulset.yaml new file mode 100644 index 00000000..067d24c9 --- /dev/null +++ b/charts/operator/templates/statefulset.yaml @@ -0,0 +1,131 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "operator.fullname" . }} + labels: + {{- include "operator.labels" . | nindent 4 }} +spec: + replicas: 1 + serviceName: {{ include "operator.fullname" . }} + podManagementPolicy: OrderedReady + minReadySeconds: 5 + updateStrategy: + type: RollingUpdate + rollingUpdate: + partition: 0 + selector: + matchLabels: + {{- include "operator.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "operator.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "operator.serviceAccountName" . }} + automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + terminationGracePeriodSeconds: 30 + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: operator + image: "{{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - api + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: HOME + value: /home/operator + # HOME is an emptyDir, so the default ~/.operator/worktrees would + # discard every agent worktree on restart. Keep them on the PVC. + - name: OPERATOR_PATHS__WORKTREES + value: /op/.worktrees + - name: OPERATOR_REST_API__HOST + value: 0.0.0.0 + - name: OPERATOR_REST_API__PORT + value: {{ .Values.service.port | quote }} + {{- with .Values.publicUrl }} + - name: OPERATOR_REST_API__PUBLIC_URL + value: {{ . | quote }} + {{- end }} + {{- with .Values.bootstrap.existingSecret }} + - name: OPERATOR_BOOTSTRAP_PASSWORD_FILE + value: /run/secrets/operator-bootstrap/password + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.extraEnvFrom }} + envFrom: + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 2 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /livez + port: http + initialDelaySeconds: 10 + periodSeconds: 20 + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: workspace + mountPath: /op + - name: home + mountPath: /home/operator + - name: tmp + mountPath: /tmp + {{- with .Values.bootstrap.existingSecret }} + - name: bootstrap + mountPath: /run/secrets/operator-bootstrap + readOnly: true + {{- end }} + volumes: + - name: home + emptyDir: {} + - name: tmp + emptyDir: {} + {{- with .Values.bootstrap.existingSecret }} + - name: bootstrap + secret: + secretName: {{ . | quote }} + optional: true + items: + - key: {{ $.Values.bootstrap.passwordKey | quote }} + path: password + {{- end }} + volumeClaimTemplates: + - metadata: + name: workspace + labels: + {{- include "operator.labels" . | nindent 10 }} + spec: + accessModes: + - ReadWriteOnce + {{- with .Values.persistence.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} diff --git a/charts/operator/values.schema.json b/charts/operator/values.schema.json new file mode 100644 index 00000000..45fa5d21 --- /dev/null +++ b/charts/operator/values.schema.json @@ -0,0 +1,184 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "nameOverride": { "type": "string" }, + "fullnameOverride": { "type": "string" }, + "image": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "tag", "pullPolicy"], + "properties": { + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string", "not": { "const": "latest" } }, + "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] } + } + }, + "imagePullSecrets": { "type": "array", "items": { "type": "object" } }, + "publicUrl": { "type": "string" }, + "bootstrap": { + "type": "object", + "additionalProperties": false, + "required": ["existingSecret", "passwordKey"], + "properties": { + "existingSecret": { "type": "string" }, + "passwordKey": { "type": "string", "minLength": 1 } + } + }, + "serviceAccount": { + "type": "object", + "additionalProperties": false, + "required": ["name", "annotations"], + "properties": { + "name": { "type": "string" }, + "annotations": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["port"], + "properties": { + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + "persistence": { + "type": "object", + "additionalProperties": false, + "required": ["size", "storageClass"], + "properties": { + "size": { "type": "string", "pattern": "^[0-9]+([EPTGMK]i?)?$" }, + "storageClass": { "type": "string" } + } + }, + "podAnnotations": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "podLabels": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "podSecurityContext": { "type": "object" }, + "containerSecurityContext": { "type": "object" }, + "resources": { "type": "object" }, + "extraEnv": { "type": "array", "items": { "type": "object" } }, + "extraEnvFrom": { "type": "array", "items": { "type": "object" } }, + "ingress": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "className", "annotations", "host", "path", "pathType", "tls"], + "properties": { + "enabled": { "type": "boolean" }, + "className": { "type": "string" }, + "annotations": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "host": { "type": "string" }, + "path": { "type": "string", "pattern": "^/" }, + "pathType": { "enum": ["Exact", "Prefix", "ImplementationSpecific"] }, + "tls": { + "type": "object", + "additionalProperties": false, + "required": ["secretName"], + "properties": { + "secretName": { "type": "string" } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "tls": { + "properties": { "secretName": { "minLength": 1 } } + } + } + }, + "then": { "properties": { "host": { "minLength": 1 } } } + } + ] + }, + "networkPolicy": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "ingress", "egress"], + "properties": { + "enabled": { "type": "boolean" }, + "ingress": { + "type": "object", + "additionalProperties": false, + "required": ["from"], + "properties": { + "from": { + "type": "array", + "items": { "$ref": "#/definitions/networkPeer" } + } + } + }, + "egress": { + "type": "object", + "additionalProperties": false, + "required": ["allowDNS", "to"], + "properties": { + "allowDNS": { "type": "boolean" }, + "to": { + "type": "array", + "items": { "$ref": "#/definitions/networkPeer" } + } + } + } + } + } + }, + "definitions": { + "labelSelector": { + "type": "object", + "additionalProperties": false, + "properties": { + "matchLabels": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "matchExpressions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["key", "operator"], + "properties": { + "key": { "type": "string", "minLength": 1 }, + "operator": { "enum": ["In", "NotIn", "Exists", "DoesNotExist"] }, + "values": { "type": "array", "items": { "type": "string" } } + } + } + } + } + }, + "ipBlock": { + "type": "object", + "additionalProperties": false, + "required": ["cidr"], + "properties": { + "cidr": { "type": "string", "minLength": 1 }, + "except": { "type": "array", "items": { "type": "string" } } + } + }, + "networkPeer": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "podSelector": { "$ref": "#/definitions/labelSelector" }, + "namespaceSelector": { "$ref": "#/definitions/labelSelector" }, + "ipBlock": { "$ref": "#/definitions/ipBlock" } + } + } + } +} diff --git a/charts/operator/values.yaml b/charts/operator/values.yaml new file mode 100644 index 00000000..d972394f --- /dev/null +++ b/charts/operator/values.yaml @@ -0,0 +1,72 @@ +nameOverride: "" +fullnameOverride: "" + +image: + repository: untra/operator + tag: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] +publicUrl: "" + +bootstrap: + existingSecret: "" + passwordKey: password + +serviceAccount: + name: "" + annotations: {} + +service: + port: 7008 + +persistence: + size: 20Gi + storageClass: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + +containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: "2" + memory: 2Gi + +extraEnv: [] +extraEnvFrom: [] + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + path: / + pathType: Prefix + tls: + secretName: "" + +networkPolicy: + enabled: false + ingress: + from: [] + egress: + allowDNS: true + to: [] diff --git a/coder-module/README.md b/coder-module/README.md index 9d6925ed..48f5becc 100644 --- a/coder-module/README.md +++ b/coder-module/README.md @@ -70,8 +70,8 @@ The workspace image must include `tmux` (or your chosen `session_wrapper`) for o Coder automatically injects environment variables into every workspace that operator can reference in ticket templates and agent prompts: -- `CODER_WORKSPACE_NAME` — workspace identifier -- `CODER_WORKSPACE_OWNER` — workspace owner username -- `CODER_AGENT_TOKEN` — agent authentication token +- `CODER_WORKSPACE_NAME` - workspace identifier +- `CODER_WORKSPACE_OWNER` - workspace owner username +- `CODER_AGENT_TOKEN` - agent authentication token -No operator configuration is needed to access these — they are ambient in the workspace environment. +No operator configuration is needed to access these - they are ambient in the workspace environment. diff --git a/coder-module/main.tf b/coder-module/main.tf index 7a5e4cb3..c922c2c6 100644 --- a/coder-module/main.tf +++ b/coder-module/main.tf @@ -38,7 +38,7 @@ variable "slug" { variable "install_version" { type = string description = "The version of operator to install (must match a GitHub release tag)." - default = "0.2.6" + default = "0.2.7" } variable "install_prefix" { diff --git a/collections/README.md b/collections/README.md index 8a9f9d4d..74c6f070 100644 --- a/collections/README.md +++ b/collections/README.md @@ -1,6 +1,6 @@ # Community Collections -This directory hosts **community-contributed issuetype collections** — shareable +This directory hosts **community-contributed issuetype collections** - shareable AI workflow shapes that operator instances can browse and install from [operator.untra.io/collections](https://operator.untra.io/collections/). @@ -12,7 +12,7 @@ curated embedded set lives in `src/collections/`. > Working with an AI agent? Point it at > [`.claude/commands/new-collection.md`](../.claude/commands/new-collection.md) -> (or run `/new-collection`) — it covers the design questions to answer first, +> (or run `/new-collection`) - it covers the design questions to answer first, > the full schema, and the validation loop. 1. Create `collections/community/<id>/` where `<id>` matches @@ -21,21 +21,21 @@ curated embedded set lives in `src/collections/`. [the collection schema](https://operator.untra.io/collections/schema.json): - `schema_version: 1` - `id` equal to the directory name - - `tier: "community"` with **`author`, `url`, and `license`** (SPDX id) — + - `tier: "community"` with **`author`, `url`, and `license`** (SPDX id) - required for community submissions - `issue_types`: 1–32 entries; keys match `^[A-Z][A-Z0-9_]{1,15}$` (hyphens are reserved for the `{KEY}-{number}` ticket-id separator); paths are bare filenames next to the manifest - optional `workflow_hints` (loop shape, memory surfaces, review gates, stop conditions) and `kanban_defaults.suggested_type_mappings` - (descriptive only — they inform users and onboarding, not execution) + (descriptive only - they inform users and onboarding, not execution) 3. Add one `<KEY>.json` per issuetype conforming to [the issuetype schema](https://operator.untra.io/schemas/issuetype.json), plus an optional `<KEY>.md` ticket template. -4. Add an `icon.svg` following the Operator icon standard — a single-path 24×24 +4. Add an `icon.svg` following the Operator icon standard - a single-path 24×24 glyph with no `fill`/`stroke`/`width`/`height`, titled with the collection's display name. Rules and rationale in `docs/design-system/`. -5. Do **not** set checksums — the docs generator computes them at publish time. Run the CI gates locally before opening a PR to ensure the collection is correctly structured: +5. Do **not** set checksums - the docs generator computes them at publish time. Run the CI gates locally before opening a PR to ensure the collection is correctly structured: ```bash cargo test --test community_collections diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index 16950927..e2c38760 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -93,6 +93,9 @@ docs: - title: GitLab url: /getting-started/git/gitlab/ icon: gitlab + - title: Gitea + url: /getting-started/git/gitea/ + icon: gitea - title: Provider Support url: /getting-started/git/provider-support/ - title: Supported Notification Integrations @@ -113,6 +116,9 @@ docs: - title: Docker url: /getting-started/platforms/docker/ icon: docker + - title: Kubernetes + url: /getting-started/platforms/kubernetes/ + icon: kubernetes - title: Automation Platform Integrations url: /getting-started/integrations/ children: @@ -133,6 +139,12 @@ docs: - title: Shortcuts url: /shortcuts/ codicon: keyboard + - title: Security + url: /security/ + codicon: shield + children: + - title: Authentication + url: /security/authentication/ - title: Design System url: /design-system/ codicon: symbol-color diff --git a/docs/architecture/operator-opr8r.md b/docs/architecture/operator-opr8r.md index 1a47f3dc..6febdbb7 100644 --- a/docs/architecture/operator-opr8r.md +++ b/docs/architecture/operator-opr8r.md @@ -22,7 +22,7 @@ Operator ships as **two executables built from one repository**: `operator`, a l ## Two independent channels -Operator and opr8r never share memory or a socket file handle directly — everything crosses a process boundary. There are two distinct channels, used for two distinct purposes: +Operator and opr8r never share memory or a socket file handle directly - everything crosses a process boundary. There are two distinct channels, used for two distinct purposes: ``` operator process @@ -36,13 +36,13 @@ operator process relay_ask / relay_reply / relay_broadcast / relay_peers / relay_rename ``` -1. **Launch (operator → session, one-way, process spawn).** Operator decides what to run and starts it — see [Launching agents](#launching-agents). -2. **Step completion (opr8r → operator, HTTP).** After the wrapped LLM command exits, `opr8r` reports the result back to Operator's REST API — see [The REST channel](#the-rest-channel-opr8r-as-step-wrapper). -3. **Peer messaging (agent ↔ agent, via operator).** A *different* opr8r subcommand, `opr8r relay`, runs as an MCP server so the LLM tool itself can message other agents through Operator's relay hub. This is a separate feature covered in full on the [Relay](/docs/relay/) page — this doc only places it in the launch topology. +1. **Launch (operator → session, one-way, process spawn).** Operator decides what to run and starts it - see [Launching agents](#launching-agents). +2. **Step completion (opr8r → operator, HTTP).** After the wrapped LLM command exits, `opr8r` reports the result back to Operator's REST API - see [The REST channel](#the-rest-channel-opr8r-as-step-wrapper). +3. **Peer messaging (agent ↔ agent, via operator).** A *different* opr8r subcommand, `opr8r relay`, runs as an MCP server so the LLM tool itself can message other agents through Operator's relay hub. This is a separate feature covered in full on the [Relay](/docs/relay/) page - this doc only places it in the launch topology. ## Launching agents -Operator decides which LLM tool, model, and prompt to use (see the [LLM Tools](/docs/llm-tools/) and [Delegators](/docs/delegators/) references) and starts that process inside a session wrapper. Today the LLM tool is spawned directly — `opr8r` does not sit between Operator and the agent for a single, non-stepped launch. +Operator decides which LLM tool, model, and prompt to use (see the [LLM Tools](/docs/llm-tools/) and [Delegators](/docs/delegators/) references) and starts that process inside a session wrapper. Today the LLM tool is spawned directly - `opr8r` does not sit between Operator and the agent for a single, non-stepped launch. Where `opr8r` becomes the parent process is **multi-step ticket workflows**, where a ticket's issuetype defines a sequence of steps (e.g. `plan` → `build` → `test`) and each step needs its completion reported before the next can run: @@ -82,11 +82,11 @@ Operator's handler (`complete_step` in `src/rest/routes/launch.rs`) records the } ``` -- If `auto_proceed` is true, `opr8r` `exec()`s the `next_command` — on Unix this replaces the current process image in place (same terminal, same pane, no new session), on Windows it spawns and waits since there's no `exec()` equivalent. +- If `auto_proceed` is true, `opr8r` `exec()`s the `next_command` - on Unix this replaces the current process image in place (same terminal, same pane, no new session), on Windows it spawns and waits since there's no `exec()` equivalent. - If a review is required, `opr8r` prints an "awaiting review" banner and exits, leaving the terminal open for the operator to advance the ticket manually or via the TUI. - `--no-auto-proceed` disables the exec regardless of what the server returns. -**Current status:** the endpoint, request/response contract, and `opr8r` chain-exec logic are implemented; the server-side construction of a fully general `next_command` for arbitrary next steps is still a placeholder in `complete_step` (see the `// For now, return a placeholder` comment in `src/rest/routes/launch.rs`) — treat multi-step auto-chaining as alpha until that lands. +**Current status:** the endpoint, request/response contract, and `opr8r` chain-exec logic are implemented; the server-side construction of a fully general `next_command` for arbitrary next steps is still a placeholder in `complete_step` (see the `// For now, return a placeholder` comment in `src/rest/routes/launch.rs`) - treat multi-step auto-chaining as alpha until that lands. ### Discovering the API @@ -96,7 +96,7 @@ Operator's handler (`complete_step` in `src/rest/routes/launch.rs`) records the |---|---|---| | 1 | `--api-url` flag | Explicit override | | 2 | `OPERATOR_API_URL` env var | Remote launches, where callbacks must route back through an SSH reverse tunnel | -| 3 | `.tickets/operator/api-session.json` | Local discovery — see below | +| 3 | `.tickets/operator/api-session.json` | Local discovery - see below | | 4 | `http://localhost:7008` | Default fallback | Operator writes `api-session.json` (`{"port", "pid", "started_at", "version"}`) into `.tickets/operator/` when its REST server starts (`src/rest/server.rs::write_session_file`), and removes it on shutdown. This is the primary discovery mechanism: opr8r reads the file to find the live port without any configuration. @@ -115,15 +115,15 @@ Operator writes `api-session.json` (`{"port", "pid", "started_at", "version"}`) ## The relay channel: opr8r as MCP peer -Separately from step-wrapping, `opr8r relay` runs as an MCP stdio server — a *child* of the LLM tool rather than its parent — connecting to Operator's in-process relay hub over a Unix socket so agents on different tickets can message each other (`relay_ask`, `relay_reply`, `relay_broadcast`, `relay_peers`, `relay_rename`). Operator locates and injects this automatically for delegators with `operator_relay = true`. Full protocol, socket discovery, and wiring details live on the [Relay](/docs/relay/) page — this doc's scope is just where it sits in the launch/communication topology relative to the step-wrapper role above. +Separately from step-wrapping, `opr8r relay` runs as an MCP stdio server - a *child* of the LLM tool. It connects to Operator's in-process relay hub over a Unix socket so agents on different tickets can message each other (`relay_ask`, `relay_reply`, `relay_broadcast`, `relay_peers`, `relay_rename`). Operator locates and injects this automatically for delegators with `operator_relay = true`. Full protocol, socket discovery, and wiring details live on the [Relay](/docs/relay/) page - this doc's scope is just where it sits in the launch/communication topology relative to the step-wrapper role above. ## Why one binary, two roles -`opr8r` step-wrapping and `opr8r relay` are both subcommands of the same binary (`relay` is a `Cmd::Relay` variant; step-wrapper mode is the default when no subcommand is given). This means only one small artifact needs to be built, signed, and bundled with Operator releases and the VS Code extension — there is no separate `operator-relay` binary to maintain (a legacy standalone `operator-relay` is still detected for backward compatibility, but is not produced by current builds). +`opr8r` step-wrapping and `opr8r relay` are both subcommands of the same binary (`relay` is a `Cmd::Relay` variant; step-wrapper mode is the default when no subcommand is given). This means only one small artifact needs to be built, signed, and bundled with Operator releases and the VS Code extension - there is no separate `operator-relay` binary to maintain (a legacy standalone `operator-relay` is still detected for backward compatibility, but is not produced by current builds). ## See also -- [Relay](/docs/relay/) — the MCP peer-to-peer protocol and hub, in full -- [CLI Reference](/docs/cli/) — `opr8r`'s full flag reference -- [Delegators](/docs/delegators/) — how Operator picks the LLM tool/model a session launches with -- [LLM Tools](/docs/llm-tools/) — how Operator detects and invokes CLI coding agents +- [Relay](/docs/relay/) - the MCP peer-to-peer protocol and hub, in full +- [CLI Reference](/docs/cli/) - `opr8r`'s full flag reference +- [Delegators](/docs/delegators/) - how Operator picks the LLM tool/model a session launches with +- [LLM Tools](/docs/llm-tools/) - how Operator detects and invokes CLI coding agents diff --git a/docs/assets/icons/gitea.svg b/docs/assets/icons/gitea.svg new file mode 100644 index 00000000..348846fa --- /dev/null +++ b/docs/assets/icons/gitea.svg @@ -0,0 +1 @@ +<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Gitea diff --git a/docs/assets/icons/kubernetes.svg b/docs/assets/icons/kubernetes.svg new file mode 100644 index 00000000..667f2d47 --- /dev/null +++ b/docs/assets/icons/kubernetes.svg @@ -0,0 +1 @@ +Kubernetes \ No newline at end of file diff --git a/docs/cli/index.md b/docs/cli/index.md index 38fcc936..0d54b879 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -147,6 +147,12 @@ Convert between operator issuetypes and other orchestration formats No additional arguments. +### `auth` + +Local authentication administration and recovery + +No additional arguments. + ## Environment Variables All configuration can be overridden via environment variables using the `OPERATOR_` prefix with `__` as the separator for nested config paths. @@ -176,6 +182,7 @@ All configuration can be overridden via environment variables using the `OPERATO | `OPERATOR_PATHS__TICKETS` | Directory containing ticket files | .tickets | | `OPERATOR_PATHS__PROJECTS` | Root directory for project discovery | . | | `OPERATOR_PATHS__STATE` | Directory for persistent operator state | .tickets/operator | +| `OPERATOR_PATHS__WORKTREES` | Directory for per-ticket git worktrees | ~/.operator/worktrees | | `OPERATOR_UI__REFRESH_RATE_MS` | UI refresh rate in milliseconds | 250 | | `OPERATOR_UI__SUMMARY_MAX_LENGTH` | Maximum length of ticket summaries in the UI | 60 | | `OPERATOR_LAUNCH__MODE` | Agent launch mode (tmux or direct) | tmux | @@ -232,6 +239,7 @@ All configuration can be overridden via environment variables using the `OPERATO | `OPERATOR_PATHS__TICKETS` | Directory containing ticket files | .tickets | | `OPERATOR_PATHS__PROJECTS` | Root directory for project discovery | . | | `OPERATOR_PATHS__STATE` | Directory for persistent operator state | .tickets/operator | +| `OPERATOR_PATHS__WORKTREES` | Directory for per-ticket git worktrees | ~/.operator/worktrees | ### UI diff --git a/docs/delegators/index.md b/docs/delegators/index.md index 85bf571b..87edb173 100644 --- a/docs/delegators/index.md +++ b/docs/delegators/index.md @@ -109,16 +109,16 @@ target = "cloud" **Resolution precedence** (first match wins): -1. `target` name — explicit `[[targets]]` entry, the synthesized +1. `target` name - explicit `[[targets]]` entry, the synthesized `local`/`docker` targets, or a `[[hosts]]` name. Unknown names are a hard error, never a silent fallback to local. -2. `host` name (deprecated) — the `[[hosts]]` entry of that name -3. `docker = true` (deprecated) — the synthesized docker target -4. `docker = false` — local -5. `launch.docker.enabled = true` — the synthesized docker target. +2. `host` name (deprecated) - the `[[hosts]]` entry of that name +3. `docker = true` (deprecated) - the synthesized docker target +4. `docker = false` - local +5. `launch.docker.enabled = true` - the synthesized docker target. **Behavior change:** this was previously only a TUI dialog gate; it is now a real fallback, so REST/CLI/auto launches with it set run in docker. -6. otherwise — local +6. otherwise - local Legacy inputs are synthesized rather than special-cased: `[launch.docker]` becomes a target named `docker`, and every `[[hosts]]` entry becomes an ssh @@ -130,13 +130,13 @@ erroring. A coder target's execution shape is an SSH target with a dynamically provisioned alias: Operator creates (or restarts) a per-ticket workspace from -`template`, writes an SSH config fragment (`ProxyCommand coder ssh --stdio `), prepares the git checkout, and launches over the shared SSH remote path. Workspaces are stopped on completion and **never deleted** — reclamation belongs to the Coder admin's +`template`, writes an SSH config fragment (`ProxyCommand coder ssh --stdio `), prepares the git checkout, and launches over the shared SSH remote path. Workspaces are stopped on completion and **never deleted** - reclamation belongs to the Coder admin's autostop policy. Credentials are held **by name**: `url_env` / `token_env` name environment variables, and the token variable is stripped from every agent's spawn environment on all target kinds. **Blast radius:** a Coder session token can -create, delete, and SSH into every workspace its user owns — scope accordingly. +create, delete, and SSH into every workspace its user owns - scope accordingly. Known limitation: prompt files are written on the operator side, so a coder target currently requires the workspace to reach them (e.g. Operator itself @@ -202,14 +202,14 @@ prompt_suffix = "\n\nThink carefully before acting." ## Agent profiles & remote agents -A delegator can be serialized to a portable **agent profile** (`agent-profile.json`) — a +A delegator can be serialized to a portable **agent profile** (`agent-profile.json`) - a tool-agnostic interchange format with a shared core (`provider`, `model`, `system_prompt`, `skills`, `mcp_servers`, `tools`) plus namespaced extension bags: `x_operator` (Operator's launch config and model properties) and per-platform opaque bags (`x_agnt`, `x_openai`) that are preserved verbatim. Profiles round-trip losslessly in both directions, so a profile authored on another platform survives `import → export` byte-for-byte. -A delegator may also carry a **`remote_agent`** reference — a `{ platform, id }` pointer to a +A delegator may also carry a **`remote_agent`** reference - a `{ platform, id }` pointer to a remote, named agent that lives on another service: ```toml @@ -222,7 +222,7 @@ id = "a1b2c3d4-…" # AGNT agent UUID, or an OpenAI asst_… id ``` Remote agents are **export-only**: Operator has no runtime client for those platforms, so a -delegator carrying a `remote_agent` cannot be launched locally — resolution returns a +delegator carrying a `remote_agent` cannot be launched locally - resolution returns a `RemoteOnlyDelegator` error on every launch path. When the platform is `agnt`, the reference is surfaced in the [`--format agnt` workflow export](/getting-started/workflows/agnt/) as a native AGNT `agnt-agent` node; other platforms ride opaquely in the profile. @@ -232,9 +232,9 @@ ride opaquely in the profile. > back into Operator, which then hits the `RemoteOnlyDelegator` guard and errors. Don't bind a > non-AGNT remote delegator as the step agent of a workflow you intend to export to AGNT. -> **Design note — the interchange is tool-agnostic.** AGNT was the first remote platform; adding +> **Design note - the interchange is tool-agnostic.** AGNT was the first remote platform; adding > OpenAI Assistants as the second cost only a generic `remote_agent { platform, id }` reference and -> an opaque `x_openai` bag mirroring `x_agnt` — **no new mapping logic, no executor, no export +> an opaque `x_openai` bag mirroring `x_agnt` - **no new mapping logic, no executor, no export > node.** That's the evidence the schema core is not shaped around any one tool. ## REST API @@ -256,6 +256,6 @@ See the [OpenAPI reference](/schemas/openapi.json) for request/response shapes. ## See also -- [Configuration reference](/configuration/) — full `operator.toml` schema -- [LLM Tools](/llm-tools/) — which tools Operator can detect and launch -- [Schema reference](/schemas/config/) — type definitions for `Delegator` and `DelegatorLaunchConfig` +- [Configuration reference](/configuration/) - full `operator.toml` schema +- [LLM Tools](/llm-tools/) - which tools Operator can detect and launch +- [Schema reference](/schemas/config/) - type definitions for `Delegator` and `DelegatorLaunchConfig` diff --git a/docs/design-system/index.md b/docs/design-system/index.md index 22d49edf..082d0e1b 100644 --- a/docs/design-system/index.md +++ b/docs/design-system/index.md @@ -4,10 +4,7 @@ description: "Operator's brand palette, design tokens, and the consistency rules layout: doc --- -Operator! presents one brand across four -rendering surfaces. This page is the human-readable companion to the brand -tokens — it explains *intent* the raw `:root` block can't, and records the rules -each surface follows so the look stays consistent as the codebase grows. +Operator! presents one brand across four rendering surfaces. This page is the human-readable companion to the brand tokens. ## Source of truth @@ -25,16 +22,16 @@ docs/assets/css/tokens.css ← single source of truth (:root + [data-theme="da | Token | Light | Role | |-------|-------|------| -| `--color-salmon` | `#e05d44` | Terracotta — primary brand, headings accents, CTAs | -| `--color-cornflower` | `#6688aa` | Muted blue — secondary/muted text, separators | +| `--color-salmon` | `#e05d44` | Terracotta - primary brand, headings accents, CTAs | +| `--color-cornflower` | `#6688aa` | Muted blue - secondary/muted text, separators | | `--color-cream` | `#f2eac9` | Warm accent / highlight surfaces | | `--color-coral` | `#e05d44` | Links / accents (alias of salmon in light mode) | | `--color-bg` | `#faf8f5` | Page background | | `--color-white` | `#ffffff` | Base surface | -| `--color-green-l1` | `#66aa99` | Sage — nav buttons | -| `--color-green-l2` | `#448880` | Teal — hover / success | -| `--color-green-l3` | `#115566` | Deep pine — selected / primary text | -| `--color-green-l4` | `#082226` | Midnight — darkest | +| `--color-green-l1` | `#66aa99` | Sage - nav buttons | +| `--color-green-l2` | `#448880` | Teal - hover / success | +| `--color-green-l3` | `#115566` | Deep pine - selected / primary text | +| `--color-green-l4` | `#082226` | Midnight - darkest | | `--color-teal` | `#115566` | Body text (equals green-l3) | Dark mode (`[data-theme="dark"]`) keeps salmon constant, brightens coral, and @@ -57,25 +54,25 @@ and `--font-sans|--font-mono`. ## The four surfaces -Each surface gets the rule that fits it — they are deliberately not styled +Each surface gets the rule that fits it - they are deliberately not styled identically. | Surface | Where | Rule | |---------|-------|------| | **Docs site** (Jekyll) | `docs/assets/css/main.css` | Links `tokens.css`; style with `var(--...)`, never raw hex. | | **Embedded SPA** (Vite/React) | `ui/src/index.css` + `*.module.css` | Imports `tokens.css`; uses semantic tokens, never raw hex. | -| **Ratatui TUI** | `src/ui/*.rs` | Terminal can't render hex — map a semantic **role to ANSI** (danger→Red, success→Green, warning→Yellow, focus→Cyan). | +| **Ratatui TUI** | `src/ui/*.rs` | Terminal can't render hex - map a semantic **role to ANSI** (danger→Red, success→Green, warning→Yellow, focus→Cyan). | | **VS Code webview** | `vscode-extension/webview-ui/` | Defers to the VS Code host theme via raw `var(--vscode-*)` custom properties (`styles/webview.css`); brand only as `--op-*` accents. Never overrides the editor theme. No MUI/CSS-in-JS. | ## Concept icons (codicons) Each high-level Operator concept gets **one icon** so the same idea reads the same across surfaces. The vocabulary is [codicons](https://github.com/microsoft/vscode-codicons) -— the icon set VS Code uses — chosen because the VS Code extension already renders +- the icon set VS Code uses - chosen because the VS Code extension already renders its tree with codicon `ThemeIcon`s. This table is the **single source of truth**: consult it (and update it) whenever you give a concept an icon. -Each surface follows it by convention — there is no shared runtime registry: +Each surface follows it by convention - there is no shared runtime registry: - **Embedded SPA** reads it via `ui/src/concepts.ts` (`CONCEPTS[key].icon`), rendered by `ui/src/components/ConceptIcon.tsx`. The font is imported once in `main.tsx`. @@ -84,7 +81,7 @@ Each surface follows it by convention — there is no shared runtime registry: is linked from `_includes/head.html` (`assets/css/codicon.css` + `assets/fonts/codicon.ttf`). - **VS Code extension** already uses codicon `ThemeIcon`s directly. -This is **additive** — distinct from the issue-type `glyph`→icon map in +This is **additive** - distinct from the issue-type `glyph`→icon map in `vscode-extension/src/issuetype-service.ts` and the `glyph_for_key`/`color_for_key` helpers in `src/templates/mod.rs` (documented below). It follows the same "central key → presentation" pattern, keyed by section concept. @@ -111,7 +108,7 @@ helpers in `src/templates/mod.rs` (documented below). It follows the same | design-system | | `symbol-color` | | ✓ | Keys match the `SectionId` serde renames in `src/ui/status_panel.rs` (and the SPA's -section ids). Every codicon name is unique — the Kanban-vs-Managed-Projects collision +section ids). Every codicon name is unique - the Kanban-vs-Managed-Projects collision was resolved as kanban→`layout`, projects→`project`. > **Attribution:** codicon **icons** are licensed [CC-BY-4.0](https://github.com/microsoft/vscode-codicons/blob/main/LICENSE); @@ -120,7 +117,7 @@ was resolved as kanban→`layout`, projects→`project`. ## Brand & collection icons (SVG) -Codicons cover concepts. Everything else — provider logos, collection glyphs — is a hand-shipped SVG, and every one of them follows the **Operator icon standard**: a single monochrome `` on a 24×24 canvas carrying no color or +Codicons cover concepts. Everything else - provider logos, collection glyphs - is a hand-shipped SVG, and every one of them follows the **Operator icon standard**: a single monochrome `` on a 24×24 canvas carrying no color or size of its own. That shape is what makes one file work on all four surfaces at once. The docs @@ -155,14 +152,14 @@ cargo test --test svg_icon_standard That test governs every directory above and catches a missing title, a stray `fill`, a second ``, a wrong viewBox, or embedded script. The only -exemption is `docs/assets/img/operator_logo.svg` — a full-color wordmark, not a -glyph — and the test also asserts that exemption is still needed. +exemption is `docs/assets/img/operator_logo.svg` - a full-color wordmark, not a +glyph - and the test also asserts that exemption is still needed. ## Issue type glyphs & colors Issue type color + glyph are defined once in the collection JSON schemas and read through `color_for_key` / `glyph_for_key` in `src/templates/mod.rs`. Reuse -those helpers — do not re-hardcode the mapping in new UI. +those helpers - do not re-hardcode the mapping in new UI. | Type | Glyph | Color | |------|-------|-------| diff --git a/docs/downloads/index.md b/docs/downloads/index.md index 857deb61..ab2c3c43 100644 --- a/docs/downloads/index.md +++ b/docs/downloads/index.md @@ -34,6 +34,26 @@ For headless servers, CI/CD pipelines, or advanced workflows, download the CLI b | Windows | x86_64 | [operator-windows-x86_64.exe]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-windows-x86_64.exe)
sha256:{{ site.data.checksums.operator.windows_x86_64 }} | | Windows | ARM64 | [operator-windows-arm64.exe]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-windows-arm64.exe)
sha256:{{ site.data.checksums.operator.windows_arm64 }} | +## Kubernetes + +Run Operator in a cluster from the official OCI Helm chart: + +```bash +helm install operator oci://ghcr.io/untra/charts/operator \ + --namespace operator --create-namespace \ + --set publicUrl=https://operator.example.com +``` + +See the [Kubernetes guide](/getting-started/platforms/kubernetes/) for bootstrap, TLS, persistence, and NetworkPolicy. + +## Container image + +```bash +docker pull untra/operator:{{ site.version }} +``` + +Multi-arch (`linux/amd64`, `linux/arm64`). See the [Docker guide](/getting-started/platforms/docker/). + ## All Releases [View all releases on GitHub]({{ site.github.repo }}/releases) diff --git a/docs/getting-started/agents/index.md b/docs/getting-started/agents/index.md index 9ba0c345..97a3b9ed 100644 --- a/docs/getting-started/agents/index.md +++ b/docs/getting-started/agents/index.md @@ -52,9 +52,9 @@ Created -> Running -> Completed Every issue type declares a `mode`, and that decides how much of your attention its tickets need: -- **Autonomous** — launch and monitor. Minimal intervention, and several can run +- **Autonomous** - launch and monitor. Minimal intervention, and several can run in parallel across different projects. -- **Paired** — active human participation, with back-and-forth discussion. One at +- **Paired** - active human participation, with back-and-forth discussion. One at a time, because they compete for the same operator: you. Mode is a property of the issue type, not of the agent, so a collection decides @@ -74,12 +74,12 @@ Agent sessions persist under `.operator/`: ``` Session files record ticket information, start and end times, status history, and -output logs. Operator can also detect completion from files an agent produces — +output logs. Operator can also detect completion from files an agent produces - see [Artifact Detection](/artifact-detection/). ## Best Practices -1. **Monitor paired agents** — stay engaged with paired work -2. **Review autonomous work** — check completed tickets -3. **Handle failures promptly** — address failed agents quickly -4. **Balance load** — don't overload with too many agents +1. **Monitor paired agents** - stay engaged with paired work +2. **Review autonomous work** - check completed tickets +3. **Handle failures promptly** - address failed agents quickly +4. **Balance load** - don't overload with too many agents diff --git a/docs/getting-started/concepts/index.md b/docs/getting-started/concepts/index.md index 282692fb..30986b17 100644 --- a/docs/getting-started/concepts/index.md +++ b/docs/getting-started/concepts/index.md @@ -6,7 +6,7 @@ layout: doc Operator! runs on two ideas: work is written down as **tickets**, and tickets move across a **kanban** board as agents pick them up and finish them. -- **[Tickets](/getting-started/tickets/)** — the unit of work. A markdown file describing one task for an agent, carrying an issue type that decides how the work is done. -- **[Kanban](/getting-started/concepts/kanban/)** — the board. Where tickets wait, get worked, and land when done. New to kanban? Start here. +- **[Tickets](/getting-started/tickets/)** - the unit of work. A markdown file describing one task for an agent, carrying an issue type that decides how the work is done. +- **[Kanban](/getting-started/concepts/kanban/)** - the board. Where tickets wait, get worked, and land when done. New to kanban? Start here. -Everything else — [agents](/getting-started/agents/), [providers](/getting-started/kanban/), [workflows](/workflows/) — builds on these two. +Everything else - [agents](/getting-started/agents/), [providers](/getting-started/kanban/), [workflows](/workflows/) - builds on these two. diff --git a/docs/getting-started/concepts/kanban.md b/docs/getting-started/concepts/kanban.md index 8ee34762..b1f0ba77 100644 --- a/docs/getting-started/concepts/kanban.md +++ b/docs/getting-started/concepts/kanban.md @@ -14,13 +14,13 @@ Kanban is a way of managing work by making it visible. A **board** holds columns Two rules do most of the work: -1. **Pull, don't push.** Nobody is handed work — whoever has capacity pulls the next card. +1. **Pull, don't push.** Nobody is handed work - whoever has capacity pulls the next card. 2. **Limit work in progress.** Few cards in flight at once means work finishes instead of piling up half-done. That's it. The board *is* the status report. ## How Operator uses kanban -In Operator!, the cards are [tickets](/getting-started/tickets/) and the workers are AI agents. Operator holds three internal states — **todo**, **doing**, **done** — and enforces both rules: agents pull the next ticket when a slot frees up, and parallelism limits cap work in progress. +In Operator!, the cards are [tickets](/getting-started/tickets/) and the workers are AI agents. Operator holds three internal states - **todo**, **doing**, **done** - and enforces both rules: agents pull the next ticket when a slot frees up, and parallelism limits cap work in progress. -You can run entirely from local tickets, or sync the board with an external [kanban provider](/getting-started/kanban/) like Jira, Linear, or GitHub Projects — Operator maps its three states onto your board's columns and moves cards as agents work. +You can run entirely from local tickets, or sync the board with an external [kanban provider](/getting-started/kanban/) like Jira, Linear, or GitHub Projects - Operator maps its three states onto your board's columns and moves cards as agents work. diff --git a/docs/getting-started/git/gitea.md b/docs/getting-started/git/gitea.md new file mode 100644 index 00000000..15c53068 --- /dev/null +++ b/docs/getting-started/git/gitea.md @@ -0,0 +1,58 @@ +--- +title: "Gitea" +description: "Configure Gitea and delegated Git credentials." +layout: doc +--- + +Operator uses **Tea 0.13 or newer with the `tea api` command** for Gitea PR operations. +Install Tea on Operator and on each execution target that will create PRs. Operator does not install client binaries. + +```toml +[git] +provider = "gitea" + +[git.gitea] +enabled = true +host = "https://gitea.kube.untra.casa" +token_env = "GITEA_TOKEN" +wip_prefix = "WIP: " +``` + +Set `GITEA_TOKEN` in Operator's environment to an existing account's PAT. The token needs repository access and permission to read the authenticated user. Create it at your instance's `/user/settings/applications` page. Private network hosts are supported; external CLI networking is outside Operator's Rust egress policy. Use deployment network controls to restrict destinations. + +## Delegator configuration + +Each existing named delegator may own its Git settings. Add these sections to its TOML entry, use delegator CRUD, or edit **Git settings** on the Model Providers page: + +```toml +[delegators.git.identity] +name = "Operator agent {ticket_id}" +email = "agent-{ticket_id}@example.org" + +[delegators.git.credentials] +repository_url = "https://gitea.kube.untra.casa/team/project.git" +username = "operator-agent" +token_env = "PROJECT_AGENT_TOKEN" + +[[delegators.git.settings]] +key = "commit.gpgsign" +value = "false" +``` + +The environment variable holds the secret; the configuration, REST responses, and portable profiles contain only its name. Operator does not mint accounts or tokens. Use an account whose provider permissions match the intended repository scope. + +The optional global `[git.identity]` supplies a default author and committer. A delegator's identity replaces that pair. Templates accept `{ticket_id}`, `{project}`, and `{ticket_type}`. With no identity configuration, ambient identity remains in effect. + +HTTPS credentials require an HTTPS origin matching `repository_url`; SSH origins are rejected before provisioning. Git receives a repository-bound helper and process-local settings. Shared Git configuration and shared CLI logins are not changed. + +Local, SSH, Coder, and Docker launches receive private runtime credentials. Remote credentials travel over SSH stdin before use; Docker receives a read-only runtime mount. Agent commands can use `git push` and `tea pulls create`; the session's Tea configuration uses the delegated account. Operator-side PR creation and monitoring use the captured Git context as well. + +Runtime credentials are removed on normal exit and handled termination. Abrupt host failure can leave private credential files; provider tokens remain valid until their administrator revokes them. This is credential isolation between launches, not a sandbox against an agent running as the same OS user. + +Draft creation prefixes the title with the configured WIP prefix. Configure this prefix to match the Gitea server. PR reads use the server's `draft` result. Comment association is reported as `NONE` where unavailable. + +## Validation + +The optional read-only live test uses `OPERATOR_GITPROVIDER_TEST_ENABLED=true`, `OPERATOR_GITPROVIDER_TEST_REPO_GITEA` (an HTTPS URL), `OPERATOR_GITPROVIDER_TEST_PR_GITEA`, and `GITEA_TOKEN`. Run `cargo test --test gitprovider_integration gitea_provider_live`. + +Forgejo host detection and provider-independent Git settings are available. Forgejo PR operations and `fj` integration remain deferred. diff --git a/docs/getting-started/git/index.md b/docs/getting-started/git/index.md index dbc9518a..23ec3c93 100644 --- a/docs/getting-started/git/index.md +++ b/docs/getting-started/git/index.md @@ -16,10 +16,15 @@ All providers require: ## Available Integrations +Statuses follow the [feature maturity](/maturity/) scale; see +[Provider Support](/getting-started/git/provider-support/) for the full tier +table and architecture. + | Platform | Status | CLI Tool | Notes | |----------|--------|----------|-------| -| [GitHub](/getting-started/git/github/) | Supported | `gh` | Full PR integration | -| [GitLab](/getting-started/git/gitlab/) | Partial | `glab` | Detection and config ready; MR operations planned | +| [GitHub](/getting-started/git/github/) | Beta | `gh` | Full PR integration | +| [GitLab](/getting-started/git/gitlab/) | Alpha | `glab` | Full MR integration | +| [Gitea](/getting-started/git/gitea/) | Alpha | `tea` | Full PR integration via `tea api` | ## Provider Auto-Detection @@ -27,7 +32,7 @@ Operator detects your Git provider from the remote URL automatically. You can ov ```toml [git] -provider = "github" # or "gitlab" +provider = "github" # or "gitlab", "gitea" ``` ## Shared Git Configuration @@ -62,7 +67,7 @@ Even without platform integration, Operator manages local Git operations: - Branch cleanup after completion - Worktree management for parallel development -Local git operations require only the `git` binary—no provider CLI or tokens needed. +Local git operations require only the `git` binary-no provider CLI or tokens needed. ## Adding Provider Support diff --git a/docs/getting-started/git/provider-support.md b/docs/getting-started/git/provider-support.md index 3ce80176..2e9f7c48 100644 --- a/docs/getting-started/git/provider-support.md +++ b/docs/getting-started/git/provider-support.md @@ -4,226 +4,184 @@ description: "Architecture guide for adding new Git provider integrations." layout: doc --- -This guide explains how Operator integrates with Git hosting providers and how to add support for new providers. - -## Architecture Overview - -Operator uses a trait-based architecture for Git provider support: - +This guide explains how Operator integrates with Git hosting providers, and +how to add support for a new one. + +## Support Tiers + +Statuses follow the [feature maturity](/maturity/) scale. + +| Provider | CLI | Tier | Operations | +|----------|-----|------|------------| +| [GitHub](/getting-started/git/github/) | `gh` | Beta | Full - read, create, list, read comments | +| [GitLab](/getting-started/git/gitlab/) | `glab` | Alpha | Full - read, create, list, read comments | +| Bitbucket | `bb` | Proto | Detection only | +| Azure DevOps | `az` | Proto | Detection only | +| Forgejo | `fj` | Proto | Detection only | +| [Gitea](/getting-started/git/gitea/) | `tea api` | Alpha | Read, create, list, comments, reviews | + +"Detection only" means Operator recognizes the provider from its remote URL and can report which CLI to install, but has no operational `PrService` +implementation yet - creating, reading, or listing code review requests (including reading their comments) for that provider isn't wired up. + +Delegator Git identity and supplied HTTPS credentials are provider-independent, including for detect-only providers. PR authentication is operational for GitHub, GitLab, and Gitea. Forgejo CLI integration remains deferred. + +## Architecture + +```text +PrWorkflow / PrMonitorService + | + PrServiceRouter (configuration + captured delegator context) + / | \ +GitHubService GitLabService GiteaService + | | | + GhCli GlabCli TeaCli + | | | + gh glab tea api ``` -┌─────────────────────────────────────────┐ -│ PrService trait │ -│ (get_pr, is_ready_to_merge, etc.) │ -├─────────────────────────────────────────┤ -│ GitHubService │ NewProviderService │ -├─────────────────────────────────────────┤ -│ GhCli │ ProviderCli │ -│ (gh binary) │ (cli binary) │ -└─────────────────────────────────────────┘ -``` - -## Implementation Approaches - -### CLI-Based (Recommended) - -Uses the provider's official CLI tool: - -| Provider | CLI Tool | Install | Status | -|----------|----------|---------|--------| -| GitHub | `gh` | `brew install gh` | Implemented | -| GitLab | `glab` | `brew install glab` | Detection only | -| Bitbucket | `bb` | — | Detection only | -| Azure DevOps | `az` | — | Detection only | - -**Advantages:** -- Built-in authentication management -- OAuth flows handled by CLI -- Credentials stored securely in system keychain -- Consistent behavior with official tooling - -**Disadvantages:** -- Requires external binary installation -- May have version compatibility concerns -### API-Based +Gitea uses Tea's JSON API interface and per-invocation private login configuration. Reads retry transient failures; PR creation reconciles an ambiguous response before returning an error and never blindly repeats the POST. -Direct REST/GraphQL API calls: -**Advantages:** -- No external dependencies -- Fine-grained control -- Works in restricted environments +`GitHubService` and `GitLabService` both wrap their CLI with exponential +backoff retry (via `backon`) and implement the same trait, so every layer +above the retry services is provider-agnostic. Bitbucket, Azure DevOps, +Forgejo, and Gitea are detected (`GitProvider::from_remote_url`) but have no +service/CLI-wrapper pair yet, so `pr_service_for` returns an +`UnsupportedProviderError` for them. -**Disadvantages:** -- Manual token management -- Must implement OAuth flows -- API versioning complexity +### The `PrService` trait -## Core Traits - -### PrService - -Provider-agnostic interface for PR/MR operations: +`src/api/pr_service.rs` defines the provider-agnostic contract every service +implements: ```rust #[async_trait] pub trait PrService: Send + Sync { - /// Get PR/MR information - async fn get_pr(&self, repo: &RepoInfo, number: i64) - -> Result; - - /// Check if PR/MR is ready to merge - async fn is_ready_to_merge(&self, repo: &RepoInfo, number: i64) - -> Result; - - /// Get review/approval state - async fn get_review_state(&self, repo: &RepoInfo, number: i64) - -> Result; - - /// Create a new PR/MR - async fn create_pr(&self, repo: &RepoInfo, request: &CreatePrRequest) - -> Result; -} -``` + /// Get the provider name (e.g., "github", "gitlab") + fn provider_name(&self) -> &str; -### RepoProvider + /// Check if the service is available and authenticated + async fn check_available(&self) -> Result; -For status tracking and CI integration: + /// Get the authenticated user + async fn get_authenticated_user(&self) -> Result; -```rust -#[async_trait] -pub trait RepoProvider: Send + Sync { - fn name(&self) -> &str; - fn is_configured(&self) -> bool; - - async fn get_pr_status(&self, repo: &str, number: u64) - -> Result; - async fn get_check_runs(&self, repo: &str, ref_sha: &str) - -> Result, ApiError>; - async fn test_connection(&self) -> Result; -} -``` - -## Adding a New Provider - -### 1. Create CLI Wrapper (if CLI-based) - -```rust -// src/api/newprovider_cli.rs -pub struct NewProviderCli; - -impl NewProviderCli { - pub async fn is_installed() -> bool { ... } - pub async fn check_auth() -> Result { ... } - pub async fn create_pr(...) -> Result { ... } -} -``` - -### 2. Implement PrService - -```rust -// src/api/newprovider_service.rs -pub struct NewProviderService { - cli: NewProviderCli, - // or api_client for API-based -} - -#[async_trait] -impl PrService for NewProviderService { - async fn get_pr(...) -> Result { ... } - // ... other methods -} -``` - -### 3. Implement RepoProvider - -```rust -// src/api/providers/repo/newprovider.rs -pub struct NewProviderProvider { ... } - -#[async_trait] -impl RepoProvider for NewProviderProvider { ... } -``` - -### 4. Add Configuration + /// Get PR/MR information + async fn get_pr(&self, repo_info: &RepoInfo, pr_number: i64) -> Result; -```rust -// src/config.rs -#[derive(Debug, Clone, Deserialize)] -pub struct NewProviderConfig { - pub enabled: bool, - pub token_env: String, - pub host: Option, -} -``` + /// Check if PR/MR is ready to merge (approved + checks pass) + async fn is_ready_to_merge(&self, repo_info: &RepoInfo, pr_number: i64) -> Result; -### 5. Register Provider + /// Get the review state of a PR/MR + async fn get_review_state(&self, repo_info: &RepoInfo, pr_number: i64) + -> Result; -```rust -// src/api/providers/mod.rs -pub fn create_pr_service(config: &Config) -> Box { - match config.git.provider { - GitProvider::GitHub => Box::new(GitHubService::new()), - GitProvider::NewProvider => Box::new(NewProviderService::new()), - // ... - } + /// Create a new PR/MR + async fn create_pr( + &self, + repo_info: &RepoInfo, + request: &CreatePrRequest, + cwd: &Path, + ) -> Result; + + /// List PRs/MRs for a branch + async fn list_prs_for_branch( + &self, + repo_info: &RepoInfo, + branch: &str, + ) -> Result>; + + /// Get all comments on a PR/MR + async fn get_all_comments( + &self, + repo_info: &RepoInfo, + pr_number: i64, + ) -> Result>; + + /// Open PR/MR in browser + async fn open_in_browser(&self, repo_info: &RepoInfo, pr_number: i64) -> Result<()>; + + /// Get comments since a given time + async fn get_comments_since( + &self, + repo_info: &RepoInfo, + pr_number: i64, + since: chrono::DateTime, + ) -> Result>; + + /// Find an existing PR for a branch + async fn find_pr_for_branch( + &self, + repo_info: &RepoInfo, + branch: &str, + ) -> Result>; } ``` -### 6. Add Tests +`PrServiceRouter` also implements `PrService`: it holds a resolver +(`pr_service_for` by default) and dispatches every per-repo call to the +operational service for `repo_info.provider`. `provider_name()`, +`check_available()`, and `get_authenticated_user()` take no `RepoInfo`, so the +router falls back to GitHub for those and reports its own name as `"auto"`. -```rust -// tests/providers/newprovider_test.rs -#[tokio::test] -async fn test_newprovider_create_pr() { ... } -``` +## Terminology -## Terminology Mapping +Providers name the same underlying concepts differently. Operator's code and +UI default to the neutral term "code review request (PR/MR)"; provider brand +names appear only when talking about that specific provider. -Different providers use different terminology for similar concepts: +| Concept | GitHub | GitLab | Bitbucket | Azure DevOps | Forgejo | Gitea | +|---------|--------|--------|-----------|--------------|---------|-------| +| Code Review Request | Pull Request | Merge Request | Pull Request | Pull Request | Pull Request | Pull Request | +| CI Status | Checks | Pipelines | Build status | Checks | Checks | Checks | +| CI Automation | Actions | CI/CD | Pipelines | Azure Pipelines | Actions | Actions | +| Approval | Review | Approval | Approval | Approval | Review | Review | -| Concept | GitHub | GitLab | Bitbucket | -|---------|--------|--------|-----------| -| Code Review Request | Pull Request | Merge Request | Pull Request | -| CI Status | Checks | Pipelines | Pipelines | -| CI Automation | Actions | CI/CD | Pipelines | -| Approval | Review | Approval | Approval | +GitHub and GitLab are operational today; Bitbucket, Azure DevOps, Forgejo, +and Gitea terminology above is provided for reference ahead of their +`PrService` implementations. ## Provider Detection -Operator auto-detects the provider from git remote URLs: - -```rust -pub fn detect_provider(remote_url: &str) -> Option { - let url_lower = remote_url.to_lowercase(); - if url_lower.contains("github.com") { - Some(GitProvider::GitHub) - } else if url_lower.contains("gitlab.com") || url_lower.contains("gitlab.") { - Some(GitProvider::GitLab) - } else if url_lower.contains("bitbucket.org") { - Some(GitProvider::Bitbucket) - } else if url_lower.contains("dev.azure.com") || url_lower.contains("visualstudio.com") { - Some(GitProvider::AzureDevOps) - } else { - None - } -} -``` - -## Shared vs Provider-Specific Config - -### Shared Configuration +`src/types/pr.rs` detects the provider straight from a repo's remote URL - +there's no separate "detect" step to configure: + +- `GitProvider::from_remote_url(remote_url: &str) -> Option` - + matches on hostname substrings (`github.com`, `gitlab.` / `gitlab.com`, + `bitbucket.org`, `dev.azure.com` / `visualstudio.com`, `codeberg.org`, + `gitea.com`). +- `RepoInfo::from_remote_url(remote_url: &str) -> Result` - + calls the above, then parses `owner`/`repo_name` out of the URL with a + provider-specific regex. + +GitLab subgroups are supported: `https://gitlab.com/group/subgroup/repo.git` +parses to `owner = "group/subgroup"`, `repo_name = "repo"` (the owner regex +for GitLab captures everything up to the final path segment, unlike the other +providers' single-segment owner). + +## Configuration + +Git provider configuration lives in `src/config/git_config.rs`: + +- `GitConfig` - top-level `[git]` table: `provider` (`Option`, + auto-detected from the remote when unset), `github` (`GitHubConfig`), `gitlab` + (`GitLabConfig`), `branch_format` (default `"{type}/{ticket_id}"`), and + `use_worktrees` (default `false`). +- `GitProviderConfig` - the explicit-override enum (`GitHub`, `GitLab`, + `Bitbucket`, `AzureDevOps`, `Forgejo`, `Gitea`), serialized `#[serde(rename_all + = "lowercase")]`; `From for GitProvider` converts it into + the runtime enum used by `pr_service_for`. +- `GitHubConfig` - `enabled` (default `true`), `token_env` (default + `"GITHUB_TOKEN"`). +- `GitLabConfig` - `enabled` (default `false`), `token_env` (default + `"GITLAB_TOKEN"`), `host` (`Option`, for self-hosted instances). ```toml [git] -provider = "github" # Auto-detected if not specified +provider = "gitlab" # optional; auto-detected from the remote if omitted branch_format = "{type}/{ticket_id}" use_worktrees = false -``` - -### Provider-Specific -```toml [git.github] enabled = true token_env = "GITHUB_TOKEN" @@ -231,20 +189,114 @@ token_env = "GITHUB_TOKEN" [git.gitlab] enabled = true token_env = "GITLAB_TOKEN" -host = "gitlab.example.com" # For self-hosted instances +host = "gitlab.example.com" # self-hosted instances only ``` -## Testing Guidelines +## Code-Review Gating -1. **Unit tests**: Mock CLI output / API responses -2. **Integration tests**: Use test repositories (opt-in, requires tokens) -3. **Mock responses**: Store in `tests/fixtures/providers/` +Once a code review request exists, `PrMonitorService` +(`src/services/pr_monitor.rs`) polls it every 60 seconds through the same +`PrService` (routed via `PrServiceRouter`), watching for merge, close, +approval, changes-requested, and ready-to-merge/ready-for-review transitions. -```rust -#[test] -fn test_parse_pr_response() { - let json = include_str!("../fixtures/providers/github_pr.json"); - let pr: PullRequestInfo = serde_json::from_str(json).unwrap(); - assert_eq!(pr.state, PrState::Open); -} +An agent working a ticket carries a `review_state` marker +(`src/state.rs`) while it waits on a human: + +- `pending_pr_creation` - the agent finished and Operator is opening the PR/MR. +- `pending_pr_merge` - the PR/MR is open and awaiting merge. + +Both surface in the in-progress panel (`src/ui/in_progress_panel.rs`) with +their own icon and status text. A human resolves the gate either from the +TUI's agents panel (`y` to approve, `x` to reject - see +`src/ui/keybindings.rs`) or via REST (`POST /api/v1/agents/{agent_id}/approve` +/ `.../reject`, `src/rest/routes/agents.rs`), which write a review signal file +the agent picks back up. See [Supported Coding Agents](/getting-started/agents/) +for the agent-side half of this flow. + +## Adding a New Provider + +Checklist for taking a provider from detect-only to fully operational (this +is what GitHub and GitLab already went through): + +1. **Enum variants** - add the provider to both `GitProvider` + (`src/types/pr.rs`, `#[serde(rename_all = "lowercase")]`) and + `GitProviderConfig` (`src/config/git_config.rs`), plus the + `From for GitProvider` arm. +2. **Catalog entry** - add a row in `src/integrations/catalog.rs`'s + `all_integrations()` under `Vertical::Git`. `tests/vertical_parity.rs` + enforces the tier rules: `Alpha`+ needs a `docs_path` pointing at a real + docs page; `Beta`+ additionally needs `readme_badge: true` (and a matching + README badge). +3. **CLI detection row** - add a `CliSpec` to `CLI_SPECS` in + `src/api/cli_detection.rs` so `detect_all_clis`/`detect_for` can probe the + new CLI. +4. **CLI wrapper + retry service + `PrService` impl** - a `src/api/_cli.rs` + wrapper (mirroring `GhCli`/`GlabCli`), a `src/api/_service.rs` + retry wrapper around it (mirroring `GitHubService`/`GitLabService`), and a + `PrService` impl for that service in `src/api/pr_service.rs`. +5. **Router wiring** - add the new service to the `match` in `pr_service_for` + (`src/api/pr_service.rs`), replacing its `UnsupportedProviderError` arm. +6. **Onboarding metadata** - once operational, add a `ProviderMeta` entry (and + a `meta_for` arm) in `src/app/git_onboarding.rs` so the TUI's onboarding + flow can walk a user through CLI install / token setup for it. +7. **Live test row** - add a `ProviderCase` to `PROVIDER_CASES` in + `tests/gitprovider_integration.rs` (a template comment there shows the + shape) plus a `#[tokio::test]` calling `run_provider_case` for it. +8. **Docs page + nav** - a `docs/getting-started/git//index.md` + page, added to `navigation.yml`. + +Forgejo (CLI `fj`, see +[codeberg.org/forgejo-contrib/forgejo-cli](https://codeberg.org/forgejo-contrib/forgejo-cli)) +and Gitea (CLI `tea`, see +[gitea.com/gitea/tea](https://gitea.com/gitea/tea)) are the most likely next +operational candidates - both are currently detect-only (`Proto`). + +## Legacy Note + +`src/api/providers/repo/` is a separate, experimental REST-polling module +(its own `RepoProvider` trait, currently only a `GitHubProvider` impl). It +predates the `PrService` stack, is not part of the provider contract +described above, and is slated for consolidation into `PrService`. Don't +extend it for new provider support - follow the checklist above instead. + +## Testing + +`tests/gitprovider_integration.rs` drives the live `PrService` stack +(`gh`/`glab`) against real, external test repositories. It's strictly +read-only - no create/write operations against any provider. Table-shape +invariants (env-var name uniqueness, slug/provider matching) run under plain +`cargo test`, no setup required: + +```bash +cargo test --test gitprovider_integration provider_case_table +``` + +The live per-provider tests are opt-in and gated on: + +- `OPERATOR_GITPROVIDER_TEST_ENABLED=true` - required to run any test in the + file. +- `OPERATOR_GITPROVIDER_TEST_REPO_GITHUB` - full remote URL of a GitHub test + repo (e.g. `https://github.com/owner/repo`). +- `OPERATOR_GITPROVIDER_TEST_PR_GITHUB` - number of an open GitHub PR on that + repo with **at least one comment**. +- `OPERATOR_GITPROVIDER_TEST_REPO_GITLAB` - full remote URL of a GitLab test + repo. +- `OPERATOR_GITPROVIDER_TEST_PR_GITLAB` - number of an open GitLab MR on that + repo with **at least one comment**. + +Each row also needs its CLI (`gh`, `glab`) installed and authenticated; a row +skips itself (rather than failing the suite) if its env var is unset or its +CLI isn't available: + +```bash +OPERATOR_GITPROVIDER_TEST_ENABLED=true \ + OPERATOR_GITPROVIDER_TEST_REPO_GITHUB=https://github.com/owner/repo \ + OPERATOR_GITPROVIDER_TEST_PR_GITHUB=1 \ + OPERATOR_GITPROVIDER_TEST_REPO_GITLAB=https://gitlab.com/owner/repo \ + OPERATOR_GITPROVIDER_TEST_PR_GITLAB=1 \ + cargo test --test gitprovider_integration -- --nocapture ``` + +The comment requirement matters: `get_all_comments` is asserted non-empty, so +the test repo's designated PR/MR needs at least one existing comment (general +or inline) before the run. diff --git a/docs/getting-started/kanban/github.md b/docs/getting-started/kanban/github.md index d17926c3..f6008fbb 100644 --- a/docs/getting-started/kanban/github.md +++ b/docs/getting-started/kanban/github.md @@ -6,9 +6,9 @@ layout: doc Connect Operator to [**GitHub Projects v2**](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects) for issue tracking and project management. -> **⚠ Token Disambiguation — read this first** +> **⚠ Token Disambiguation - read this first** > -> GitHub Projects uses a **separate** API token from Operator's git provider (the one that creates pull requests). Even if you've already set `GITHUB_TOKEN` for PR workflows, you'll need a *second* token in `OPERATOR_GITHUB_TOKEN` with the `project` (or `read:project`) scope. The two **can** be the same physical PAT minted with both scopes — but they must be exposed via two different environment variables so Operator can route them correctly. +> GitHub Projects uses a **separate** API token from Operator's git provider (the one that creates pull requests). Even if you've already set `GITHUB_TOKEN` for PR workflows, you'll need a *second* token in `OPERATOR_GITHUB_TOKEN` with the `project` (or `read:project`) scope. The two **can** be the same physical PAT minted with both scopes - but they must be exposed via two different environment variables so Operator can route them correctly. > > | Operator subsystem | Env var | Required scopes | Configured at | > |-------------------------------|--------------------------|--------------------------------------------------|------------------------------------| @@ -20,24 +20,24 @@ Connect Operator to [**GitHub Projects v2**](https://docs.github.com/en/issues/p ## Prerequisites - A GitHub account with access to at least one Project v2 (user-owned or org-owned) -- A Personal Access Token (PAT) — classic or fine-grained — with the `project` scope, or a GitHub App installation token with `organization_projects: write` +- A Personal Access Token (PAT) - classic or fine-grained - with the `project` scope, or a GitHub App installation token with `organization_projects: write` - Operator installed and running ## Create a Token You have two options. **Fine-grained PATs are recommended** because they're scoped to specific orgs/repos and have built-in expiration. -### Option A — Classic Personal Access Token (simpler) +### Option A - Classic Personal Access Token (simpler) 1. Go to [github.com/settings/tokens](https://github.com/settings/tokens) 2. Click **Generate new token (classic)** 3. Name it something like *"Operator Kanban (read+write)"* 4. Select scopes: - - `project` (full read + write to Projects v2) — **or** `read:project` (read-only) + - `project` (full read + write to Projects v2) - **or** `read:project` (read-only) - Optionally `read:org` if you need to enumerate org projects 5. Click **Generate token**, then copy the `ghp_...` value -### Option B — Fine-Grained Personal Access Token (recommended) +### Option B - Fine-Grained Personal Access Token (recommended) 1. Go to [github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens) 2. Click **Generate new token** @@ -78,7 +78,7 @@ doing = "In Progress" # Status pushed when a ticket is launched/claimed done = "Done" # Status pushed when a ticket completes ``` -The hashmap key under `[kanban.github.""]` is the GitHub owner login (user or org). Project keys inside `projects` are **GraphQL node IDs** (e.g. `PVT_kwDOABcdefg`) — not project numbers — because every Projects v2 mutation needs the node ID and storing it directly avoids an extra lookup per call. +The hashmap key under `[kanban.github.""]` is the GitHub owner login (user or org). Project keys inside `projects` are **GraphQL node IDs** (e.g. `PVT_kwDOABcdefg`) - not project numbers - because every Projects v2 mutation needs the node ID and storing it directly avoids an extra lookup per call. ### 3. Multiple Owners with Different Tokens @@ -133,7 +133,7 @@ query($login: String!) { The `id` field is what you put in `[kanban.github."".projects.]`. -If you'd rather skip this step, use the **VS Code extension** or **Operator TUI** onboarding flow — both will list your projects after validating your token and write the config for you. +If you'd rather skip this step, use the **VS Code extension** or **Operator TUI** onboarding flow - both will list your projects after validating your token and write the config for you. ## Finding Your `sync_user_id` @@ -154,8 +154,8 @@ gh api graphql -f query='query { viewer { databaseId login } }' Operator's GitHub Projects provider exposes issue types via two paths, in order of preference: -1. **Org-level Issue Types** (recommended where available) — the new first-class GitHub feature. See [docs.github.com/en/issues/tracking-your-work-with-issues/configuring-issues/managing-issue-types-in-an-organization](https://docs.github.com/en/issues/tracking-your-work-with-issues/configuring-issues/managing-issue-types-in-an-organization). If your org has issue types configured, the provider exposes them directly. -2. **Repo labels (fallback)** — when issue types aren't available (user-owned projects or orgs without the feature), the provider aggregates labels from all repos linked through project items. +1. **Org-level Issue Types** (recommended where available) - the new first-class GitHub feature. See [docs.github.com/en/issues/tracking-your-work-with-issues/configuring-issues/managing-issue-types-in-an-organization](https://docs.github.com/en/issues/tracking-your-work-with-issues/configuring-issues/managing-issue-types-in-an-organization). If your org has issue types configured, the provider exposes them directly. +2. **Repo labels (fallback)** - when issue types aren't available (user-owned projects or orgs without the feature), the provider aggregates labels from all repos linked through project items. Configure mappings via `type_mappings` in your `ProjectSyncConfig`: @@ -196,7 +196,7 @@ real option names via `GET /api/v1/kanban/github/PVT_kwDOABcdefg/statuses`. > **Migrating from `sync_statuses`:** the old list is no longer read (the key > is silently ignored). Re-express it as the `status_mapping` table above. -The keys in `type_mappings` are the GraphQL label IDs (or issue type IDs) returned by `get_issue_types()` — they're persisted in the local issue type catalog after the first sync, and you can find them with: +The keys in `type_mappings` are the GraphQL label IDs (or issue type IDs) returned by `get_issue_types()` - they're persisted in the local issue type catalog after the first sync, and you can find them with: ```bash cat .tickets/operator/kanban/github/PVT_kwDOABcdefg/issuetypes.json @@ -210,7 +210,7 @@ Pull issues from GitHub Projects: operator sync ``` -The provider client-side filters by your `sync_user_id` (project items don't support server-side assignee filtering in the GraphQL API), so very large projects may pull a few extra pages before applying the filter. Status filtering uses the `Status` single-select field's option names — make sure the values in `status_mapping` exactly match the names defined in your project (case-insensitive). +The provider client-side filters by your `sync_user_id` (project items don't support server-side assignee filtering in the GraphQL API), so very large projects may pull a few extra pages before applying the filter. Status filtering uses the `Status` single-select field's option names - make sure the values in `status_mapping` exactly match the names defined in your project (case-insensitive). ### What gets synced @@ -230,15 +230,15 @@ The `key` field on the synced ticket follows these formats: For v1, the GitHub Projects provider creates **draft issues only** via the `addProjectV2DraftIssue` mutation. Draft issues live inside the project (not in any repo) and can be promoted to real issues later from the GitHub UI. -If you need real repo issues, create them through GitHub's normal flows — they'll appear in operator after the next sync if they're added to a project the operator is configured for. +If you need real repo issues, create them through GitHub's normal flows - they'll appear in operator after the next sync if they're added to a project the operator is configured for. ## Troubleshooting ### "Token authenticated but lacks 'project' scope" -This is the disambiguation guard rail firing. It means the token reached GitHub's API successfully but doesn't have the `project` scope — most likely you accidentally pasted your `GITHUB_TOKEN` (which is repo-scoped for PR workflows). Re-mint a token with the `project` (or `read:project`) scope and re-run onboarding. +This is the disambiguation guard rail firing. It means the token reached GitHub's API successfully but doesn't have the `project` scope - most likely you accidentally pasted your `GITHUB_TOKEN` (which is repo-scoped for PR workflows). Re-mint a token with the `project` (or `read:project`) scope and re-run onboarding. -If you're using a fine-grained PAT and you're sure it has Projects permissions, double-check the **Resource owner** matches the org/user whose projects you're trying to sync — fine-grained PATs are scoped per resource owner. +If you're using a fine-grained PAT and you're sure it has Projects permissions, double-check the **Resource owner** matches the org/user whose projects you're trying to sync - fine-grained PATs are scoped per resource owner. ### Authentication errors @@ -251,7 +251,7 @@ curl -H "Authorization: bearer $OPERATOR_GITHUB_TOKEN" \ -d '{"query":"{ viewer { login databaseId } }"}' ``` -For classic PATs, also check the response headers — they include `x-oauth-scopes`: +For classic PATs, also check the response headers - they include `x-oauth-scopes`: ```bash curl -i -H "Authorization: bearer $OPERATOR_GITHUB_TOKEN" \ diff --git a/docs/getting-started/platform-support.md b/docs/getting-started/platform-support.md index f01b5a6a..dbc27d66 100644 --- a/docs/getting-started/platform-support.md +++ b/docs/getting-started/platform-support.md @@ -61,6 +61,24 @@ macOS is the primary development platform. No known feature gaps. --- +## Containers and Kubernetes + +Operator also ships as a container image and a Helm chart. Both are Linux-only +(`linux/amd64`, `linux/arm64`) regardless of the host you drive them from. + +| Distribution | Status | Notes | +|--------------|--------|-------| +| Docker image `untra/operator` | ✅ Supported | Multi-arch. See [Docker](/getting-started/platforms/docker/) | +| Helm chart `oci://ghcr.io/untra/charts/operator` | ⚠️ Alpha | Single-replica StatefulSet, ReadWriteOnce persistence. See [Kubernetes](/getting-started/platforms/kubernetes/) | +| Example Helmfile | ⚠️ Alpha | `examples/helmfile.yaml` in the repository | + +| Feature | Status | Reason | Workaround | +|---------|--------|--------|------------| +| Horizontal scaling of the chart | ❌ N/A | Operator is a single-writer process over a ReadWriteOnce volume with a local queue and SQLite auth database | Run one replica; scale by running separate instances against separate workspaces | +| Agent process isolation | ⚠️ Planned | Agents run as child processes sharing Operator's user and filesystem | Treat the configured agent tool as trusted. See [Security](/security/#the-agent-process-is-inside-the-trust-boundary) | + +--- + ## Integration-Level Gaps (all platforms) These gaps apply on every operating system because the integration itself is not fully implemented. diff --git a/docs/getting-started/platforms/docker.md b/docs/getting-started/platforms/docker.md index f179307e..8bf5a017 100644 --- a/docs/getting-started/platforms/docker.md +++ b/docs/getting-started/platforms/docker.md @@ -26,14 +26,28 @@ startup; otherwise it falls back to built-in defaults. Subcommands are appended after the image name. Run as a background REST API service: ```bash -docker run --rm -v $(pwd):/op:rw -p 127.0.0.1:7008:7008 untra/operator api +docker run --rm -v $(pwd):/op:rw \ + -e OPERATOR_REST_API__HOST=0.0.0.0 \ + -e OPERATOR_BOOTSTRAP_PASSWORD_FILE=/run/secrets/bootstrap \ + -p 127.0.0.1:7008:7008 untra/operator api ``` -> **Security:** the REST API is **unauthenticated**, sends permissive CORS headers, and -> exposes mutating endpoints (launching agents, editing config). The publish above binds it -> to loopback (`127.0.0.1`) only — a bare `-p 7008:7008` would expose it on every host -> interface. Do not publish it to untrusted networks; if you need remote access, put it -> behind an authenticating reverse proxy. +**`OPERATOR_REST_API__HOST=0.0.0.0` is required to publish the port at all.** + +Operator binds `127.0.0.1` by default, which inside a container means the *container's* loopback — unreachable from the host no matter how you publish it. +Setting the bind address to `0.0.0.0` makes it reachable from the container network; `-p 127.0.0.1:7008:7008` then restricts which host interface it appears on. Both halves are needed, and they do different jobs. + +Outside a container the default is unchanged: Operator binds loopback, and you do not need to set this. + +**`-p 127.0.0.1:7008:7008` binds the published port to host loopback only.** A +bare `-p 7008:7008` publishes on every host interface. + +> **Authentication.** The REST API is authenticated. On first start Operator has +> no admin account and only the bootstrap, login, and probe endpoints respond; +> visit `/setup` to set the admin password, or mount a bootstrap password so the +> account cannot be claimed by whoever reaches the port first. CORS defaults to +> same-origin; set `[rest_api].cors_origins` to allow specific origins. See +> [Authentication](/security/authentication/). Any Operator subcommand works the same way: @@ -71,11 +85,11 @@ docker run --rm -v $(pwd):/op:rw -it untra/operator:{{ site.version }} RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \ && npm install -g @anthropic-ai/claude-code \ && rm -rf /var/lib/apt/lists/* - USER operator + USER 10001 ``` 2. **Mount + env vars** — mount an already-installed, authenticated CLI from the host - and pass credentials. The container runs as uid 1000 with `$HOME=/home/operator`: + and pass credentials. The container runs as uid/gid 10001 with `$HOME=/home/operator`: ```bash docker run --rm -v $(pwd):/op:rw \ @@ -106,11 +120,10 @@ A global override at `~/.config/operator/config.toml` (i.e. ### Permission denied writing to the mounted directory -The container runs as the unprivileged `operator` user (uid 1000), so it can only write into -`/op` (state, prompts, logs) if the mounted host directory is writable by uid 1000. On a -typical single-user Linux host your uid is already 1000 and it just works; otherwise either -make the directory writable (e.g. `chmod -R g+w` with a matching group, or `chown`) or run the -container as your own uid: +The container runs as the unprivileged `operator` user (uid/gid 10001), so it can only write +into `/op` (state, prompts, logs) if the mounted host directory is writable by uid 10001. +Either make the directory writable (e.g. `chown -R 10001:10001`, or `chmod -R g+w` with a +matching group) or run the container as your own uid: ```bash docker run --rm -v $(pwd):/op:rw --user $(id -u):$(id -g) -it untra/operator diff --git a/docs/getting-started/platforms/index.md b/docs/getting-started/platforms/index.md index 45819821..ff5127cd 100644 --- a/docs/getting-started/platforms/index.md +++ b/docs/getting-started/platforms/index.md @@ -12,3 +12,4 @@ Operator can run as a background service in remote workspace platforms, providin |--------|--------|-------| | [Coder](/getting-started/platforms/coder/) | Supported | Terraform module, runs Operator as background API server with dashboard | | [Docker](/getting-started/platforms/docker/) | Supported | Official multi-arch image (`untra/operator`); container is the workspace, mount your projects root at `/op` | +| [Kubernetes](/getting-started/platforms/kubernetes/) | Alpha | OCI Helm chart; single-replica StatefulSet with persistent workspace, authenticated REST API and dashboard | diff --git a/docs/getting-started/platforms/kubernetes.md b/docs/getting-started/platforms/kubernetes.md new file mode 100644 index 00000000..89397a9e --- /dev/null +++ b/docs/getting-started/platforms/kubernetes.md @@ -0,0 +1,293 @@ +--- +title: "Kubernetes" +description: "Run Operator in Kubernetes from the official OCI Helm chart, with persistent workspace state and an authenticated API." +layout: doc +--- + +Alpha + +Run [Operator](https://operator.untra.io) in a cluster from the official Helm chart. +The chart deploys a single-replica StatefulSet with a persistent workspace volume, a ClusterIP Service. + +**Chart:** `oci://ghcr.io/untra/charts/operator` — **Image:** [`untra/operator`](https://hub.docker.com/r/untra/operator) + +## What the chart does not contain + +Stated up front, because it is the first thing worth knowing about running an agent orchestrator in your cluster: + +- **No Docker socket** is mounted. +- **No Kubernetes controller.** Operator does not watch, create, or reconcile cluster resources. +- **No Role, RoleBinding, or ClusterRole** is created. +- The ServiceAccount sets `automountServiceAccountToken: false`, so the pod has no Kubernetes API credential at all. + +Operator in your cluster is an application with a volume and a port. It cannot reach the Kubernetes API, because it has no token and no client to use one with. + +## Install + +```bash +helm install operator oci://ghcr.io/untra/charts/operator \ + --namespace operator --create-namespace \ + --set publicUrl=https://operator.example.com +``` + +The chart's `appVersion` is the image tag. It is pinned to an exact release — the chart never deploys `latest`. + +### Bootstrap the admin account + +Operator's API is [always authenticated](/security/authentication/). Before installing, create the bootstrap Secret holding a temporary password: + +```bash +kubectl -n operator create secret generic operator-bootstrap \ + --from-literal=password="$(openssl rand -base64 24)" +``` + +Then reference it: + +```bash +helm install operator oci://ghcr.io/untra/charts/operator \ + --namespace operator --create-namespace \ + --set publicUrl=https://operator.example.com \ + --set bootstrap.existingSecret=operator-bootstrap +``` + +The Secret is mounted read-only and is read **only while the authentication database is uninitialized**. +Once you have set the admin password at `/setup`, it is ignored. Delete it afterward: + +```bash +kubectl -n operator delete secret operator-bootstrap +``` + +> **Kubernetes Secrets are not encrypted in etcd by default.** Anyone who can +> read etcd, restore an etcd backup, or `get` Secrets in this namespace can read +> the bootstrap password. Enable +> [encryption at rest](https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/) +> for Secrets and restrict `get`/`list` on them before using this pattern. See +> [Security](/security/#secrets-are-not-encrypted-by-default). + +## DNS and TLS + +The chart does not manage certificates. Create the TLS Secret yourself, or let +cert-manager create it, then point the Ingress at it. + +With cert-manager: + +```yaml +ingress: + enabled: true + className: nginx + host: operator.example.com + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + tls: + secretName: operator-tls +``` + +Without cert-manager, create the Secret from your own certificate: + +```bash +kubectl -n operator create secret tls operator-tls \ + --cert=fullchain.pem --key=privkey.pem +``` + +Ingress is **disabled by default**, and no Gateway API resource is provided. +Until you enable it, Operator is reachable only in-cluster. + +Set `publicUrl` to the externally reachable URL whenever you expose Operator. This is required. + +> **Setting `publicUrl` also hardens outbound request validation.** A non-empty +> value switches model-server destination checks to the hardened policy, which +> rejects loopback and private ranges (`10.0.0.0/8`, `172.16.0.0/12`, +> `192.168.0.0/16`). If you point Operator at a self-hosted LLM inside the +> cluster or on the LAN, configuring that model server will fail once +> `publicUrl` is set, with an error that does not mention `publicUrl`. Only the +> model-server probe and save paths are affected; Git, kanban, webhook, and +> collection traffic is not. + +## Persistence + +The StatefulSet claims a **ReadWriteOnce** volume, `20Gi` by default, from the +cluster's default StorageClass: + +```yaml +persistence: + size: 50Gi + storageClass: fast-ssd +``` + +The volume is mounted at `/op` and holds the workspace, repositories, +`.tickets/`, and the authentication database. It is the only durable state. + +**Horizontal scaling is not supported.** Operator is a single-writer process +over a ReadWriteOnce volume with a local queue and a local SQLite database. +`replicas` is fixed at 1; raising it would mean two processes racing over one +volume, and the chart does not offer the option. + +## NetworkPolicy + +Optional and disabled by default. Enabling it is how you bound Operator's +egress — the code-level destination validation described in +[Security](/security/#server-side-request-forgery) is one control, and this is +the other. + +```yaml +networkPolicy: + enabled: true + ingress: + from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + egress: + allowDNS: true + to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 # cloud metadata + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 +``` + +Operator needs egress to your model provider, kanban provider, and Git host. It +does not need egress to the rest of your cluster. + +## Custom agent images + +The base image ships `git`, `tmux`, and `ca-certificates`, but **no agent CLI** +— no `claude`, `codex`, or `gemini`, and no credentials for them. Supply your +own image: + +```dockerfile +FROM untra/operator:0.2.6 +USER root +RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \ + && npm install -g @anthropic-ai/claude-code \ + && rm -rf /var/lib/apt/lists/* +USER 10001 +``` + +```yaml +image: + repository: registry.example.com/operator-claude + tag: "0.2.6" +``` + +Provide the agent's credentials as environment variables from a Secret: + +```yaml +extraEnvFrom: + - secretRef: + name: operator-agent-credentials +``` + +Note that an agent process runs as the same user as Operator and can read +these. That is inherent to the current execution model — see +[the trust boundary discussion](/security/#the-agent-process-is-inside-the-trust-boundary). + +## Security context + +Applied by default; you should not need to change any of it: + +```yaml +podSecurityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault +containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +``` + +The root filesystem is read-only. Writable paths are the persistent volume at `/op`, plus emptyDir mounts for the home directory and `/tmp`, which tmux and the agent runtime need. + +## Upgrades + +```bash +helm upgrade operator oci://ghcr.io/untra/charts/operator --reuse-values +``` + +The StatefulSet uses `RollingUpdate`, but with one replica on a ReadWriteOnce volume the old pod must terminate before the new one attaches. + +The authentication database migrates forward automatically on start. + +## Backup and restore + +Back up the persistent volume. It holds everything: workspace, tickets, state, and `auth.sqlite3`. + +Treat the backup as sensitive — it contains the authentication database, which holds the token signing key. + +To restore, pre-create the PersistentVolumeClaim the StatefulSet expects, backed +by your snapshot, before installing the chart. A StatefulSet adopts an existing claim whose name matches its `volumeClaimTemplate`, which is `workspace--0`: + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: workspace-operator-0 + namespace: operator +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 20Gi + dataSource: + name: operator-snapshot + kind: VolumeSnapshot + apiGroup: snapshot.storage.k8s.io +``` + +Apply that, then `helm install` as normal. The requested size and StorageClass +must match the chart's `persistence` values, or the StatefulSet will reject the existing claim. + +Restoring an **old** authentication database resurrects credentials revoked +after the snapshot. If the database is lost or stale, prefer re-bootstrapping: +delete `auth.sqlite3`, supply a fresh bootstrap Secret, and re-issue integration +access keys. That generates a new signing key and invalidates everything issued +previously, which is the safe direction. See [Backup and recovery](/security/#backup-and-recovery). + +If you are locked out but the volume is intact, recover locally: + +```bash +kubectl -n operator exec -it statefulset/operator -- operator auth reset-admin-password +``` + +There is no HTTP password-reset route by design. + +## Troubleshooting + +### The pod starts but every request returns 401 + +Expected before bootstrap. Visit `/setup` to set the admin password, or confirm +the bootstrap Secret is mounted if you expected it to be consumed. + +### Probes fail while the API works + +The chart uses **HTTP probes** against `/livez` (liveness) and `/readyz` +(readiness). Both are public and carry no workspace metadata. `/livez` answers as +soon as the server is serving; `/readyz` additionally checks that the +authentication database on the persistent volume is reachable, returning `503` +with `auth store unavailable` when it is not — so a pod stuck `NotReady` with a +healthy `/livez` points at the volume, not the process. + +Do not repoint either probe at `/api/v1/health`: that endpoint reports workspace +identity and requires authentication, so an HTTP probe against it fails with `401` +even on a perfectly healthy pod. + +### OAuth or MCP URLs point at the wrong host + +`publicUrl` is unset or wrong. Operator generates the OAuth device-flow verification URIs and the MCP SSE transport URL from `publicUrl` rather than trusting the `Host` header. + +### Agents fail to launch + +The base image intentionally omits the agent CLI. Confirm your derived image +provides an authenticated `claude`, `codex`, or `gemini` on `PATH`: + +```bash +kubectl -n operator exec statefulset/operator -- sh -c 'command -v claude' +``` diff --git a/docs/getting-started/sessions/cmux.md b/docs/getting-started/sessions/cmux.md index 98615f25..ba407d85 100644 --- a/docs/getting-started/sessions/cmux.md +++ b/docs/getting-started/sessions/cmux.md @@ -12,16 +12,16 @@ layout: doc ## What is cmux? -cmux is a macOS-native terminal multiplexer that organizes work into **windows** and **workspaces**. Each workspace provides an isolated terminal environment within a window, similar to how tmux organizes sessions and panes — but with a native macOS interface. +cmux is a macOS-native terminal multiplexer that organizes work into **windows** and **workspaces**. Each workspace provides an isolated terminal environment within a window, similar to how tmux organizes sessions and panes - but with a native macOS interface. Operator uses cmux workspaces to run LLM agent sessions, allowing you to focus and switch between agents without leaving your terminal environment. ## Prerequisites -1. **macOS** — cmux is a macOS-only application -2. **cmux installed** — by default, Operator looks for the binary at `/Applications/cmux.app/Contents/Resources/bin/cmux` -3. **cmux 0.64.8 or newer** — Operator's placement policies rely on `new-workspace --window`, which landed in cmux 0.64.8. Older versions are rejected at startup with an explicit `Unsupported version` error — update cmux to resolve it. -4. **Running inside cmux** — Operator must be launched from within a cmux session (the `CMUX_WORKSPACE_ID` environment variable must be present) +1. **macOS** - cmux is a macOS-only application +2. **cmux installed** - by default, Operator looks for the binary at `/Applications/cmux.app/Contents/Resources/bin/cmux` +3. **cmux 0.64.8 or newer** - Operator's placement policies rely on `new-workspace --window`, which landed in cmux 0.64.8. Older versions are rejected at startup with an explicit `Unsupported version` error - update cmux to resolve it. +4. **Running inside cmux** - Operator must be launched from within a cmux session (the `CMUX_WORKSPACE_ID` environment variable must be present) ## Configuration @@ -86,7 +86,7 @@ When Operator launches a ticket: ### Focusing Agents -When you press Enter on an agent in the TUI, Operator focuses the corresponding cmux workspace. Unlike tmux, this does **not** suspend the TUI — cmux handles window/workspace focus natively. +When you press Enter on an agent in the TUI, Operator focuses the corresponding cmux workspace. Unlike tmux, this does **not** suspend the TUI - cmux handles window/workspace focus natively. ### Session Preview diff --git a/docs/getting-started/sessions/cursor.md b/docs/getting-started/sessions/cursor.md index 5d40e673..ed814fd8 100644 --- a/docs/getting-started/sessions/cursor.md +++ b/docs/getting-started/sessions/cursor.md @@ -8,13 +8,13 @@ layout: doc Install from OpenVSX -[Cursor](https://www.cursor.com) is a fork of VS Code that natively runs most VS Code extensions and adds its own MCP configuration surface. The same `operator-terminals` extension that powers the VS Code session manager runs in Cursor unmodified — the only difference is **where** the extension writes the MCP server entry. +[Cursor](https://www.cursor.com) is a fork of VS Code that natively runs most VS Code extensions and adds its own MCP configuration surface. The same `operator-terminals` extension that powers the VS Code session manager runs in Cursor unmodified - the only difference is **where** the extension writes the MCP server entry. ## Two Integration Paths ### 1. Extension Path (recommended) -Install `operator-terminals` from OpenVSX (Cursor's default extension registry) or via a downloaded `.vsix`, then run `Operator: Connect MCP Server` from the command palette. Inside Cursor, the extension writes the operator MCP entry to `~/.cursor/mcp.json` instead of VS Code's workspace `mcp.servers` — Cursor's MCP UI only surfaces user-scope entries, so writing workspace config would have no effect. +Install `operator-terminals` from OpenVSX (Cursor's default extension registry) or via a downloaded `.vsix`, then run `Operator: Connect MCP Server` from the command palette. Inside Cursor, the extension writes the operator MCP entry to `~/.cursor/mcp.json` instead of VS Code's workspace `mcp.servers` - Cursor's MCP UI only surfaces user-scope entries, so writing workspace config would have no effect. This path also gives you the sidebar (Queue / In Progress / Completed), styled terminals, and the rest of the extension's features. @@ -54,12 +54,12 @@ The extension shares the same configuration as the VS Code session manager. Sett ## MCP Integration -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. +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. ### Requirements - Operator must be running with `[mcp].stdio_advertised = true` in its config (this is the default). Restart the operator API after toggling. -- The operator binary path written into `~/.cursor/mcp.json` is taken from the running operator process — if you reinstall or move the binary, re-run `Operator: Connect MCP Server` to refresh the path. +- The operator binary path written into `~/.cursor/mcp.json` is taken from the running operator process - if you reinstall or move the binary, re-run `Operator: Connect MCP Server` to refresh the path. ### Merge Semantics @@ -69,11 +69,11 @@ The extension's Cursor-write path is additive: - Any existing `mcpServers.*` entries (other servers you've registered) are preserved. - Only `mcpServers.operator` is set or overwritten on each run. -If `~/.cursor/mcp.json` exists but contains malformed JSON, the extension shows an error and refuses to overwrite the file — fix or remove it manually and re-run the command. +If `~/.cursor/mcp.json` exists but contains malformed JSON, the extension shows an error and refuses to overwrite the file - fix or remove it manually and re-run the command. ## Commands -Same set as the VS Code session manager — access via the command palette (`Cmd+Shift+P`): +Same set as the VS Code session manager - access via the command palette (`Cmd+Shift+P`): | Command | Description | |---------|-------------| @@ -108,4 +108,4 @@ If you want a clean slate in only one of the two editors, delete the entry from 1. Check that `~/.cursor/mcp.json` exists and contains `mcpServers.operator` with `command`, `args`, and `cwd`. 2. Restart Cursor or open **Cursor Settings → MCP** and toggle the operator server off and on. -3. Confirm the `command` path in the JSON is executable (`ls -l ` and run it manually with ` mcp` — it should hang waiting for JSON-RPC on stdin, which is correct). +3. Confirm the `command` path in the JSON is executable (`ls -l ` and run it manually with ` mcp` - it should hang waiting for JSON-RPC on stdin, which is correct). diff --git a/docs/getting-started/sessions/remote-hosts/index.md b/docs/getting-started/sessions/remote-hosts/index.md index 52fdf81f..9d3dbad8 100644 --- a/docs/getting-started/sessions/remote-hosts/index.md +++ b/docs/getting-started/sessions/remote-hosts/index.md @@ -36,8 +36,7 @@ Because the tracked pane is local, screen scraping, attach, idle detection, and ## Remote host requirements - **SSH access** via an alias in `~/.ssh/config`, with key-based auth. - Connect once manually first (`ssh gpu-vm`) to accept host keys — launches use - `BatchMode`, which cannot answer interactive prompts. + Connect once manually first (`ssh gpu-vm`) to accept host keys. Launches use `BatchMode`, which cannot answer interactive prompts. - **tmux** installed on the remote PATH. - **The agent CLI** (`claude`, `codex`, `gemini`) on the remote PATH, already authenticated there (e.g. remote `~/.claude` credentials). diff --git a/docs/getting-started/sessions/zed.md b/docs/getting-started/sessions/zed.md index 7209f65e..11b1119a 100644 --- a/docs/getting-started/sessions/zed.md +++ b/docs/getting-started/sessions/zed.md @@ -30,19 +30,19 @@ The [Zed](https://zed.dev) extension for Operator provides three integration lay After installing the extension, Zed automatically registers `operator mcp` as a context server. All Operator tools appear in the Agent Panel: -- `operator_health` / `operator_status` — system health -- `operator_list_tickets` — query queue, in-progress, completed tickets -- `operator_claim_ticket` / `operator_complete_ticket` / `operator_return_to_queue` — ticket lifecycle -- `operator_create_ticket` — create tickets from templates -- `operator_list_issue_types` / `operator_list_collections` / `operator_list_skills` — registry queries -- `operator_launch_ticket` / `operator_pause_queue` / `operator_resume_queue` — queue operations -- `operator_approve_agent` / `operator_reject_agent` — review actions +- `operator_health` / `operator_status` - system health +- `operator_list_tickets` - query queue, in-progress, completed tickets +- `operator_claim_ticket` / `operator_complete_ticket` / `operator_return_to_queue` - ticket lifecycle +- `operator_create_ticket` - create tickets from templates +- `operator_list_issue_types` / `operator_list_collections` / `operator_list_skills` - registry queries +- `operator_launch_ticket` / `operator_pause_queue` / `operator_resume_queue` - queue operations +- `operator_approve_agent` / `operator_reject_agent` - review actions If the `operator` binary is not found, the extension shows installation instructions. ### ACP Agent Server (one-time setup) -Run `/op-setup-agent` in the AI assistant to generate the config snippet, then paste it into `~/.config/zed/settings.json`. After restarting Zed, Operator appears as an agent in the Agent Panel — you can send prompts that flow through ACP to a Claude Code delegator. +Run `/op-setup-agent` in the AI assistant to generate the config snippet, then paste it into `~/.config/zed/settings.json`. After restarting Zed, Operator appears as an agent in the Agent Panel - you can send prompts that flow through ACP to a Claude Code delegator. ## Slash Commands @@ -67,9 +67,9 @@ Commands with arguments support tab-completion from live API data. Operator integrates with Zed through three communication channels: -- **MCP Context Server** — Runs `operator mcp` via stdio. Tools and ticket resources appear natively in the Agent Panel without additional configuration. -- **ACP Agent Server** — Runs `operator acp` via stdio. Prompts sent to the Operator agent flow through a delegator to Claude Code, with streaming output back to Zed. -- **Slash Commands** — Communicate with the Operator REST API for quick status checks and operations directly in the AI assistant. +- **MCP Context Server** - Runs `operator mcp` via stdio. Tools and ticket resources appear natively in the Agent Panel without additional configuration. +- **ACP Agent Server** - Runs `operator acp` via stdio. Prompts sent to the Operator agent flow through a delegator to Claude Code, with streaming output back to Zed. +- **Slash Commands** - Communicate with the Operator REST API for quick status checks and operations directly in the AI assistant. ## Configuration diff --git a/docs/getting-started/sessions/zellij.md b/docs/getting-started/sessions/zellij.md index 47aed420..abdefd64 100644 --- a/docs/getting-started/sessions/zellij.md +++ b/docs/getting-started/sessions/zellij.md @@ -12,14 +12,14 @@ layout: doc ## What is Zellij? -Zellij is a terminal workspace manager written in Rust. It organizes work into **sessions**, **tabs**, and **panes** — a 3-tier hierarchy that provides flexible terminal management with a modern interface. +Zellij is a terminal workspace manager written in Rust. It organizes work into **sessions**, **tabs**, and **panes** - a 3-tier hierarchy that provides flexible terminal management with a modern interface. Operator uses Zellij tabs to run LLM agent sessions, with each agent getting its own dedicated tab. This keeps agents isolated while letting you switch between them using Zellij's native tab navigation. ## Prerequisites -1. **Zellij installed** — install via your package manager or from [zellij.dev](https://zellij.dev) -2. **Running inside Zellij** — Operator must be launched from within a Zellij session (the `ZELLIJ` environment variable must be present) +1. **Zellij installed** - install via your package manager or from [zellij.dev](https://zellij.dev) +2. **Running inside Zellij** - Operator must be launched from within a Zellij session (the `ZELLIJ` environment variable must be present) ## Configuration @@ -51,7 +51,7 @@ When Operator launches a ticket: ### Focusing Agents -When you press Enter on an agent in the TUI, Operator focuses the corresponding Zellij tab. Like cmux, this does **not** suspend the TUI — Zellij handles tab focus natively. +When you press Enter on an agent in the TUI, Operator focuses the corresponding Zellij tab. Like cmux, this does **not** suspend the TUI - Zellij handles tab focus natively. ### Session Preview @@ -63,11 +63,6 @@ Press `p` on an agent to preview its terminal content directly in the TUI. Opera When a workflow step specifies a different agent (delegator), Operator gracefully exits the current agent and launches the new one in the same Zellij tab using the 3-tier escalation (`/exit` → `Ctrl+C` → `Ctrl+D`). -## Known Limitations - -- **Screen capture requires focus:** Zellij's `dump-screen` command captures the currently focused pane. Operator must briefly switch tabs to capture content, which may cause a momentary visual flicker. -- **Tab operations require focus:** Closing a tab or sending text requires first focusing the tab. There is a potential race condition if you manually switch tabs at the same moment. - ## Troubleshooting ### "zellij is not installed" diff --git a/docs/llms.txt b/docs/llms.txt index 295ce3df..40b63ca1 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -12,6 +12,7 @@ Operator runs from the root of your work directory, discovers projects by LLM ma - [Tickets](https://operator.untra.io/getting-started/tickets/): Create and manage tickets with markdown format, naming conventions, and best practices for LLM agents. - [Supported Kanban Providers](https://operator.untra.io/getting-started/kanban/): Kanban and issue tracking integrations for Operator. - [Supported Coding Agents](https://operator.untra.io/getting-started/agents/): AI coding agents compatible with Operator. +- [Kubernetes](https://operator.untra.io/getting-started/platforms/kubernetes/): Run Operator in Kubernetes from the official OCI Helm chart, with persistent workspace state and an authenticated API. - [Downloads](https://operator.untra.io/downloads/): Download Operator! binaries for macOS, Linux, and Windows. ## Workflows @@ -31,6 +32,8 @@ Operator runs from the root of your work directory, discovers projects by LLM ma - [Keyboard Shortcuts](https://operator.untra.io/shortcuts/): TUI keyboard shortcuts by context. - [Project Taxonomy](https://operator.untra.io/taxonomy/): Project Kinds across five tiers. - [Artifact Detection](https://operator.untra.io/artifact-detection/): How Operator uses file artifacts as positive signals for step completion. +- [Security](https://operator.untra.io/security/): Threat model and security architecture for Operator: trust boundaries, route classification, credential handling, and residual risks. +- [Authentication](https://operator.untra.io/security/authentication/): How Operator authenticates: the admin account, bootstrap, browser sessions, OAuth device flow, access keys, scopes, and recovery. ## Optional - [GitHub Repository](https://github.com/untra/operator): Source code (Rust, MIT). diff --git a/docs/maturity/index.md b/docs/maturity/index.md index b15112d1..1cb4ee53 100644 --- a/docs/maturity/index.md +++ b/docs/maturity/index.md @@ -10,10 +10,10 @@ Operator integrates with many providers and tools across several **verticals**. ## Support levels -- ![GA](https://img.shields.io/badge/GA-1BB91F) — Generally available and supported. -- ![Beta](https://img.shields.io/badge/Beta-E8A33D) — Stable-ish and hardening toward general availability. -- ![Alpha](https://img.shields.io/badge/Alpha-6495ED) — Usable, but expect breaking changes. Advertised with caveats. -- ![Proto](https://img.shields.io/badge/Proto-6B7280) — Experimental — present in code with no guarantees. Not advertised yet. +- ![GA](https://img.shields.io/badge/GA-1BB91F) - Generally available and supported. +- ![Beta](https://img.shields.io/badge/Beta-E8A33D) - Stable-ish and hardening toward general availability. +- ![Alpha](https://img.shields.io/badge/Alpha-6495ED) - Usable, but expect breaking changes. Advertised with caveats. +- ![Proto](https://img.shields.io/badge/Proto-6B7280) - Experimental - present in code with no guarantees. Not advertised yet. ## Kanban Provider @@ -33,8 +33,8 @@ Operator integrates with many providers and tools across several **verticals**. | Google | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Google](https://operator.untra.io/getting-started/model-servers/google/) | | Ollama | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Ollama](https://operator.untra.io/getting-started/model-servers/ollama/) | | OpenRouter | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [OpenRouter](https://operator.untra.io/getting-started/model-servers/openrouter/) | -| OpenAI-compatible | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | -| LM Studio | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | +| OpenAI-compatible | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | +| LM Studio | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | ## Git Version Control @@ -42,10 +42,10 @@ Operator integrates with many providers and tools across several **verticals**. |---|---|---| | GitHub | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [GitHub](https://operator.untra.io/getting-started/git/github/) | | GitLab | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [GitLab](https://operator.untra.io/getting-started/git/gitlab/) | -| Bitbucket | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | -| Azure DevOps | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | -| Forgejo | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | -| Gitea | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | +| Bitbucket | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | +| Azure DevOps | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | +| Forgejo | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | +| Gitea | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | ## Session @@ -77,6 +77,7 @@ Operator integrates with many providers and tools across several **verticals**. |---|---|---| | Docker | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Docker](https://operator.untra.io/getting-started/platforms/docker/) | | Coder | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Coder](https://operator.untra.io/getting-started/platforms/coder/) | +| Kubernetes | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Kubernetes](https://operator.untra.io/getting-started/platforms/kubernetes/) | ## Integration diff --git a/docs/schemas/api.md b/docs/schemas/api.md index e133b3f9..d69dd927 100644 --- a/docs/schemas/api.md +++ b/docs/schemas/api.md @@ -11,8 +11,26 @@ The Operator REST API provides endpoints for managing issue types and collection ## Quick Links - **Base URL**: `http://localhost:7008/api/v1` -- **Health Check**: `GET /api/v1/health` -- **Status**: `GET /api/v1/status` +- **Liveness probe**: `GET /livez` (public) +- **Readiness probe**: `GET /readyz` (public) +- **Health Check**: `GET /api/v1/health` (requires `read`) +- **Status**: `GET /api/v1/status` (requires `read`) + +## Authentication + +Every endpoint below is authenticated except the probes, the bootstrap and +login endpoints, and the OAuth device and token endpoints. Two schemes are +accepted: + +| Scheme | Used by | Notes | +|--------|---------|-------| +| `bearerAuth` | CLI, IDE clients, integrations, MCP | `Authorization: Bearer `. Access tokens are short-lived; obtain one at the token endpoint with a refresh token or a service access key. | +| `sessionCookie` | The web dashboard | Opaque `__Host-operator_session` cookie. Cookie-authenticated mutations additionally require a CSRF token and a matching `Origin`. | + +Each operation declares the scope it requires - `read`, `write`, `execute`, or `admin`. A credential without the scope receives `403`; no credential at all receives `401`. + +Full details, including bootstrap, the device flow, and access-key lifecycle, +are in [Authentication](/security/authentication/). ## Starting the API Server diff --git a/docs/schemas/index.md b/docs/schemas/index.md index 3e887c40..34374733 100644 --- a/docs/schemas/index.md +++ b/docs/schemas/index.md @@ -6,7 +6,7 @@ layout: doc -This section documents all JSON schemas and type definitions used by Operator. +This section documents Operator's file schemas and public REST API contract. ## Documentation @@ -28,28 +28,18 @@ 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.0 | REST API specification (generated via utoipa) | +| [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) | -## TypeScript Types - -TypeScript type definitions are available for frontend integration: - -- Source: `shared/types.ts` (generated via ts-rs) -- API docs can be generated locally with `npm run docs:typescript` - ## Regenerating Schemas Schemas are auto-generated from source code. To regenerate: ```bash -# Generate JSON schemas and TypeScript types +# Generate JSON schemas cargo run --bin generate_types # Generate documentation pages cargo run -- docs - -# Generate TypeScript API docs -npm run docs:typescript ``` diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 07d77c2b..1396ab1c 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -31,24 +31,844 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/agents/{agent_id}": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Get details for a single agent by ID", + "description": "Returns full details for a specific agent, including all tracked state.", + "operationId": "agents_get_detail", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "The agent ID to look up", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Agent details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentDetailResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Agent not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/agents/{agent_id}/approve": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Approve an agent's pending review", + "description": "Clears the review state and signals the agent to continue.\nThe agent must be in `awaiting_input` status with a pending review.", + "operationId": "agents_approve_review", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "The agent ID to approve", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Review approved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Agent not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" + } + }, + "/api/v1/agents/{agent_id}/focus": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Focus the terminal session of a running agent in its session wrapper.", + "description": "The web UI's launch panel calls this for **cmux** launches: cmux exposes no\nbrowser URL scheme, so the operator control plane (which runs inside cmux)\nshells out to `cmux focus-workspace` for the agent's saved workspace ref to\nbring its pane to the foreground. Other wrappers are unsupported here — VS\nCode focuses through its extension's URI handler, and tmux/zellij are\ndisplay-only in the UI, so it never calls this for them.", + "operationId": "agents_focus_session", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "The agent ID whose session to focus", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session focused" + }, + "400": { + "description": "Wrapper unsupported, or no session refs to focus", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Agent not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "execute" + } + }, + "/api/v1/agents/{agent_id}/reject": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Reject an agent's pending review", + "description": "Signals the agent that the review was rejected with feedback.\nThe agent should re-do the work based on the rejection reason.", + "operationId": "agents_reject_review", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "The agent ID to reject", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RejectReviewRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Review rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Agent not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" + } + }, + "/api/v1/alerts": { + "post": { + "tags": [ + "Tickets" + ], + "summary": "Raise an external alert as an investigation (INV) ticket.", + "description": "Creates an investigation through the same embedded-template path as\n[`create`], folding the alert's `source`/`severity` into the ticket values.\nPowers the AGNT `operator-alert` node.", + "operationId": "alerts_create", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAlertRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Investigation created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAlertResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "500": { + "description": "Failed to create investigation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" + } + }, + "/api/v1/auth/bootstrap": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Bootstrap status", + "operationId": "auth_bootstrap_status", + "responses": { + "200": { + "description": "Bootstrap state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BootstrapStatusResponse" + } + } + } + } + }, + "security": [] + }, + "post": { + "tags": [ + "Auth" + ], + "summary": "Claim the admin account", + "operationId": "auth_bootstrap_submit", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BootstrapSubmitRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Admin account created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BootstrapSubmitResponse" + } + } + } + }, + "409": { + "description": "Already bootstrapped", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Too many attempts", + "headers": { + "Retry-After": { + "schema": { + "type": "integer" + }, + "description": "Seconds the client must wait before retrying." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [] + } + }, + "/api/v1/auth/csrf": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Issue a CSRF token for the current session", + "operationId": "auth_csrf_token", + "responses": { + "200": { + "description": "CSRF token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CsrfTokenResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/auth/device/approve": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Approve a device", + "operationId": "auth_device_approve", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceApprovalRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Device approved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceApprovalResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Unknown or expired user code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" + } + }, + "/api/v1/auth/device/code": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Begin device authorization", + "operationId": "auth_device_code", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceAuthorizationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Device code issued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceAuthorizationResponse" + } + } + } + }, + "429": { + "description": "Too many attempts", + "headers": { + "Retry-After": { + "schema": { + "type": "integer" + }, + "description": "Seconds the client must wait before retrying." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [] + } + }, + "/api/v1/auth/keys": { + "get": { + "tags": [ + "Auth" + ], + "summary": "List access keys", + "operationId": "auth_list_access_keys", + "responses": { + "200": { + "description": "Access keys", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessKeyListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" + }, + "post": { + "tags": [ + "Auth" + ], + "summary": "Create an access key", + "operationId": "auth_create_access_key", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAccessKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Key created; secret returned once", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAccessKeyResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" + } + }, + "/api/v1/auth/keys/{id}": { + "delete": { + "tags": [ + "Auth" + ], + "summary": "Revoke an access key", + "operationId": "auth_revoke_access_key", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Access key id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokeAccessKeyResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Unknown or already revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" + } + }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Log in", + "operationId": "auth_login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Logged in", + "headers": { + "Set-Cookie": { + "schema": { + "type": "string" + }, + "description": "Sets the opaque HttpOnly browser session cookie" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "401": { + "description": "Bad password", + "headers": { + "WWW-Authenticate": { + "schema": { + "type": "string" + }, + "description": "Authentication challenge naming the accepted schemes." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Too many attempts", + "headers": { + "Retry-After": { + "schema": { + "type": "integer" + }, + "description": "Seconds the client must wait before retrying." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [] } }, - "/api/v1/agents/{agent_id}": { - "get": { + "/api/v1/auth/logout": { + "post": { "tags": [ - "Agents" + "Auth" ], - "summary": "Get details for a single agent by ID", - "description": "Returns full details for a specific agent, including all tracked state.", - "operationId": "agents_get_detail", + "summary": "Log out", + "operationId": "auth_logout", "parameters": [ { - "name": "agent_id", - "in": "path", - "description": "The agent ID to look up", - "required": true, + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, "schema": { "type": "string" } @@ -56,148 +876,181 @@ ], "responses": { "200": { - "description": "Agent details", + "description": "Logged out", + "headers": { + "Set-Cookie": { + "schema": { + "type": "string" + }, + "description": "Expires the browser session cookie" + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentDetailResponse" + "$ref": "#/components/schemas/LogoutResponse" } } } }, - "404": { - "description": "Agent not found" + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" } }, - "/api/v1/agents/{agent_id}/approve": { - "post": { + "/api/v1/auth/session": { + "get": { "tags": [ - "Agents" - ], - "summary": "Approve an agent's pending review", - "description": "Clears the review state and signals the agent to continue.\nThe agent must be in `awaiting_input` status with a pending review.", - "operationId": "agents_approve_review", - "parameters": [ - { - "name": "agent_id", - "in": "path", - "description": "The agent ID to approve", - "required": true, - "schema": { - "type": "string" - } - } + "Auth" ], + "summary": "Current session", + "operationId": "auth_current_session", "responses": { "200": { - "description": "Review approved", + "description": "Current principal", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReviewResponse" + "$ref": "#/components/schemas/CurrentSessionResponse" } } } }, - "404": { - "description": "Agent not found" + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, - "/api/v1/agents/{agent_id}/focus": { - "post": { + "/api/v1/auth/sessions": { + "get": { "tags": [ - "Agents" - ], - "summary": "Focus the terminal session of a running agent in its session wrapper.", - "description": "The web UI's launch panel calls this for **cmux** launches: cmux exposes no\nbrowser URL scheme, so the operator control plane (which runs inside cmux)\nshells out to `cmux focus-workspace` for the agent's saved workspace ref to\nbring its pane to the foreground. Other wrappers are unsupported here — VS\nCode focuses through its extension's URI handler, and tmux/zellij are\ndisplay-only in the UI, so it never calls this for them.", - "operationId": "agents_focus_session", - "parameters": [ - { - "name": "agent_id", - "in": "path", - "description": "The agent ID whose session to focus", - "required": true, - "schema": { - "type": "string" - } - } + "Auth" ], + "summary": "List sessions and devices", + "operationId": "auth_list_sessions", "responses": { "200": { - "description": "Session focused" + "description": "Sessions and devices", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionListResponse" + } + } + } }, - "400": { - "description": "Wrapper unsupported, or no session refs to focus" + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "404": { - "description": "Agent not found" + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" } }, - "/api/v1/agents/{agent_id}/reject": { - "post": { + "/api/v1/auth/sessions/{id}": { + "delete": { "tags": [ - "Agents" + "Auth" ], - "summary": "Reject an agent's pending review", - "description": "Signals the agent that the review was rejected with feedback.\nThe agent should re-do the work based on the rejection reason.", - "operationId": "agents_reject_review", + "summary": "Revoke a session", + "operationId": "auth_revoke_session", "parameters": [ { - "name": "agent_id", + "name": "id", "in": "path", - "description": "The agent ID to reject", + "description": "Session id", "required": true, "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RejectReviewRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Review rejected", + "description": "Revoked", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReviewResponse" + "$ref": "#/components/schemas/LogoutResponse" } } } }, - "404": { - "description": "Agent not found" + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" } }, - "/api/v1/alerts": { + "/api/v1/auth/token": { "post": { "tags": [ - "Tickets" + "Auth" ], - "summary": "Raise an external alert as an investigation (INV) ticket.", - "description": "Creates an investigation through the same embedded-template path as\n[`create`], folding the alert's `source`/`severity` into the ticket values.\nPowers the AGNT `operator-alert` node.", - "operationId": "alerts_create", + "summary": "Exchange a credential for an access token", + "operationId": "auth_token", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateAlertRequest" + "$ref": "#/components/schemas/TokenRequest" } } }, @@ -205,17 +1058,35 @@ }, "responses": { "200": { - "description": "Investigation created", + "description": "Access token issued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateAlertResponse" + "$ref": "#/components/schemas/TokenResponse" } } } }, - "500": { - "description": "Failed to create investigation", + "400": { + "description": "OAuth error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthErrorResponse" + } + } + } + }, + "429": { + "description": "Too many attempts", + "headers": { + "Retry-After": { + "schema": { + "type": "integer" + }, + "description": "Seconds the client must wait before retrying." + } + }, "content": { "application/json": { "schema": { @@ -224,7 +1095,8 @@ } } } - } + }, + "security": [] } }, "/api/v1/collections": { @@ -247,8 +1119,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/collections/active": { @@ -269,6 +1156,12 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "description": "No active collection", "content": { @@ -279,7 +1172,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/collections/{name}": { @@ -311,6 +1213,12 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "description": "Collection not found", "content": { @@ -321,7 +1229,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/collections/{name}/activate": { @@ -340,6 +1257,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -353,6 +1279,12 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "description": "Collection not found", "content": { @@ -363,7 +1295,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" } }, "/api/v1/configuration": { @@ -371,47 +1312,98 @@ "tags": [ "Configuration" ], - "summary": "Get the current configuration", - "description": "Returns the full operator configuration as a JSON object. The body is left\nopaque in the OpenAPI spec because the `Config` tree is large and no client\nconsumes its OpenAPI schema (the TS `Config` type is generated separately by\nts-rs).", "operationId": "configuration_get", "responses": { "200": { - "description": "Current configuration as a JSON object", + "description": "Supported operational configuration", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/ConfigurationResponse" + } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" }, - "put": { + "patch": { "tags": [ "Configuration" ], - "summary": "Update configuration and save to disk", - "operationId": "configuration_update", + "operationId": "configuration_patch", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/UpdateConfigurationRequest" + } } }, "required": true }, "responses": { "200": { - "description": "Updated configuration as a JSON object", + "description": "Updated operational configuration", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/ConfigurationResponse" + } } } }, - "500": { - "description": "Failed to save configuration" + "400": { + "description": "Invalid, empty, or null-valued patch", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" } }, "/api/v1/delegators": { @@ -431,8 +1423,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" }, "post": { "tags": [ @@ -440,6 +1447,17 @@ ], "summary": "Create a new delegator", "operationId": "delegators_create", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -461,10 +1479,32 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "409": { - "description": "Delegator already exists" + "description": "Delegator already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" } }, "/api/v1/delegators/from-tool": { @@ -475,6 +1515,17 @@ "summary": "Create a delegator from a detected LLM tool", "description": "Pre-populates delegator fields from the detected tool, requiring minimal input.", "operationId": "delegators_create_from_tool", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -496,13 +1547,42 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { - "description": "Tool not detected" + "description": "Tool not detected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, "409": { - "description": "Delegator already exists" + "description": "Delegator already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" } }, "/api/v1/delegators/import-profile": { @@ -513,6 +1593,17 @@ "summary": "Import an `AgentProfile` as a new delegator.", "description": "The shared-core fields Operator can't model and the opaque `x_agnt` bag are\npreserved on the created delegator so a later export round-trips losslessly.", "operationId": "delegators_import_profile", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -534,10 +1625,32 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "409": { - "description": "Delegator already exists" + "description": "Delegator already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" } }, "/api/v1/delegators/{name}": { @@ -569,10 +1682,32 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { - "description": "Delegator not found" + "description": "Delegator not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" }, "put": { "tags": [ @@ -589,6 +1724,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "requestBody": { @@ -612,10 +1756,32 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { - "description": "Delegator not found" + "description": "Delegator not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" }, "delete": { "tags": [ @@ -632,6 +1798,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -645,10 +1820,32 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { - "description": "Delegator not found" + "description": "Delegator not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" } }, "/api/v1/delegators/{name}/profile": { @@ -681,10 +1878,67 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { - "description": "Delegator not found" + "description": "Delegator not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/execution-targets": { + "get": { + "tags": [ + "Configuration" + ], + "operationId": "configuration_execution_targets", + "responses": { + "200": { + "description": "Safe execution-target summaries", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecutionTargetsResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/health": { @@ -704,8 +1958,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/integrations": { @@ -729,8 +1998,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/issuetypes": { @@ -768,6 +2052,12 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "description": "Unknown collection", "content": { @@ -778,7 +2068,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" }, "post": { "tags": [ @@ -786,6 +2085,17 @@ ], "summary": "Create a new issue type", "operationId": "issuetypes_create", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -817,6 +2127,12 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "409": { "description": "Issue type already exists", "content": { @@ -827,7 +2143,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" } }, "/api/v1/issuetypes/{key}": { @@ -871,6 +2196,12 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "description": "Issue type not found", "content": { @@ -881,7 +2212,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" }, "put": { "tags": [ @@ -898,6 +2238,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "requestBody": { @@ -931,6 +2280,9 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, "403": { "description": "Cannot modify builtin type", "content": { @@ -951,7 +2303,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" }, "delete": { "tags": [ @@ -968,12 +2329,24 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { "200": { "description": "Issue type deleted" }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, "403": { "description": "Cannot delete builtin type", "content": { @@ -994,7 +2367,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" } }, "/api/v1/issuetypes/{key}/document": { @@ -1037,6 +2419,12 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "description": "Issue type not found", "content": { @@ -1047,7 +2435,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/issuetypes/{key}/steps": { @@ -1082,6 +2479,12 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "description": "Issue type not found", "content": { @@ -1092,7 +2495,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/issuetypes/{key}/steps/{step_name}": { @@ -1133,6 +2545,12 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "description": "Issue type or step not found", "content": { @@ -1143,7 +2561,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" }, "put": { "tags": [ @@ -1169,6 +2596,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "requestBody": { @@ -1202,6 +2638,9 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, "403": { "description": "Cannot modify builtin type", "content": { @@ -1222,7 +2661,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" } }, "/api/v1/issuetypes/{key}/workflow-preview": { @@ -1264,6 +2712,12 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "description": "Issue type not found", "content": { @@ -1274,7 +2728,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/kanban/config": { @@ -1285,6 +2748,17 @@ "summary": "PUT /`api/v1/kanban/config`", "description": "Write or upsert a kanban provider+project section into `config.toml`.\nDoes NOT receive the actual secret — only the env var name (`api_key_env`).", "operationId": "kanban_write_config", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -1305,8 +2779,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" } }, "/api/v1/kanban/projects": { @@ -1317,6 +2806,17 @@ "summary": "POST /`api/v1/kanban/projects`", "description": "List available projects/teams for the given provider using ephemeral\ncredentials. No persistence side effects.", "operationId": "kanban_list_projects", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -1337,8 +2837,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "execute" } }, "/api/v1/kanban/providers": { @@ -1362,8 +2877,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/kanban/session-env": { @@ -1374,6 +2904,17 @@ "summary": "POST /`api/v1/kanban/session-env`", "description": "Set kanban env vars on the server process for the current session so\nsubsequent `from_config()` calls find the API key. Returns a\n`shell_export_block` with placeholder values for the client to display.", "operationId": "kanban_set_session_env", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -1394,8 +2935,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" } }, "/api/v1/kanban/statuses": { @@ -1406,6 +2962,17 @@ "summary": "POST /`api/v1/kanban/statuses`", "description": "List the workflow statuses/columns of a specific project using ephemeral\ncredentials, so onboarding UIs can offer real column names in the\ntodo/doing/done mapping dropdowns. No persistence side effects.", "operationId": "kanban_list_statuses", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -1426,8 +2993,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "execute" } }, "/api/v1/kanban/validate": { @@ -1438,6 +3020,17 @@ "summary": "POST /`api/v1/kanban/validate`", "description": "Validate credentials against the live provider API without persisting\nanything. Auth failures return `valid: false` with an `error` string\nrather than a 4xx/5xx status so clients can display errors inline.", "operationId": "kanban_validate_credentials", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -1458,8 +3051,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "execute" } }, "/api/v1/kanban/{provider}/{project_key}/issuetypes": { @@ -1505,12 +3113,41 @@ } }, "400": { - "description": "Unknown provider/project" + "description": "Unknown provider/project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" }, "500": { - "description": "Failed to read catalog or fetch from provider" + "description": "Failed to read catalog or fetch from provider", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/kanban/{provider}/{project_key}/issuetypes/sync": { @@ -1539,6 +3176,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -1553,12 +3199,41 @@ } }, "400": { - "description": "Unknown provider/project" + "description": "Unknown provider/project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" }, "500": { - "description": "Failed to sync from provider" + "description": "Failed to sync from provider", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" } }, "/api/v1/kanban/{provider}/{project_key}/statuses": { @@ -1601,12 +3276,41 @@ } }, "400": { - "description": "Unknown provider/project" + "description": "Unknown provider/project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" }, "500": { - "description": "Failed to fetch statuses from provider" + "description": "Failed to fetch statuses from provider", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/llm-tools": { @@ -1626,8 +3330,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] } - } + ], + "x-operator-scope": "read" } }, "/api/v1/llm-tools/default": { @@ -1647,8 +3366,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" }, "put": { "tags": [ @@ -1656,6 +3390,17 @@ ], "summary": "Set the global default LLM tool and model", "operationId": "llm_tools_set_default", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -1677,10 +3422,32 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { - "description": "Tool not detected" + "description": "Tool not detected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" } }, "/api/v1/mcp/descriptor": { @@ -1701,8 +3468,134 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/mcp/message": { + "post": { + "tags": [ + "MCP" + ], + "summary": "Message endpoint — receives JSON-RPC requests and sends responses via SSE", + "operationId": "mcp_message", + "parameters": [ + { + "name": "sessionId", + "in": "query", + "description": "MCP SSE session id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "required": true + }, + "responses": { + "202": { + "description": "JSON-RPC request accepted for delivery on the SSE stream" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Session belongs to another principal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Session not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "execute" + } + }, + "/api/v1/mcp/sse": { + "get": { + "tags": [ + "MCP" + ], + "summary": "SSE endpoint — opens an event stream and sends the message endpoint URL", + "description": "The client connects here first, receives the message endpoint URL,\nthen sends JSON-RPC requests to that endpoint.", + "operationId": "mcp_sse", + "responses": { + "200": { + "description": "SSE stream carrying the message endpoint and JSON-RPC responses", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "execute" } }, "/api/v1/model-servers": { @@ -1722,8 +3615,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" }, "post": { "tags": [ @@ -1731,6 +3639,17 @@ ], "summary": "Create a new model server", "operationId": "model_servers_create", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -1752,10 +3671,32 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "409": { - "description": "Model server already exists" + "description": "Model server already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" } }, "/api/v1/model-servers/kinds": { @@ -1778,8 +3719,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/model-servers/kinds/{slug}/models": { @@ -1812,10 +3768,32 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { - "description": "Unknown provider kind" + "description": "Unknown provider kind", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/model-servers/{name}": { @@ -1847,10 +3825,32 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { - "description": "Model server not found" + "description": "Model server not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" }, "put": { "tags": [ @@ -1867,6 +3867,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "requestBody": { @@ -1890,13 +3899,42 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { - "description": "Model server not found" + "description": "Model server not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, "409": { - "description": "Cannot update implicit builtin server" + "description": "Cannot update implicit builtin server", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" }, "delete": { "tags": [ @@ -1914,6 +3952,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -1922,18 +3969,47 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ModelServerResponse" + "$ref": "#/components/schemas/ModelServerResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Model server not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Cannot delete implicit builtin server", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } + } + }, + "security": [ + { + "bearerAuth": [] }, - "404": { - "description": "Model server not found" - }, - "409": { - "description": "Cannot delete implicit builtin server" + { + "sessionCookie": [] } - } + ], + "x-operator-scope": "admin" } }, "/api/v1/model-servers/{name}/models": { @@ -1966,10 +4042,32 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { - "description": "Model server not found" + "description": "Model server not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "execute" } }, "/api/v1/projects": { @@ -1992,8 +4090,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/projects/{name}/assess": { @@ -2012,6 +4125,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -2025,10 +4147,32 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { - "description": "Project not found" + "description": "Project not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" } }, "/api/v1/queue/kanban": { @@ -2049,8 +4193,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/queue/pause": { @@ -2061,6 +4220,17 @@ "summary": "Pause queue processing", "description": "Sets the queue paused state to true, stopping automatic ticket launches.", "operationId": "queue_pause", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Queue paused successfully", @@ -2071,8 +4241,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" } }, "/api/v1/queue/resume": { @@ -2083,6 +4268,17 @@ "summary": "Resume queue processing", "description": "Sets the queue paused state to false, resuming automatic ticket launches.", "operationId": "queue_resume", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Queue resumed successfully", @@ -2093,8 +4289,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" } }, "/api/v1/queue/status": { @@ -2115,8 +4326,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/queue/sync": { @@ -2127,6 +4353,17 @@ "summary": "Sync kanban collections", "description": "Fetches issues from configured external kanban providers (Jira, Linear, etc.)\nand creates local tickets in the queue.", "operationId": "queue_sync", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Kanban sync completed", @@ -2137,8 +4374,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "execute" } }, "/api/v1/queue/sync/{provider}/{project_key}": { @@ -2167,6 +4419,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -2179,8 +4440,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "execute" } }, "/api/v1/sections": { @@ -2204,8 +4480,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/skills": { @@ -2225,8 +4516,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/status": { @@ -2246,8 +4552,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/tickets": { @@ -2258,6 +4579,17 @@ "summary": "Create a new ticket from a template and write it to the queue.", "description": "Reuses the same [`TicketCreator`] the CLI (`operator create`) and MCP\n(`operator_create_ticket`) use, so a ticket created over HTTP is identical to\none created on any other surface. Powers the AGNT `operator-create-ticket`\nnode.", "operationId": "tickets_create", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -2288,8 +4620,23 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" } }, "/api/v1/tickets/{id}": { @@ -2322,10 +4669,32 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { - "description": "Ticket not found" + "description": "Ticket not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/tickets/{id}/launch": { @@ -2345,6 +4714,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "requestBody": { @@ -2363,21 +4741,57 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LaunchTicketResponse" + "$ref": "#/components/schemas/LaunchTicketResponse" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Ticket not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Ticket already in progress", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } + } + }, + "security": [ + { + "bearerAuth": [] }, - "400": { - "description": "Invalid request" - }, - "404": { - "description": "Ticket not found" - }, - "409": { - "description": "Ticket already in progress" + { + "sessionCookie": [] } - } + ], + "x-operator-scope": "execute" } }, "/api/v1/tickets/{id}/status": { @@ -2397,6 +4811,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "requestBody": { @@ -2421,12 +4844,41 @@ } }, "400": { - "description": "Invalid status value" + "description": "Invalid status value", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" }, "404": { - "description": "Ticket not found" + "description": "Ticket not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" } }, "/api/v1/tickets/{id}/steps/{step}/complete": { @@ -2455,6 +4907,15 @@ "schema": { "type": "string" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "requestBody": { @@ -2479,12 +4940,41 @@ } }, "400": { - "description": "Invalid request" + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" }, "404": { - "description": "Ticket not found" + "description": "Ticket not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "execute" } }, "/api/v1/tickets/{id}/workflow-export": { @@ -2513,6 +5003,15 @@ "schema": { "$ref": "#/components/schemas/WorkflowFormat" } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -2526,6 +5025,12 @@ } } }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, "404": { "description": "Ticket or issue type not found", "content": { @@ -2536,7 +5041,16 @@ } } } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" } }, "/api/v1/workflow-formats": { @@ -2560,13 +5074,141 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } - } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/livez": { + "get": { + "tags": [ + "Health" + ], + "summary": "Liveness probe", + "description": "Answers only \"is the process serving HTTP\". It deliberately does not touch\nthe database: a liveness failure restarts the pod, and restarting will not\nfix a corrupt database — it would just crash-loop.", + "operationId": "livez", + "responses": { + "200": { + "description": "Process is alive" + } + }, + "security": [] + } + }, + "/readyz": { + "get": { + "tags": [ + "Health" + ], + "summary": "Readiness probe", + "description": "Answers \"can this instance serve requests\", which additionally requires the auth database to be reachable\nAn uninitialized deployment awaiting bootstrap is **ready**", + "operationId": "readyz", + "responses": { + "200": { + "description": "Ready to serve" + }, + "503": { + "description": "Not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [] } } }, "components": { "schemas": { + "AccessKeyListResponse": { + "type": "object", + "description": "All access keys, active and revoked.", + "required": [ + "keys" + ], + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AccessKeySummary" + }, + "description": "The keys." + } + } + }, + "AccessKeySummary": { + "type": "object", + "description": "Access key metadata. Carries no secret and no hash.", + "required": [ + "id", + "name", + "scopes", + "created_at", + "expires_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the key was created." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "When the key expires." + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Stable identifier, safe to display and to reference for revocation." + }, + "last_used_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the key was last exchanged for a token; `None` if never used." + }, + "name": { + "type": "string", + "description": "Human-readable label." + }, + "revoked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the key was revoked; `None` while active." + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + }, + "description": "Scopes granted." + } + } + }, "ActiveAgentResponse": { "type": "object", "description": "A single active agent", @@ -2825,45 +5467,173 @@ } ] }, - "skills": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Named skills. Preserved opaquely across import." + "skills": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Named skills. Preserved opaquely across import." + }, + "system_prompt": { + "type": [ + "string", + "null" + ], + "description": "System prompt. Operator has no first-class system prompt, so this is\npreserved opaquely across import (see [`Delegator::unmapped_core`])." + }, + "tools": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tool names. Preserved opaquely across import." + }, + "x_agnt": { + "description": "AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`,\n`creditLimit`, ...). Operator never interprets this — pure pass-through." + }, + "x_openai": { + "description": "OpenAI-owned extension fields, opaque (`instructions`, `tools`,\n`tool_resources`, `metadata`, thread refs, ...). Mirror of `x_agnt` for a\nsecond platform — never interpreted. This field is the whole per-tool cost\nof adding `OpenAI`: a passthrough bag, no mapping logic." + }, + "x_operator": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/XOperator", + "description": "Operator-owned extension fields (typed). `None` when the agent carries no\nOperator-specific configuration." + } + ] + } + } + }, + "AgentsConfiguration": { + "type": "object", + "required": [ + "max_parallel", + "cores_reserved", + "max_agents_per_repo", + "health_check_interval", + "generation_timeout_secs", + "sync_interval", + "step_timeout", + "silence_threshold" + ], + "properties": { + "cores_reserved": { + "type": "integer", + "minimum": 0 + }, + "generation_timeout_secs": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "health_check_interval": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "max_agents_per_repo": { + "type": "integer", + "minimum": 0 + }, + "max_parallel": { + "type": "integer", + "minimum": 0 + }, + "silence_threshold": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "step_timeout": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "sync_interval": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "AgentsConfigurationPatch": { + "type": "object", + "properties": { + "cores_reserved": { + "type": [ + "integer", + "null" + ], + "default": null, + "minimum": 0 + }, + "generation_timeout_secs": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "default": null, + "minimum": 0 + }, + "health_check_interval": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "default": null, + "minimum": 0 }, - "system_prompt": { + "max_agents_per_repo": { "type": [ - "string", + "integer", "null" ], - "description": "System prompt. Operator has no first-class system prompt, so this is\npreserved opaquely across import (see [`Delegator::unmapped_core`])." + "default": null, + "minimum": 0 }, - "tools": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Tool names. Preserved opaquely across import." + "max_parallel": { + "type": [ + "integer", + "null" + ], + "default": null, + "minimum": 0 }, - "x_agnt": { - "description": "AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`,\n`creditLimit`, ...). Operator never interprets this — pure pass-through." + "silence_threshold": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "default": null, + "minimum": 0 }, - "x_openai": { - "description": "OpenAI-owned extension fields, opaque (`instructions`, `tools`,\n`tool_resources`, `metadata`, thread refs, ...). Mirror of `x_agnt` for a\nsecond platform — never interpreted. This field is the whole per-tool cost\nof adding `OpenAI`: a passthrough bag, no mapping logic." + "step_timeout": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "default": null, + "minimum": 0 }, - "x_operator": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/XOperator", - "description": "Operator-owned extension fields (typed). `None` when the agent carries no\nOperator-specific configuration." - } - ] + "sync_interval": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "default": null, + "minimum": 0 } - } + }, + "additionalProperties": false }, "AssessTicketResponse": { "type": "object", @@ -2888,6 +5658,74 @@ } } }, + "BootstrapState": { + "type": "string", + "description": "Where the deployment sits in the one-time admin-creation sequence.", + "enum": [ + "uninitialized", + "awaiting_password", + "complete" + ] + }, + "BootstrapStatusResponse": { + "type": "object", + "description": "Current bootstrap state, readable without authentication so a client can\nroute a first-time visitor to setup rather than to login.", + "required": [ + "state", + "requires_temporary_password" + ], + "properties": { + "requires_temporary_password": { + "type": "boolean", + "description": "Whether a temporary password was supplied out of band (a mounted\nbootstrap secret). When true, submission must present it." + }, + "state": { + "$ref": "#/components/schemas/BootstrapState", + "description": "The state this deployment is in." + } + } + }, + "BootstrapSubmitRequest": { + "type": "object", + "description": "Claim the admin account and set its password.", + "required": [ + "new_password" + ], + "properties": { + "new_password": { + "type": "string", + "format": "password", + "description": "The admin password to set. Never persisted in plaintext or logged.", + "writeOnly": true, + "maxLength": 1024, + "minLength": 12 + }, + "temporary_password": { + "type": [ + "string", + "null" + ], + "format": "password", + "description": "The out-of-band temporary password, when\n`requires_temporary_password` is set. Never persisted or logged.", + "writeOnly": true, + "maxLength": 1024, + "minLength": 12 + } + } + }, + "BootstrapSubmitResponse": { + "type": "object", + "description": "Result of a successful bootstrap.", + "required": [ + "state" + ], + "properties": { + "state": { + "$ref": "#/components/schemas/BootstrapState", + "description": "The state after submission — `Complete` on success." + } + } + }, "CollectionResponse": { "type": "object", "description": "Response for a collection", @@ -2987,6 +5825,86 @@ } } }, + "ConfigurationResponse": { + "type": "object", + "description": "The deliberately supported, integration-safe configuration surface.", + "required": [ + "agents", + "queue", + "ui", + "launch" + ], + "properties": { + "agents": { + "$ref": "#/components/schemas/AgentsConfiguration" + }, + "launch": { + "$ref": "#/components/schemas/LaunchConfiguration" + }, + "queue": { + "$ref": "#/components/schemas/QueueConfiguration" + }, + "ui": { + "$ref": "#/components/schemas/UiConfiguration" + } + }, + "additionalProperties": false + }, + "CreateAccessKeyRequest": { + "type": "object", + "description": "Create a service access key for an integration.", + "required": [ + "name", + "scopes", + "expires_in_days" + ], + "properties": { + "expires_in_days": { + "type": "integer", + "format": "int64", + "description": "Days until the key expires. Expiry is mandatory — there is no\nnon-expiring key.", + "maximum": 365, + "minimum": 1 + }, + "name": { + "type": "string", + "description": "Human-readable label identifying what holds this key.", + "maxLength": 128, + "minLength": 1 + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + }, + "description": "Scopes to grant. Only what the integration needs.", + "maxItems": 4, + "minItems": 1 + } + } + }, + "CreateAccessKeyResponse": { + "type": "object", + "description": "A newly created access key. **The secret appears here and nowhere else,\never** — only its hash is stored, so it cannot be shown again.", + "required": [ + "key", + "secret" + ], + "properties": { + "key": { + "$ref": "#/components/schemas/AccessKeySummary", + "description": "Metadata for the created key." + }, + "secret": { + "type": "string", + "format": "password", + "description": "The key secret, returned exactly once. Store it now; it is unrecoverable.", + "readOnly": true, + "maxLength": 47, + "minLength": 47 + } + } + }, "CreateAlertRequest": { "type": "object", "description": "Request to raise an external alert as an investigation ticket.", @@ -3355,6 +6273,7 @@ "review_type": { "type": "string", "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" + "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" } } }, @@ -3418,6 +6337,55 @@ } } }, + "CsrfTokenResponse": { + "type": "object", + "description": "A freshly minted CSRF token for the current session.", + "required": [ + "csrf_token" + ], + "properties": { + "csrf_token": { + "type": "string", + "format": "password", + "description": "Send as the CSRF header on cookie-authenticated mutations.", + "readOnly": true + } + } + }, + "CurrentSessionResponse": { + "type": "object", + "description": "The caller's current authenticated identity.", + "required": [ + "subject", + "scopes", + "principal_kind" + ], + "properties": { + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When this credential expires, if it does." + }, + "principal_kind": { + "$ref": "#/components/schemas/PrincipalKind", + "description": "How the caller authenticated." + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + }, + "description": "Scopes this credential holds." + }, + "subject": { + "type": "string", + "description": "Account name — always `admin`, the single human account." + } + } + }, "DefaultLlmResponse": { "type": "object", "description": "Response with the current default LLM tool and model", @@ -3681,70 +6649,224 @@ }, "description": "List of delegators" }, - "total": { + "total": { + "type": "integer", + "description": "Total count", + "minimum": 0 + } + } + }, + "DetectedToolSummary": { + "type": "object", + "description": "Public view of a detected agent tool without local paths or command flags.", + "required": [ + "name", + "version", + "version_ok", + "model_aliases", + "capabilities", + "health_ok" + ], + "properties": { + "capabilities": { + "$ref": "#/components/schemas/ToolCapabilitiesSummary" + }, + "health_ok": { + "type": "boolean" + }, + "health_ok": { + "type": "boolean", + "description": "Whether the tool passed its health check at detection on startup" + }, + "min_version": { + "type": [ + "string", + "null" + ] + }, + "model_aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "version_ok": { + "type": "boolean" + } + } + }, + "DeviceApprovalRequest": { + "type": "object", + "description": "Approve a pending device authorization from an authenticated session.", + "required": [ + "user_code" + ], + "properties": { + "user_code": { + "type": "string", + "description": "The user code shown on the requesting device.", + "pattern": "^[A-HJ-KM-NP-TV-Z2-9]{4}-[A-HJ-KM-NP-TV-Z2-9]{4}$" + } + } + }, + "DeviceApprovalResponse": { + "type": "object", + "description": "Result of approving a device.", + "required": [ + "client_id", + "scopes", + "approved" + ], + "properties": { + "approved": { + "type": "boolean", + "description": "Whether approval completed." + }, + "client_id": { + "type": "string", + "description": "Client that requested authorization, echoed so the approver can confirm." + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + }, + "description": "Scopes granted." + } + } + }, + "DeviceAuthorizationRequest": { + "type": "object", + "description": "Begin device authorization for a public client that cannot hold a secret.", + "required": [ + "client_id" + ], + "properties": { + "client_id": { + "type": "string", + "description": "Identifier for the requesting client (e.g. `vscode`).", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + }, + "description": "Scopes requested. IDE clients request all four, because such a client\nacts as the human admin." + } + } + }, + "DeviceAuthorizationResponse": { + "type": "object", + "description": "RFC 8628 device authorization response.", + "required": [ + "device_code", + "user_code", + "verification_uri", + "verification_uri_complete", + "expires_in", + "interval" + ], + "properties": { + "device_code": { + "type": "string", + "format": "password", + "description": "Opaque code the client polls the token endpoint with. Never logged.", + "readOnly": true, + "maxLength": 43, + "minLength": 43 + }, + "expires_in": { + "type": "integer", + "format": "int64", + "description": "Seconds until the device code expires.", + "minimum": 0 + }, + "interval": { "type": "integer", - "description": "Total count", + "format": "int64", + "description": "Minimum seconds the client must wait between polls.", "minimum": 0 + }, + "user_code": { + "type": "string", + "description": "Short code the human types into the approval screen.", + "readOnly": true, + "pattern": "^[A-HJ-KM-NP-TV-Z2-9]{4}-[A-HJ-KM-NP-TV-Z2-9]{4}$" + }, + "verification_uri": { + "type": "string", + "description": "Where the human goes to approve." + }, + "verification_uri_complete": { + "type": "string", + "description": "`verification_uri` with the user code pre-filled." } } }, - "DetectedTool": { + "DeviceSummary": { "type": "object", - "description": "A detected CLI tool (e.g., claude binary)", + "description": "A client authorized through the device flow.", "required": [ - "name", - "path", - "version" + "id", + "client_id", + "scopes", + "created_at", + "expires_at" ], "properties": { - "capabilities": { - "$ref": "#/components/schemas/ToolCapabilities", - "description": "Tool capabilities" + "client_id": { + "type": "string", + "description": "Client identifier supplied at authorization (e.g. `vscode`).", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" }, - "command_template": { + "created_at": { "type": "string", - "description": "Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders" + "format": "date-time", + "description": "When the device was approved." }, - "health_ok": { - "type": "boolean", - "description": "Whether the tool passed its health check at detection on startup" + "expires_at": { + "type": "string", + "format": "date-time", + "description": "When the device's refresh credential reaches its absolute deadline." }, - "min_version": { + "id": { + "type": "string", + "format": "uuid", + "description": "Stable identifier, safe to display and to reference for revocation." + }, + "last_used_at": { "type": [ "string", "null" ], - "description": "Minimum required version for Operator compatibility" - }, - "model_aliases": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Available model aliases (e.g., [\"opus\", \"sonnet\", \"haiku\"])" - }, - "name": { - "type": "string", - "description": "Tool name (e.g., \"claude\")" + "format": "date-time", + "description": "When the device last refreshed." }, - "path": { - "type": "string", - "description": "Path to the binary" - }, - "version": { - "type": "string", - "description": "Version string" - }, - "version_ok": { - "type": "boolean", - "description": "Whether the installed version meets the minimum requirement" + "revoked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the device was revoked; `None` while active." }, - "yolo_flags": { + "scopes": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/Scope" }, - "description": "CLI flags for YOLO (auto-accept) mode" + "description": "Scopes granted to this device." } } }, @@ -3764,6 +6886,61 @@ } } }, + "ExecutionTargetKind": { + "type": "string", + "description": "Execution target transport category.", + "enum": [ + "local", + "docker", + "coder", + "ssh" + ] + }, + "ExecutionTargetSummary": { + "type": "object", + "description": "A named execution target without connection or credential plumbing.", + "required": [ + "name", + "kind", + "available" + ], + "properties": { + "available": { + "type": "boolean" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/components/schemas/ExecutionTargetKind" + }, + "name": { + "type": "string" + } + } + }, + "ExecutionTargetsResponse": { + "type": "object", + "required": [ + "targets", + "total" + ], + "properties": { + "targets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExecutionTargetSummary" + } + }, + "total": { + "type": "integer", + "minimum": 0 + } + } + }, "ExternalIssueTypeSummary": { "type": "object", "description": "Summary of an issue type from an external kanban provider (Jira, Linear)", @@ -3858,7 +7035,9 @@ "properties": { "token": { "type": "string", - "description": "GitHub PAT, fine-grained PAT, or app installation token" + "format": "password", + "description": "GitHub PAT, fine-grained PAT, or app installation token", + "writeOnly": true } } }, @@ -3908,7 +7087,9 @@ "type": "string" }, "token": { - "type": "string" + "type": "string", + "format": "password", + "writeOnly": true } } }, @@ -4138,7 +7319,9 @@ "properties": { "api_token": { "type": "string", - "description": "API token / personal access token" + "format": "password", + "description": "API token / personal access token", + "writeOnly": true }, "domain": { "type": "string", @@ -4164,7 +7347,9 @@ "type": "string" }, "api_token": { - "type": "string" + "type": "string", + "format": "password", + "writeOnly": true }, "domain": { "type": "string" @@ -4487,6 +7672,105 @@ } } }, + "LaunchConfiguration": { + "type": "object", + "required": [ + "confirm_autonomous", + "confirm_paired", + "launch_delay_ms", + "docker_enabled", + "docker_image", + "yolo_enabled", + "session_wrapper" + ], + "properties": { + "confirm_autonomous": { + "type": "boolean" + }, + "confirm_paired": { + "type": "boolean" + }, + "docker_enabled": { + "type": "boolean" + }, + "docker_image": { + "type": "string" + }, + "launch_delay_ms": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "session_wrapper": { + "$ref": "#/components/schemas/SessionWrapper" + }, + "yolo_enabled": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "LaunchConfigurationPatch": { + "type": "object", + "properties": { + "confirm_autonomous": { + "type": [ + "boolean", + "null" + ], + "default": null + }, + "confirm_paired": { + "type": [ + "boolean", + "null" + ], + "default": null + }, + "docker_enabled": { + "type": [ + "boolean", + "null" + ], + "default": null + }, + "docker_image": { + "type": [ + "string", + "null" + ], + "default": null + }, + "launch_delay_ms": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "default": null, + "minimum": 0 + }, + "session_wrapper": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SessionWrapper" + } + ], + "default": null + }, + "yolo_enabled": { + "type": [ + "boolean", + "null" + ], + "default": null + } + }, + "additionalProperties": false + }, "LaunchTicketRequest": { "type": "object", "description": "Request to launch a ticket", @@ -4642,7 +7926,9 @@ "properties": { "api_key": { "type": "string", - "description": "Linear API key (prefixed `lin_api_`)" + "format": "password", + "description": "Linear API key (prefixed `lin_api_`)", + "writeOnly": true } } }, @@ -4655,7 +7941,9 @@ ], "properties": { "api_key": { - "type": "string" + "type": "string", + "format": "password", + "writeOnly": true }, "api_key_env": { "type": "string" @@ -4860,7 +8148,7 @@ "tools": { "type": "array", "items": { - "$ref": "#/components/schemas/DetectedTool" + "$ref": "#/components/schemas/DetectedToolSummary" }, "description": "Detected CLI tools with model aliases and capabilities" }, @@ -4871,6 +8159,65 @@ } } }, + "LoginRequest": { + "type": "object", + "description": "Password login, exchanged for an opaque server-side session cookie.", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string", + "format": "password", + "description": "The admin password. Never persisted in plaintext or logged.", + "writeOnly": true, + "maxLength": 1024, + "minLength": 12 + } + } + }, + "LoginResponse": { + "type": "object", + "description": "Successful login. The session itself rides in a `Set-Cookie` header, not in\nthis body — a body-borne session identifier would be readable by script.", + "required": [ + "scopes", + "expires_at", + "csrf_token" + ], + "properties": { + "csrf_token": { + "type": "string", + "format": "password", + "description": "CSRF token to send on subsequent cookie-authenticated mutations.", + "readOnly": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "When the session expires." + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + }, + "description": "Scopes the session holds." + } + } + }, + "LogoutResponse": { + "type": "object", + "description": "Result of destroying the current session server-side.", + "required": [ + "ended" + ], + "properties": { + "ended": { + "type": "boolean", + "description": "Always true; present so the response has a stable, non-empty shape." + } + } + }, "McpDescriptorResponse": { "type": "object", "description": "MCP server descriptor for client discovery", @@ -5152,7 +8499,7 @@ }, "review_type": { "type": "string", - "description": "Review type: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" + "description": "Review type: \"none\", \"plan\", \"visual\", \"pr\"" } } }, @@ -5263,6 +8610,74 @@ } } }, + "PanelNamesConfiguration": { + "type": "object", + "required": [ + "status", + "queue", + "in_progress", + "completed" + ], + "properties": { + "completed": { + "type": "string" + }, + "in_progress": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "additionalProperties": false + }, + "PanelNamesConfigurationPatch": { + "type": "object", + "properties": { + "completed": { + "type": [ + "string", + "null" + ], + "default": null + }, + "in_progress": { + "type": [ + "string", + "null" + ], + "default": null + }, + "queue": { + "type": [ + "string", + "null" + ], + "default": null + }, + "status": { + "type": [ + "string", + "null" + ], + "default": null + } + }, + "additionalProperties": false + }, + "PrincipalKind": { + "type": "string", + "description": "What kind of credential authenticated a request.", + "enum": [ + "session", + "access_token", + "local_process", + "agent_callback" + ] + }, "ProjectSummary": { "type": "object", "description": "Summary of a project with analysis data", @@ -5404,15 +8819,72 @@ "type": "integer", "minimum": 0 }, - "inv": { + "inv": { + "type": "integer", + "minimum": 0 + }, + "spike": { + "type": "integer", + "minimum": 0 + } + } + }, + "QueueConfiguration": { + "type": "object", + "required": [ + "auto_assign", + "priority_order", + "poll_interval_ms" + ], + "properties": { + "auto_assign": { + "type": "boolean" + }, + "poll_interval_ms": { "type": "integer", + "format": "int64", "minimum": 0 }, - "spike": { - "type": "integer", + "priority_order": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "QueueConfigurationPatch": { + "type": "object", + "properties": { + "auto_assign": { + "type": [ + "boolean", + "null" + ], + "default": null + }, + "poll_interval_ms": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "default": null, "minimum": 0 + }, + "priority_order": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "default": null } - } + }, + "additionalProperties": false }, "QueueControlResponse": { "type": "object", @@ -5523,6 +8995,26 @@ } } }, + "RevokeAccessKeyResponse": { + "type": "object", + "description": "Result of revoking an access key.", + "required": [ + "id", + "revoked_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "The revoked key's identifier." + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "description": "When revocation took effect." + } + } + }, "RowActionDto": { "type": "object", "description": "A browser-openable action on a status section row (e.g. \"Open Web UI\",\n\"Swagger\"). Only URL-style actions surface to the web UI; TUI-only actions\n(toggles, env edits) are omitted.", @@ -5541,6 +9033,16 @@ } } }, + "Scope": { + "type": "string", + "description": "A typed authorization scope.\n\nScopes are **not hierarchical**: `Write` does not imply `Read`. A credential\nis granted each scope it needs explicitly, so an integration's authority is\nlegible from its scope list alone rather than requiring the reader to reason\nabout implication.", + "enum": [ + "read", + "write", + "execute", + "admin" + ] + }, "SectionDto": { "type": "object", "description": "A status section with its health and child rows.", @@ -5640,6 +9142,86 @@ } } }, + "SessionListResponse": { + "type": "object", + "description": "All sessions and devices for the admin account.", + "required": [ + "sessions", + "devices" + ], + "properties": { + "devices": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeviceSummary" + }, + "description": "Device-flow clients." + }, + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionSummary" + }, + "description": "Browser sessions." + } + } + }, + "SessionSummary": { + "type": "object", + "description": "An active or expired browser session. Carries no session identifier — the\ncookie value is never readable back out, only the session's `id` for\nrevocation.", + "required": [ + "id", + "created_at", + "expires_at", + "current" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the session began." + }, + "current": { + "type": "boolean", + "description": "Whether this is the session making the request." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "When the session expires." + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Stable identifier, safe to display and to reference for revocation." + }, + "last_used_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the session was last used." + }, + "revoked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the session was revoked; `None` while active." + } + } + }, + "SessionWrapper": { + "type": "string", + "enum": [ + "tmux", + "vscode", + "cmux", + "zellij" + ] + }, "SetDefaultLlmRequest": { "type": "object", "description": "Request to set the global default LLM tool and model", @@ -6187,20 +9769,276 @@ } } }, - "ToolCapabilities": { + "TokenRequest": { + "oneOf": [ + { + "type": "object", + "description": "Poll for a previously approved device authorization.", + "required": [ + "device_code", + "client_id", + "grant_type" + ], + "properties": { + "client_id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "device_code": { + "type": "string", + "format": "password", + "writeOnly": true, + "maxLength": 43, + "minLength": 43 + }, + "grant_type": { + "type": "string", + "enum": [ + "urn:ietf:params:oauth:grant-type:device_code" + ] + } + } + }, + { + "type": "object", + "description": "Redeem a rotating refresh token.", + "required": [ + "refresh_token", + "client_id", + "grant_type" + ], + "properties": { + "client_id": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "grant_type": { + "type": "string", + "enum": [ + "refresh_token" + ] + }, + "refresh_token": { + "type": "string", + "format": "password", + "writeOnly": true, + "maxLength": 43, + "minLength": 43 + } + } + }, + { + "type": "object", + "description": "Exchange a service access key.", + "required": [ + "access_key", + "grant_type" + ], + "properties": { + "access_key": { + "type": "string", + "format": "password", + "writeOnly": true, + "maxLength": 47, + "minLength": 47 + }, + "grant_type": { + "type": "string", + "enum": [ + "operator:access-key" + ] + } + } + } + ], + "description": "Token endpoint request. The discriminator makes unrelated credential\ncombinations unrepresentable." + }, + "TokenResponse": { + "type": "object", + "description": "A newly issued access token, and a refresh token when the grant produces one.", + "required": [ + "access_token", + "token_type", + "expires_in", + "scopes" + ], + "properties": { + "access_token": { + "type": "string", + "format": "password", + "description": "Signed, short-lived bearer token.", + "readOnly": true + }, + "expires_in": { + "type": "integer", + "format": "int64", + "description": "Seconds until `access_token` expires.", + "minimum": 0 + }, + "refresh_token": { + "type": [ + "string", + "null" + ], + "format": "password", + "description": "Opaque rotating refresh token. Absent for access-key exchange, which is\nre-exercised with the key itself rather than refreshed.", + "readOnly": true + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + }, + "description": "Scopes the access token carries." + }, + "token_type": { + "type": "string", + "description": "Always `Bearer`." + } + } + }, + "ToolCapabilitiesSummary": { "type": "object", - "description": "Tool capabilities", + "required": [ + "supports_sessions", + "supports_headless" + ], "properties": { "supports_headless": { - "type": "boolean", - "description": "Whether the tool can run in headless/non-interactive mode" + "type": "boolean" }, "supports_sessions": { - "type": "boolean", - "description": "Whether the tool supports session continuity via UUID" + "type": "boolean" } } }, + "UiConfiguration": { + "type": "object", + "required": [ + "refresh_rate_ms", + "completed_history_hours", + "summary_max_length", + "panel_names" + ], + "properties": { + "completed_history_hours": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "panel_names": { + "$ref": "#/components/schemas/PanelNamesConfiguration" + }, + "refresh_rate_ms": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "summary_max_length": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "UiConfigurationPatch": { + "type": "object", + "properties": { + "completed_history_hours": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "default": null, + "minimum": 0 + }, + "panel_names": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PanelNamesConfigurationPatch" + } + ], + "default": null + }, + "refresh_rate_ms": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "default": null, + "minimum": 0 + }, + "summary_max_length": { + "type": [ + "integer", + "null" + ], + "default": null, + "minimum": 0 + } + }, + "additionalProperties": false + }, + "UpdateConfigurationRequest": { + "type": "object", + "description": "Field-level patch for the public operational configuration.", + "properties": { + "agents": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/AgentsConfigurationPatch" + } + ], + "default": null + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchConfigurationPatch" + } + ], + "default": null + }, + "queue": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/QueueConfigurationPatch" + } + ], + "default": null + }, + "ui": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UiConfigurationPatch" + } + ], + "default": null + } + }, + "additionalProperties": false + }, "UpdateIssueTypeRequest": { "type": "object", "description": "Request to update an issue type", @@ -6886,6 +10724,50 @@ } } } + }, + "responses": { + "Forbidden": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "Unauthorized": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "schema": { + "type": "string" + }, + "description": "Authentication challenge naming the accepted schemes." + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Short-lived signed access token. Obtain one at the token endpoint with a refresh token or a service access key." + }, + "sessionCookie": { + "type": "apiKey", + "in": "cookie", + "name": "__Host-operator_session", + "description": "Opaque server-side browser session. Cookie-authenticated mutations additionally require a CSRF token and a matching Origin." + } } }, "tags": [ @@ -6956,6 +10838,10 @@ { "name": "Kanban", "description": "Kanban provider issue types and onboarding" + }, + { + "name": "Auth", + "description": "Bootstrap, sessions, OAuth device flow, and access keys" } ] } \ No newline at end of file diff --git a/docs/schemas/state.json b/docs/schemas/state.json index 8c24a4ec..802dc8ac 100644 --- a/docs/schemas/state.json +++ b/docs/schemas/state.json @@ -229,7 +229,7 @@ "default": null }, "review_state": { - "description": "Review state for `awaiting_input` agents\nValues: \"`pending_plan`\", \"`pending_visual`\", \"`pending_pr_creation`\", \"`pending_pr_merge`\"", + "description": "Review state for `awaiting_input` agents\nValues: \"`pending_plan`\", \"`pending_visual`\", \"`pending_proof`\", \"`pending_pr_creation`\", \"`pending_pr_merge`\"", "type": [ "string", "null" diff --git a/docs/schemas/state.md b/docs/schemas/state.md index 8c03a7bf..9bcb2390 100644 --- a/docs/schemas/state.md +++ b/docs/schemas/state.md @@ -64,7 +64,7 @@ This file tracks the current state of agents, completed tickets, and system stat | `llm_tool` | `string` \| `null` | No | LLM tool used (e.g., "claude", "gemini", "codex") | | `llm_model` | `string` \| `null` | No | LLM model alias (e.g., "opus", "sonnet", "gpt-4o") | | `launch_mode` | `string` \| `null` | No | Launch mode: `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]` (derived from the resolved execution target; parse with `agents::parse_launch_mode`, never substring-match) | -| `review_state` | `string` \| `null` | No | Review state for `awaiting_input` agents Values: "`pending_plan`", "`pending_visual`", "`pending_pr_creation`", "`pending_pr_merge`" | +| `review_state` | `string` \| `null` | No | Review state for `awaiting_input` agents Values: "`pending_plan`", "`pending_visual`", "`pending_proof`", "`pending_pr_creation`", "`pending_pr_merge`" | | `dev_server_pid` | `integer` \| `null` | No | Server process ID for visual review cleanup (if applicable) | | `worktree_path` | `string` \| `null` | No | Path to the git worktree for this ticket (per-ticket isolation) | | `remote_host` | `string` \| `null` | No | Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local) | diff --git a/docs/security/authentication.md b/docs/security/authentication.md new file mode 100644 index 00000000..4857c0a0 --- /dev/null +++ b/docs/security/authentication.md @@ -0,0 +1,164 @@ +--- +title: "Authentication" +description: "How Operator authenticates: the admin account, bootstrap, browser sessions, OAuth device flow, access keys, scopes, and recovery." +layout: doc +--- + +Operator's HTTP surface is authenticated **always**. + +For trust boundaries and residual risks, see [Security](/security/). + +## One human account + +Operator has exactly one human account: **`admin`**. This is a deliberate constraint, not a limitation waiting to be lifted. +Operator orchestrates one operator's attention across their projects, and multi-user access control would imply an ownership model the ticket queue does not have. + +Everything else that authenticates is a delegated, scoped credential: + +| Identity | Kind | How it authenticates | +|----------|------|----------------------| +| `admin` | Human | Password, then a browser session or a device-flow token | +| IDE clients | Delegated | Local token when on the daemon's host; otherwise OAuth device authorization, all four scopes | +| Integrations | Delegated | Service access key exchanged for a short-lived token, only the scopes selected | +| Agent callbacks | Delegated | Single-purpose token minted at launch, pinned to one ticket and step | + +Delegated credentials are not additional users. They act on the admin's authority, they carry only the scopes granted, and they can all be revoked individually. + +These are distinct from the operating-system and Kubernetes identities, which are separate boundaries: the container runs as Unix user `operator` (UID/GID 10001) and the Kubernetes ServiceAccount is `operator` with no token and no RBAC. Neither has anything to do with logging in. + +## Scopes + +Four typed scopes, checked per route: + +| Scope | Grants | +|-------|--------| +| `read` | Observing the queue, agents, tickets, projects, and issue types | +| `write` | Mutating tickets, issue types, steps, and collections | +| `execute` | Launching agents, completing steps, probing providers, calling MCP tools | +| `admin` | Configuration, delegators, model servers, and authentication administration | + +Scopes are not hierarchical. A credential holding `write` does not implicitly hold `read`; it is granted both, or neither. +This makes an integration's grant legible in one glance rather than requiring you to reason about implication. + +IDE clients receive all four, because an IDE client *is* the human admin working through a different surface. Integrations receive only what you select. + +## Bootstrap + +On first start the authentication database does not exist, and Operator is in +the `Uninitialized` state. Only the bootstrap and probe endpoints respond; +everything else returns `401`. + +The state machine: + +``` +Uninitialized ──► AwaitingPassword ──► Complete + │ │ │ + │ │ └─ normal operation; login required + │ └─ temporary password accepted, new password required + └─ no admin exists; bootstrap endpoint open +``` + +Admin creation is **atomic**. The account row is constrained to a single identity. + +### Bootstrap in a container + +Supply a temporary password out of band and mount it as a read-only file. While the database is uninitialized, Operator reads it and requires the visitor to set a new admin password before anything else works. + +### Bootstrap locally + +A local run needs none of this. See [local access](#local-access) below. + +The first-run setup wizard offers a **Web UI password** step as a shortcut. It is optional and skippable: the terminal and the CLI authenticate without it, and it exists only so the web dashboard is reachable. Skipping it leaves the deployment uninitialized. The step is hidden once an admin account exists. + +## Browser sessions + +Logging in through the dashboard creates an **opaque server-side session**. The cookie carries a random identifier; all session state lives in the database, so a cookie is not a token and cannot be replayed anywhere else. + +The cookie is named `__Host-operator_session` and is set `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`. The `__Host-` prefix is enforced by the browser: it refuses the cookie unless it is `Secure`, has no `Domain` attribute, and has `Path=/`. That makes it impossible for a sibling subdomain to set or overwrite the session cookie. + +Because a cookie is sent automatically, cookie-authenticated **mutations** additionally require a CSRF token and a matching `Origin`. + +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 + +Non-browser clients use bearer tokens. + +**Access tokens** are signed bearer tokens with a **15-minute** lifetime. Clients must use the returned expiry and scopes rather than depending on token internals. + +Access tokens are not revocable individually, so their blast radius is bounded by expiry rather than by revocation. + +**Refresh tokens** are opaque, rotating, and stored only as hashes. Each has a **30-day idle** lifetime and a **90-day absolute** lifetime. Using it resets the idle clock, but the absolute deadline is fixed at issuance, so a continuously refreshed session still requires re-authentication quarterly. + +Refresh tokens rotate on every use: redeeming one issues a replacement and retires the original. If a *retired* token is presented again, that means two +parties hold the same token - the legitimate client and a thief. Operator cannot tell which is which, so it **revokes the entire token family**. Both are +logged out, and the admin re-authenticates. This is deliberate: a noisy failure is preferable. + +## OAuth device authorization + +IDE clients and other public clients, which cannot keep a client secret, use the +OAuth device authorization flow when they cannot use the [local token](#local-access): + +1. The client requests a device code and receives a user code and a verification URL. +2. The client opens the browser to that URL; the human approves in an authenticated session. +3. The client polls the token endpoint, honoring the returned interval, until approval completes. + +The client never handles the password, and the approval happens in a context +where the human can see what is being authorized. + +**VS Code stores refresh credentials in `SecretStorage` only** - never in settings, workspace files, logs, or webview state. Settings sync to other machines and workspace files land in Git; neither is an acceptable home for a credential. + +## Service access keys + +Integrations authenticate with access keys, which are exchanged at the token endpoint for a short-lived access token. The key itself is never a bearer credential for the API - it buys a token, and the token does the work. + +Access keys have: + +- **selected scopes**, only what you grant, +- **mandatory expiry** - there is no non-expiring key, +- **hash-only storage** - the secret is displayed exactly once, at creation, and + cannot be retrieved afterward, +- **revocation** and **last-use tracking**, so a key that stopped being used is + visible and can be retired. + +If a key is lost, create a new one and revoke the old. There is no recovery path, by design. + +## Agent callback credentials + +When Operator launches an agent, it mints a **single-purpose token** pinned to that ticket, step, and session, and injects it into the agent's environment.The agent's wrapper presents it when reporting step completion. + +Its lifetime is tied to the step rather than the standard 15 minutes, because a step may legitimately run for hours and a callback that expires mid-work would strand the agent. The narrow claims are what bound it: the token completes one step of one ticket and is useful for nothing else. + +Per the [threat model](/security/#the-agent-process-is-inside-the-trust-boundary), the agent can read this token - as it can read everything else in its environment. + +## Rate limiting + +Bootstrap, login, device-code creation, device-code polling, and token exchange are all rate-limited with persisted backoff, +so restarting the process does not reset an attacker's budget. + +Backoff **never becomes a permanent lockout**. A permanent lockout on a single-account system is a denial-of-service vector against the only human who +can fix it: an attacker who can guess wrong repeatedly could otherwise lock the admin out of their own deployment. Delay grows; the door does not lock. + +## Local access + +A local `operator` run - the TUI, the CLI, and the agent wrapper talking to loopback - requires no login and no bootstrap. + +The VS Code extension host runs as the same user, so when it talks to a loopback daemon it reads the same file and needs no sign-in either; this holds under Remote-SSH too, where the extension host runs on the remote machine. The daemon advertises the state directory in its session file so a non-default `paths.state` is still found. The token is only ever presented to a loopback address. + +When Operator binds loopback, it issues itself a local admin credential and writes it to the state directory with **owner-only permissions**. Only the user +account running Operator can read it, which is the same trust boundary a local login would establish. + +This is not an authentication bypass. The credential is a real one, checked the same way as any other; it is simply issued automatically to a caller who has already proven, through file ownership, that they are the user who started the process. + +## Recovery + +Run `operator auth reset-admin-password` only **locally**, against the database file. +It sets a new admin password and revokes every session, refresh-token family, issued token record, and access key. + +In Kubernetes that means `kubectl exec`, which is itself an audited, RBAC-gated action. + +## Audit records + +Operator records security-relevant authentication activity without secret +material. In particular, alert on refresh-token reuse: it means a retired token +was presented again and the affected token family was revoked. diff --git a/docs/security/index.md b/docs/security/index.md new file mode 100644 index 00000000..7d56fd22 --- /dev/null +++ b/docs/security/index.md @@ -0,0 +1,187 @@ +--- +title: "Security" +description: "Threat model and security architecture for Operator: trust boundaries, route classification, credential handling, and residual risks." +layout: doc +--- + +Operator launches AI coding agents against your source code, holds credentials for kanban and model providers, and exposes a REST API, a web dashboard, and an MCP server. + +This page is the threat model for that surface: what Operator trusts, what it does not, and what remains your responsibility to control. + +For the authentication mechanism itself — accounts, tokens, scopes, and recovery — see [Authentication](/security/authentication/). + +## Trust boundaries + +Operator sits inside four nested boundaries. Each one is enforced by a different mechanism, and each has a different failure mode. + +| Boundary | Enforced by | What crossing it means | +|----------|-------------|------------------------| +| Host ↔ container | Container runtime, non-root UID/GID 10001, read-only root filesystem, dropped capabilities | A compromised process inside the container cannot write the host filesystem or escalate to root | +| Cluster ↔ pod | NetworkPolicy, no mounted ServiceAccount token, no RBAC | Operator cannot call the Kubernetes API, because it has no credential to call it with | +| Network ↔ Operator | Authentication, typed scopes, CORS, Host validation | Every request carries a principal and a scope, or it is rejected | +| Operator ↔ agent process | **Nothing.** See below. | An agent process runs as the same user, with the same filesystem access, as Operator itself | + +### The agent process is inside the trust boundary + +Operator and the agent processes it spawns share **one Unix identity and one filesystem**. The non-root user protects the host and the cluster from a +compromised agent. It does not protect *Operator's own state* from that agent. + +An agent process can read and write: + +- the workspace and every repository in it, +- `.tickets/` in full, including the queue, ticket bodies, and `state.json`, +- `config.toml`, +- the authentication database and the local session token, +- any environment variable Operator passed it, including provider API keys. + +Local execution shares the Operator OS user. SSH and Coder targets can isolate the agent filesystem when their remote environments do not mount Operator state. + +The practical consequence: **treat the agent tool you configure, and the model provider behind it, as trusted components.** + +Operator's authentication protects the boundary between the network and Operator. It does not sandbox the agent. + +## Route classification + +Every HTTP route falls into exactly one of five classes. The mapping is held in a single table in the source and is verified by a test that walks the generated OpenAPI specification, so a new route cannot be added without being classified. + +| Class | Requirement | Examples | +|-------|-------------|----------| +| Probe | Public | `/livez`, `/readyz` | +| Bootstrap / login | Public, rate-limited | Bootstrap status and submission, login, OAuth device-code and token endpoints | +| Read | `read` scope | Queue, agents, tickets, projects, issue types, collections, health, status | +| Write | `write` scope | Ticket creation and status changes, issue type and step edits, collection activation, kanban sync | +| Execute | `execute` scope | Ticket launch, step completion, model-provider probes, MCP tool calls, session focus | +| Admin | `admin` scope | Configuration read and write, delegator and model-server management, session and access-key administration | + +Two deliberate choices in that table: + +**Configuration is `admin`, not `write`.** The REST API exposes a deliberately narrow operational projection rather than the full internal configuration but changing launch behavior and resource limits still requires administrative +authority. +Model servers, delegators, and execution targets have focused endpoints with their own response types. + +**Health and status are not public.** They report the workspace directory name and a directory identifier. That is workspace identity, and it is exactly the sort of detail a public probe should not disclose — hence the separate, metadata-free `/livez` and `/readyz` endpoints for Kubernetes. + +### The dashboard bundle is public + +Operator's web dashboard is a single-page application using fragment-based +routing. Route names such as `#/config` live in the URL *fragment*, which +browsers never transmit to the server. The server therefore cannot distinguish +a request for the login screen from a request for any other screen: it serves +one HTML document and one JavaScript bundle for all of them. + +Consequently the dashboard bundle is served publicly, and authorization is +enforced entirely at the API layer. An unauthenticated visitor can load the +shell; every data request returns `401`, and the client redirects to the login +screen. + +**Residual risk:** the set of route names and the structure of the UI are +public. No workspace data, configuration, or credentials are in the bundle — +all of it arrives over authenticated API calls — but the shape of the +application is discoverable. This is accepted deliberately; the alternative is +a separately served login document, which is a larger change for a small +reduction in disclosure. + +## Credential handling + +### Credentials Operator holds + +Operator stores **no third-party secret values** in `config.toml`. Configuration +holds the *name* of an environment variable, and the value is read from the +process environment. A configuration export therefore contains integration +topology, not credentials. + +### Credentials Operator issues + +The authentication database at `.tickets/operator/auth.sqlite3` is created owner-readable only and holds password verification data, token-signing material, sessions, and hashes of refresh tokens and access keys. It is identity-critical state and must be protected like a credential store. + +Plaintext passwords, temporary bootstrap passwords, device codes, refresh tokens, and access keys are never written to disk or logs. + +### 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. + +## Server-side request forgery + +Several Operator features fetch a URL that the caller or the configuration +controls: + +- model-server reachability and model-listing probes, which attach the provider's API key to the request, +- outbound notification webhooks, +- hosted-collection fetches, where the fetched manifest itself supplies subsequent relative URLs, +- kanban provider hosts, where the host is a configuration key. + +Untreated, the model-server probe is the sharpest of these: an authenticated +caller sets a base URL, triggers a probe, and Operator makes the request *with a +provider API key attached*. Redirects compound it — a permitted host can +redirect to a forbidden one. + +Four controls apply together, and none is sufficient alone: + +1. **Authentication and scopes** — probing requires `execute`; changing a + model-server URL requires `admin`. An anonymous caller cannot reach either. +2. **Destination validation** — loopback, link-local, multicast, and cloud-metadata addresses are rejected unless explicitly allowed, and schemes and CIDR ranges are validated against configuration. +3. **Redirect re-validation** — every redirect hop is re-checked against the + same policy, not just the initial URL. +4. **NetworkPolicy** — in Kubernetes, egress is restricted at the network + layer, so a validation bug does not become cluster-internal access. + +Control 2 is code, control 4 is cluster configuration, and **you must configure control 4 yourself**; the chart ships the template but leaves it disabled by default. + +## Kubernetes exposure + +The chart is deliberately minimal about what it can touch: + +- **No Docker socket** is mounted. +- **No Kubernetes controller** ships in the image, and no Kubernetes client tooling is installed. +- **No Role, RoleBinding, or ClusterRole** is created. +- The ServiceAccount has `automountServiceAccountToken: false`, so no API token is present in the pod at all. + +Operator running in your cluster cannot enumerate, create, or delete cluster resources, because it has neither the credential nor the tooling to try. + +Ingress is disabled by default. Enabling it publishes an authenticated service, which is the intended posture — but it is your TLS certificate, your DNS name, and your decision. + +### Secrets are not encrypted by default + +Kubernetes Secrets are stored **unencrypted** in etcd unless you have enabled encryption at rest. +Anyone who can read etcd, take an etcd backup, or `get` Secrets in the namespace can read the bootstrap password you supply. + +Before putting a bootstrap Secret in a cluster: + +- enable [encryption at rest](https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/) for Secret resources. +- restrict `get`/`list` on Secrets in the namespace to the smallest possible set of principals. +- delete the bootstrap Secret once the admin password has been set. + +See the [Kubernetes guide](/getting-started/platforms/kubernetes/) for the mechanics. + +## Backup and recovery + +`auth.sqlite3` is **identity-critical state**. It holds credential and signing state, so: + +- Backing it up preserves every issued token's validity. Treat a backup with + the same care as the live database. +- Losing it is not catastrophic but is disruptive: recovery means re-bootstrapping, which generates a new signing key and invalidates every + session, refresh token, and access key. Integrations must be re-issued keys. +- Restoring an *old* copy resurrects credentials that were revoked after the backup was taken. Prefer re-bootstrapping over restoring a stale database. + +The persistent volume also holds the workspace, the ticket queue, and +`state.json`. A backup that captures the volume captures all of it, including +the authentication database — so the volume snapshot inherits the same +sensitivity. + +Password recovery is **local only**: `operator auth reset-admin-password` operates directly on the database. +It is never exposed as an HTTP route, so there is no network-reachable password-reset path to attack. + +## Residual risks + +Recorded deliberately, in rough order of significance. + +1. **Local agents share Operator filesystem access.** SSH and Coder targets can place agents on separate filesystems, provided the target does not mount Operator state. Same-user processes can still read private Git runtime files: per-launch credentials prevent accidental identity mixing, not hostile same-user access. +2. **The dashboard bundle is public**, so UI structure and route names are discoverable. No data or credentials are exposed. +3. **A compromised authentication database yields the signing key**, allowing + token forgery until the key is rotated by re-bootstrapping. File permissions and volume access control are the only barriers. +4. **The admin account is a single point of authority.** There is one human + account by design; there is no separation of duties and no second approver. +5. **Integration access keys are bearer credentials.** Anyone holding one has + its scopes until it expires or is revoked. Keys have mandatory expiry and last-use tracking so an unused key is visible, but there is no proof of possession. +6. **Egress is unrestricted unless you restrict it.** The destination + validation above blocks the well-known dangerous targets, but Operator is designed to call third-party APIs; NetworkPolicy is what bounds that. diff --git a/docs/startup/index.md b/docs/startup/index.md index 8ce64082..500ae05d 100644 --- a/docs/startup/index.md +++ b/docs/startup/index.md @@ -15,18 +15,19 @@ When Operator starts and no `.tickets/` directory exists, the setup wizard guide | 1 | Welcome | Splash screen showing detected LLM tools and discovered projects | | 2 | Session Wrapper Choice | Select which session wrapper to use for launching coding agents | | 3 | Worktree Preference | Choose whether to use git worktrees for ticket isolation | -| 4 | Tmux Onboarding | Help and documentation about tmux session management (shown if tmux selected) | -| 5 | VS Code Setup | VS Code extension setup and verification (shown if VS Code selected) | -| 6 | Cmux Setup | cmux session wrapper setup (shown if cmux selected) | -| 7 | Zellij Setup | Zellij session wrapper setup (shown if Zellij selected) | -| 8 | Kanban Info | Kanban integration overview and provider credential detection | -| 9 | Kanban Provider Setup | Per-provider credential validation and project selection | -| 10 | Collection Source | Choose which issue type collection to use | -| 11 | Custom Collection | Select individual issue types (only shown if Custom Selection chosen) | -| 12 | Task Field Config | Configure optional fields for TASK issue type | -| 13 | Acceptance Criteria | Review and configure acceptance criteria for ticket completion | -| 14 | Startup Tickets | Optionally create tickets to bootstrap your projects | -| 15 | Confirm | Review settings and confirm initialization | +| 4 | Web UI Password | Optionally set the admin password for the web dashboard | +| 5 | Tmux Onboarding | Help and documentation about tmux session management (shown if tmux selected) | +| 6 | VS Code Setup | VS Code extension setup and verification (shown if VS Code selected) | +| 7 | Cmux Setup | cmux session wrapper setup (shown if cmux selected) | +| 8 | Zellij Setup | Zellij session wrapper setup (shown if Zellij selected) | +| 9 | Kanban Info | Kanban integration overview and provider credential detection | +| 10 | Kanban Provider Setup | Per-provider credential validation and project selection | +| 11 | Collection Source | Choose which issue type collection to use | +| 12 | Hosted Collections | Browse and select hosted collections (only shown if Browse chosen) | +| 13 | Task Field Config | Configure optional fields for TASK issue type | +| 14 | Acceptance Criteria | Review and configure acceptance criteria for ticket completion | +| 15 | Startup Tickets | Optionally create tickets to bootstrap your projects | +| 16 | Confirm | Review settings and confirm initialization | ## Step Details @@ -70,7 +71,21 @@ Worktrees allow multiple agents to work on different tickets simultaneously with **Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back -### 4. Tmux Onboarding +### 4. Web UI Password + +*Optionally set the admin password for the web dashboard* + +Operator has a single human account, `admin`. + +This terminal and the CLI need no password: a loopback process authenticates with an owner-only token file in the state directory. A browser cannot read that file, so the web dashboard stays locked until an admin password exists. + +Leave both fields blank to skip. You can set one later with `operator auth bootstrap` or from the /setup page. + +The password must be at least 12 characters. This step is hidden when an admin account already exists. + +**Navigation**: Tab to switch fields, Enter to continue (blank to skip), Esc to go back + +### 5. Tmux Onboarding *Help and documentation about tmux session management (shown if tmux selected)* @@ -84,7 +99,7 @@ Operator session names start with 'op-' for easy identification. **Navigation**: Enter to continue, Esc to go back -### 5. VS Code Setup +### 6. VS Code Setup *VS Code extension setup and verification (shown if VS Code selected)* @@ -95,7 +110,7 @@ Install the extension from the VS Code marketplace if prompted. **Navigation**: Enter to continue, Esc to go back -### 6. Cmux Setup +### 7. Cmux Setup *cmux session wrapper setup (shown if cmux selected)* @@ -105,7 +120,7 @@ This step verifies the cmux app's CLI binary exists at the configured binary_pat **Navigation**: Enter to continue, Esc to go back -### 7. Zellij Setup +### 8. Zellij Setup *Zellij session wrapper setup (shown if Zellij selected)* @@ -115,7 +130,7 @@ This step verifies Zellij is installed and configures the layout Operator will u **Navigation**: Enter to continue, Esc to go back -### 8. Kanban Info +### 9. Kanban Info *Kanban integration overview and provider credential detection* @@ -126,7 +141,7 @@ Credentials are read from environment variables (e.g. OPERATOR_JIRA_API_KEY). Th **Navigation**: Enter to continue, Esc to go back -### 9. Kanban Provider Setup +### 10. Kanban Provider Setup *Per-provider credential validation and project selection* @@ -139,7 +154,7 @@ Only projects you select will be synced to your ticket queue. You can skip this **Navigation**: ↑/↓ or j/k to navigate, Space to select projects, Enter to confirm, Esc to go back -### 10. Collection Source +### 11. Collection Source *Choose which issue type collection to use* @@ -151,22 +166,19 @@ Select a preset collection of issue types: **Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back -### 11. Custom Collection +### 12. Hosted Collections + +*Browse and select hosted collections (only shown if Browse chosen)* -*Select individual issue types (only shown if Custom Selection chosen)* +Pick one or more curated collections published at operator.untra.io. -Toggle individual issue types to include: -- **TASK**: Focused task that executes one specific thing -- **FEAT**: New feature or enhancement -- **FIX**: Bug fix, follow-up work, tech debt -- **SPIKE**: Research or exploration (paired mode) -- **INV**: Incident investigation (paired mode) +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. -At least one issue type must be selected to proceed. +Selections are additive - choose as many as apply. **Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back -### 12. Task Field Config +### 13. Task Field Config *Configure optional fields for TASK issue type* @@ -179,7 +191,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 -### 13. Acceptance Criteria +### 14. Acceptance Criteria *Review and configure acceptance criteria for ticket completion* @@ -190,7 +202,7 @@ The default criteria cover formatting, tests, and lint checks. You can customize **Navigation**: Enter to continue, Esc to go back -### 14. Startup Tickets +### 15. Startup Tickets *Optionally create tickets to bootstrap your projects* @@ -203,7 +215,7 @@ 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 -### 15. Confirm +### 16. Confirm *Review settings and confirm initialization* diff --git a/icons/gitea.svg b/icons/gitea.svg new file mode 100644 index 00000000..348846fa --- /dev/null +++ b/icons/gitea.svg @@ -0,0 +1 @@ +Gitea diff --git a/opr8r/src/api.rs b/opr8r/src/api.rs index 90a7ef99..d63dc9f0 100644 --- a/opr8r/src/api.rs +++ b/opr8r/src/api.rs @@ -8,6 +8,12 @@ const DEFAULT_API_PORT: u16 = 7008; /// API session file path relative to working directory const API_SESSION_FILE: &str = ".tickets/operator/api-session.json"; +/// Env var carrying the callback credential operator injects at launch. +/// +/// The operator REST API is authenticated, and the step-completion endpoint +/// launches processes. This token is scoped to exactly one ticket and step. +const API_TOKEN_ENV: &str = "OPERATOR_API_TOKEN"; + /// Retry configuration const MAX_RETRIES: u32 = 3; const INITIAL_BACKOFF_MS: u64 = 1000; @@ -166,6 +172,9 @@ pub struct CurrentStepInfo { pub struct ApiClient { client: Client, base_url: String, + /// Callback credential injected by operator at launch. Absent only when + /// running against a server that pre-dates authentication. + token: Option, } #[derive(Debug)] @@ -210,6 +219,11 @@ fn resolve_base_url( impl ApiClient { /// Create a new API client with the given base URL pub fn new(base_url: &str) -> Self { + Self::with_token(base_url, std::env::var(API_TOKEN_ENV).ok()) + } + + /// Create a client with an explicit callback credential. + pub fn with_token(base_url: &str, token: Option) -> Self { let client = Client::builder() .timeout(Duration::from_secs(30)) .build() @@ -218,6 +232,7 @@ impl ApiClient { Self { client, base_url: base_url.trim_end_matches('/').to_string(), + token: token.filter(|t| !t.trim().is_empty()), } } @@ -266,7 +281,12 @@ impl ApiClient { backoff_ms *= 2; // Exponential backoff } - match self.client.post(url).json(body).send().await { + let mut request = self.client.post(url).json(body); + if let Some(token) = &self.token { + request = request.bearer_auth(token); + } + + match request.send().await { Ok(response) => { let status = response.status(); if status.is_success() { @@ -366,13 +386,32 @@ mod tests { #[test] fn test_api_client_new() { - let client = ApiClient::new("http://localhost:7008/"); + let client = ApiClient::with_token("http://localhost:7008/", None); assert_eq!(client.base_url, "http://localhost:7008"); - let client = ApiClient::new("http://localhost:7008"); + let client = ApiClient::with_token("http://localhost:7008", None); assert_eq!(client.base_url, "http://localhost:7008"); } + #[test] + fn test_blank_token_is_treated_as_absent() { + // An unset env var arrives as an empty string through some shells; + // sending `Authorization: Bearer ` would be worse than sending nothing. + for blank in ["", " ", "\n"] { + let client = ApiClient::with_token("http://localhost:7008", Some(blank.to_string())); + assert!( + client.token.is_none(), + "blank token {blank:?} should be dropped" + ); + } + } + + #[test] + fn test_token_is_retained_when_supplied() { + let client = ApiClient::with_token("http://localhost:7008", Some("cb-token".to_string())); + assert_eq!(client.token.as_deref(), Some("cb-token")); + } + #[test] fn test_api_error_display() { let err = ApiError::Unreachable("connection refused".to_string()); diff --git a/scripts/cicdprep.sh b/scripts/cicdprep.sh index 915246c2..100ff755 100755 --- a/scripts/cicdprep.sh +++ b/scripts/cicdprep.sh @@ -173,23 +173,20 @@ needs_docs() { has_changes '^(docs/|src/docs_gen/|src/taxonomy/taxonomy\.t needs_bun_root() { has_changes '^(package\.json|bun\.lock)$'; } needs_bun_ui() { has_changes '^ui/(package\.json|bun\.lock)$'; } needs_bun_webcomp() { has_changes '^webcomponents/(package\.json|bun\.lock)$'; } -needs_bun_backstage() { has_changes '^backstage-server/(.*/)?(package\.json|bun\.lock)$'; } # --- 0. Bun lockfiles --- # # Run this first, cheaply, across every bun project so a stale lockfile fails # loudly and early instead of midway through a UI build. Mirrors CI's # `bun install --frozen-lockfile`, and additionally covers the root and -# backstage-server lockfiles that CI does not currently enforce. -if needs_bun_root || needs_bun_ui || needs_bun_webcomp || needs_bun_backstage; then +if needs_bun_root || needs_bun_ui || needs_bun_webcomp ; then section "Bun lockfiles" require_tool bun "bun lockfile sync" if needs_bun_root; then check_bun_lockfile "."; else skip "Lockfile sync: . (no changes)"; fi if needs_bun_ui; then check_bun_lockfile "ui"; else skip "Lockfile sync: ui (no changes)"; fi if needs_bun_webcomp; then check_bun_lockfile "webcomponents"; else skip "Lockfile sync: webcomponents (no changes)"; fi - if needs_bun_backstage; then check_bun_lockfile "backstage-server"; else skip "Lockfile sync: backstage-server (no changes)"; fi else skip "Bun lockfiles" fi diff --git a/shared/types.ts b/shared/types.ts index 9180ce69..51c3cb33 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -897,7 +897,7 @@ llm_model: string | null, launch_mode: string | null, /** * Review state for `awaiting_input` agents - * Values: "`pending_plan`", "`pending_visual`", "`pending_pr_creation`", "`pending_pr_merge`" + * Values: "`pending_plan`", "`pending_visual`", "`pending_proof`", "`pending_pr_creation`", "`pending_pr_merge`" */ review_state: string | null, /** diff --git a/src/agents/agent_switcher.rs b/src/agents/agent_switcher.rs index 4d895d8e..99ba484f 100644 --- a/src/agents/agent_switcher.rs +++ b/src/agents/agent_switcher.rs @@ -336,6 +336,7 @@ mod tests { fn make_delegator(name: &str, tool: &str, model: &str) -> Delegator { Delegator { + git: None, name: name.to_string(), llm_tool: tool.to_string(), model: model.to_string(), diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index 59efadef..228f6eae 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -426,6 +426,7 @@ mod tests { fn make_delegator(name: &str, tool: &str, model: &str) -> Delegator { Delegator { + git: None, name: name.to_string(), llm_tool: tool.to_string(), model: model.to_string(), @@ -722,6 +723,7 @@ mod tests { fn test_resolve_delegator_applies_launch_config() { let mut config = Config::default(); config.delegators.push(Delegator { + git: None, name: "full".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), diff --git a/src/agents/launcher/coder.rs b/src/agents/launcher/coder.rs index 7231f85b..48d2b271 100644 --- a/src/agents/launcher/coder.rs +++ b/src/agents/launcher/coder.rs @@ -207,7 +207,16 @@ pub(crate) fn provision_workspace( ticket_id: &str, remote_url: Option<&str>, branch: Option<&str>, + git: Option<&crate::config::GitExecutionConfig>, ) -> Result { + if let Some(context) = git { + if context.credentials.is_some() { + crate::git::runtime::validate_remote( + context, + remote_url.context("Git credentials require a repository origin")?, + )?; + } + } // Fail fast before any lifecycle action: credentials, then CLI presence. let session = resolve_session(coder)?; if !cli_available() { @@ -259,11 +268,25 @@ pub(crate) fn provision_workspace( // Ensure the checkout before the agent lands in the workdir. if let (Some(url), Some(branch)) = (remote_url, branch) { - let script = checkout_script(&workdir, url, branch); + let runtime = git + .map(crate::git::runtime::GitRuntime::create) + .transpose()?; + let mut script = checkout_script(&workdir, url, branch); + if let Some(runtime) = &runtime { + let host = RemoteHost { + name: workspace.clone(), + ssh_alias: alias.clone(), + workdir: workdir.clone(), + display_name: None, + ssh_config_path: Some(fragment.to_string_lossy().into_owned()), + }; + super::remote::transfer_git_runtime(&host, &runtime.path)?; + let path = super::prompt::shell_escape(&runtime.path.to_string_lossy()); + script = format!(". {path}/env.sh\ntrap 'rm -rf -- {path}' EXIT\n{script}"); + } run_ssh(&fragment, &alias, &script) .with_context(|| format!("Failed to prepare checkout on workspace '{workspace}'"))?; } - Ok(RemoteHost { name: workspace.clone(), ssh_alias: alias, diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index 9b4dc510..07006cfe 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -254,6 +254,19 @@ pub fn build_docker_command( } } + let git_enabled = + config.git.identity.is_some() || config.delegators.iter().any(|d| d.git.is_some()); + if git_enabled { + docker_args.push("${OPERATOR_GIT_RUNTIME:+-v}".into()); + docker_args.push( + "${OPERATOR_GIT_RUNTIME:+\"$OPERATOR_GIT_RUNTIME:$OPERATOR_GIT_RUNTIME:ro\"}".into(), + ); + docker_args.extend(["-e".into(), "OPERATOR_GIT_RUNTIME".into()]); + for name in crate::git::identity::IDENTITY_ENV_NAMES { + docker_args.extend(["-e".into(), (*name).into()]); + } + } + // Add the image docker_args.push(docker_config.image.clone()); @@ -262,7 +275,12 @@ pub fn build_docker_command( // silently dropped. docker_args.push("sh".to_string()); docker_args.push("-c".to_string()); - docker_args.push(shell_escape(inner_cmd)); + let inner_cmd = if git_enabled { + format!("if [ -n \"${{OPERATOR_GIT_RUNTIME:-}}\" ]; then . \"$OPERATOR_GIT_RUNTIME/env.sh\"; fi; {inner_cmd}") + } else { + inner_cmd.to_string() + }; + docker_args.push(shell_escape(&inner_cmd)); Ok(docker_args.join(" ")) } @@ -723,10 +741,6 @@ mod tests { } } - // ======================================== - // apply_yolo_flags() tests - // ======================================== - #[test] fn test_apply_yolo_flags_inserts_after_tool_name() { let tool = make_detected_tool(); diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index 36c566d5..922c72fd 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -138,6 +138,7 @@ impl Launcher { /// Uses custom tmux config if it has been generated and exists. /// Also creates a cmux client if the wrapper type is Cmux. pub fn new(config: &Config) -> Result { + crate::git::runtime::reconcile_local(config); // Use custom tmux config if it exists let tmux: Arc = if config.tmux.config_generated { let config_path = config.tmux_config_path(); @@ -260,6 +261,55 @@ impl Launcher { &self, ticket: &Ticket, options: LaunchOptions, + ) -> Result { + let git = crate::git::identity::resolve_config( + &self.config, + ticket, + options.delegator_name.as_deref(), + )?; + if let Some(context) = &git { + let path = options + .project_override + .as_ref() + .map(|p| self.get_project_path_for(p)) + .unwrap_or_else(|| self.get_project_path(ticket))?; + if context.credentials.is_some() { + let remote = + crate::git::GitCli::get_remote_url(PathBuf::from(path).as_path()).await?; + crate::git::runtime::validate_remote(context, &remote)?; + } + crate::git::runtime::GitRuntime::create(context)?; + } + crate::git::runtime::scope(git, Box::pin(self.launch_with_git_context(ticket, options))) + .await + } + + /// The git provider this project's pull requests will target, when it resolves + /// to one. Explicit `[git] provider` wins; otherwise it is detected from the + /// project's `origin` remote. + /// + /// `None` means Operator cannot tell which provider is in play, so no provider + /// CLI is required -- requiring one would block launches that never open a PR. + async fn resolve_git_provider( + config: &Config, + project_path: impl AsRef, + ) -> Option { + if let Some(configured) = config.git.provider.clone() { + return Some(configured.into()); + } + let hosts = crate::types::pr::ProviderHosts::from_config(&config.git).ok()?; + let url = crate::git::GitCli::get_remote_url(project_path.as_ref()) + .await + .ok()?; + crate::types::pr::RepoInfo::from_remote_url_with_hosts(&url, &hosts) + .ok() + .map(|repo| repo.provider) + } + + async fn launch_with_git_context( + &self, + ticket: &Ticket, + options: LaunchOptions, ) -> Result { let mut options = options; // Clone ticket so we can update worktree info @@ -368,6 +418,12 @@ impl Launcher { &ticket.id, remote_url.as_deref(), Some(&branch), + crate::git::identity::resolve_config( + &self.config, + ticket, + options.delegator_name.as_deref(), + )? + .as_ref(), )?; options.api_url_override = coder_cfg.callback_url.clone().filter(|u| !u.is_empty()); options.provisioned_host = Some(host); @@ -386,15 +442,34 @@ impl Launcher { let agent_id = Uuid::new_v4().to_string(); // Build operator environment variables for the terminal session + let step_name = if ticket.step.is_empty() { + "initial".to_string() + } else { + ticket.step.clone() + }; + // Without this the agent cannot report step completion. + let callback_token = + crate::auth::callback::mint(&self.config, &ticket.id, &step_name, &agent_id) + .unwrap_or_else(|e| { + tracing::error!( + error = %e, + ticket = %ticket.id, + "failed to mint the agent callback token; step completion will be rejected" + ); + String::new() + }); + let operator_env = prompt::OperatorEnvVars { + git_context: crate::git::identity::resolve_config( + &self.config, + ticket, + options.delegator_name.as_deref(), + )?, agent_id: agent_id.clone(), ticket_id: ticket.id.clone(), project: ticket.project.clone(), - step: if ticket.step.is_empty() { - "initial".to_string() - } else { - ticket.step.clone() - }, + step: step_name, + callback_token, ui_url: format!( "http://localhost:{}/#/agent/{}", self.config.rest_api.port, agent_id @@ -409,7 +484,8 @@ impl Launcher { .provider .as_ref() .map_or("claude", |p| p.tool.as_str()); - remote::run_preflight(&host, tool)?; + let git_provider = Self::resolve_git_provider(&self.config, &working_dir_str).await; + remote::run_preflight(&host, tool, git_provider)?; } // Dispatch based on session wrapper type @@ -488,6 +564,7 @@ impl Launcher { // Store session name in state for later recovery state.update_agent_session(&agent_id, &session_name)?; + state.update_agent_git_context(&agent_id, operator_env.git_context)?; // Store session wrapper type state.update_agent_session_wrapper(&agent_id, wrapper_name)?; @@ -1475,6 +1552,47 @@ impl Launcher { /// Used when a tmux session died but the ticket is still in progress. /// Can optionally resume from an existing Claude session ID. pub async fn relaunch(&self, ticket: &Ticket, options: RelaunchOptions) -> Result { + let resolved = crate::git::identity::resolve_config( + &self.config, + ticket, + options.launch_options.delegator_name.as_deref(), + )?; + let git = if options.resume_session_id.is_some() { + State::load(&self.config)? + .agents + .iter() + .rev() + .find(|a| { + a.ticket_id == ticket.id + && a.current_step.as_deref().unwrap_or_default() == ticket.step + }) + .map(|a| a.git_context.clone()) + .unwrap_or(resolved) + } else { + resolved + }; + if let Some(context) = &git { + if context.credentials.is_some() { + let remote = crate::git::GitCli::get_remote_url( + PathBuf::from(self.get_project_path(ticket)?).as_path(), + ) + .await?; + crate::git::runtime::validate_remote(context, &remote)?; + } + crate::git::runtime::GitRuntime::create(context)?; + } + crate::git::runtime::scope( + git, + Box::pin(self.relaunch_with_git_context(ticket, options)), + ) + .await + } + + async fn relaunch_with_git_context( + &self, + ticket: &Ticket, + options: RelaunchOptions, + ) -> Result { let mut options = options; // Clone ticket so we can update worktree info if needed let mut ticket = ticket.clone(); @@ -1527,15 +1645,30 @@ impl Launcher { let agent_id = Uuid::new_v4().to_string(); // Build operator environment variables for the terminal session + let step_name = if ticket.step.is_empty() { + "initial".to_string() + } else { + ticket.step.clone() + }; + // Without this the agent cannot report step completion, so a failure to mint is logged loudly + let callback_token = + crate::auth::callback::mint(&self.config, &ticket.id, &step_name, &agent_id) + .unwrap_or_else(|e| { + tracing::error!( + error = %e, + ticket = %ticket.id, + "failed to mint the agent callback token; step completion will be rejected" + ); + String::new() + }); + let operator_env = prompt::OperatorEnvVars { + git_context: crate::git::runtime::current(), agent_id: agent_id.clone(), ticket_id: ticket.id.clone(), project: ticket.project.clone(), - step: if ticket.step.is_empty() { - "initial".to_string() - } else { - ticket.step.clone() - }, + step: step_name, + callback_token, ui_url: format!( "http://localhost:{}/#/agent/{}", self.config.rest_api.port, agent_id @@ -1554,7 +1687,8 @@ impl Launcher { .provider .as_ref() .map_or("claude", |p| p.tool.as_str()); - remote::run_preflight(&host, tool)?; + let git_provider = Self::resolve_git_provider(&self.config, &working_dir_str).await; + remote::run_preflight(&host, tool, git_provider)?; } // Dispatch based on session wrapper type @@ -1633,6 +1767,7 @@ impl Launcher { // Store session name in state for later recovery state.update_agent_session(&agent_id, &session_name)?; + state.update_agent_git_context(&agent_id, operator_env.git_context)?; // Store session wrapper type state.update_agent_session_wrapper(&agent_id, wrapper_name)?; diff --git a/src/agents/launcher/prompt.rs b/src/agents/launcher/prompt.rs index 6d0722e8..f369d5a8 100644 --- a/src/agents/launcher/prompt.rs +++ b/src/agents/launcher/prompt.rs @@ -14,12 +14,15 @@ use crate::templates::{schema::TemplateSchema, TemplateType}; /// for branding (status line, pane title, UI deep-links). #[derive(Debug, Clone, Default)] pub struct OperatorEnvVars { + pub git_context: Option, pub agent_id: String, pub ticket_id: String, pub project: String, pub step: String, pub ui_url: String, pub ui_port: u16, + /// Single-purpose callback credential, pinned to this ticket and step. + pub callback_token: String, } impl OperatorEnvVars { @@ -29,7 +32,7 @@ impl OperatorEnvVars { /// `api-session.json` fallback) so local step-completion reporting never /// depends on disk-based discovery. pub fn to_export_block(&self) -> String { - format!( + let mut block = format!( "export OPERATOR_AGENT_ID={}\nexport OPERATOR_TICKET_ID={}\nexport OPERATOR_PROJECT={}\nexport OPERATOR_STEP={}\nexport OPERATOR_UI_URL={}\nexport OPERATOR_UI_PORT={}\nexport OPERATOR_API_URL=http://127.0.0.1:{}\n", shell_escape(&self.agent_id), shell_escape(&self.ticket_id), @@ -38,7 +41,17 @@ impl OperatorEnvVars { shell_escape(&self.ui_url), self.ui_port, self.ui_port, - ) + ); + if !self.callback_token.is_empty() { + block.push_str(&format!( + "export OPERATOR_API_TOKEN={}\n", + shell_escape(&self.callback_token) + )); + } + if let Some(git) = &self.git_context { + block.push_str(&crate::git::runtime::identity_exports(git)); + } + block } /// Render an OSC 2 escape sequence to set the terminal pane title. @@ -229,10 +242,29 @@ pub fn write_command_file( } }; + let runtime = operator_env + .and_then(|e| e.git_context.as_ref()) + .map(crate::git::runtime::GitRuntime::create) + .transpose()?; + let git_block = runtime + .as_ref() + .map(|r| { + format!( + "export OPERATOR_GIT_RUNTIME={}\ntrap 'rm -rf -- \"$OPERATOR_GIT_RUNTIME\"' EXIT\n. \"$OPERATOR_GIT_RUNTIME/env.sh\" || exit 1\n", + shell_escape(&r.path.to_string_lossy()) + ) + }) + .unwrap_or_default(); + let run = if runtime.is_some() { + format!("printf '%s\\n' \"$$\" > \"$OPERATOR_GIT_RUNTIME/pid\"\ntrap 'rm -rf -- \"$OPERATOR_GIT_RUNTIME\"' EXIT\n( {llm_command} ) <&0 &\noperator_git_child=$!\ntrap 'kill -TERM \"$operator_git_child\" 2>/dev/null; wait \"$operator_git_child\"; exit 143' TERM\ntrap 'kill -INT \"$operator_git_child\" 2>/dev/null; wait \"$operator_git_child\"; exit 130' INT\ntrap 'kill -HUP \"$operator_git_child\" 2>/dev/null; wait \"$operator_git_child\"; exit 129' HUP\nwait \"$operator_git_child\"\n") + } else { + format!("exec {llm_command}\n") + }; + let script_content = format!( - "#!/bin/bash\n{env_block}{provider_block}{strip_block}{pane_title}cd {}\nexec {}\n", + "#!/bin/bash\n{env_block}{provider_block}{strip_block}{git_block}{pane_title}cd {} || exit 1\n{}", shell_escape(project_path), - llm_command + run ); fs::write(&command_file, &script_content).context("Failed to write command file")?; @@ -246,6 +278,13 @@ pub fn write_command_file( .context("Failed to set command file permissions")?; } + if let Some(runtime) = runtime { + fs::write( + command_file.with_extension("git-runtime"), + runtime.path.to_string_lossy().as_bytes(), + )?; + runtime.persist(); + } Ok(command_file) } @@ -522,12 +561,14 @@ mod tests { #[test] fn test_operator_env_vars_to_export_block() { let env = OperatorEnvVars { + git_context: None, agent_id: "abc-123".to_string(), ticket_id: "FEAT-042".to_string(), project: "gamesvc".to_string(), step: "implement".to_string(), ui_url: "http://localhost:7007/#/agent/abc-123".to_string(), ui_port: 7007, + callback_token: String::new(), }; let block = env.to_export_block(); assert!(block.contains("export OPERATOR_AGENT_ID='abc-123'")); @@ -541,12 +582,14 @@ mod tests { #[test] fn test_operator_env_vars_to_pane_title_line() { let env = OperatorEnvVars { + git_context: None, agent_id: "abc-123".to_string(), ticket_id: "FEAT-042".to_string(), project: "gamesvc".to_string(), step: "implement".to_string(), ui_url: "http://localhost:7007/#/agent/abc-123".to_string(), ui_port: 7007, + callback_token: String::new(), }; let line = env.to_pane_title_line(); assert!(line.contains("\\033]2;")); @@ -562,12 +605,14 @@ mod tests { let config = make_test_config_with_tickets_path(temp_dir.path()); let env = OperatorEnvVars { + git_context: None, agent_id: "test-agent-id".to_string(), ticket_id: "FEAT-001".to_string(), project: "myproject".to_string(), step: "plan".to_string(), ui_url: "http://localhost:7007/#/agent/test-agent-id".to_string(), ui_port: 7007, + callback_token: String::new(), }; let result = write_command_file( @@ -679,4 +724,38 @@ mod tests { assert!(!content.contains("\\033]2;")); assert!(content.starts_with("#!/bin/bash\ncd")); } + #[test] + fn command_payload_applies_git_identity_and_removes_runtime() { + let temp = tempfile::tempdir().unwrap(); + let config = make_test_config_with_tickets_path(temp.path()); + let env = OperatorEnvVars { + git_context: Some(crate::config::GitExecutionConfig { + identity: Some(crate::config::GitIdentityConfig { + name: "Ticket Agent".into(), + email: "agent@example.org".into(), + }), + ..Default::default() + }), + ..Default::default() + }; + let command = write_command_file( + &config, + "git-context-test", + temp.path().to_str().unwrap(), + "printf '%s|%s' \"$GIT_AUTHOR_NAME\" \"$GIT_COMMITTER_EMAIL\"", + Some(&env), + None, + ) + .unwrap(); + let runtime = fs::read_to_string(command.with_extension("git-runtime")).unwrap(); + let output = std::process::Command::new("bash") + .arg(command) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8(output.stdout) + .unwrap() + .ends_with("Ticket Agent|agent@example.org")); + assert!(!std::path::Path::new(&runtime).exists()); + } } diff --git a/src/agents/launcher/remote.rs b/src/agents/launcher/remote.rs index 9830dca5..451013ef 100644 --- a/src/agents/launcher/remote.rs +++ b/src/agents/launcher/remote.rs @@ -26,6 +26,104 @@ fn ssh_config_flag(host: &RemoteHost) -> String { .unwrap_or_default() } +pub(crate) fn reconcile_git_runtime(config: &Config, host: &RemoteHost) { + for (pointer, path) in crate::git::runtime::abandoned_runtime_pointers(config) { + let marker = pointer.with_extension("git-remote"); + if std::fs::read_to_string(&marker).ok().as_deref() != Some(&host.ssh_alias) { + continue; + } + let target = shell_escape(&path.to_string_lossy()); + let script = format!("if [ -L {target} ]; then exit 1; fi; if [ -f {target}/pid ]; then read -r pid < {target}/pid; case \"$pid\" in ''|*[!0-9]*|0) exit 1;; esac; if kill -0 \"$pid\" 2>/dev/null; then exit 1; fi; fi; rm -rf -- {target}"); + let mut command = std::process::Command::new("ssh"); + if let Some(config) = &host.ssh_config_path { + command.args(["-F", config]); + } + if command + .args([ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + &host.ssh_alias, + &script, + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + { + let _ = std::fs::remove_file(pointer); + let _ = std::fs::remove_file(marker); + } + } +} + +pub(crate) fn transfer_git_runtime(host: &RemoteHost, path: &Path) -> Result<()> { + use std::io::Write; + use std::process::{Command, Stdio}; + fn send(host: &RemoteHost, script: &str, data: &[u8]) -> Result<()> { + let mut command = Command::new("ssh"); + if let Some(config) = &host.ssh_config_path { + command.args(["-F", config]); + } + let mut child = command + .args(["-o", "BatchMode=yes", &host.ssh_alias, script]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + child + .stdin + .take() + .context("Opening SSH credential transport")? + .write_all(data)?; + anyhow::ensure!(child.wait()?.success(), "Git credential transport failed"); + Ok(()) + } + fn copy(host: &RemoteHost, path: &Path) -> Result<()> { + send( + host, + &format!( + "umask 077; mkdir -- {}", + shell_escape(&path.to_string_lossy()) + ), + &[], + )?; + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let kind = entry.file_type()?; + if kind.is_dir() { + copy(host, &entry.path())?; + } else { + anyhow::ensure!(kind.is_file(), "Unexpected Git runtime file type"); + let mode = if entry.file_name() == "helper" + || entry.path().parent().is_some_and(|p| p.ends_with("bin")) + { + "700" + } else { + "600" + }; + let target = shell_escape(&entry.path().to_string_lossy()); + send( + host, + &format!("umask 077; set -C; cat > {target} && chmod {mode} {target}"), + &std::fs::read(entry.path())?, + )?; + } + } + Ok(()) + } + let result = copy(host, path); + if result.is_err() { + let _ = send( + host, + &format!("rm -rf -- {}", shell_escape(&path.to_string_lossy())), + &[], + ); + } + result +} + /// Remote path the prompt file is shipped to. pub(crate) fn remote_prompt_path(host: &RemoteHost, session_uuid: &str) -> String { format!( @@ -252,6 +350,19 @@ pub(crate) fn launch_remote_in_session( Some(&provider_env), )?; + reconcile_git_runtime(config, host); + let runtime_pointer = payload_file.with_extension("git-runtime"); + if runtime_pointer.exists() { + let path = PathBuf::from(std::fs::read_to_string(&runtime_pointer)?); + std::fs::write( + runtime_pointer.with_extension("git-remote"), + &host.ssh_alias, + )?; + let transferred = transfer_git_runtime(host, &path); + let _ = std::fs::remove_dir_all(&path); + transferred?; + } + let wrapper_content = build_remote_wrapper_script( host, session_name, @@ -288,20 +399,42 @@ pub(crate) fn launch_remote_in_session( const PREFLIGHT_NO_TMUX: i32 = 40; const PREFLIGHT_NO_TOOL: i32 = 41; const PREFLIGHT_NO_WORKDIR: i32 = 42; +const PREFLIGHT_NO_PROVIDER_CLI: i32 = 43; +const PREFLIGHT_NO_GIT: i32 = 44; /// The check script run on the remote host by [`run_preflight`]. -fn preflight_script(host: &RemoteHost, tool_name: &str) -> String { - format!( - "command -v tmux >/dev/null || exit {PREFLIGHT_NO_TMUX}; command -v {tool} >/dev/null || exit {PREFLIGHT_NO_TOOL}; test -d {workdir} || exit {PREFLIGHT_NO_WORKDIR}", +/// +/// `provider` is the git provider the project resolves to, when it resolves to +/// one. Operator ships no client binaries, so the target supplies `gh`/`glab`/ +/// `tea` itself and this is where a missing one is caught -- before a session +/// exists, rather than halfway through a ticket. +fn preflight_script( + host: &RemoteHost, + tool_name: &str, + provider: Option, +) -> String { + let mut checks = format!( + "command -v tmux >/dev/null || exit {PREFLIGHT_NO_TMUX}; command -v {tool} >/dev/null || exit {PREFLIGHT_NO_TOOL}; test -d {workdir} || exit {PREFLIGHT_NO_WORKDIR}; command -v git >/dev/null || exit {PREFLIGHT_NO_GIT}", tool = shell_escape(tool_name), workdir = shell_escape(&host.workdir), - ) + ); + if let Some(provider) = provider { + checks.push_str(&format!( + "; command -v {cli} >/dev/null || exit {PREFLIGHT_NO_PROVIDER_CLI}", + cli = shell_escape(crate::api::cli_detection::binary_for(provider)), + )); + } + checks } /// Check the remote host can run the agent before any session is created: /// reachable over SSH (`BatchMode` so a password prompt can't wedge the TUI), /// tmux and the tool on the remote PATH, and the workdir present. -pub(crate) fn run_preflight(host: &RemoteHost, tool_name: &str) -> Result<()> { +pub(crate) fn run_preflight( + host: &RemoteHost, + tool_name: &str, + provider: Option, +) -> Result<()> { let mut cmd = std::process::Command::new("ssh"); if let Some(ref frag) = host.ssh_config_path { cmd.args(["-F", frag]); @@ -309,7 +442,7 @@ pub(crate) fn run_preflight(host: &RemoteHost, tool_name: &str) -> Result<()> { let status = cmd .args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]) .arg(&host.ssh_alias) - .arg(preflight_script(host, tool_name)) + .arg(preflight_script(host, tool_name, provider)) .status() .context("Failed to run ssh for remote preflight")?; @@ -328,6 +461,24 @@ pub(crate) fn run_preflight(host: &RemoteHost, tool_name: &str) -> Result<()> { host.name, host.workdir ), + Some(c) if c == PREFLIGHT_NO_GIT => anyhow::bail!( + "Remote host '{}' has no git on PATH; install git there first", + host.name + ), + Some(c) if c == PREFLIGHT_NO_PROVIDER_CLI => { + let spec = crate::api::cli_detection::spec_for( + provider.expect("exit 43 is only emitted when a provider was checked"), + ); + anyhow::bail!( + "Remote host '{}' has no '{}' on PATH, needed to open {} pull requests. \ + Operator does not install client binaries -- install it on the target \ + (or bake it into the workspace image): {}", + host.name, + spec.command, + spec.display_name, + spec.install_url + ) + } _ => anyhow::bail!( "Cannot reach remote host '{}' via `ssh {}` (BatchMode). Verify the alias in ~/.ssh/config and connect once manually to accept host keys", host.name, @@ -507,10 +658,37 @@ mod tests { #[test] fn test_preflight_script_distinct_exit_codes() { - let s = preflight_script(&host(), "claude"); + let s = preflight_script(&host(), "claude", None); assert!(s.contains("command -v tmux >/dev/null || exit 40")); assert!(s.contains("command -v 'claude' >/dev/null || exit 41")); assert!(s.contains("test -d '/srv/agents/proj' || exit 42")); + assert!(s.contains("command -v git >/dev/null || exit 44")); + } + + /// A missing provider CLI must be caught here, before a session exists. + /// Operator installs no client binaries, so this check is the only thing + /// standing between a BYO target and a PR that fails halfway through. + #[test] + fn preflight_requires_the_provider_cli_when_one_is_known() { + use crate::types::pr::GitProvider; + let s = preflight_script(&host(), "claude", Some(GitProvider::GitHub)); + assert!(s.contains("command -v 'gh' >/dev/null || exit 43")); + + let gitea = preflight_script(&host(), "claude", Some(GitProvider::Gitea)); + assert!(gitea.contains("command -v 'tea' >/dev/null || exit 43")); + + // Forgejo rides the Gitea-compatible tea CLI; never `fj`. + let forgejo = preflight_script(&host(), "claude", Some(GitProvider::Forgejo)); + assert!(forgejo.contains("command -v 'tea' >/dev/null || exit 43")); + assert!(!forgejo.contains("fj")); + } + + /// No resolvable provider means no PR will be attempted, so requiring a + /// provider CLI would block launches that never needed one. + #[test] + fn preflight_skips_the_provider_check_when_no_provider_is_resolved() { + let s = preflight_script(&host(), "claude", None); + assert!(!s.contains(&format!("exit {PREFLIGHT_NO_PROVIDER_CLI}"))); } #[test] diff --git a/src/agents/launcher/step_command.rs b/src/agents/launcher/step_command.rs index 291efb92..beb23aba 100644 --- a/src/agents/launcher/step_command.rs +++ b/src/agents/launcher/step_command.rs @@ -330,6 +330,7 @@ mod tests { // The FEAT "code" step names agent "claude-opus"; register a // delegator by that name pointing at a different tool + model. config.delegators = vec![Delegator { + git: None, name: "claude-opus".to_string(), llm_tool: "gemini".to_string(), model: "gemini-pro".to_string(), diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index 7a2d7727..5e831a3a 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -509,12 +509,14 @@ use crate::agents::tmux::TmuxClient; fn make_test_operator_env() -> OperatorEnvVars { OperatorEnvVars { + git_context: None, agent_id: Uuid::new_v4().to_string(), ticket_id: "TEST-001".to_string(), project: "test-project".to_string(), step: "initial".to_string(), ui_url: "http://localhost:7008/#/agent/test".to_string(), ui_port: 7008, + callback_token: String::new(), } } @@ -1854,6 +1856,7 @@ use crate::state::{PendingSubAgent, State}; fn add_delegators(config: &mut Config, names: &[&str]) { for name in names { config.delegators.push(Delegator { + git: None, name: (*name).to_string(), llm_tool: "claude".to_string(), model: "sonnet".to_string(), diff --git a/src/agents/pr_workflow.rs b/src/agents/pr_workflow.rs index bdbe056c..a18e3c70 100644 --- a/src/agents/pr_workflow.rs +++ b/src/agents/pr_workflow.rs @@ -19,23 +19,43 @@ use crate::types::pr::{CreatePrError, CreatePrRequest, PrState, PullRequestInfo, /// Handles the PR/MR workflow for a step pub struct PrWorkflow { + hosts: crate::types::pr::ProviderHosts, + git_context: Option, service: Arc, } impl Default for PrWorkflow { fn default() -> Self { - Self::new() + Self::with_service(Arc::new(PrServiceRouter::with_config( + &crate::config::Config::default(), + None, + ))) } } impl PrWorkflow { - /// Create a new PR workflow handler (routes per-call by provider) - pub fn new() -> Self { + /// Build a workflow over an explicit `PrService`. The injection seam the + /// orchestration layer was missing -- both real constructors hardcoded a + /// router, so nothing above `PrService` could be tested with a mock. + pub fn with_service(service: Arc) -> Self { Self { - service: Arc::new(PrServiceRouter::new()), + hosts: crate::types::pr::ProviderHosts::default(), + git_context: None, + service, } } + pub fn with_config( + config: &crate::config::Config, + git_context: Option, + ) -> Result { + Ok(Self { + hosts: crate::types::pr::ProviderHosts::from_config(&config.git)?, + service: Arc::new(PrServiceRouter::with_config(config, git_context.clone())), + git_context, + }) + } + /// Get repo info from a worktree path #[instrument(skip(self))] pub async fn get_repo_info(&self, worktree_path: &Path) -> Result { @@ -43,7 +63,7 @@ impl PrWorkflow { .await .context("Failed to get remote URL")?; - RepoInfo::from_remote_url(&remote_url) + RepoInfo::from_remote_url_with_hosts(&remote_url, &self.hosts) .map_err(|e| anyhow::anyhow!("Failed to parse repository URL: {e}")) } @@ -56,7 +76,15 @@ impl PrWorkflow { set_upstream: bool, ) -> Result<()> { info!("Pushing branch {} to remote", branch); - GitCli::push(worktree_path, "origin", branch, set_upstream).await + if let Some(context) = &self.git_context { + let remote = GitCli::get_remote_url(worktree_path).await?; + crate::git::runtime::validate_remote(context, &remote)?; + } + crate::git::runtime::scope( + self.git_context.clone(), + GitCli::push(worktree_path, "origin", branch, set_upstream), + ) + .await } /// Create a PR for the current branch @@ -156,9 +184,11 @@ impl PrWorkflow { ticket_id: &str, ) -> Result<()> { let repo_info = self.get_repo_info(worktree_path).await?; - monitor - .track_pr(repo_info, pr_number, ticket_id.to_string()) - .await + crate::git::runtime::scope( + self.git_context.clone(), + monitor.track_pr(repo_info, pr_number, ticket_id.to_string()), + ) + .await } /// Stop tracking a PR @@ -244,6 +274,6 @@ mod tests { #[test] fn test_create_workflow() { - let _workflow = PrWorkflow::new(); + let _workflow = PrWorkflow::default(); } } diff --git a/src/api/argv.rs b/src/api/argv.rs new file mode 100644 index 00000000..e424b440 --- /dev/null +++ b/src/api/argv.rs @@ -0,0 +1,72 @@ +//! Provider CLI command construction, separated from execution. +//! +//! Each provider builds its arg vector as a pure function so it can be +//! asserted without spawning a binary. The three providers spell the same +//! request very differently -- `gh` uses `--head`/`--base`/`--body`, `glab` +//! uses `--source-branch`/`--target-branch`/`--description`, and `tea` posts +//! JSON -- and nothing caught a bad flag until the argv became testable. + +/// A provider CLI invocation: the binary and its arguments, ready to run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderCommand { + /// The binary, always sourced from [`crate::api::cli_detection`]. + pub program: &'static str, + pub args: Vec, +} + +impl ProviderCommand { + pub fn new(program: &'static str, args: impl IntoIterator>) -> Self { + Self { + program, + args: args.into_iter().map(Into::into).collect(), + } + } + + /// Borrowed args, for handing straight to `Command::args`. + pub fn arg_refs(&self) -> Vec<&str> { + self.args.iter().map(String::as_str).collect() + } + + /// The long flags this invocation passes (`--json`, `--draft`, ...). + /// + /// Used to check an argv against the installed CLI's own `--help`, which + /// is how a flag that the binary does not accept gets caught without + /// credentials or a live repository. + pub fn long_flags(&self) -> Vec<&str> { + self.args + .iter() + .map(String::as_str) + .filter(|a| a.starts_with("--") && a.len() > 2) + .collect() + } + + /// The subcommand path before the first flag (`["pr", "create"]`). + pub fn subcommand(&self) -> Vec<&str> { + self.args + .iter() + .map(String::as_str) + .take_while(|a| !a.starts_with('-')) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn long_flags_and_subcommand_split_an_argv() { + let cmd = ProviderCommand::new( + "gh", + ["pr", "create", "--repo", "o/r", "--draft", "-q", ".number"], + ); + assert_eq!(cmd.subcommand(), ["pr", "create"]); + assert_eq!(cmd.long_flags(), ["--repo", "--draft"]); + } + + #[test] + fn a_bare_double_dash_is_not_a_flag() { + let cmd = ProviderCommand::new("gh", ["pr", "list", "--"]); + assert!(cmd.long_flags().is_empty()); + } +} diff --git a/src/api/cli_detection.rs b/src/api/cli_detection.rs index 3afc2372..ec09baf1 100644 --- a/src/api/cli_detection.rs +++ b/src/api/cli_detection.rs @@ -21,12 +21,24 @@ pub struct CliInfo { pub version: Option, } -/// Static description of a provider CLI: display name and command to probe. -/// `provider` is `None` for the provider-agnostic `git` binary. -struct CliSpec { +/// Static description of a provider CLI: the binary to probe plus everything +/// onboarding needs to install and authenticate it. +pub struct CliSpec { provider: Option, - name: &'static str, - command: &'static str, + /// Human name of the tool ("GitHub CLI"). + pub name: &'static str, + /// The binary as invoked on PATH. + pub command: &'static str, + /// Args that print an auth token on stdout, empty when unsupported. + pub auth_args: &'static [&'static str], + /// Where to download the CLI. + pub install_url: &'static str, + /// Where the user mints a personal access token. + pub pat_url: &'static str, + /// Provider brand name ("GitHub"). + pub display_name: &'static str, + /// Placeholder shown in the token entry field. + pub placeholder: &'static str, } const CLI_SPECS: &[CliSpec] = &[ @@ -34,39 +46,106 @@ const CLI_SPECS: &[CliSpec] = &[ provider: None, name: "Git", command: "git", + auth_args: &[], + install_url: "https://git-scm.com/downloads", + pat_url: "", + display_name: "Git", + placeholder: "", }, CliSpec { provider: Some(GitProvider::GitHub), name: "GitHub CLI", command: "gh", + auth_args: &["auth", "token"], + install_url: "https://cli.github.com/", + pat_url: "https://github.com/settings/personal-access-tokens/new", + display_name: "GitHub", + placeholder: "ghp_...", }, CliSpec { provider: Some(GitProvider::GitLab), name: "GitLab CLI", command: "glab", + auth_args: &["auth", "token"], + install_url: "https://docs.gitlab.com/cli", + pat_url: "https://gitlab.com/-/user_settings/personal_access_tokens", + display_name: "GitLab", + placeholder: "glpat-...", }, CliSpec { provider: Some(GitProvider::Bitbucket), name: "Bitbucket CLI", command: "bb", + auth_args: &[], + install_url: "https://bitbucket.org/", + pat_url: "", + display_name: "Bitbucket", + placeholder: "", }, CliSpec { provider: Some(GitProvider::AzureDevOps), name: "Azure CLI", command: "az", + auth_args: &[], + install_url: "https://learn.microsoft.com/cli/azure/install-azure-cli", + pat_url: "", + display_name: "Azure DevOps", + placeholder: "", }, + // Forgejo speaks Gitea's API, so `tea` drives it verbatim; there is no + // separate `fj` dependency to install. CliSpec { provider: Some(GitProvider::Forgejo), - name: "Forgejo CLI", - command: "fj", + name: "Gitea CLI (Forgejo-compatible)", + command: "tea", + auth_args: &[], + install_url: "https://about.gitea.com/products/tea/", + pat_url: "", + display_name: "Forgejo", + placeholder: "", }, CliSpec { provider: Some(GitProvider::Gitea), name: "Gitea CLI", command: "tea", + auth_args: &[], + install_url: "https://about.gitea.com/products/tea/", + pat_url: "https://gitea.com/user/settings/applications", + display_name: "Gitea", + placeholder: "Personal access token", }, ]; +/// The full spec for a provider. +pub fn spec_for(provider: GitProvider) -> &'static CliSpec { + CLI_SPECS + .iter() + .find(|s| s.provider == Some(provider)) + .expect("CLI_SPECS covers every GitProvider variant") +} + +/// The binary a provider's operations shell out to. +pub fn binary_for(provider: GitProvider) -> &'static str { + spec_for(provider).command +} + +/// The spec for a provider slug, when that provider has an onboarding story +/// (a PAT URL to send the user to). Providers Operator can only *detect* have +/// no token flow and resolve to `None`. +pub fn onboarding_spec_for_slug(slug: &str) -> Option<&'static CliSpec> { + let provider = GitProvider::ALL.into_iter().find(|p| p.slug() == slug)?; + let spec = spec_for(provider); + (!spec.pat_url.is_empty()).then_some(spec) +} + +/// The provider-agnostic `git` binary's spec. +pub fn git_spec() -> &'static CliSpec { + CLI_SPECS + .iter() + .find(|s| s.provider.is_none()) + .expect("CLI_SPECS carries the git binary") +} + /// Detect all provider CLIs (and `git` itself), in table order. pub async fn detect_all_clis() -> Vec { let checks = CLI_SPECS.iter().map(probe); @@ -75,11 +154,7 @@ pub async fn detect_all_clis() -> Vec { /// Detect the CLI for a specific provider. pub async fn detect_for(provider: GitProvider) -> CliInfo { - let spec = CLI_SPECS - .iter() - .find(|s| s.provider == Some(provider)) - .expect("CLI_SPECS covers every GitProvider variant"); - probe(spec).await + probe(spec_for(provider)).await } async fn probe(spec: &CliSpec) -> CliInfo { @@ -121,13 +196,14 @@ mod tests { async fn test_detect_all_clis() { let clis = detect_all_clis().await; assert_eq!(clis.len(), 7); - assert!(clis.iter().any(|c| c.command == "git")); - assert!(clis.iter().any(|c| c.command == "gh")); - assert!(clis.iter().any(|c| c.command == "glab")); - assert!(clis.iter().any(|c| c.command == "bb")); - assert!(clis.iter().any(|c| c.command == "az")); - assert!(clis.iter().any(|c| c.command == "fj")); - assert!(clis.iter().any(|c| c.command == "tea")); + for expected in ["git", "gh", "glab", "bb", "az", "tea"] { + assert!( + clis.iter().any(|c| c.command == expected), + "no probe for {expected}" + ); + } + // Forgejo and Gitea share `tea`, so the table is 7 rows over 6 binaries. + assert!(!clis.iter().any(|c| c.command == "fj")); } #[test] @@ -140,6 +216,69 @@ mod tests { } } + #[test] + fn binary_for_covers_every_provider() { + for provider in GitProvider::ALL { + assert!( + !binary_for(provider).is_empty(), + "no CLI binary for provider {provider}" + ); + } + } + + #[test] + fn forgejo_is_served_by_the_gitea_compatible_tea_cli() { + assert_eq!(binary_for(GitProvider::Forgejo), "tea"); + assert_eq!(binary_for(GitProvider::Gitea), "tea"); + } + + #[test] + fn onboarding_metadata_present_for_operational_providers() { + for provider in [GitProvider::GitHub, GitProvider::GitLab, GitProvider::Gitea] { + let spec = spec_for(provider); + assert!(!spec.install_url.is_empty(), "{provider}: no install_url"); + assert!(!spec.pat_url.is_empty(), "{provider}: no pat_url"); + assert!(!spec.display_name.is_empty(), "{provider}: no display_name"); + assert!(!spec.placeholder.is_empty(), "{provider}: no placeholder"); + } + } + + /// The registry is the only place a provider binary may be named. A second + /// copy is how `gh`/`glab`/`tea` drifted apart in the first place. + #[test] + fn provider_binaries_are_not_hardcoded_outside_the_registry() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut offenders = Vec::new(); + visit(&src, &mut |path: &std::path::Path, body: &str| { + if path.ends_with("api/cli_detection.rs") { + return; + } + for (n, line) in body.lines().enumerate() { + for bin in ["gh", "glab", "tea"] { + if line.contains(&format!("Command::new(\"{bin}\")")) { + offenders.push(format!("{}:{}", path.display(), n + 1)); + } + } + } + }); + assert!( + offenders.is_empty(), + "provider binaries must come from CLI_SPECS, not a literal: {offenders:#?}" + ); + } + + fn visit(dir: &std::path::Path, f: &mut impl FnMut(&std::path::Path, &str)) { + for entry in std::fs::read_dir(dir).expect("src should be readable") { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + visit(&path, f); + } else if path.extension().is_some_and(|e| e == "rs") { + let body = std::fs::read_to_string(&path).expect("rust source should be readable"); + f(&path, &body); + } + } + } + #[tokio::test] async fn test_detect_for_github() { let info = detect_for(GitProvider::GitHub).await; @@ -157,8 +296,8 @@ mod tests { #[tokio::test] async fn test_detect_for_forgejo() { let info = detect_for(GitProvider::Forgejo).await; - assert_eq!(info.command, "fj"); - assert_eq!(info.name, "Forgejo CLI"); + assert_eq!(info.command, "tea"); + assert_eq!(info.name, "Gitea CLI (Forgejo-compatible)"); } #[tokio::test] diff --git a/src/api/gh_cli.rs b/src/api/gh_cli.rs index a0904a26..fce9068d 100644 --- a/src/api/gh_cli.rs +++ b/src/api/gh_cli.rs @@ -17,6 +17,9 @@ use std::process::Stdio; use tokio::process::Command; use tracing::{debug, instrument, warn}; +use crate::api::argv::ProviderCommand; +use crate::api::cli_detection::binary_for; +use crate::types::pr::GitProvider; use crate::types::pr::{ CreatePrError, CreatePrRequest, GitHubRepoInfo, PrReviewState, PrState, PullRequestInfo, UnifiedPrComment, @@ -30,13 +33,15 @@ impl GhCli { async fn run_gh(args: &[&str], cwd: Option<&Path>) -> Result { debug!(?args, "Running gh command"); - let mut cmd = Command::new("gh"); + let mut cmd = Command::new(binary_for(GitProvider::GitHub)); cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); if let Some(dir) = cwd { cmd.current_dir(dir); } + let _git_runtime = crate::git::runtime::configure_command(&mut cmd)?; + let output = cmd.output().await.context("Failed to execute gh command")?; if !output.status.success() { @@ -53,7 +58,7 @@ impl GhCli { /// Check if gh CLI is installed pub async fn is_installed() -> bool { - Command::new("gh") + Command::new(binary_for(GitProvider::GitHub)) .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -75,6 +80,38 @@ impl GhCli { Self::run_gh(&["api", "user", "--jq", ".login"], None).await } + /// Build the `gh pr create` invocation. Pure, so the flags are asserted + /// without spawning `gh`. + /// + /// Note there is no `--json`: `gh pr create` does not accept it and prints + /// the new PR's URL on stdout instead. The number is read back from that + /// URL and hydrated via `get_pr`, matching the GitLab path. + pub fn create_pr_argv( + repo_info: &GitHubRepoInfo, + request: &CreatePrRequest, + ) -> ProviderCommand { + let mut args = vec![ + "pr".to_string(), + "create".to_string(), + "--repo".to_string(), + repo_info.full_name(), + "--head".to_string(), + request.head_branch.clone(), + "--base".to_string(), + request.base_branch.clone(), + "--title".to_string(), + request.title.clone(), + ]; + if let Some(body) = &request.body { + args.push("--body".to_string()); + args.push(body.clone()); + } + if request.draft.unwrap_or(false) { + args.push("--draft".to_string()); + } + ProviderCommand::new(binary_for(GitProvider::GitHub), args) + } + /// Create a PR using gh CLI #[instrument(skip(request))] pub async fn create_pr( @@ -82,95 +119,30 @@ impl GhCli { request: &CreatePrRequest, cwd: &Path, ) -> Result { - // Check if gh is installed if !Self::is_installed().await { return Err(CreatePrError::ProviderCliNotInstalled); } - // Check if authenticated if !Self::check_auth().await.unwrap_or(false) { return Err(CreatePrError::ProviderCliNotLoggedIn); } - let repo_full_name = repo_info.full_name(); - let mut args = vec![ - "pr", - "create", - "--repo", - &repo_full_name, - "--head", - &request.head_branch, - "--base", - &request.base_branch, - "--title", - &request.title, - ]; - - // Add body if provided - let body_arg: String; - if let Some(ref body) = request.body { - body_arg = body.clone(); - args.push("--body"); - args.push(&body_arg); - } - - // Add draft flag if requested - if request.draft.unwrap_or(false) { - args.push("--draft"); - } + let command = Self::create_pr_argv(repo_info, request); + let output = Self::run_gh(&command.arg_refs(), Some(cwd)) + .await + .map_err(|e| classify_create_error(&e.to_string(), request))?; - // Request JSON output - args.push("--json"); - args.push("number,url,state,isDraft,title"); - - let output = Self::run_gh(&args, Some(cwd)).await.map_err(|e| { - let err_str = e.to_string(); - - if err_str.contains("already exists") { - // Try to extract PR number from error - if let Some(captures) = extract_existing_pr_info(&err_str) { - return CreatePrError::PrAlreadyExists { - pr_number: captures.0, - url: captures.1, - }; - } - } - - if err_str.contains("not pushed") || err_str.contains("has no commits") { - return CreatePrError::BranchNotPushed { - branch: request.head_branch.clone(), - }; - } - - if err_str.contains("not found") && err_str.contains(&request.base_branch) { - return CreatePrError::TargetBranchNotFound { - branch: request.base_branch.clone(), - }; - } - - CreatePrError::ProviderApiError { message: err_str } - })?; - - // Parse the JSON response - let pr_response: GhPrCreateResponse = - serde_json::from_str(&output).map_err(|e| CreatePrError::ProviderApiError { - message: format!("Failed to parse PR response: {e}"), + // `gh pr create` prints the PR URL; everything else comes from `get_pr`. + let number = + pr_number_from_url(&output).ok_or_else(|| CreatePrError::ProviderApiError { + message: format!("Could not read a PR number from gh output: {output}"), })?; - Ok(PullRequestInfo { - number: pr_response.number, - url: pr_response.url, - state: if pr_response.state.eq_ignore_ascii_case("open") { - PrState::Open - } else if pr_response.state.eq_ignore_ascii_case("merged") { - PrState::Merged - } else { - PrState::Closed - }, - merge_commit_sha: None, - title: Some(pr_response.title), - is_draft: pr_response.is_draft, - }) + Self::get_pr(repo_info, number) + .await + .map_err(|e| CreatePrError::ProviderApiError { + message: e.to_string(), + }) } /// Get PR info using gh CLI @@ -505,10 +477,116 @@ fn extract_existing_pr_info(error: &str) -> Option<(i64, String)> { None } +/// Map a failed `gh pr create` to a structured error the UI can act on. +fn classify_create_error(err: &str, request: &CreatePrRequest) -> CreatePrError { + if err.contains("already exists") { + if let Some((pr_number, url)) = extract_existing_pr_info(err) { + return CreatePrError::PrAlreadyExists { pr_number, url }; + } + } + if err.contains("not pushed") || err.contains("has no commits") { + return CreatePrError::BranchNotPushed { + branch: request.head_branch.clone(), + }; + } + if err.contains("not found") && err.contains(&request.base_branch) { + return CreatePrError::TargetBranchNotFound { + branch: request.base_branch.clone(), + }; + } + CreatePrError::ProviderApiError { + message: err.to_string(), + } +} + +/// Read the PR number out of a `.../pull/` URL anywhere in `output`. +fn pr_number_from_url(output: &str) -> Option { + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + RE.get_or_init(|| regex::Regex::new(r"/pull/(\d+)").expect("static regex")) + .captures(output)? + .get(1)? + .as_str() + .parse() + .ok() +} + #[cfg(test)] mod tests { use super::*; + fn request() -> CreatePrRequest { + CreatePrRequest { + title: "Add widget".into(), + body: Some("Body text".into()), + head_branch: "feat/widget".into(), + base_branch: "main".into(), + draft: Some(true), + } + } + + #[test] + fn create_pr_argv_is_the_documented_gh_contract() { + let repo = GitHubRepoInfo::new(GitProvider::GitHub, "owner", "repo"); + let cmd = GhCli::create_pr_argv(&repo, &request()); + assert_eq!(cmd.program, "gh"); + assert_eq!( + cmd.args, + [ + "pr", + "create", + "--repo", + "owner/repo", + "--head", + "feat/widget", + "--base", + "main", + "--title", + "Add widget", + "--body", + "Body text", + "--draft", + ] + ); + } + + /// `gh pr create` has no `--json`; it prints the new PR's URL on stdout. + /// Asking for JSON made every GitHub PR creation fail on flag parse. + #[test] + fn create_pr_argv_does_not_ask_gh_for_json() { + let repo = GitHubRepoInfo::new(GitProvider::GitHub, "owner", "repo"); + let cmd = GhCli::create_pr_argv(&repo, &request()); + assert!( + !cmd.long_flags().contains(&"--json"), + "gh pr create does not accept --json" + ); + } + + #[test] + fn create_pr_argv_omits_optional_flags_when_unset() { + let repo = GitHubRepoInfo::new(GitProvider::GitHub, "owner", "repo"); + let bare = CreatePrRequest { + body: None, + draft: None, + ..request() + }; + let flags = GhCli::create_pr_argv(&repo, &bare); + assert!(!flags.long_flags().contains(&"--body")); + assert!(!flags.long_flags().contains(&"--draft")); + } + + #[test] + fn pr_number_is_read_from_the_created_url() { + assert_eq!( + pr_number_from_url("https://github.com/owner/repo/pull/42"), + Some(42) + ); + assert_eq!( + pr_number_from_url("noise\nhttps://github.com/o/r/pull/7\n"), + Some(7) + ); + assert_eq!(pr_number_from_url("no url here"), None); + } + #[tokio::test] async fn test_is_installed() { // This test just verifies the function doesn't panic diff --git a/src/api/gitea_service.rs b/src/api/gitea_service.rs new file mode 100644 index 00000000..a49e951e --- /dev/null +++ b/src/api/gitea_service.rs @@ -0,0 +1,473 @@ +use super::{pr_service::PrService, tea_cli::TeaCli}; +use crate::config::GiteaConfig; +use crate::types::pr::{ + CreatePrError, CreatePrRequest, PrReviewState, PrState, PullRequestInfo, RepoInfo, + UnifiedPrComment, +}; +use anyhow::{ensure, Result}; +use async_trait::async_trait; +use backon::{ExponentialBuilder, Retryable}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::Path; + +const PAGE_SIZE: usize = 50; +const MAX_PAGES: usize = 1000; + +pub struct GiteaService { + cli: TeaCli, + wip_prefix: String, +} + +#[derive(Deserialize)] +struct Pull { + number: i64, + html_url: String, + state: String, + #[serde(default)] + merged: bool, + #[serde(default)] + draft: bool, + merge_commit_sha: Option, + title: Option, +} + +impl From for PullRequestInfo { + fn from(pr: Pull) -> Self { + Self { + number: pr.number, + url: pr.html_url, + state: if pr.merged { + PrState::Merged + } else if pr.state == "closed" { + PrState::Closed + } else { + PrState::Open + }, + merge_commit_sha: pr.merge_commit_sha, + title: pr.title, + is_draft: pr.draft, + } + } +} + +impl GiteaService { + pub fn new(config: GiteaConfig) -> Self { + Self { + wip_prefix: config.wip_prefix.clone(), + cli: TeaCli::new(config), + } + } + + fn repo_path(&self, repo: &RepoInfo) -> Result { + if let Some(host) = &repo.host { + ensure!( + self.cli + .base_url()? + .host_str() + .is_some_and(|configured| configured.eq_ignore_ascii_case(host)), + "Repository host does not match configured Gitea host" + ); + } + ensure!( + !repo.owner.contains('/') && !repo.owner.is_empty() && !repo.repo_name.is_empty(), + "Invalid Gitea repository owner/name" + ); + let mut url = self.cli.base_url()?; + url.path_segments_mut() + .map_err(|()| anyhow::anyhow!("Invalid Gitea base URL"))? + .extend(["repos", &repo.owner, &repo.repo_name]); + Ok(url.path().trim_start_matches('/').to_owned()) + } + + async fn get(&self, endpoint: &str) -> Result { + (|| self.cli.request("GET", endpoint, None, None)) + .retry(ExponentialBuilder::default().with_max_times(3)) + .when(|e| e.to_string().contains("transient") || e.to_string().contains("timed out")) + .await + } + + async fn pages(&self, endpoint: &str) -> Result> { + let mut all = Vec::new(); + for page in 1..=MAX_PAGES { + let separator = if endpoint.contains('?') { '&' } else { '?' }; + let response = self + .get(&format!( + "{endpoint}{separator}limit={PAGE_SIZE}&page={page}" + )) + .await?; + let entries = response + .as_array() + .ok_or_else(|| anyhow::anyhow!("Expected Gitea collection"))?; + if entries.is_empty() { + return Ok(all); + } + all.extend(entries.iter().cloned()); + } + anyhow::bail!("Gitea pagination limit exceeded") + } +} + +impl GiteaService { + /// The instance this service talks to. Exposed so callers can prove the + /// configured self-hosted host actually threaded through. + pub fn base_url(&self) -> Result { + self.cli.base_url() + } +} + +#[async_trait] +impl PrService for GiteaService { + fn provider_name(&self) -> &'static str { + "gitea" + } + async fn check_available(&self) -> Result { + if !self.cli.available().await { + return Ok(false); + } + Ok(self.get_authenticated_user().await.is_ok()) + } + async fn get_authenticated_user(&self) -> Result { + let user = self.get("user").await?; + Ok(user["login"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Gitea user has no login"))? + .to_owned()) + } + async fn get_pr(&self, repo: &RepoInfo, number: i64) -> Result { + Ok(serde_json::from_value::( + self.get(&format!("{}/pulls/{number}", self.repo_path(repo)?)) + .await?, + )? + .into()) + } + async fn is_ready_to_merge(&self, repo: &RepoInfo, number: i64) -> Result { + let base = self.repo_path(repo)?; + let pr = self.get(&format!("{base}/pulls/{number}")).await?; + if pr["draft"].as_bool().unwrap_or(true) || pr["state"] != "open" || pr["mergeable"] != true + { + return Ok(false); + } + if self.get_review_state(repo, number).await? != PrReviewState::Approved { + return Ok(false); + } + let sha = pr["head"]["sha"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing PR head SHA"))?; + ensure!( + sha.chars().all(|c| c.is_ascii_hexdigit()), + "Invalid PR head SHA" + ); + Ok(self.get(&format!("{base}/commits/{sha}/status")).await?["state"] == "success") + } + async fn get_review_state(&self, repo: &RepoInfo, number: i64) -> Result { + let reviews = self + .pages(&format!("{}/pulls/{number}/reviews", self.repo_path(repo)?)) + .await?; + Ok(review_state(&reviews)) + } + async fn create_pr( + &self, + repo: &RepoInfo, + request: &CreatePrRequest, + cwd: &Path, + ) -> Result { + let result: Result = async { + if let Some(existing) = self.find_pr_for_branch(repo, &request.head_branch).await? { return Ok(existing); } + let title = if request.draft.unwrap_or(false) && !request.title.starts_with(&self.wip_prefix) { format!("{}{}", self.wip_prefix, request.title) } else { request.title.clone() }; + let body = json!({"title": title, "body": request.body, "head": request.head_branch, "base": request.base_branch}); + let endpoint = format!("{}/pulls", self.repo_path(repo)?); + match self.cli.request("POST", &endpoint, Some(&body), Some(cwd)).await { + Ok(value) => Ok(serde_json::from_value::(value)?.into()), + Err(error) => { + if let Ok(Some(existing)) = self.find_pr_for_branch(repo, &request.head_branch).await { return Ok(existing); } + Err(error) + } + } + }.await; + result.map_err(|e| CreatePrError::ProviderApiError { + message: e.to_string(), + }) + } + async fn list_prs_for_branch( + &self, + repo: &RepoInfo, + branch: &str, + ) -> Result> { + let prs = self + .pages(&format!("{}/pulls?state=all", self.repo_path(repo)?)) + .await?; + prs.into_iter() + .filter(|p| { + p["head"]["ref"] == branch && p["head"]["repo"]["full_name"] == repo.full_name() + }) + .map(|p| Ok(serde_json::from_value::(p)?.into())) + .collect() + } + async fn get_all_comments( + &self, + repo: &RepoInfo, + number: i64, + ) -> Result> { + let base = self.repo_path(repo)?; + let mut comments = self + .pages(&format!("{base}/issues/{number}/comments")) + .await? + .iter() + .map(|c| comment(c, false)) + .collect::>>()?; + for review in self + .pages(&format!("{base}/pulls/{number}/reviews")) + .await? + { + let id = review["id"] + .as_i64() + .ok_or_else(|| anyhow::anyhow!("Missing review ID"))?; + for inline in self + .pages(&format!("{base}/pulls/{number}/reviews/{id}/comments")) + .await? + { + comments.push(comment(&inline, true)?); + } + } + comments.sort_by_key(UnifiedPrComment::created_at); + Ok(comments) + } + async fn open_in_browser(&self, repo: &RepoInfo, number: i64) -> Result<()> { + let mut url = self.cli.base_url()?; + url.path_segments_mut() + .map_err(|()| anyhow::anyhow!("Invalid Gitea URL"))? + .extend([&repo.owner, &repo.repo_name, "pulls", &number.to_string()]); + #[cfg(target_os = "macos")] + let program = "open"; + #[cfg(not(target_os = "macos"))] + let program = "xdg-open"; + ensure!( + tokio::process::Command::new(program) + .arg(url.as_str()) + .status() + .await? + .success(), + "Could not open PR in browser" + ); + Ok(()) + } + async fn get_comments_since( + &self, + repo: &RepoInfo, + number: i64, + since: DateTime, + ) -> Result> { + Ok(self + .get_all_comments(repo, number) + .await? + .into_iter() + .filter(|c| c.created_at() > since) + .collect()) + } + async fn find_pr_for_branch( + &self, + repo: &RepoInfo, + branch: &str, + ) -> Result> { + Ok(self + .list_prs_for_branch(repo, branch) + .await? + .into_iter() + .find(|p| p.state == PrState::Open)) + } +} + +fn review_state(reviews: &[Value]) -> PrReviewState { + let mut latest = std::collections::BTreeMap::new(); + for review in reviews { + let id = review["id"].as_i64().unwrap_or_default(); + let user = review["user"]["login"].as_str().unwrap_or_default(); + let state = review["state"].as_str().unwrap_or_default(); + if !matches!( + state, + "APPROVED" | "REQUEST_CHANGES" | "COMMENT" | "DISMISSED" + ) { + continue; + } + let entry = latest.entry(user).or_insert((id, state)); + if id >= entry.0 { + *entry = (id, state); + } + } + if latest.values().any(|(_, s)| *s == "REQUEST_CHANGES") { + PrReviewState::ChangesRequested + } else if latest.values().any(|(_, s)| *s == "APPROVED") { + PrReviewState::Approved + } else if latest.values().any(|(_, s)| *s == "COMMENT") { + PrReviewState::Commented + } else { + PrReviewState::Pending + } +} + +fn comment(value: &Value, inline: bool) -> Result { + let id = value["id"] + .as_i64() + .ok_or_else(|| anyhow::anyhow!("Missing comment ID"))?; + let author = value["user"]["login"] + .as_str() + .unwrap_or("ghost") + .to_owned(); + let body = value["body"].as_str().unwrap_or_default().to_owned(); + let created_at = serde_json::from_value(value["created_at"].clone())?; + let url = value["html_url"].as_str().unwrap_or_default().to_owned(); + if inline { + Ok(UnifiedPrComment::Review { + id, + author, + author_association: "NONE".into(), + body, + created_at, + url, + path: value["path"].as_str().unwrap_or_default().to_owned(), + line: value["position"].as_i64(), + diff_hunk: value["diff_hunk"].as_str().unwrap_or_default().to_owned(), + }) + } else { + Ok(UnifiedPrComment::General { + id: id.to_string(), + author, + author_association: "NONE".into(), + body, + created_at, + url, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn merged_and_draft_are_preserved() { + let pr: Pull = serde_json::from_value(json!({"number": 2, "html_url": "https://gitea.example/a/b/pulls/2", "state": "closed", "merged": true, "draft": true})).unwrap(); + let info: PullRequestInfo = pr.into(); + assert_eq!(info.state, PrState::Merged); + assert!(info.is_draft); + } + #[test] + fn comments_have_explicit_unknown_association() { + let c = comment( + &json!({"id": 1,"user":{"login":"bot"},"created_at":"2026-01-01T00:00:00Z"}), + false, + ) + .unwrap(); + assert!( + matches!(c, UnifiedPrComment::General { author_association, .. } if author_association == "NONE") + ); + } + #[test] + fn latest_review_replaces_previous_decision() { + let reviews = json!([{"id": 2,"user":{"login":"a"},"state":"APPROVED"},{"id":1,"user":{"login":"a"},"state":"REQUEST_CHANGES"}]); + assert_eq!( + review_state(reviews.as_array().unwrap()), + PrReviewState::Approved + ); + } + #[test] + fn cli_contract() { + const CHILD: &str = "OPERATOR_GITEA_CONTRACT_CHILD"; + if std::env::var_os(CHILD).is_some() { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let service = GiteaService::new(GiteaConfig::default()); + let repo = RepoInfo::new(crate::types::pr::GitProvider::Gitea, "team", "repo"); + assert!(service.check_available().await.unwrap()); + assert_eq!(service.get_authenticated_user().await.unwrap(), "agent"); + assert_eq!(service.get_pr(&repo, 1).await.unwrap().number, 1); + assert!(service.is_ready_to_merge(&repo, 1).await.unwrap()); + assert_eq!( + service.get_review_state(&repo, 1).await.unwrap(), + PrReviewState::Approved + ); + assert!(service + .list_prs_for_branch(&repo, "new") + .await + .unwrap() + .is_empty()); + let request = CreatePrRequest { + title: "Change".into(), + body: Some("Description".into()), + head_branch: "new".into(), + base_branch: "main".into(), + draft: Some(true), + }; + assert_eq!( + service + .create_pr(&repo, &request, Path::new("/tmp")) + .await + .unwrap() + .number, + 1 + ); + assert_eq!(service.get_all_comments(&repo, 1).await.unwrap().len(), 1); + assert_eq!( + service + .get_comments_since(&repo, 1, "2025-01-01T00:00:00Z".parse().unwrap()) + .await + .unwrap() + .len(), + 1 + ); + assert!(service + .find_pr_for_branch(&repo, "new") + .await + .unwrap() + .is_none()); + }); + return; + } + let root = tempfile::tempdir().unwrap(); + let script = r#"#!/bin/sh +for arg do endpoint=$arg; done +[ "$endpoint" = --help ] && exit 0 +[ -f "$XDG_CONFIG_HOME/tea/config.yml" ] || exit 40 +case "$endpoint" in +user) printf '%s' '{"login":"agent"}';; +*page=2) printf '[]';; +*/reviews[?]*) printf '%s' '[{"id":1,"state":"APPROVED","user":{"login":"reviewer"}}]';; +*/issues/*/comments[?]*) printf '%s' '[{"id":1,"body":"review","user":{"login":"reviewer"},"created_at":"2026-01-01T00:00:00Z"}]';; +*/reviews/*/comments[?]*) printf '[]';; +*/status) printf '%s' '{"state":"success"}';; +*pulls[?]*) printf '[]';; +*/pulls) body=$(cat); case "$body" in *'WIP: Change'*) ;; *) exit 41;; esac +printf '%s' '{"number":1,"html_url":"https://gitea.com/team/repo/pulls/1","state":"open","draft":true}';; +*/pulls/1) printf '%s' '{"number":1,"html_url":"https://gitea.com/team/repo/pulls/1","state":"open","draft":false,"mergeable":true,"head":{"sha":"abc123"}}';; +*) exit 42;; +esac +"#; + crate::git::runtime::private_file(&root.path().join("tea"), script.as_bytes(), true) + .unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "api::gitea_service::tests::cli_contract", + "--nocapture", + ]) + .env(CHILD, "1") + .env("GITEA_TOKEN", "contract-secret") + .env( + "PATH", + format!( + "{}:{}", + root.path().display(), + std::env::var("PATH").unwrap_or_default() + ), + ) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("contract-secret")); + } +} diff --git a/src/api/glab_cli.rs b/src/api/glab_cli.rs index ea8c1608..58a2b682 100644 --- a/src/api/glab_cli.rs +++ b/src/api/glab_cli.rs @@ -11,6 +11,9 @@ use std::process::Stdio; use tokio::process::Command; use tracing::{debug, instrument}; +use crate::api::argv::ProviderCommand; +use crate::api::cli_detection::binary_for; +use crate::types::pr::GitProvider; use crate::types::pr::{ CreatePrError, CreatePrRequest, PrReviewState, PrState, PullRequestInfo, RepoInfo, UnifiedPrComment, @@ -24,13 +27,15 @@ impl GlabCli { async fn run_glab(args: &[&str], cwd: Option<&Path>) -> Result { debug!(?args, "Running glab command"); - let mut cmd = Command::new("glab"); + let mut cmd = Command::new(binary_for(GitProvider::GitLab)); cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); if let Some(dir) = cwd { cmd.current_dir(dir); } + let _git_runtime = crate::git::runtime::configure_command(&mut cmd)?; + let output = cmd .output() .await @@ -50,7 +55,7 @@ impl GlabCli { /// Check if glab CLI is installed pub async fn is_installed() -> bool { - Command::new("glab") + Command::new(binary_for(GitProvider::GitLab)) .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -75,6 +80,31 @@ impl GlabCli { Ok(user.username) } + /// Build the `glab mr create` invocation. Pure, so the flags are asserted without spawning `glab`. + pub fn create_pr_argv(repo_info: &RepoInfo, request: &CreatePrRequest) -> ProviderCommand { + let mut args = vec![ + "mr".to_string(), + "create".to_string(), + "--repo".to_string(), + repo_info.full_name(), + "--source-branch".to_string(), + request.head_branch.clone(), + "--target-branch".to_string(), + request.base_branch.clone(), + "--title".to_string(), + request.title.clone(), + ]; + if let Some(body) = &request.body { + args.push("--description".to_string()); + args.push(body.clone()); + } + if request.draft.unwrap_or(false) { + args.push("--draft".to_string()); + } + args.push("--yes".to_string()); + ProviderCommand::new(binary_for(GitProvider::GitLab), args) + } + /// Create an MR using glab CLI #[instrument(skip(request))] pub async fn create_pr( @@ -90,38 +120,10 @@ impl GlabCli { return Err(CreatePrError::ProviderCliNotLoggedIn); } - let repo_full_name = repo_info.full_name(); - let mut args = vec![ - "mr", - "create", - "--repo", - &repo_full_name, - "--source-branch", - &request.head_branch, - "--target-branch", - &request.base_branch, - "--title", - &request.title, - ]; - - let body_arg: String; - if let Some(ref body) = request.body { - body_arg = body.clone(); - args.push("--description"); - args.push(&body_arg); - } - - if request.draft.unwrap_or(false) { - args.push("--draft"); - } - - args.push("--yes"); - - let output = Self::run_glab(&args, Some(cwd)).await.map_err(|e| { - CreatePrError::ProviderApiError { - message: e.to_string(), - } - })?; + let command = Self::create_pr_argv(repo_info, request); + let output = Self::run_glab(&command.arg_refs(), Some(cwd)) + .await + .map_err(|e| classify_create_error(&e.to_string(), request))?; let mr_number = extract_mr_number(&output).ok_or_else(|| CreatePrError::ProviderApiError { @@ -427,10 +429,107 @@ fn extract_mr_number(output: &str) -> Option { None } +/// Map a failed `glab mr create` to a structured error, matching the GitHub +/// path. Previously every GitLab failure collapsed into `ProviderApiError`. +fn classify_create_error(err: &str, request: &CreatePrRequest) -> CreatePrError { + let lower = err.to_lowercase(); + if lower.contains("already exists") || lower.contains("open merge request") { + if let Some(pr_number) = extract_mr_number(err) { + return CreatePrError::PrAlreadyExists { + pr_number, + url: err.to_string(), + }; + } + } + if lower.contains("not pushed") + || lower.contains("has no commits") + || lower.contains("no commits between") + { + return CreatePrError::BranchNotPushed { + branch: request.head_branch.clone(), + }; + } + if lower.contains("not found") && err.contains(&request.base_branch) { + return CreatePrError::TargetBranchNotFound { + branch: request.base_branch.clone(), + }; + } + CreatePrError::ProviderApiError { + message: err.to_string(), + } +} + #[cfg(test)] mod tests { use super::*; + fn request() -> CreatePrRequest { + CreatePrRequest { + title: "Add widget".into(), + body: Some("Body text".into()), + head_branch: "feat/widget".into(), + base_branch: "main".into(), + draft: Some(true), + } + } + + #[test] + fn create_pr_argv_is_the_documented_glab_contract() { + let repo = RepoInfo::new(GitProvider::GitLab, "group/sub", "repo"); + let cmd = GlabCli::create_pr_argv(&repo, &request()); + assert_eq!(cmd.program, "glab"); + assert_eq!( + cmd.args, + [ + "mr", + "create", + "--repo", + "group/sub/repo", + "--source-branch", + "feat/widget", + "--target-branch", + "main", + "--title", + "Add widget", + "--description", + "Body text", + "--draft", + "--yes", + ] + ); + } + + /// GitLab does not take GitHub's flag names; mixing them up is silent + /// until a live MR is attempted. + #[test] + fn create_pr_argv_does_not_borrow_github_flag_names() { + let repo = RepoInfo::new(GitProvider::GitLab, "owner", "repo"); + let flags = GlabCli::create_pr_argv(&repo, &request()); + for github_only in ["--head", "--base", "--body", "--json"] { + assert!( + !flags.long_flags().contains(&github_only), + "{github_only} is a gh flag, not a glab flag" + ); + } + } + + #[test] + fn create_failures_map_to_structured_errors() { + let req = request(); + assert!(matches!( + classify_create_error("no commits between main and feat/widget", &req), + CreatePrError::BranchNotPushed { .. } + )); + assert!(matches!( + classify_create_error("an open merge request already exists: !42", &req), + CreatePrError::PrAlreadyExists { pr_number: 42, .. } + )); + assert!(matches!( + classify_create_error("something else broke", &req), + CreatePrError::ProviderApiError { .. } + )); + } + #[tokio::test] async fn test_is_installed() { // This test just verifies the function doesn't panic diff --git a/src/api/mod.rs b/src/api/mod.rs index fc6b8edc..b9376d18 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -8,21 +8,25 @@ //! - Capabilities system for capability-based feature enablement //! - Error handling with auth failure tracking +pub mod argv; pub mod cli_detection; pub mod error; pub mod gh_cli; +pub mod gitea_service; pub mod github_service; pub mod gitlab_service; pub mod glab_cli; pub mod kanban_sync; pub mod pr_service; pub mod providers; +pub mod tea_cli; // Legacy modules (kept for backward compatibility during migration) pub mod anthropic; pub mod github; // Re-export commonly used types from providers +pub use argv::ProviderCommand; pub use error::ApiError; pub use providers::ai::{AiProvider, AnthropicProvider, RateLimitInfo}; pub use providers::repo::{GitHubProvider, IssueStatus, PrStatus, RepoProvider}; diff --git a/src/api/pr_service.rs b/src/api/pr_service.rs index bc88f194..507f7f37 100644 --- a/src/api/pr_service.rs +++ b/src/api/pr_service.rs @@ -250,21 +250,26 @@ pub struct UnsupportedProviderError { provider: GitProvider, } -/// Build the `PrService` for a given provider. +/// Build the `PrService` for a given provider, against `git`. /// -/// GitHub and GitLab are operational; Bitbucket, Azure DevOps, Forgejo, and -/// Gitea are detect-only today (see `GitProvider::ALL`) and return -/// `UnsupportedProviderError` until their CLI stacks are implemented. +/// Takes the config rather than defaulting: a defaulted `GiteaConfig` has no +/// host, which silently pointed self-hosted instances at gitea.com. +/// +/// GitHub, GitLab, and Gitea are operational; Bitbucket, Azure DevOps, and +/// Forgejo return `UnsupportedProviderError` until their CLI stacks exist. pub fn pr_service_for( provider: GitProvider, + git: &crate::config::GitConfig, ) -> Result, UnsupportedProviderError> { match provider { GitProvider::GitHub => Ok(Arc::new(GitHubService::new())), GitProvider::GitLab => Ok(Arc::new(GitLabService::new())), - GitProvider::Bitbucket - | GitProvider::AzureDevOps - | GitProvider::Forgejo - | GitProvider::Gitea => Err(UnsupportedProviderError { provider }), + GitProvider::Gitea => Ok(Arc::new(crate::api::gitea_service::GiteaService::new( + git.gitea.clone(), + ))), + GitProvider::Bitbucket | GitProvider::AzureDevOps | GitProvider::Forgejo => { + Err(UnsupportedProviderError { provider }) + } } } @@ -282,20 +287,47 @@ type Resolver = /// `"auto"` so callers can tell it's the router rather than a concrete /// provider. pub struct PrServiceRouter { + default_provider: GitProvider, resolve: Resolver, } -impl Default for PrServiceRouter { - fn default() -> Self { - Self::new() - } -} - impl PrServiceRouter { - /// Create a new provider-routing PR service, backed by `pr_service_for` - pub fn new() -> Self { + pub fn with_config( + config: &crate::config::Config, + context: Option, + ) -> Self { + let git = config.git.clone(); Self { - resolve: Box::new(pr_service_for), + default_provider: git.provider.clone().map(Into::into).unwrap_or_default(), + resolve: Box::new(move |provider| { + let service = pr_service_for(provider, &git)?; + let auth = match provider { + GitProvider::GitHub => Some(crate::git::runtime::ProviderAuth { + provider, + token_env: if git.github.token_env.is_empty() { + "GITHUB_TOKEN".into() + } else { + git.github.token_env.clone() + }, + host: None, + }), + GitProvider::GitLab => Some(crate::git::runtime::ProviderAuth { + provider, + token_env: if git.gitlab.token_env.is_empty() { + "GITLAB_TOKEN".into() + } else { + git.gitlab.token_env.clone() + }, + host: git.gitlab.host.clone(), + }), + _ => None, + }; + Ok(Arc::new(ScopedPrService { + inner: service, + context: context.clone(), + auth, + })) + }), } } @@ -308,6 +340,7 @@ impl PrServiceRouter { + 'static, ) -> Self { Self { + default_provider: GitProvider::GitHub, resolve: Box::new(resolve), } } @@ -320,11 +353,15 @@ impl PrService for PrServiceRouter { } async fn check_available(&self) -> Result { - GitHubService::new().check_available().await + (self.resolve)(self.default_provider)? + .check_available() + .await } async fn get_authenticated_user(&self) -> Result { - GitHubService::new().get_authenticated_user().await + (self.resolve)(self.default_provider)? + .get_authenticated_user() + .await } async fn get_pr(&self, repo_info: &RepoInfo, pr_number: i64) -> Result { @@ -410,12 +447,185 @@ impl PrService for PrServiceRouter { } } +struct ScopedPrService { + auth: Option, + inner: Arc, + context: Option, +} + +#[async_trait] +impl PrService for ScopedPrService { + fn provider_name(&self) -> &str { + self.inner.provider_name() + } + async fn check_available(&self) -> Result { + crate::git::runtime::auth_scope( + self.auth.clone(), + crate::git::runtime::scope( + self.context.clone().or_else(crate::git::runtime::current), + self.inner.check_available(), + ), + ) + .await + } + async fn get_authenticated_user(&self) -> Result { + crate::git::runtime::auth_scope( + self.auth.clone(), + crate::git::runtime::scope( + self.context.clone().or_else(crate::git::runtime::current), + self.inner.get_authenticated_user(), + ), + ) + .await + } + async fn get_pr(&self, repo: &RepoInfo, number: i64) -> Result { + crate::git::runtime::auth_scope( + self.auth.clone(), + crate::git::runtime::scope( + self.context.clone().or_else(crate::git::runtime::current), + self.inner.get_pr(repo, number), + ), + ) + .await + } + async fn is_ready_to_merge(&self, repo: &RepoInfo, number: i64) -> Result { + crate::git::runtime::auth_scope( + self.auth.clone(), + crate::git::runtime::scope( + self.context.clone().or_else(crate::git::runtime::current), + self.inner.is_ready_to_merge(repo, number), + ), + ) + .await + } + async fn get_review_state(&self, repo: &RepoInfo, number: i64) -> Result { + crate::git::runtime::auth_scope( + self.auth.clone(), + crate::git::runtime::scope( + self.context.clone().or_else(crate::git::runtime::current), + self.inner.get_review_state(repo, number), + ), + ) + .await + } + async fn create_pr( + &self, + repo: &RepoInfo, + request: &CreatePrRequest, + cwd: &Path, + ) -> Result { + crate::git::runtime::auth_scope( + self.auth.clone(), + crate::git::runtime::scope( + self.context.clone().or_else(crate::git::runtime::current), + self.inner.create_pr(repo, request, cwd), + ), + ) + .await + } + async fn list_prs_for_branch( + &self, + repo: &RepoInfo, + branch: &str, + ) -> Result> { + crate::git::runtime::auth_scope( + self.auth.clone(), + crate::git::runtime::scope( + self.context.clone().or_else(crate::git::runtime::current), + self.inner.list_prs_for_branch(repo, branch), + ), + ) + .await + } + async fn get_all_comments( + &self, + repo: &RepoInfo, + number: i64, + ) -> Result> { + crate::git::runtime::auth_scope( + self.auth.clone(), + crate::git::runtime::scope( + self.context.clone().or_else(crate::git::runtime::current), + self.inner.get_all_comments(repo, number), + ), + ) + .await + } + async fn open_in_browser(&self, repo: &RepoInfo, number: i64) -> Result<()> { + crate::git::runtime::auth_scope( + self.auth.clone(), + crate::git::runtime::scope( + self.context.clone().or_else(crate::git::runtime::current), + self.inner.open_in_browser(repo, number), + ), + ) + .await + } + async fn get_comments_since( + &self, + repo: &RepoInfo, + number: i64, + since: chrono::DateTime, + ) -> Result> { + crate::git::runtime::auth_scope( + self.auth.clone(), + crate::git::runtime::scope( + self.context.clone().or_else(crate::git::runtime::current), + self.inner.get_comments_since(repo, number, since), + ), + ) + .await + } + async fn find_pr_for_branch( + &self, + repo: &RepoInfo, + branch: &str, + ) -> Result> { + crate::git::runtime::auth_scope( + self.auth.clone(), + crate::git::runtime::scope( + self.context.clone().or_else(crate::git::runtime::current), + self.inner.find_pr_for_branch(repo, branch), + ), + ) + .await + } +} + #[cfg(test)] mod tests { use super::*; use crate::types::pr::PrState; use std::sync::atomic::{AtomicUsize, Ordering}; + fn git() -> crate::config::GitConfig { + crate::config::GitConfig::default() + } + + /// A defaulted `GiteaConfig` has no host and resolves to gitea.com, so the + /// factory must be handed the real config or a self-hosted instance is + /// silently wrong. + #[test] + fn gitea_service_honors_the_configured_self_hosted_host() { + let mut config = git(); + config.gitea.host = Some("https://git.example.org".into()); + let service = pr_service_for(GitProvider::Gitea, &config).expect("gitea is operational"); + assert_eq!(service.provider_name(), "gitea"); + + let direct = crate::api::gitea_service::GiteaService::new(config.gitea.clone()); + assert_eq!( + direct.base_url().unwrap().as_str(), + "https://git.example.org/" + ); + assert_eq!( + crate::api::gitea_service::GiteaService::new(git().gitea) + .base_url() + .unwrap() + .host_str(), + Some("gitea.com") + ); + } + #[test] fn test_github_service_provider_name() { let service = GitHubService::new(); @@ -430,19 +640,19 @@ mod tests { #[test] fn test_pr_service_for_github() { - let service = pr_service_for(GitProvider::GitHub).unwrap(); + let service = pr_service_for(GitProvider::GitHub, &git()).unwrap(); assert_eq!(service.provider_name(), "github"); } #[test] fn test_pr_service_for_gitlab() { - let service = pr_service_for(GitProvider::GitLab).unwrap(); + let service = pr_service_for(GitProvider::GitLab, &git()).unwrap(); assert_eq!(service.provider_name(), "gitlab"); } #[test] fn test_pr_service_for_unsupported_provider_errors() { - let result = pr_service_for(GitProvider::Bitbucket); + let result = pr_service_for(GitProvider::Bitbucket, &git()); let message = match result { Ok(_) => panic!("expected UnsupportedProviderError for Bitbucket"), Err(e) => e.to_string(), @@ -457,15 +667,14 @@ mod tests { GitProvider::Bitbucket, GitProvider::AzureDevOps, GitProvider::Forgejo, - GitProvider::Gitea, ] { - assert!(pr_service_for(provider).is_err()); + assert!(pr_service_for(provider, &git()).is_err()); } } #[test] fn test_router_provider_name_is_auto() { - let router = PrServiceRouter::new(); + let router = PrServiceRouter::with_config(&crate::config::Config::default(), None); assert_eq!(router.provider_name(), "auto"); } diff --git a/src/api/providers/model_server/probe.rs b/src/api/providers/model_server/probe.rs index 16af0058..5975db74 100644 --- a/src/api/providers/model_server/probe.rs +++ b/src/api/providers/model_server/probe.rs @@ -8,6 +8,7 @@ //! Parsing is split out from the HTTP call ([`parse_models`]) so the per-protocol //! response shapes can be unit-tested without a live server. +use crate::auth::egress::EgressPolicy; use std::time::Duration; use serde_json::Value; @@ -66,14 +67,17 @@ impl ProbeOutcome { /// Probe a server for its model list. Never panics; returns a [`ProbeOutcome`] /// summarizing reachability so callers can render status without handling errors. -pub async fn probe_models(server: &ModelServer) -> ProbeOutcome { - match probe_models_inner(server).await { +pub async fn probe_models(server: &ModelServer, policy: &EgressPolicy) -> ProbeOutcome { + match probe_models_inner(server, policy).await { Ok(models) => ProbeOutcome::ok(models), Err(err) => ProbeOutcome::failed(&err), } } -async fn probe_models_inner(server: &ModelServer) -> Result, ProbeError> { +async fn probe_models_inner( + server: &ModelServer, + policy: &EgressPolicy, +) -> Result, ProbeError> { let kind = ModelServerKind::from_slug(&server.kind) .ok_or_else(|| ProbeError::UnknownKind(server.kind.clone()))?; @@ -98,10 +102,13 @@ async fn probe_models_inner(server: &ModelServer) -> Result, Prob .and_then(|var| std::env::var(var).ok()) .filter(|k| !k.is_empty()); - let client = reqwest::Client::builder() - .user_agent("operator-tui") - .timeout(Duration::from_secs(5)) - .build() + // 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 + // redirect hop is what stops a caller-supplied `base_url` from turning this + // into a credentialed request against the cloud metadata endpoint. + crate::auth::egress::validate(&url, policy).map_err(|e| ProbeError::Network(e.to_string()))?; + + let client = crate::auth::egress::validated_client(policy.clone(), Duration::from_secs(5)) .map_err(|e| ProbeError::Network(e.to_string()))?; let mut req = client.get(&url); @@ -504,7 +511,7 @@ mod tests { extra_env: std::collections::HashMap::new(), display_name: None, }; - let outcome = probe_models(&server).await; + let outcome = probe_models(&server, &EgressPolicy::default()).await; assert!(!outcome.reachable); assert!(outcome.error.is_some()); } diff --git a/src/api/tea_cli.rs b/src/api/tea_cli.rs new file mode 100644 index 00000000..034ff198 --- /dev/null +++ b/src/api/tea_cli.rs @@ -0,0 +1,125 @@ +use crate::api::cli_detection::binary_for; +use crate::config::{GitCredentialConfig, GitExecutionConfig, GiteaConfig}; +use crate::git::runtime::{self, GitRuntime}; +use crate::types::pr::{provider_base_url, GitProvider}; +use anyhow::{ensure, Context, Result}; +use serde_json::Value; +use std::{path::Path, process::Stdio, time::Duration}; +use tokio::{io::AsyncWriteExt, process::Command}; + +pub struct TeaCli { + config: GiteaConfig, +} + +impl TeaCli { + pub fn new(config: GiteaConfig) -> Self { + Self { config } + } + + pub fn base_url(&self) -> Result { + provider_base_url(self.config.host.as_deref(), "gitea.com") + } + + pub async fn available(&self) -> bool { + Command::new(binary_for(GitProvider::Gitea)) + .args(["api", "--help"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .status() + .await + .is_ok_and(|s| s.success()) + } + + pub async fn request( + &self, + method: &str, + endpoint: &str, + body: Option<&Value>, + cwd: Option<&Path>, + ) -> Result { + ensure!( + !endpoint.contains("://") && !endpoint.starts_with("//"), + "Tea endpoint must be relative" + ); + let base = self.base_url()?; + let selected = runtime::current().and_then(|c| c.credentials); + if let Some(credentials) = &selected { + let destination = crate::git::identity::credential_url(&credentials.repository_url)?; + ensure!( + destination.origin() == base.origin(), + "Delegated Git credential does not match Gitea host" + ); + if let Some(path) = endpoint.strip_prefix("repos/") { + let repo = path + .split('?') + .next() + .unwrap_or(path) + .split('/') + .take(2) + .collect::>() + .join("/"); + ensure!( + destination + .path() + .trim_matches('/') + .trim_end_matches(".git") + == repo, + "Delegated credential does not match Gitea repository" + ); + } + } + let credentials = selected.unwrap_or(GitCredentialConfig { + repository_url: base.join("operator/authentication")?.to_string(), + username: "operator".into(), + token_env: self.config.token_env.clone(), + }); + let private = GitRuntime::create(&GitExecutionConfig { + credentials: Some(credentials), + ..Default::default() + })?; + let mut command = Command::new(binary_for(GitProvider::Gitea)); + command.args(["api", "--login", "operator", "--method", method]); + if body.is_some() { + command.args(["--data", "@-"]); + } + command + .arg(endpoint) + .env("XDG_CONFIG_HOME", &private.path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + let mut child = command + .spawn() + .context("Gitea requires tea with the api command on PATH")?; + if let Some(mut input) = child.stdin.take() { + if let Some(body) = body { + input.write_all(&serde_json::to_vec(body)?).await?; + } + } + let output = tokio::time::timeout(Duration::from_secs(30), child.wait_with_output()) + .await + .context("Gitea CLI request timed out")??; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("401") || stderr.contains("403") { + anyhow::bail!("Gitea authentication or repository permission denied"); + } + if stderr.contains("429") + || stderr.contains("502") + || stderr.contains("503") + || stderr.contains("504") + { + anyhow::bail!("Gitea transient server failure"); + } + anyhow::bail!( + "Gitea CLI request failed; check endpoint permissions and tea API support" + ); + } + serde_json::from_slice(&output.stdout).context("Gitea CLI returned invalid JSON") + } +} diff --git a/src/app/git_onboarding.rs b/src/app/git_onboarding.rs index 58238c1f..0a536049 100644 --- a/src/app/git_onboarding.rs +++ b/src/app/git_onboarding.rs @@ -7,44 +7,9 @@ use std::process::{Command, Stdio}; use anyhow::{Context, Result}; +use crate::api::cli_detection::onboarding_spec_for_slug; use crate::config::{Config, GitProviderConfig}; -/// Per-provider constants for onboarding. -struct ProviderMeta { - cli_command: &'static str, - cli_auth_args: &'static [&'static str], - cli_install_url: &'static str, - pat_url: &'static str, - display_name: &'static str, - placeholder: &'static str, -} - -const GITHUB: ProviderMeta = ProviderMeta { - cli_command: "gh", - cli_auth_args: &["auth", "token"], - cli_install_url: "https://cli.github.com/", - pat_url: "https://github.com/settings/personal-access-tokens/new", - display_name: "GitHub", - placeholder: "ghp_...", -}; - -const GITLAB: ProviderMeta = ProviderMeta { - cli_command: "glab", - cli_auth_args: &["auth", "token"], - cli_install_url: "https://docs.gitlab.com/cli", - pat_url: "https://gitlab.com/-/user_settings/personal_access_tokens", - display_name: "GitLab", - placeholder: "glpat-...", -}; - -fn meta_for(provider: &str) -> Option<&'static ProviderMeta> { - match provider { - "github" => Some(&GITHUB), - "gitlab" => Some(&GITLAB), - _ => None, - } -} - /// The resolved onboarding step for a provider. #[derive(Debug)] pub enum OnboardingStep { @@ -147,16 +112,19 @@ pub fn validate_gitlab_token(token: &str) -> Result { /// /// Checks CLI installation → CLI authentication → returns the appropriate step. pub fn resolve_onboarding(provider: &str) -> Option { - let meta = meta_for(provider)?; + let meta = onboarding_spec_for_slug(provider)?; - if !is_cli_installed(meta.cli_command) { + if !is_cli_installed(meta.command) { return Some(OnboardingStep::InstallCli { - install_url: meta.cli_install_url.to_string(), + install_url: meta.install_url.to_string(), provider_display: meta.display_name.to_string(), }); } - if let Some(token) = grab_cli_token(meta.cli_command, meta.cli_auth_args) { + if let Some(token) = (!meta.auth_args.is_empty()) + .then(|| grab_cli_token(meta.command, meta.auth_args)) + .flatten() + { // Validate the token let username = match provider { "github" => validate_github_token(&token), @@ -198,6 +166,12 @@ pub fn complete_git_onboarding(config: &mut Config, provider: &str, token: &str) config.save()?; std::env::set_var(&config.git.gitlab.token_env, token); } + "gitea" => { + config.git.provider = Some(GitProviderConfig::Gitea); + config.git.gitea.enabled = true; + config.save()?; + std::env::set_var(&config.git.gitea.token_env, token); + } _ => anyhow::bail!("Unsupported provider: {provider}"), } Ok(()) @@ -212,14 +186,56 @@ pub fn validate_token(provider: &str, token: &str) -> Result { } } +pub fn resolve_onboarding_with_config(config: &Config, provider: &str) -> Option { + let mut step = resolve_onboarding(provider)?; + if provider == "gitea" { + let base = + crate::types::pr::provider_base_url(config.git.gitea.host.as_deref(), "gitea.com") + .ok()?; + if let OnboardingStep::CollectToken { pat_url, .. } = &mut step { + *pat_url = base.join("user/settings/applications").ok()?.to_string(); + } + } + Some(step) +} + +pub fn validate_token_with_config(config: &Config, provider: &str, token: &str) -> Result { + if provider != "gitea" { + return validate_token(provider, token); + } + let base = crate::types::pr::provider_base_url(config.git.gitea.host.as_deref(), "gitea.com")?; + let git = crate::config::GitExecutionConfig { + credentials: Some(crate::config::GitCredentialConfig { + repository_url: base.join("operator/authentication")?.to_string(), + username: "operator".into(), + token_env: config.git.gitea.token_env.clone(), + }), + ..Default::default() + }; + let runtime = crate::git::runtime::GitRuntime::create_with_token(&git, Some(token))?; + let output = Command::new(crate::api::cli_detection::binary_for( + crate::types::pr::GitProvider::Gitea, + )) + .args(["api", "--login", "operator", "user"]) + .env("XDG_CONFIG_HOME", &runtime.path) + .output() + .context("Gitea requires tea with the api command")?; + anyhow::ensure!(output.status.success(), "Gitea token validation failed"); + let body: serde_json::Value = serde_json::from_slice(&output.stdout)?; + body["login"] + .as_str() + .map(str::to_owned) + .context("Gitea response missing login") +} + #[cfg(test)] mod tests { use super::*; #[test] fn test_meta_for_github() { - let meta = meta_for("github").unwrap(); - assert_eq!(meta.cli_command, "gh"); + let meta = onboarding_spec_for_slug("github").unwrap(); + assert_eq!(meta.command, "gh"); assert_eq!(meta.display_name, "GitHub"); assert_eq!( meta.pat_url, @@ -229,8 +245,8 @@ mod tests { #[test] fn test_meta_for_gitlab() { - let meta = meta_for("gitlab").unwrap(); - assert_eq!(meta.cli_command, "glab"); + let meta = onboarding_spec_for_slug("gitlab").unwrap(); + assert_eq!(meta.command, "glab"); assert_eq!(meta.display_name, "GitLab"); assert_eq!( meta.pat_url, @@ -240,8 +256,8 @@ mod tests { #[test] fn test_meta_for_unknown_returns_none() { - assert!(meta_for("bitbucket").is_none()); - assert!(meta_for("").is_none()); + assert!(onboarding_spec_for_slug("bitbucket").is_none()); + assert!(onboarding_spec_for_slug("").is_none()); } #[test] diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index 99cca30f..c156c872 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -19,6 +19,27 @@ impl App { // Setup screen takes absolute priority if let Some(ref mut setup) = self.setup_screen { + // The password step needs raw characters, and the wizard bindings + // below would eat them: `i` runs initialize_tickets() outright, + // `c` quits the app, and `j`/`k`/space navigate. Route text keys to + // the field first and leave only Enter/Esc/Tab to the shared + // handling. Mirrors the git-token dialog's routing further down. + if setup.step == crate::ui::setup::SetupStep::AdminPassword + && matches!( + code, + KeyCode::Char(_) + | KeyCode::Backspace + | KeyCode::Delete + | KeyCode::Left + | KeyCode::Right + | KeyCode::Home + | KeyCode::End + ) + { + setup.handle_password_key(code); + return Ok(()); + } + match code { KeyCode::Char('i' | 'I') if setup.confirm_selected => { self.initialize_tickets()?; @@ -315,7 +336,11 @@ impl App { } else { let provider = self.git_token_dialog.provider.clone(); let provider_display = self.git_token_dialog.provider_display.clone(); - match git_onboarding::validate_token(&provider, &token) { + match git_onboarding::validate_token_with_config( + &self.config, + &provider, + &token, + ) { Ok(username) => { match git_onboarding::complete_git_onboarding( &mut self.config, diff --git a/src/app/mod.rs b/src/app/mod.rs index 44be2041..b7634756 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -176,11 +176,21 @@ impl App { }) .collect(); - let setup = SetupScreen::new( + let mut setup = SetupScreen::new( tickets_path.to_string_lossy().to_string(), detected_tools, projects_by_tool, ); + // Set after construction so `SetupScreen::new`'s signature stays + // stable for its other callers and tests. A store that cannot be + // opened is treated as "not configured". + setup.admin_password_configured = + crate::auth::store::AuthStore::open(&config.state_path()) + .and_then(|store| store.bootstrap_state()) + .is_ok_and(|state| { + state != crate::rest::dto::auth::BootstrapState::Uninitialized + }); + // Projects will be saved to config during initialize_tickets() (Some(setup), discovered_projects) } else { @@ -239,9 +249,14 @@ impl App { let (version_tx, version_rx) = mpsc::unbounded_channel(); // Create PR monitor service and get shared access to tracked PRs - let mut pr_monitor = PrMonitorService::new(pr_event_tx) - .with_poll_interval(Duration::from_secs(config.api.pr_check_interval_secs)) - .with_shutdown(pr_shutdown_rx); + let mut pr_monitor = PrMonitorService::with_service( + Arc::new(crate::api::pr_service::PrServiceRouter::with_config( + &config, None, + )), + pr_event_tx, + ) + .with_poll_interval(Duration::from_secs(config.api.pr_check_interval_secs)) + .with_shutdown(pr_shutdown_rx); let pr_tracked = pr_monitor.tracked_prs(); // Spawn PR monitor as background task diff --git a/src/app/pr_workflow.rs b/src/app/pr_workflow.rs index 8cd90b98..def7c124 100644 --- a/src/app/pr_workflow.rs +++ b/src/app/pr_workflow.rs @@ -3,7 +3,7 @@ use anyhow::Result; use crate::agents::PrWorkflow; use crate::notifications::NotificationEvent; use crate::queue::Queue; -use crate::services::{PrStatusEvent, TrackedPr}; +use crate::services::{PrMonitorService, PrStatusEvent, TrackedPr}; use crate::state::State; use super::App; @@ -216,7 +216,7 @@ impl App { let base_branch = ticket.branch.as_deref().unwrap_or("main"); // Create PR via PrWorkflow - let workflow = PrWorkflow::new(); + let workflow = PrWorkflow::with_config(&self.config, agent.git_context.clone())?; let pr_title = format!("{}: {}", ticket.ticket_type, ticket.summary); let pr_body = Some(ticket.content.clone()); @@ -281,8 +281,9 @@ impl App { } // Add PR to tracking - let key = format!("{}#{}", repo_info.full_name(), pr.number); + let key = PrMonitorService::pr_key(&repo_info, pr.number); let tracked_pr = TrackedPr { + git_context: agent.git_context.clone(), repo_info: repo_info.clone(), pr_number: pr.number, last_state: crate::types::pr::PrState::Open, diff --git a/src/app/review.rs b/src/app/review.rs index 4f3c433d..04ef917a 100644 --- a/src/app/review.rs +++ b/src/app/review.rs @@ -7,7 +7,7 @@ use super::App; impl App { /// Handle review approval for the selected agent /// - /// Only works for agents in `awaiting_input` with a `review_state` of `pending_plan` or `pending_visual`. + /// Only works for agents in `awaiting_input` with a `review_state` of `pending_plan`, `pending_visual`, or `pending_proof`. /// Creates a signal file to trigger resume in the next sync cycle. pub(super) fn handle_review_approval(&mut self) -> Result<()> { // Only works when in-progress panel is focused @@ -21,7 +21,9 @@ impl App { }; // Only process if agent has a review state that can be approved - if let Some("pending_plan" | "pending_visual") = agent.review_state.as_deref() { + if let Some("pending_plan" | "pending_visual" | "pending_proof") = + agent.review_state.as_deref() + { // Write signal file to trigger resume if let Some(ref session_name) = agent.session_name { let signal_file = format!("/tmp/operator-detach-{session_name}.signal"); @@ -43,7 +45,7 @@ impl App { /// Handle review rejection for the selected agent /// - /// Only works for agents in `awaiting_input` with a `review_state` of `pending_plan` or `pending_visual`. + /// Only works for agents in `awaiting_input` with a `review_state` of `pending_plan`, `pending_visual`, or `pending_proof`. /// For now, this just logs the rejection. A full implementation would show a dialog /// for entering a rejection reason and possibly restart the step. pub(super) fn handle_review_rejection(&mut self) -> Result<()> { @@ -58,7 +60,9 @@ impl App { }; // Only process if agent has a review state that can be rejected - if let Some("pending_plan" | "pending_visual") = agent.review_state.as_deref() { + if let Some("pending_plan" | "pending_visual" | "pending_proof") = + agent.review_state.as_deref() + { // TODO: Show rejection dialog for entering reason // For now, just log the rejection tracing::info!( diff --git a/src/app/status_actions.rs b/src/app/status_actions.rs index 6c0e9cec..3c87c63a 100644 --- a/src/app/status_actions.rs +++ b/src/app/status_actions.rs @@ -164,7 +164,7 @@ impl App { } } StatusAction::ConfigureGitProvider { provider } => { - match git_onboarding::resolve_onboarding(&provider) { + match git_onboarding::resolve_onboarding_with_config(&self.config, &provider) { Some(git_onboarding::OnboardingStep::InstallCli { install_url, provider_display, diff --git a/src/app/tests.rs b/src/app/tests.rs index 2c02da90..558c3c1b 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -538,7 +538,10 @@ mod review_signals { // Test the condition check without full App let review_state: Option<&str> = Some("pending_plan"); - let can_approve = matches!(review_state, Some("pending_plan" | "pending_visual")); + let can_approve = matches!( + review_state, + Some("pending_plan" | "pending_visual" | "pending_proof") + ); assert!(can_approve); } @@ -548,16 +551,35 @@ mod review_signals { // Symmetric test for the pending_visual match arm let review_state: Option<&str> = Some("pending_visual"); - let can_approve = matches!(review_state, Some("pending_plan" | "pending_visual")); + let can_approve = matches!( + review_state, + Some("pending_plan" | "pending_visual" | "pending_proof") + ); assert!(can_approve, "pending_visual should also be approvable"); } + #[test] + fn test_review_approval_pending_proof() { + // Symmetric test for the pending_proof match arm + let review_state: Option<&str> = Some("pending_proof"); + + let can_approve = matches!( + review_state, + Some("pending_plan" | "pending_visual" | "pending_proof") + ); + + assert!(can_approve, "pending_proof should also be approvable"); + } + #[test] fn test_review_approval_blocked_for_other_states() { let review_state: Option<&str> = Some("running"); - let can_approve = matches!(review_state, Some("pending_plan" | "pending_visual")); + let can_approve = matches!( + review_state, + Some("pending_plan" | "pending_visual" | "pending_proof") + ); assert!(!can_approve); } @@ -567,7 +589,10 @@ mod review_signals { // Mirrors approval tests but for rejection path — same guard logic applies let review_state: Option<&str> = Some("running"); - let can_reject = matches!(review_state, Some("pending_plan" | "pending_visual")); + let can_reject = matches!( + review_state, + Some("pending_plan" | "pending_visual" | "pending_proof") + ); assert!( !can_reject, @@ -576,7 +601,10 @@ mod review_signals { // Also verify None is blocked let review_state: Option<&str> = None; - let can_reject = matches!(review_state, Some("pending_plan" | "pending_visual")); + let can_reject = matches!( + review_state, + Some("pending_plan" | "pending_visual" | "pending_proof") + ); assert!( !can_reject, "Rejection should be blocked when no review state" @@ -584,19 +612,35 @@ mod review_signals { // And verify pending states ARE rejectable let review_state: Option<&str> = Some("pending_plan"); - let can_reject = matches!(review_state, Some("pending_plan" | "pending_visual")); + let can_reject = matches!( + review_state, + Some("pending_plan" | "pending_visual" | "pending_proof") + ); assert!(can_reject, "pending_plan should be rejectable"); let review_state: Option<&str> = Some("pending_visual"); - let can_reject = matches!(review_state, Some("pending_plan" | "pending_visual")); + let can_reject = matches!( + review_state, + Some("pending_plan" | "pending_visual" | "pending_proof") + ); assert!(can_reject, "pending_visual should be rejectable"); + + let review_state: Option<&str> = Some("pending_proof"); + let can_reject = matches!( + review_state, + Some("pending_plan" | "pending_visual" | "pending_proof") + ); + assert!(can_reject, "pending_proof should be rejectable"); } #[test] fn test_review_approval_blocked_for_none() { let review_state: Option<&str> = None; - let can_approve = matches!(review_state, Some("pending_plan" | "pending_visual")); + let can_approve = matches!( + review_state, + Some("pending_plan" | "pending_visual" | "pending_proof") + ); assert!(!can_approve); } diff --git a/src/app/tickets.rs b/src/app/tickets.rs index 3e708c87..8fc494bf 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -3,6 +3,7 @@ use std::fs; use crate::agents::{generate_status_script, generate_tmux_conf}; use crate::agents::{AgentTicketCreator, AssessTicketCreator}; +use crate::auth::store::AuthStore; use crate::queue::TicketCreator; use crate::setup::filter_schema_fields; use crate::state::State; @@ -117,6 +118,16 @@ impl App { let discovered_projects: Vec = discovered_full.iter().map(|p| p.name.clone()).collect(); + // Create the admin account before the config is written. + if let Some(password) = self + .setup_screen + .as_ref() + .and_then(|s| s.admin_password.as_deref()) + { + let store = AuthStore::open(&self.config.state_path())?; + persist_admin_password(&store, Some(password))?; + } + // Update config with discovered projects and save self.config.projects = discovered_projects.clone(); self.config.save()?; @@ -406,3 +417,90 @@ impl App { }) } } + +/// Create the admin account from the wizard's optional password. +/// +/// `None` means the operator skipped the step, which is not a failure. +/// +/// A `false` return from `create_admin` may mean an account already existed. +fn persist_admin_password(store: &AuthStore, password: Option<&str>) -> Result<()> { + let Some(password) = password else { + return Ok(()); + }; + + if store.create_admin(password, false)? { + store.audit("admin created", Some("via setup wizard"), true)?; + } else { + tracing::info!("admin account already exists; setup wizard password not applied"); + } + Ok(()) +} + +#[cfg(test)] +mod admin_password_tests { + use super::*; + + #[test] + fn test_skipped_password_creates_no_account() { + use crate::rest::dto::auth::BootstrapState; + + let store = AuthStore::in_memory().unwrap(); + persist_admin_password(&store, None).unwrap(); + + assert_eq!( + store.bootstrap_state().unwrap(), + BootstrapState::Uninitialized, + "skipping the step must leave the deployment unbootstrapped" + ); + } + + #[test] + fn test_password_creates_a_usable_admin_account() { + use crate::rest::dto::auth::BootstrapState; + + let store = AuthStore::in_memory().unwrap(); + persist_admin_password(&store, Some("a properly long password")).unwrap(); + + assert_eq!(store.bootstrap_state().unwrap(), BootstrapState::Complete); + assert!(store + .verify_admin_password("a properly long password") + .unwrap()); + } + + #[test] + fn test_existing_admin_is_left_alone_rather_than_failing() { + // A server that bootstrapped between wizard start and finish must not + // make initialization fail, and must keep its own password. + let store = AuthStore::in_memory().unwrap(); + store.create_admin("the original password", false).unwrap(); + + persist_admin_password(&store, Some("the wizard password")).unwrap(); + + assert!(store + .verify_admin_password("the original password") + .unwrap()); + assert!(!store.verify_admin_password("the wizard password").unwrap()); + } + + #[test] + fn test_invalid_password_surfaces_as_an_error() { + // The wizard validates first, so this only happens if that check is + // bypassed — it must still not create a weak account silently. + let store = AuthStore::in_memory().unwrap(); + assert!(persist_admin_password(&store, Some("short")).is_err()); + } + + #[test] + fn test_creation_is_audited() { + let store = AuthStore::in_memory().unwrap(); + persist_admin_password(&store, Some("a properly long password")).unwrap(); + + let recent = store.recent_audit(10).unwrap(); + let entry = recent + .iter() + .find(|(_, event, _, _)| event == "admin created") + .expect("account creation should be audited"); + assert_eq!(entry.2.as_deref(), Some("via setup wizard")); + assert!(entry.3, "recorded as a success"); + } +} diff --git a/src/auth/callback.rs b/src/auth/callback.rs new file mode 100644 index 00000000..1437f0c3 --- /dev/null +++ b/src/auth/callback.rs @@ -0,0 +1,121 @@ +//! Minting the single-purpose credential an agent uses to report step completion. +//! +//! The `opr8r` client calls `POST /api/v1/tickets/{id}/steps/{step}/complete`, +//! which launches processes and advances a workflow. Before authentication that +//! endpoint was reachable by anything that could open a socket to the port. +//! +//! The token minted here is deliberately narrow rather than long-lived-and-broad: +//! it carries only `execute`, is issued for the `opr8r-callback` audience so it +//! cannot authenticate any ordinary API route, and pins the ticket and step the +//! handler then matches against the request path. + +use anyhow::{Context, Result}; +use chrono::{Duration, Utc}; + +use crate::auth::store::{AuthStore, ADMIN_SUBJECT}; +use crate::auth::tokens::callback_claims; +use crate::config::Config; + +/// Lifetime of a callback token. +/// +/// Deliberately far longer than the 15-minute access-token TTL. A step may legitimately run for hours, and a credential that expired mid-run would +/// strand an agent holding completed work it cannot report — turning a security control into a reliability bug. The token is bounded by its claims instead of by the clock. +const CALLBACK_TTL: Duration = Duration::hours(24); + +/// Mint a callback token for one ticket, step, and agent session. +/// +/// Opens the auth database rather than holding a handle: launches are infrequent, `Launcher` is constructed from a bare `Config` in the CLI, +/// the TUI, and the REST API alike, and threading an auth handle through all three would be a large change for a per-launch cost that is already dominated by spawning a process. +pub fn mint(config: &Config, ticket_id: &str, step: &str, session_id: &str) -> Result { + let store = AuthStore::open(&config.state_path()).context("opening the auth store")?; + let key = store + .load_or_create_signing_key() + .context("loading the token signing key")?; + + let claims = callback_claims( + ADMIN_SUBJECT, + ticket_id, + step, + session_id, + CALLBACK_TTL, + Utc::now(), + uuid::Uuid::new_v4().to_string(), + ); + key.sign(&claims).context("signing the callback token") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::tokens::{AUDIENCE_API, AUDIENCE_CALLBACK}; + use crate::rest::dto::auth::Scope; + + fn config_in(dir: &std::path::Path) -> Config { + let mut config = Config::default(); + config.paths.state = dir.to_string_lossy().to_string(); + config + } + + #[test] + fn test_minted_token_verifies_and_is_pinned_to_its_step() { + let dir = tempfile::tempdir().unwrap(); + let config = config_in(dir.path()); + + let token = mint(&config, "FEAT-42", "build", "sess-1").unwrap(); + + let store = AuthStore::open(&config.state_path()).unwrap(); + let key = store.load_or_create_signing_key().unwrap(); + let claims = key.verify(&token, AUDIENCE_CALLBACK).unwrap(); + + assert_eq!(claims.ticket_id.as_deref(), Some("FEAT-42")); + assert_eq!(claims.step.as_deref(), Some("build")); + assert_eq!(claims.session_id.as_deref(), Some("sess-1")); + assert_eq!(claims.scopes(), vec![Scope::Execute]); + } + + #[test] + fn test_callback_token_cannot_authenticate_ordinary_api_routes() { + // The audience is the boundary: an agent that leaks its callback token + // has not leaked API access. + let dir = tempfile::tempdir().unwrap(); + let config = config_in(dir.path()); + let token = mint(&config, "FEAT-42", "build", "sess-1").unwrap(); + + let store = AuthStore::open(&config.state_path()).unwrap(); + let key = store.load_or_create_signing_key().unwrap(); + assert!(key.verify(&token, AUDIENCE_API).is_err()); + } + + #[test] + fn test_tokens_minted_across_calls_share_the_persisted_key() { + // Minting must not rotate the signing key; doing so would invalidate + // every other credential on every launch. + let dir = tempfile::tempdir().unwrap(); + let config = config_in(dir.path()); + + let first = mint(&config, "FEAT-1", "build", "s1").unwrap(); + let second = mint(&config, "FEAT-2", "review", "s2").unwrap(); + + let store = AuthStore::open(&config.state_path()).unwrap(); + let key = store.load_or_create_signing_key().unwrap(); + assert!(key.verify(&first, AUDIENCE_CALLBACK).is_ok()); + assert!(key.verify(&second, AUDIENCE_CALLBACK).is_ok()); + } + + #[test] + fn test_token_outlives_a_long_running_step() { + let dir = tempfile::tempdir().unwrap(); + let config = config_in(dir.path()); + let token = mint(&config, "FEAT-1", "build", "s1").unwrap(); + + let store = AuthStore::open(&config.state_path()).unwrap(); + let key = store.load_or_create_signing_key().unwrap(); + let claims = key.verify(&token, AUDIENCE_CALLBACK).unwrap(); + + // A multi-hour step must not lose the ability to report completion. + assert!( + claims.exp - claims.iat >= 8 * 60 * 60, + "callback tokens must outlive a long step" + ); + } +} diff --git a/src/auth/egress.rs b/src/auth/egress.rs new file mode 100644 index 00000000..1a0c76ec --- /dev/null +++ b/src/auth/egress.rs @@ -0,0 +1,324 @@ +//! Outbound request destination validation (SSRF guard). +//! +//! Several Operator features fetch a URL that configuration or a request body +//! supplies. The sharpest is the model-server probe: a caller sets a base URL, +//! triggers a probe, and Operator makes the request **with the provider's API +//! key attached**. Pointed at `169.254.169.254`, that reads cloud instance +//! credentials; pointed at an internal address, it is a port scanner with a +//! bearer token. +//! +//! Authentication and scopes are the first control — probing needs `execute`, +//! changing a URL needs `admin`. This module is the second: even an authorized +//! caller cannot aim Operator at the loopback interface, link-local space, or +//! the cloud metadata endpoint. +//! +//! Redirects are re-validated. Every existing call site uses reqwest's default +//! redirect policy, so validating only the initial URL would let an allowed +//! host bounce the request to a forbidden one. + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use url::Url; + +/// The cloud instance metadata address, blocked on every major provider. +const CLOUD_METADATA_V4: Ipv4Addr = Ipv4Addr::new(169, 254, 169, 254); + +/// Schemes Operator will speak. `file://`, `ftp://`, and friends have no legitimate use here and are a classic SSRF escape hatch. +const ALLOWED_SCHEMES: &[&str] = &["http", "https"]; + +/// What an outbound request is allowed to reach. +#[derive(Debug, Clone)] +pub struct EgressPolicy { + /// Permit loopback destinations. + /// + /// On by default because Operator's normal local workflow talks to `localhost` model servers — Ollama, LM Studio, an OpenAI-compatible proxy. + /// It is turned **off** in a published deployment, where loopback means the container's own interfaces rather than the user's laptop. + pub allow_loopback: bool, + /// Permit RFC 1918 / unique-local addresses, for a self-hosted provider on the same network. + pub allow_private: bool, +} + +impl Default for EgressPolicy { + fn default() -> Self { + Self { + allow_loopback: true, + allow_private: true, + } + } +} + +impl EgressPolicy { + /// The policy for a published deployment: no loopback, no private ranges. + pub fn hardened() -> Self { + Self { + allow_loopback: false, + allow_private: false, + } + } + + /// Derive the policy from configuration. + /// + /// A deployment that has declared a public URL is reachable from outside, + /// so its outbound reach is narrowed to match. + pub fn from_config(config: &crate::config::Config) -> Self { + if config.rest_api.public_base_url().is_some() { + Self::hardened() + } else { + Self::default() + } + } +} + +/// Whether an address is in a range that must never be reachable. +/// +/// These are unconditional: no configuration turns them on, because none of +/// them is a destination Operator has any business reaching. +fn is_always_forbidden(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4 == CLOUD_METADATA_V4 + || v4.is_link_local() + || v4.is_multicast() + || v4.is_broadcast() + || v4.is_unspecified() + || v4.is_documentation() + // 0.0.0.0/8 "this network" + || v4.octets()[0] == 0 + } + IpAddr::V6(v6) => { + v6.is_multicast() + || v6.is_unspecified() + // fe80::/10 link-local + || (v6.segments()[0] & 0xffc0) == 0xfe80 + // IPv4-mapped addresses re-enter the v4 rules; without this a forbidden v4 address could be smuggled in as ::ffff:169.254.169.254 + || v6.to_ipv4_mapped().is_some_and(|v4| is_always_forbidden(IpAddr::V4(v4))) + } + } +} + +/// Whether an address is in a private range. +fn is_private(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => v4.is_private(), + // fc00::/7 unique local + IpAddr::V6(v6) => (v6.segments()[0] & 0xfe00) == 0xfc00, + } +} + +/// Check one resolved address against the policy. +pub fn check_addr(ip: IpAddr, policy: &EgressPolicy) -> Result<()> { + if is_always_forbidden(ip) { + return Err(anyhow!( + "destination {ip} is in a reserved range (link-local, multicast, or cloud metadata) \ + and is never permitted" + )); + } + if ip.is_loopback() && !policy.allow_loopback { + return Err(anyhow!( + "destination {ip} is loopback, which this deployment does not permit" + )); + } + if is_private(ip) && !policy.allow_private { + return Err(anyhow!( + "destination {ip} is a private address, which this deployment does not permit" + )); + } + Ok(()) +} + +/// Validate a URL's scheme and, when the host is an IP literal, its address. +/// +/// A hostname is *not* resolved here. DNS resolution followed by a separate +/// connection is a time-of-check/time-of-use gap (DNS rebinding), so the +/// authoritative check is [`check_addr`] applied to the address actually +/// connected to — see [`validated_client`]. +pub fn check_url(url: &Url, policy: &EgressPolicy) -> Result<()> { + if !ALLOWED_SCHEMES.contains(&url.scheme()) { + return Err(anyhow!( + "scheme `{}` is not permitted; use http or https", + url.scheme() + )); + } + + // Match on the parsed host rather than the string. `host_str()` renders an + // IPv6 literal in its bracketed form (`[::1]`), which does not parse as an + // `IpAddr` — so string-parsing silently treated every IPv6 literal as a hostname and skipped the address checks entirely. + match url.host() { + Some(url::Host::Ipv4(v4)) => check_addr(IpAddr::V4(v4), policy), + Some(url::Host::Ipv6(v6)) => check_addr(IpAddr::V6(v6), policy), + // A name is judged at connect time, not here; see the doc comment. + Some(url::Host::Domain(name)) if !name.is_empty() => Ok(()), + _ => Err(anyhow!("destination URL has no host")), + } +} + +/// Parse and validate a destination URL. +pub fn validate(raw: &str, policy: &EgressPolicy) -> Result { + let url = Url::parse(raw).map_err(|e| anyhow!("invalid URL {raw:?}: {e}"))?; + check_url(&url, policy)?; + Ok(url) +} + +/// Build an HTTP client that enforces `policy` on the initial request and on +/// every redirect hop. +/// +/// The redirect policy is where this earns its keep: an allowed host can answer +/// `302 Location: http://169.254.169.254/...`, and reqwest follows redirects by +/// default at every existing call site. +pub fn validated_client(policy: EgressPolicy, timeout: Duration) -> Result { + let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { + if attempt.previous().len() >= 10 { + return attempt.error("too many redirects"); + } + match check_url(attempt.url(), &policy) { + Ok(()) => attempt.follow(), + Err(e) => attempt.error(e), + } + }); + + reqwest::Client::builder() + .timeout(timeout) + .redirect(redirect_policy) + .build() + .map_err(|e| anyhow!("building HTTP client: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv6Addr; + + fn permissive() -> EgressPolicy { + EgressPolicy::default() + } + + #[test] + fn test_cloud_metadata_is_never_reachable() { + // The single most valuable SSRF target: it serves instance credentials to anything that asks, with no authentication. + for policy in [EgressPolicy::default(), EgressPolicy::hardened()] { + assert!(validate("http://169.254.169.254/latest/meta-data/", &policy).is_err()); + // IPv4-mapped IPv6 must not be a way around it. + assert!(validate("http://[::ffff:169.254.169.254]/", &policy).is_err()); + } + } + + #[test] + fn test_link_local_and_multicast_are_never_reachable() { + for url in [ + "http://169.254.1.1/", + "http://224.0.0.1/", + "http://[ff02::1]/", + "http://[fe80::1]/", + "http://0.0.0.0/", + ] { + assert!( + validate(url, &permissive()).is_err(), + "{url} must be refused even under the permissive policy" + ); + } + } + + #[test] + fn test_non_http_schemes_are_refused() { + for url in [ + "file:///etc/passwd", + "ftp://example.com/", + "gopher://example.com/", + ] { + assert!( + validate(url, &permissive()).is_err(), + "{url} must be refused" + ); + } + } + + #[test] + fn test_loopback_follows_the_policy() { + // Local model servers are the normal case on a developer machine... + assert!(validate("http://127.0.0.1:11434/api/tags", &permissive()).is_ok()); + assert!(validate("http://[::1]:11434/", &permissive()).is_ok()); + + // ...but in a published deployment loopback is the container itself. + assert!(validate("http://127.0.0.1:11434/", &EgressPolicy::hardened()).is_err()); + assert!(validate("http://[::1]:11434/", &EgressPolicy::hardened()).is_err()); + } + + #[test] + fn test_private_ranges_follow_the_policy() { + for url in [ + "http://10.1.2.3/", + "http://192.168.1.5/", + "http://172.16.0.9/", + ] { + assert!(validate(url, &permissive()).is_ok(), "{url} under default"); + assert!( + validate(url, &EgressPolicy::hardened()).is_err(), + "{url} under hardened" + ); + } + } + + #[test] + fn test_ordinary_public_destinations_are_allowed() { + for url in [ + "https://api.anthropic.com/v1/messages", + "https://api.openai.com/v1/models", + "http://example.com:8080/path?q=1", + ] { + assert!(validate(url, &EgressPolicy::hardened()).is_ok(), "{url}"); + } + } + + #[test] + fn test_hostnames_pass_url_validation_and_are_judged_at_connect_time() { + // Resolving here and connecting later is a rebinding gap, so a name is deliberately not resolved at this stage. + assert!(validate( + "https://ollama.internal/api/tags", + &EgressPolicy::hardened() + ) + .is_ok()); + } + + #[test] + fn test_a_url_with_no_parseable_host_is_refused() { + // Note what the `url` crate does here: `http:///nohost` normalizes to + // `http://nohost/`, so the empty authority becomes a hostname rather + // than an absent host. A genuinely host-less http URL does not parse. + assert_eq!( + Url::parse("http:///nohost").unwrap().as_str(), + "http://nohost/" + ); + assert!(Url::parse("http://").is_err()); + assert!(validate("http://", &permissive()).is_err()); + assert!(validate("not a url at all", &permissive()).is_err()); + } + + #[test] + fn test_hardened_policy_is_selected_for_a_published_deployment() { + let mut config = crate::config::Config::default(); + assert!(EgressPolicy::from_config(&config).allow_loopback); + + config.rest_api.public_url = Some("https://operator.example.com".to_string()); + let policy = EgressPolicy::from_config(&config); + assert!(!policy.allow_loopback); + assert!(!policy.allow_private); + } + + #[test] + fn test_check_addr_rejects_reserved_and_accepts_public() { + assert!(check_addr(IpAddr::V4(CLOUD_METADATA_V4), &permissive()).is_err()); + assert!(check_addr(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), &permissive()).is_ok()); + assert!(check_addr( + IpAddr::V6(Ipv6Addr::new(0x2606, 0x4700, 0, 0, 0, 0, 0, 1)), + &permissive() + ) + .is_ok()); + } + + #[test] + fn test_validated_client_builds() { + assert!(validated_client(EgressPolicy::hardened(), Duration::from_secs(5)).is_ok()); + } +} diff --git a/src/auth/local.rs b/src/auth/local.rs new file mode 100644 index 00000000..91756b82 --- /dev/null +++ b/src/auth/local.rs @@ -0,0 +1,262 @@ +//! Local auto-unlock for loopback processes. +//! +//! A local `operator` run — the TUI, the CLI, and the `opr8r` client talking to +//! `127.0.0.1` — needs no login. This is **not** an authentication bypass: the +//! credential is real and is checked like any other. It is issued +//! automatically to a caller who has already proven, by reading a file only its +//! owner can read, that they are the user who started the process. +//! +//! The proof is file ownership rather than peer-credential inspection +//! (`SO_PEERCRED` / `LOCAL_PEERCRED`). Those are Unix-socket mechanisms and +//! Operator listens on TCP, where they do not apply; mode `0600` establishes +//! the same boundary — only the owning uid (and root, which can bypass any +//! check anyway) can read the token — and works identically on Windows, where +//! the file inherits the user profile's ACL. +//! +//! Two conditions must both hold before a token is written: +//! +//! 1. The server is bound to a loopback address. +//! 2. The file is created with owner-only permissions. +//! +//! A non-loopback bind writes nothing, so a container or a `0.0.0.0` bind has +//! no local credential to find and must bootstrap. + +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::auth::secret::{generate_secret, hash_secret, hashes_equal}; + +/// Filename of the local-unlock token inside the state directory. +pub const LOCAL_TOKEN_FILENAME: &str = "local-token"; + +/// Create (or replace) a file containing `contents`, readable only by its owner. +/// +/// The mode is set **as the file is created**, not afterwards. A +/// write-then-chmod sequence leaves a window in which the token is +/// world-readable, and — as the test suite found — it also fails outright if +/// anything removes the file in between. +#[cfg(unix)] +fn write_owner_only(path: &Path, contents: &str) -> std::io::Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + + // Write to a uniquely named sibling with the mode set at creation, then + // rename over the target. + // + // Two subtleties this avoids. `mode()` applies only when a file is + // *created*, so writing straight to an existing token file would keep its + // old permissions. And `create_new` on a fixed path fails when two + // processes start at once, which is normal here — the TUI's embedded + // server and a separate `operator api` share a state directory. Rename is + // atomic and indifferent to an existing target, so both succeed and the + // last writer wins. + // Unique per call, not per process: several threads in one process issue + // concurrently, and a shared temp name means one thread renames the file + // out from under another. + let temp = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4())); + + let result = (|| { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&temp)?; + file.write_all(contents.as_bytes())?; + file.sync_all()?; + std::fs::rename(&temp, path) + })(); + + if result.is_err() { + let _ = std::fs::remove_file(&temp); + } + result +} + +/// On Windows the file inherits the user profile's ACL, which already excludes +/// other users; there is no portable mode bit to set at creation. +#[cfg(not(unix))] +fn write_owner_only(path: &Path, contents: &str) -> std::io::Result<()> { + std::fs::write(path, contents) +} + +/// A loopback bind is the precondition for issuing a local credential. +pub fn is_loopback(addr: IpAddr) -> bool { + addr.is_loopback() +} + +/// Path of the local token file. +pub fn local_token_path(state_path: &Path) -> PathBuf { + state_path.join(LOCAL_TOKEN_FILENAME) +} + +/// Issue (or re-issue) the local token, returning its value. +/// +/// Called on every loopback start, generating a fresh secret each time: a token +/// left behind by a previous run should not authenticate against this one. +pub fn issue(state_path: &Path) -> Result { + std::fs::create_dir_all(state_path) + .with_context(|| format!("creating state directory {}", state_path.display()))?; + let path = local_token_path(state_path); + let token = generate_secret()?; + + write_owner_only(&path, &token) + .with_context(|| format!("writing local token {}", path.display()))?; + + Ok(token) +} + +/// Remove the local token, on shutdown or when the bind is not loopback. +pub fn revoke(state_path: &Path) { + let path = local_token_path(state_path); + if path.exists() { + if let Err(e) = std::fs::remove_file(&path) { + tracing::warn!(error = %e, "failed to remove local auth token"); + } + } +} + +/// Read the local token, for a client that needs to call the API as itself +/// (the `ExternalApiProbe`, the CLI, `opr8r` on the same host). +pub fn read(state_path: &Path) -> Option { + std::fs::read_to_string(local_token_path(state_path)) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Whether `presented` matches the issued local token, in constant time. +pub fn matches(issued: &str, presented: &str) -> bool { + hashes_equal(&hash_secret(issued), &hash_secret(presented)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + + #[test] + fn test_loopback_detection() { + assert!(is_loopback(IpAddr::V4(Ipv4Addr::LOCALHOST))); + assert!(is_loopback(IpAddr::V6(Ipv6Addr::LOCALHOST))); + assert!(is_loopback(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 5)))); + + // The binds that must NOT get a free credential. + assert!(!is_loopback(IpAddr::V4(Ipv4Addr::UNSPECIFIED))); + assert!(!is_loopback(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3)))); + assert!(!is_loopback(IpAddr::V6(Ipv6Addr::UNSPECIFIED))); + } + + #[test] + fn test_issue_then_read_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let token = issue(dir.path()).unwrap(); + assert_eq!(read(dir.path()).as_deref(), Some(token.as_str())); + assert!(matches(&token, &token)); + } + + #[test] + fn test_each_issue_replaces_the_previous_token() { + // A token from a previous run must not authenticate against this one. + let dir = tempfile::tempdir().unwrap(); + let first = issue(dir.path()).unwrap(); + let second = issue(dir.path()).unwrap(); + assert_ne!(first, second); + assert!(!matches(&second, &first)); + } + + #[cfg(unix)] + #[test] + fn test_token_file_is_readable_only_by_its_owner() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + issue(dir.path()).unwrap(); + + let mode = std::fs::metadata(local_token_path(dir.path())) + .unwrap() + .permissions() + .mode(); + assert_eq!( + mode & 0o777, + 0o600, + "file ownership is the authentication check here, so the mode is a \ + security control, not tidiness" + ); + } + + #[cfg(unix)] + #[test] + fn test_token_is_never_briefly_world_readable() { + // Regression: the mode was applied after the write, which both left a + // window where the token was world-readable and made concurrent + // issue/revoke on a shared state directory fail outright. + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + + // Pre-create a permissive file so a failure to set the mode on + // *replacement* would be visible rather than masked by a fresh create. + let path = local_token_path(dir.path()); + std::fs::write(&path, "stale").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + issue(dir.path()).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn test_concurrent_issue_on_one_directory_does_not_fail() { + // The TUI's embedded server and a separate `operator api` share a state + // directory; neither start may error because the other is also starting. + let dir = tempfile::tempdir().unwrap(); + let errors: Vec = std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| { + let path = dir.path().to_path_buf(); + scope.spawn(move || issue(&path).map(|_| ())) + }) + .collect(); + handles + .into_iter() + .filter_map(|h| h.join().expect("no panic").err()) + .map(|e| format!("{e:#}")) + .collect() + }); + assert!(errors.is_empty(), "concurrent issue failed: {errors:#?}"); + assert!(read(dir.path()).is_some()); + } + + #[test] + fn test_read_returns_none_when_no_token_was_issued() { + // A non-loopback bind issues nothing, so there is nothing to find. + let dir = tempfile::tempdir().unwrap(); + assert!(read(dir.path()).is_none()); + } + + #[test] + fn test_revoke_removes_the_token() { + let dir = tempfile::tempdir().unwrap(); + issue(dir.path()).unwrap(); + revoke(dir.path()); + assert!(read(dir.path()).is_none()); + // Revoking again is a no-op, not a panic. + revoke(dir.path()); + } + + #[test] + fn test_empty_or_whitespace_token_file_reads_as_absent() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(local_token_path(dir.path()), " \n").unwrap(); + assert!(read(dir.path()).is_none()); + } + + #[test] + fn test_mismatched_token_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let token = issue(dir.path()).unwrap(); + assert!(!matches(&token, "some other value")); + assert!(!matches(&token, "")); + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 00000000..dc392402 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,19 @@ +//! Authentication and authorization. +//! +//! Operator's HTTP surface is authenticated always: there is no configuration +//! flag that turns this off. What varies is how the first credential is +//! obtained — a loopback process gets one issued automatically (see +//! [`local`]), while any other bind must bootstrap an admin password. +//! +//! This module lives in the library, not the binary, because `src/rest` does +//! and depends on it. It must not reference bin-only `crate::ui`. + +pub mod callback; +pub mod egress; +pub mod local; +pub mod password; +pub mod schema; +pub mod scope; +pub mod secret; +pub mod store; +pub mod tokens; diff --git a/src/auth/password.rs b/src/auth/password.rs new file mode 100644 index 00000000..f7c0340b --- /dev/null +++ b/src/auth/password.rs @@ -0,0 +1,125 @@ +//! Argon2id password hashing for the single admin account. +//! +//! Argon2id (rather than Argon2i or Argon2d) is the OWASP recommendation: it +//! resists both GPU cracking and side-channel attacks. Parameters come from the +//! `argon2` crate's defaults, which track the OWASP guidance; pinning our own +//! numbers here would mean maintaining them by hand as hardware moves. + +use anyhow::{anyhow, Result}; +use argon2::password_hash::{ + rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, +}; +use argon2::Argon2; + +/// Shortest password accepted. Length is the only rule enforced: composition +/// rules (a digit, a symbol) push people toward predictable substitutions +/// without adding real entropy, which is why OWASP dropped them. +pub const MIN_PASSWORD_LENGTH: usize = 12; + +/// Longest password accepted. Bounded so a very large body cannot be used to +/// make the server do unbounded KDF work. +pub const MAX_PASSWORD_LENGTH: usize = 1024; + +/// Reject passwords that are too short or too long. +pub fn validate_password(password: &str) -> Result<()> { + let len = password.chars().count(); + if len < MIN_PASSWORD_LENGTH { + return Err(anyhow!( + "password must be at least {MIN_PASSWORD_LENGTH} characters" + )); + } + if len > MAX_PASSWORD_LENGTH { + return Err(anyhow!( + "password must be at most {MAX_PASSWORD_LENGTH} characters" + )); + } + Ok(()) +} + +/// Hash a password with a fresh random salt, returning a PHC string that +/// carries the algorithm, parameters, and salt alongside the digest. +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| anyhow!("hashing password failed: {e}")) +} + +/// Verify a password against a stored PHC hash. +/// +/// Returns `Ok(false)` for a wrong password and `Err` only when the stored hash +/// is unreadable — the caller must not treat a corrupt hash as a failed login, +/// because that would silently lock the account instead of surfacing the fault. +pub fn verify_password(password: &str, phc: &str) -> Result { + let parsed = + PasswordHash::new(phc).map_err(|e| anyhow!("stored password hash is invalid: {e}"))?; + match Argon2::default().verify_password(password.as_bytes(), &parsed) { + Ok(()) => Ok(true), + Err(argon2::password_hash::Error::Password) => Ok(false), + Err(e) => Err(anyhow!("verifying password failed: {e}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const GOOD: &str = "correct horse battery staple"; + + #[test] + fn test_hash_verifies_against_its_own_password() { + let phc = hash_password(GOOD).unwrap(); + assert!(verify_password(GOOD, &phc).unwrap()); + } + + #[test] + fn test_wrong_password_is_rejected_without_erroring() { + let phc = hash_password(GOOD).unwrap(); + assert!(!verify_password("wrong horse battery staple", &phc).unwrap()); + } + + #[test] + fn test_hash_is_argon2id_and_salted() { + let phc = hash_password(GOOD).unwrap(); + assert!( + phc.starts_with("$argon2id$"), + "must be Argon2id, not argon2i/argon2d: {phc}" + ); + // A fresh salt per hash means identical passwords hash differently, + // so a stolen database cannot be scanned for shared passwords. + assert_ne!(phc, hash_password(GOOD).unwrap()); + } + + #[test] + fn test_hash_never_contains_the_plaintext() { + let phc = hash_password(GOOD).unwrap(); + assert!(!phc.contains(GOOD)); + assert!(!phc.contains("correct")); + } + + #[test] + fn test_corrupt_stored_hash_errors_rather_than_reading_as_a_bad_password() { + // Returning Ok(false) here would present a storage fault as a wrong + // password, locking the operator out with a misleading message. + assert!(verify_password(GOOD, "not-a-phc-string").is_err()); + assert!(verify_password(GOOD, "").is_err()); + } + + #[test] + fn test_password_length_bounds() { + assert!(validate_password(&"a".repeat(MIN_PASSWORD_LENGTH)).is_ok()); + assert!(validate_password(&"a".repeat(MIN_PASSWORD_LENGTH - 1)).is_err()); + assert!(validate_password(&"a".repeat(MAX_PASSWORD_LENGTH)).is_ok()); + assert!(validate_password(&"a".repeat(MAX_PASSWORD_LENGTH + 1)).is_err()); + } + + #[test] + fn test_length_is_measured_in_characters_not_bytes() { + // A 12-character passphrase of multi-byte characters is 12 characters, + // not 36 bytes; counting bytes would accept a shorter one. + let emoji = "🔐".repeat(MIN_PASSWORD_LENGTH - 1); + assert!(validate_password(&emoji).is_err()); + assert!(validate_password(&"🔐".repeat(MIN_PASSWORD_LENGTH)).is_ok()); + } +} diff --git a/src/auth/schema.rs b/src/auth/schema.rs new file mode 100644 index 00000000..b668128d --- /dev/null +++ b/src/auth/schema.rs @@ -0,0 +1,370 @@ +//! Auth database schema and migrations. +//! +//! There is no migration framework in this repo, so this is the smallest thing +//! that works: an ordered list of migrations applied inside one transaction and +//! tracked by `SQLite`'s own `user_version` pragma. Appending is the only legal +//! edit — editing a shipped migration would leave already-migrated databases +//! silently inconsistent with new ones. + +use anyhow::{Context, Result}; +use rusqlite::{Connection, TransactionBehavior}; + +/// Ordered schema migrations. **Append only.** +const MIGRATIONS: &[&str] = &[ + // v1 — initial schema. + r#" + -- The single admin account. `id` is pinned to 1 by CHECK, so a second + -- INSERT fails on the primary key rather than creating a second admin. + -- This is what makes concurrent bootstrap resolve to exactly one winner + -- without any application-level locking. + CREATE TABLE admin_account ( + id INTEGER PRIMARY KEY CHECK (id = 1), + subject TEXT NOT NULL, + password_hash TEXT NOT NULL, + -- Set while a mounted bootstrap secret has been accepted but the + -- operator has not yet chosen their own password. + awaiting_reset INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + -- Ed25519 signing keys. Keyed by `kid` so a rotation can verify tokens + -- issued under the previous key until they expire. + CREATE TABLE signing_key ( + kid TEXT PRIMARY KEY, + private_pem TEXT NOT NULL, + public_pem TEXT NOT NULL, + created_at TEXT NOT NULL, + retired_at TEXT + ); + + -- Opaque browser sessions. Only the hash of the cookie value is stored, so + -- a database read does not yield a usable cookie. + CREATE TABLE session ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + csrf_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_used_at TEXT, + revoked_at TEXT + ); + CREATE INDEX idx_session_token_hash ON session(token_hash); + + -- A refresh-token family is one continuous client authorization. Rotation + -- replaces the token within the family; presenting a retired token revokes + -- the whole family, which is why the family is a first-class row. + CREATE TABLE refresh_family ( + id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + scopes TEXT NOT NULL, + created_at TEXT NOT NULL, + -- Absolute deadline, fixed at issuance and never extended by rotation. + absolute_expires_at TEXT NOT NULL, + last_used_at TEXT, + revoked_at TEXT, + revoked_reason TEXT + ); + + CREATE TABLE refresh_token ( + id TEXT PRIMARY KEY, + family_id TEXT NOT NULL REFERENCES refresh_family(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + -- Idle deadline for this particular token in the chain. + expires_at TEXT NOT NULL, + -- Set when redeemed. A redeemed token presented again is reuse. + consumed_at TEXT + ); + CREATE INDEX idx_refresh_token_hash ON refresh_token(token_hash); + CREATE INDEX idx_refresh_token_family ON refresh_token(family_id); + + -- In-flight device authorizations (RFC 8628). + CREATE TABLE device_authorization ( + id TEXT PRIMARY KEY, + device_code_hash TEXT NOT NULL UNIQUE, + user_code TEXT NOT NULL UNIQUE, + client_id TEXT NOT NULL, + scopes TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + approved_at TEXT, + denied_at TEXT, + -- Set once exchanged, so a device code cannot be redeemed twice. + consumed_at TEXT, + -- Enforces the poll interval without trusting the client. + last_polled_at TEXT + ); + CREATE INDEX idx_device_user_code ON device_authorization(user_code); + + -- Service access keys. Hash-only: the secret is shown once at creation. + CREATE TABLE access_key ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + scopes TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_used_at TEXT, + revoked_at TEXT + ); + CREATE INDEX idx_access_key_hash ON access_key(key_hash); + + -- Append-only audit trail. Never contains secrets. + CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + event TEXT NOT NULL, + subject TEXT, + detail TEXT, + succeeded INTEGER NOT NULL + ); + CREATE INDEX idx_audit_at ON audit_log(at); + + -- Persisted rate limiting, so restarting the process does not reset an + -- attacker's budget. Keyed by bucket name (e.g. "login"). + CREATE TABLE rate_limit ( + bucket TEXT PRIMARY KEY, + attempts INTEGER NOT NULL DEFAULT 0, + first_at TEXT NOT NULL, + last_at TEXT NOT NULL, + -- Backoff only; never a permanent lockout. A permanent lockout on a + -- single-account system is a denial-of-service against the only human + -- who could undo it. + retry_after TEXT + ); + "#, +]; + +/// Apply any migrations the database has not seen. +/// +/// Two Operator processes can open the same workspace at once — the TUI runs an +/// embedded API server while `operator api` may already be running, and the +/// test suite opens many at once. So the version check and the migration must +/// be one atomic step. +/// +/// `BEGIN IMMEDIATE` takes the write lock up front, before `user_version` is +/// read. A second starter blocks there, and by the time it acquires the lock +/// the first has committed, so it re-reads the *new* version and finds nothing +/// to do. Reading the version outside the transaction instead lets both see 0 +/// and both try to create the same tables. +pub fn migrate(conn: &mut Connection) -> Result<()> { + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .context("beginning auth migration")?; + + let current: i64 = tx + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .context("reading user_version")?; + + let current = usize::try_from(current).unwrap_or(0); + if current > MIGRATIONS.len() { + anyhow::bail!( + "auth database is at schema version {current}, but this build only knows {}. \ + Downgrading is not supported.", + MIGRATIONS.len() + ); + } + if current == MIGRATIONS.len() { + return Ok(()); + } + + for (index, migration) in MIGRATIONS.iter().enumerate().skip(current) { + let version = index + 1; + tx.execute_batch(migration) + .with_context(|| format!("applying auth migration v{version}"))?; + // `PRAGMA user_version` does not accept a bound parameter. + tx.execute_batch(&format!("PRAGMA user_version = {version}")) + .with_context(|| format!("stamping auth schema version {version}"))?; + } + + tx.commit().context("committing auth migrations")?; + Ok(()) +} + +/// Connection pragmas applied on open. +pub fn apply_pragmas(conn: &Connection) -> Result<()> { + // WAL keeps a reader from blocking the writer, which matters because the + // TUI reads auth state on the same database the API server writes. + conn.pragma_update(None, "journal_mode", "WAL") + .context("enabling WAL")?; + conn.pragma_update(None, "foreign_keys", "ON") + .context("enabling foreign keys")?; + conn.pragma_update(None, "synchronous", "NORMAL") + .context("setting synchronous")?; + // Wait for a concurrent writer rather than failing instantly. Two Operator + // processes on one workspace is normal, not exceptional. + conn.busy_timeout(std::time::Duration::from_secs(5)) + .context("setting busy timeout")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn migrated() -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + apply_pragmas(&conn).unwrap(); + migrate(&mut conn).unwrap(); + conn + } + + #[test] + fn test_migrate_creates_every_table() { + let conn = migrated(); + for table in [ + "admin_account", + "signing_key", + "session", + "refresh_family", + "refresh_token", + "device_authorization", + "access_key", + "audit_log", + "rate_limit", + ] { + let count: i64 = conn + .query_row( + "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?1", + [table], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "migration should create {table}"); + } + } + + #[test] + fn test_migrate_is_idempotent() { + let mut conn = Connection::open_in_memory().unwrap(); + apply_pragmas(&conn).unwrap(); + migrate(&mut conn).unwrap(); + let version: i64 = conn + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + + // Running again must be a no-op, not an error and not a re-apply. + migrate(&mut conn).unwrap(); + let after: i64 = conn + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(version, after); + assert_eq!(after as usize, MIGRATIONS.len()); + } + + #[test] + fn test_admin_account_is_a_singleton() { + // This constraint is what makes concurrent bootstrap safe: the second + // insert fails rather than creating a second admin. + let conn = migrated(); + let insert = + "INSERT INTO admin_account (id, subject, password_hash, created_at, updated_at) \ + VALUES (?1, 'admin', 'hash', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"; + conn.execute(insert, [1]).unwrap(); + + assert!( + conn.execute(insert, [1]).is_err(), + "a second admin row with id=1 must be rejected" + ); + assert!( + conn.execute(insert, [2]).is_err(), + "the CHECK must reject any id other than 1" + ); + } + + #[test] + fn test_refresh_tokens_cascade_with_their_family() { + // Revoking a family must not leave orphaned tokens behind that could + // still be looked up by hash. + let conn = migrated(); + conn.execute( + "INSERT INTO refresh_family (id, client_id, scopes, created_at, absolute_expires_at) \ + VALUES ('fam1', 'vscode', 'read', '2026-01-01T00:00:00Z', '2026-04-01T00:00:00Z')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO refresh_token (id, family_id, token_hash, created_at, expires_at) \ + VALUES ('rt1', 'fam1', 'hash1', '2026-01-01T00:00:00Z', '2026-02-01T00:00:00Z')", + [], + ) + .unwrap(); + + conn.execute("DELETE FROM refresh_family WHERE id = 'fam1'", []) + .unwrap(); + let remaining: i64 = conn + .query_row("SELECT count(*) FROM refresh_token", [], |r| r.get(0)) + .unwrap(); + assert_eq!(remaining, 0, "tokens must cascade with their family"); + } + + #[test] + fn test_credential_hashes_are_unique() { + let conn = migrated(); + conn.execute( + "INSERT INTO access_key (id, name, key_hash, scopes, created_at, expires_at) \ + VALUES ('k1', 'ci', 'samehash', 'read', '2026-01-01T00:00:00Z', '2026-04-01T00:00:00Z')", + [], + ) + .unwrap(); + assert!( + conn.execute( + "INSERT INTO access_key (id, name, key_hash, scopes, created_at, expires_at) \ + VALUES ('k2', 'other', 'samehash', 'read', '2026-01-01T00:00:00Z', '2026-04-01T00:00:00Z')", + [], + ) + .is_err(), + "two keys must never share a hash" + ); + } + + #[test] + fn test_concurrent_migration_of_one_database_is_safe() { + // Regression: reading `user_version` outside the transaction let two + // starters both see 0 and both run migration v1, and the loser died + // with "table admin_account already exists". The TUI's embedded server + // and a separate `operator api` really do race here. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth.sqlite3"); + + let failures: Vec = std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| { + let path = path.clone(); + scope.spawn(move || { + let mut conn = Connection::open(&path)?; + apply_pragmas(&conn)?; + migrate(&mut conn) + }) + }) + .collect(); + handles + .into_iter() + .filter_map(|h| h.join().expect("thread should not panic").err()) + .map(|e| format!("{e:#}")) + .collect() + }); + + assert!( + failures.is_empty(), + "concurrent migration must be safe, got: {failures:#?}" + ); + + let conn = Connection::open(&path).unwrap(); + let version: i64 = conn + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(version as usize, MIGRATIONS.len()); + } + + #[test] + fn test_downgrade_is_refused_rather_than_silently_accepted() { + let mut conn = Connection::open_in_memory().unwrap(); + apply_pragmas(&conn).unwrap(); + conn.execute_batch("PRAGMA user_version = 999").unwrap(); + + let err = migrate(&mut conn).expect_err("a future schema must not be opened"); + assert!(err.to_string().contains("Downgrading is not supported")); + } +} diff --git a/src/auth/scope.rs b/src/auth/scope.rs new file mode 100644 index 00000000..0f44d5a3 --- /dev/null +++ b/src/auth/scope.rs @@ -0,0 +1,374 @@ +//! The route → required-scope table, and the authenticated principal. +//! +//! Authorization is decided by matching a request's [`MatchedPath`] against +//! [`ROUTE_RULES`] rather than by per-handler extractors. The tradeoff is +//! deliberate: an extractor that someone forgets to add leaves a route +//! **unprotected**, and nothing fails. A missing table entry is caught by +//! `tests/route_scope_parity.rs`, which walks the generated OpenAPI spec and +//! fails the build. The failure mode of a mistake should be a red test, not a +//! silent hole. +//! +//! [`MatchedPath`]: axum::extract::MatchedPath + +use crate::rest::dto::auth::{PrincipalKind, Scope}; + +/// What a route requires of its caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Access { + /// Reachable without any credential. The allowlist is deliberately tiny: + /// probes, the bootstrap/login endpoints needed to *obtain* a credential, + /// and the OAuth endpoints a device-flow client polls before it has one. + Public, + /// Requires an authenticated principal holding this scope. + Scoped(Scope), +} + +/// One route's authorization requirement. +#[derive(Debug, Clone, Copy)] +pub struct RouteRule { + /// HTTP method, uppercase. + pub method: &'static str, + /// The axum route pattern, exactly as mounted (e.g. `/api/v1/tickets/{id}`). + pub path: &'static str, + /// What the route requires. + pub access: Access, +} + +const fn rule(method: &'static str, path: &'static str, access: Access) -> RouteRule { + RouteRule { + method, + path, + access, + } +} + +const fn public(method: &'static str, path: &'static str) -> RouteRule { + rule(method, path, Access::Public) +} + +const fn read(method: &'static str, path: &'static str) -> RouteRule { + rule(method, path, Access::Scoped(Scope::Read)) +} + +const fn write(method: &'static str, path: &'static str) -> RouteRule { + rule(method, path, Access::Scoped(Scope::Write)) +} + +const fn execute(method: &'static str, path: &'static str) -> RouteRule { + rule(method, path, Access::Scoped(Scope::Execute)) +} + +const fn admin(method: &'static str, path: &'static str) -> RouteRule { + rule(method, path, Access::Scoped(Scope::Admin)) +} + +/// Every mounted route and what it requires. +/// +/// Two classifications here are load-bearing and worth stating: +/// +/// * **Configuration is `Admin` in both directions.** Even the narrowed public +/// projection controls process launch and resource limits. +/// * **Health and status are `Read`, not public.** They report the workspace +/// directory name and id. `/livez` and `/readyz` exist precisely so probes +/// never need that. +pub static ROUTE_RULES: &[RouteRule] = &[ + // --- Public: probes ----------------------------------------------------- + public("GET", "/livez"), + public("GET", "/readyz"), + // --- Public: obtaining a credential ------------------------------------- + public("GET", "/api/v1/auth/bootstrap"), + public("POST", "/api/v1/auth/bootstrap"), + public("POST", "/api/v1/auth/login"), + public("POST", "/api/v1/auth/device/code"), + public("POST", "/api/v1/auth/token"), + // --- Auth: authenticated session management ----------------------------- + read("GET", "/api/v1/auth/session"), + read("GET", "/api/v1/auth/csrf"), + write("POST", "/api/v1/auth/logout"), + // Approving a device grants a credential, so it is administration. + admin("POST", "/api/v1/auth/device/approve"), + admin("GET", "/api/v1/auth/sessions"), + admin("DELETE", "/api/v1/auth/sessions/{id}"), + admin("GET", "/api/v1/auth/keys"), + admin("POST", "/api/v1/auth/keys"), + admin("DELETE", "/api/v1/auth/keys/{id}"), + // --- Health / status ---------------------------------------------------- + read("GET", "/api/v1/health"), + read("GET", "/api/v1/status"), + read("GET", "/api/v1/sections"), + read("GET", "/api/v1/integrations"), + // --- Issue types -------------------------------------------------------- + read("GET", "/api/v1/issuetypes"), + write("POST", "/api/v1/issuetypes"), + read("GET", "/api/v1/issuetypes/{key}"), + write("PUT", "/api/v1/issuetypes/{key}"), + write("DELETE", "/api/v1/issuetypes/{key}"), + read("GET", "/api/v1/issuetypes/{key}/document"), + read("GET", "/api/v1/issuetypes/{key}/steps"), + read("GET", "/api/v1/issuetypes/{key}/steps/{step_name}"), + write("PUT", "/api/v1/issuetypes/{key}/steps/{step_name}"), + read("GET", "/api/v1/issuetypes/{key}/workflow-preview"), + // --- Collections -------------------------------------------------------- + read("GET", "/api/v1/collections"), + read("GET", "/api/v1/collections/active"), + read("GET", "/api/v1/collections/{name}"), + write("PUT", "/api/v1/collections/{name}/activate"), + // --- Queue -------------------------------------------------------------- + read("GET", "/api/v1/queue/kanban"), + read("GET", "/api/v1/queue/status"), + write("POST", "/api/v1/queue/pause"), + write("POST", "/api/v1/queue/resume"), + // Sync reaches out to a third-party provider with stored credentials and + // writes tickets, so it is more than a queue mutation. + execute("POST", "/api/v1/queue/sync"), + execute("POST", "/api/v1/queue/sync/{provider}/{project_key}"), + // --- Agents ------------------------------------------------------------- + read("GET", "/api/v1/agents/active"), + read("GET", "/api/v1/agents/{agent_id}"), + write("POST", "/api/v1/agents/{agent_id}/approve"), + write("POST", "/api/v1/agents/{agent_id}/reject"), + // Focus shells out to the session multiplexer binary. + execute("POST", "/api/v1/agents/{agent_id}/focus"), + // --- Projects ----------------------------------------------------------- + read("GET", "/api/v1/projects"), + write("POST", "/api/v1/projects/{name}/assess"), + // --- Tickets ------------------------------------------------------------ + read("GET", "/api/v1/tickets/{id}"), + write("POST", "/api/v1/tickets"), + write("PUT", "/api/v1/tickets/{id}/status"), + write("POST", "/api/v1/alerts"), + // --- Launch ------------------------------------------------------------- + execute("POST", "/api/v1/tickets/{id}/launch"), + execute("POST", "/api/v1/tickets/{id}/steps/{step}/complete"), + // --- Workflow export ---------------------------------------------------- + read("POST", "/api/v1/tickets/{id}/workflow-export"), + read("GET", "/api/v1/workflow-formats"), + // --- Kanban ------------------------------------------------------------- + read("GET", "/api/v1/kanban/providers"), + read("GET", "/api/v1/kanban/{provider}/{project_key}/issuetypes"), + read("GET", "/api/v1/kanban/{provider}/{project_key}/statuses"), + write( + "POST", + "/api/v1/kanban/{provider}/{project_key}/issuetypes/sync", + ), + // Onboarding takes live credentials and calls the provider with them. + execute("POST", "/api/v1/kanban/validate"), + execute("POST", "/api/v1/kanban/projects"), + execute("POST", "/api/v1/kanban/statuses"), + // Writing provider config and setting process env are administration. + admin("PUT", "/api/v1/kanban/config"), + admin("POST", "/api/v1/kanban/session-env"), + // --- Skills / LLM tools ------------------------------------------------- + read("GET", "/api/v1/skills"), + read("GET", "/api/v1/llm-tools"), + read("GET", "/api/v1/llm-tools/default"), + admin("PUT", "/api/v1/llm-tools/default"), + // --- Delegators (command templates => administration) ------------------- + read("GET", "/api/v1/delegators"), + admin("POST", "/api/v1/delegators"), + admin("POST", "/api/v1/delegators/from-tool"), + admin("POST", "/api/v1/delegators/import-profile"), + read("GET", "/api/v1/delegators/{name}/profile"), + read("GET", "/api/v1/delegators/{name}"), + admin("PUT", "/api/v1/delegators/{name}"), + admin("DELETE", "/api/v1/delegators/{name}"), + // --- Model servers ------------------------------------------------------ + read("GET", "/api/v1/model-servers"), + admin("POST", "/api/v1/model-servers"), + read("GET", "/api/v1/model-servers/kinds"), + read("GET", "/api/v1/model-servers/kinds/{slug}/models"), + read("GET", "/api/v1/model-servers/{name}"), + admin("PUT", "/api/v1/model-servers/{name}"), + admin("DELETE", "/api/v1/model-servers/{name}"), + // Probing makes an outbound request carrying the provider API key. + execute("GET", "/api/v1/model-servers/{name}/models"), + // --- Configuration ------------------------------------------------------ + admin("GET", "/api/v1/configuration"), + admin("PATCH", "/api/v1/configuration"), + read("GET", "/api/v1/execution-targets"), + // --- MCP ---------------------------------------------------------------- + read("GET", "/api/v1/mcp/descriptor"), + execute("GET", "/api/v1/mcp/sse"), + execute("POST", "/api/v1/mcp/message"), +]; + +/// Look up the requirement for a matched route, if the table knows it. +/// +/// Returns `None` for an unknown route. Callers must treat that as **deny**: +/// an unclassified route is a bug, and failing closed keeps it from being an +/// exploitable one. +pub fn required_access(method: &str, matched_path: &str) -> Option { + ROUTE_RULES + .iter() + .find(|r| r.method == method && r.path == matched_path) + .map(|r| r.access) +} + +/// An authenticated caller. +#[derive(Debug, Clone)] +pub struct Principal { + /// Account name; always `admin` today. + pub subject: String, + /// Scopes this credential carries. + pub scopes: Vec, + /// How the caller authenticated. + pub kind: PrincipalKind, + /// When the presented credential expires, if it has a fixed deadline. + pub expires_at: Option>, + /// Session id, when the caller presented a browser session cookie. + pub session_id: Option, + /// For an agent callback token: the ticket it may report on. + pub ticket_id: Option, + /// For an agent callback token: the step it may report on. + pub step: Option, +} + +impl Principal { + /// Whether this principal holds `scope`. + /// + /// Membership is explicit: scopes do not imply one another, so `Admin` does + /// not satisfy a `Read` requirement unless it was also granted. + pub fn has_scope(&self, scope: Scope) -> bool { + self.scopes.contains(&scope) + } + + /// A local loopback process, or the TUI in-process: full authority. + pub fn local(subject: impl Into) -> Self { + Self { + subject: subject.into(), + scopes: Scope::ALL.to_vec(), + kind: PrincipalKind::LocalProcess, + expires_at: None, + session_id: None, + ticket_id: None, + step: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn test_route_table_has_no_duplicate_entries() { + // A duplicate is ambiguous: `required_access` returns the first match, + // so a later, stricter entry would be silently unreachable. + let mut seen = HashSet::new(); + for r in ROUTE_RULES { + assert!( + seen.insert((r.method, r.path)), + "duplicate ROUTE_RULES entry for {} {}", + r.method, + r.path + ); + } + } + + #[test] + fn test_public_routes_are_exactly_the_documented_allowlist() { + // Widening this set is the single highest-impact mistake available in + // this file, so it is pinned literally rather than derived. + let public: HashSet<(&str, &str)> = ROUTE_RULES + .iter() + .filter(|r| r.access == Access::Public) + .map(|r| (r.method, r.path)) + .collect(); + + let expected: HashSet<(&str, &str)> = [ + ("GET", "/livez"), + ("GET", "/readyz"), + ("GET", "/api/v1/auth/bootstrap"), + ("POST", "/api/v1/auth/bootstrap"), + ("POST", "/api/v1/auth/login"), + ("POST", "/api/v1/auth/device/code"), + ("POST", "/api/v1/auth/token"), + ] + .into_iter() + .collect(); + + assert_eq!( + public, expected, + "the public route allowlist changed — this is a security boundary, \ + not a routing detail. Update the threat model and docs/security/ too." + ); + } + + #[test] + fn test_health_and_status_are_not_public() { + // They disclose the workspace directory name and id; /livez and /readyz + // exist so probes never need them. + for path in ["/api/v1/health", "/api/v1/status"] { + assert_eq!( + required_access("GET", path), + Some(Access::Scoped(Scope::Read)), + "{path} must require a credential" + ); + } + } + + #[test] + fn test_configuration_requires_admin_in_both_directions() { + // Reading returns every model-server URL and delegator command template. + assert_eq!( + required_access("GET", "/api/v1/configuration"), + Some(Access::Scoped(Scope::Admin)) + ); + assert_eq!( + required_access("PATCH", "/api/v1/configuration"), + Some(Access::Scoped(Scope::Admin)) + ); + } + + #[test] + fn test_process_launching_and_outbound_probes_require_execute() { + for (method, path) in [ + ("POST", "/api/v1/tickets/{id}/launch"), + ("POST", "/api/v1/tickets/{id}/steps/{step}/complete"), + ("POST", "/api/v1/agents/{agent_id}/focus"), + ("GET", "/api/v1/model-servers/{name}/models"), + ("GET", "/api/v1/mcp/sse"), + ("POST", "/api/v1/mcp/message"), + ] { + assert_eq!( + required_access(method, path), + Some(Access::Scoped(Scope::Execute)), + "{method} {path} launches a process or makes an outbound request" + ); + } + } + + #[test] + fn test_unknown_route_is_unclassified_so_callers_fail_closed() { + assert_eq!(required_access("GET", "/api/v1/not-a-route"), None); + assert_eq!(required_access("DELETE", "/api/v1/health"), None); + } + + #[test] + fn test_scopes_do_not_imply_one_another() { + let p = Principal { + subject: "admin".to_string(), + scopes: vec![Scope::Admin], + kind: PrincipalKind::AccessToken, + expires_at: None, + session_id: None, + ticket_id: None, + step: None, + }; + assert!(p.has_scope(Scope::Admin)); + assert!( + !p.has_scope(Scope::Read), + "Admin must not implicitly satisfy Read; grants are explicit" + ); + } + + #[test] + fn test_local_principal_holds_every_scope() { + let p = Principal::local("admin"); + for scope in Scope::ALL { + assert!(p.has_scope(scope)); + } + assert_eq!(p.kind, PrincipalKind::LocalProcess); + } +} diff --git a/src/auth/secret.rs b/src/auth/secret.rs new file mode 100644 index 00000000..c1263296 --- /dev/null +++ b/src/auth/secret.rs @@ -0,0 +1,157 @@ +//! Generating and comparing opaque credentials. +//! +//! Two distinct jobs live here, and conflating them is a classic mistake: +//! +//! * **Passwords** are low-entropy and human-chosen, so they need a slow, +//! salted KDF (Argon2id — see [`super::password`]). +//! * **Opaque tokens** (session cookies, refresh tokens, device codes, access +//! keys) are 256-bit random values *we* generate. They need only a fast +//! pre-image-resistant hash; Argon2 on a lookup path would add latency for +//! no security benefit, because there is nothing to brute-force. +//! +//! Both lookups compare in constant time. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use rand::TryRngCore; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +/// Bytes of entropy in a generated credential. 256 bits — well beyond any +/// offline search, and the reason a fast hash suffices for storage. +const SECRET_BYTES: usize = 32; + +/// Characters used for the human-typed device user code. +/// +/// Digits and letters that are hard to confuse aloud or in a terminal font are +/// excluded: no `0`/`O`, no `1`/`I`/`L`, no `U` (misheard as "you"). +const USER_CODE_ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTVWXYZ23456789"; + +/// Generate a URL-safe opaque secret with 256 bits of entropy. +/// +/// Uses the OS CSPRNG and propagates failure rather than falling back to a +/// weaker source: a silently non-random credential is worse than no credential. +pub fn generate_secret() -> anyhow::Result { + let mut bytes = [0u8; SECRET_BYTES]; + rand::rngs::OsRng + .try_fill_bytes(&mut bytes) + .map_err(|e| anyhow::anyhow!("OS random number generator unavailable: {e}"))?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +/// Generate a prefixed secret, e.g. `opk_` for an access key. The +/// prefix makes a leaked credential recognizable in logs and secret scanners. +pub fn generate_prefixed_secret(prefix: &str) -> anyhow::Result { + Ok(format!("{prefix}_{}", generate_secret()?)) +} + +/// Generate a short, human-typed device code formatted `XXXX-XXXX`. +pub fn generate_user_code() -> anyhow::Result { + let mut bytes = [0u8; 8]; + rand::rngs::OsRng + .try_fill_bytes(&mut bytes) + .map_err(|e| anyhow::anyhow!("OS random number generator unavailable: {e}"))?; + + let n = USER_CODE_ALPHABET.len(); + let chars: Vec = bytes + .iter() + // Modulo bias across a 30-character alphabet from a 256-value byte is + // at most ~2%, which is immaterial for a code that lives for minutes, + // is rate-limited, and is single-use. + .map(|b| USER_CODE_ALPHABET[usize::from(*b) % n] as char) + .collect(); + + Ok(format!( + "{}-{}", + chars[..4].iter().collect::(), + chars[4..].iter().collect::() + )) +} + +/// Hash an opaque secret for storage. Never store the secret itself. +pub fn hash_secret(secret: &str) -> String { + let digest = Sha256::digest(secret.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +/// Constant-time comparison of two hashes. +/// +/// Lookups are by hash equality, so a short-circuiting `==` would leak the +/// matching prefix length through timing. +pub fn hashes_equal(a: &str, b: &str) -> bool { + a.as_bytes().ct_eq(b.as_bytes()).into() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn test_generated_secrets_are_unique_and_url_safe() { + let mut seen = HashSet::new(); + for _ in 0..256 { + let s = generate_secret().unwrap(); + assert!(seen.insert(s.clone()), "generated a duplicate secret"); + assert!( + s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), + "secret must be URL-safe so it can ride in a cookie or header: {s}" + ); + } + } + + #[test] + fn test_secret_carries_full_entropy() { + // 32 bytes base64url without padding is 43 characters. + assert_eq!(generate_secret().unwrap().len(), 43); + } + + #[test] + fn test_prefixed_secret_is_recognizable() { + let key = generate_prefixed_secret("opk").unwrap(); + assert!(key.starts_with("opk_")); + assert_eq!(key.len(), 4 + 43); + } + + #[test] + fn test_user_code_avoids_visually_ambiguous_characters() { + for _ in 0..128 { + let code = generate_user_code().unwrap(); + assert_eq!(code.len(), 9, "expected XXXX-XXXX: {code}"); + assert_eq!(&code[4..5], "-"); + for c in code.chars().filter(|c| *c != '-') { + assert!( + !"O01ILU".contains(c), + "user code must avoid characters confused when read aloud: {code}" + ); + assert!(USER_CODE_ALPHABET.contains(&(c as u8))); + } + } + } + + #[test] + fn test_hash_is_deterministic_and_hides_the_secret() { + let secret = generate_secret().unwrap(); + let hash = hash_secret(&secret); + assert_eq!(hash, hash_secret(&secret)); + assert!(!hash.contains(&secret)); + assert_ne!(hash, secret); + } + + #[test] + fn test_distinct_secrets_hash_differently() { + let a = hash_secret(&generate_secret().unwrap()); + let b = hash_secret(&generate_secret().unwrap()); + assert_ne!(a, b); + } + + #[test] + fn test_hashes_equal_matches_only_identical_input() { + let hash = hash_secret("token"); + assert!(hashes_equal(&hash, &hash_secret("token"))); + assert!(!hashes_equal(&hash, &hash_secret("token "))); + // Differing lengths must compare false rather than panic. + assert!(!hashes_equal(&hash, "short")); + } +} diff --git a/src/auth/store.rs b/src/auth/store.rs new file mode 100644 index 00000000..d08e9351 --- /dev/null +++ b/src/auth/store.rs @@ -0,0 +1,1663 @@ +//! The auth database and the service wrapping it. +//! +//! `rusqlite` is synchronous, so every public method here is `async` and does +//! its work inside `spawn_blocking`. The connection sits behind a `Mutex` +//! rather than a pool: this is a single-writer application over one small +//! database, and a pool would add contention management for no benefit. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use rusqlite::{Connection, OptionalExtension}; +use uuid::Uuid; + +use crate::auth::password::{hash_password, validate_password, verify_password}; +use crate::auth::schema; +use crate::auth::scope::Principal; +use crate::auth::secret::{ + generate_prefixed_secret, generate_secret, generate_user_code, hash_secret, +}; +use crate::auth::tokens::SigningKey; +use crate::rest::dto::auth::{ + AccessKeySummary, BootstrapState, DeviceSummary, PrincipalKind, Scope, SessionSummary, + MAX_ACCESS_KEY_EXPIRY_DAYS, MAX_IDENTIFIER_LENGTH, +}; + +/// Filename of the auth database inside the state directory. +pub const AUTH_DB_FILENAME: &str = "auth.sqlite3"; + +/// The one human account. +pub const ADMIN_SUBJECT: &str = "admin"; + +/// Browser session lifetime. +const SESSION_TTL: Duration = Duration::hours(12); +/// Refresh token idle lifetime — using a token resets this. +const REFRESH_IDLE_TTL: Duration = Duration::days(30); +/// Refresh token absolute lifetime — fixed at issuance, never extended. +const REFRESH_ABSOLUTE_TTL: Duration = Duration::days(90); +/// Device code lifetime in seconds. +pub const DEVICE_CODE_TTL_SECS: u64 = 15 * 60; +/// Minimum seconds between device-code polls. +pub const DEVICE_POLL_INTERVAL_SECS: u64 = 5; + +/// Prefix marking a service access key, so a leaked one is recognizable to a +/// secret scanner and in logs. +const ACCESS_KEY_PREFIX: &str = "opk"; + +/// Outcome of redeeming a refresh token. +#[derive(Debug)] +pub enum RefreshOutcome { + /// Rotated successfully; the caller receives a replacement token. + Rotated { + refresh_token: String, + scopes: Vec, + }, + /// The token was valid once but has already been redeemed. The family is + /// now revoked — see [`AuthStore::redeem_refresh_token`]. + Reused, + /// No such token, or it is expired or revoked. + Invalid, +} + +/// A `device_authorization` row as read during polling: +/// `(id, client_id, scopes, expires_at, approved_at, denied_at, consumed_at, +/// last_polled_at)`. +type DeviceAuthorizationRow = ( + String, + String, + String, + String, + Option, + Option, + Option, + Option, +); + +/// Outcome of polling a device authorization. +#[derive(Debug)] +pub enum DevicePollOutcome { + /// Approved; the caller may mint tokens with these scopes. + Approved { + client_id: String, + scopes: Vec, + }, + /// Not approved yet — keep polling. + Pending, + /// Polled faster than the advertised interval. + SlowDown, + /// The human declined. + Denied, + /// Expired, already consumed, or unknown. + Expired, +} + +/// A persistent rate-limit decision. +#[derive(Debug, PartialEq, Eq)] +pub enum RateLimitDecision { + /// Proceed. + Allow, + /// Backoff is in effect; retry after this many seconds. + Backoff { retry_after_secs: u64 }, +} + +/// The auth database. +#[derive(Clone)] +pub struct AuthStore { + conn: Arc>, + path: PathBuf, +} + +impl std::fmt::Debug for AuthStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Hand-written so the connection is never formatted: it is a live + // handle to a database full of credential hashes. + f.debug_struct("AuthStore") + .field("path", &self.path) + .field("conn", &"") + .finish() + } +} + +/// Restrict a file to its owner. Credentials live in this database, and the +/// local-unlock token is authenticated *by* file ownership, so the mode is a +/// security control rather than tidiness. +#[cfg(unix)] +pub fn restrict_to_owner(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path) + .with_context(|| format!("reading permissions of {}", path.display()))? + .permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(path, perms) + .with_context(|| format!("restricting {} to its owner", path.display())) +} + +/// On Windows the file inherits the user-profile ACL, which already excludes +/// other users; there is no portable mode bit to set. +#[cfg(not(unix))] +pub fn restrict_to_owner(_path: &Path) -> Result<()> { + Ok(()) +} + +impl AuthStore { + /// Open (creating if needed) the auth database at `state_path`. + pub fn open(state_path: &Path) -> Result { + std::fs::create_dir_all(state_path) + .with_context(|| format!("creating state directory {}", state_path.display()))?; + let path = state_path.join(AUTH_DB_FILENAME); + + let mut conn = Connection::open(&path) + .with_context(|| format!("opening auth database {}", path.display()))?; + // Restrict before writing anything into it. + restrict_to_owner(&path)?; + schema::apply_pragmas(&conn)?; + schema::migrate(&mut conn)?; + + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + path, + }) + } + + /// An in-memory store, for tests. + #[cfg(test)] + pub fn in_memory() -> Result { + let mut conn = Connection::open_in_memory()?; + schema::apply_pragmas(&conn)?; + schema::migrate(&mut conn)?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + path: PathBuf::from(":memory:"), + }) + } + + fn with_conn(&self, f: impl FnOnce(&mut Connection) -> Result) -> Result { + let mut guard = self + .conn + .lock() + .map_err(|_| anyhow!("auth database lock poisoned"))?; + f(&mut guard) + } + + // ========================================================================= + // Bootstrap and the admin account + // ========================================================================= + + /// Current bootstrap state. + pub fn bootstrap_state(&self) -> Result { + self.with_conn(|conn| { + let row: Option = conn + .query_row( + "SELECT awaiting_reset FROM admin_account WHERE id = 1", + [], + |r| r.get(0), + ) + .optional()?; + Ok(match row { + None => BootstrapState::Uninitialized, + Some(0) => BootstrapState::Complete, + Some(_) => BootstrapState::AwaitingPassword, + }) + }) + } + + /// Create the admin account. **Atomic**: the `id = 1` primary key means a + /// concurrent second attempt fails rather than creating a second admin, so + /// exactly one racer wins with no application-level locking. + /// + /// `awaiting_reset` marks a password that came from a mounted bootstrap + /// secret and must be replaced before the account is usable. + pub fn create_admin(&self, password: &str, awaiting_reset: bool) -> Result { + validate_password(password)?; + let phc = hash_password(password)?; + let now = Utc::now().to_rfc3339(); + + self.with_conn(|conn| { + let inserted = conn.execute( + "INSERT OR ABORT INTO admin_account \ + (id, subject, password_hash, awaiting_reset, created_at, updated_at) \ + VALUES (1, ?1, ?2, ?3, ?4, ?4)", + rusqlite::params![ADMIN_SUBJECT, phc, i64::from(awaiting_reset), now], + ); + match inserted { + Ok(_) => Ok(true), + // A losing racer, not a fault. + Err(rusqlite::Error::SqliteFailure(e, _)) + if e.code == rusqlite::ErrorCode::ConstraintViolation => + { + Ok(false) + } + Err(e) => Err(e.into()), + } + }) + } + + /// Replace the admin password and clear `awaiting_reset`. + pub fn set_admin_password(&self, password: &str) -> Result<()> { + validate_password(password)?; + let phc = hash_password(password)?; + let now = Utc::now().to_rfc3339(); + + self.with_conn(|conn| { + let n = conn.execute( + "UPDATE admin_account SET password_hash = ?1, awaiting_reset = 0, updated_at = ?2 \ + WHERE id = 1", + rusqlite::params![phc, now], + )?; + if n == 0 { + return Err(anyhow!("no admin account exists")); + } + Ok(()) + }) + } + + /// Verify a password against the stored admin hash. + pub fn verify_admin_password(&self, password: &str) -> Result { + let phc: Option = self.with_conn(|conn| { + Ok(conn + .query_row( + "SELECT password_hash FROM admin_account WHERE id = 1", + [], + |r| r.get(0), + ) + .optional()?) + })?; + + match phc { + Some(phc) => verify_password(password, &phc), + None => Ok(false), + } + } + + /// Revoke every credential: sessions, refresh families, access keys, and + /// pending device authorizations. Used by password reset and re-bootstrap. + pub fn revoke_all_credentials(&self, reason: &str) -> Result<()> { + let now = Utc::now().to_rfc3339(); + self.with_conn(|conn| { + let tx = conn.transaction()?; + tx.execute( + "UPDATE session SET revoked_at = ?1 WHERE revoked_at IS NULL", + [&now], + )?; + tx.execute( + "UPDATE refresh_family SET revoked_at = ?1, revoked_reason = ?2 \ + WHERE revoked_at IS NULL", + rusqlite::params![now, reason], + )?; + tx.execute( + "UPDATE access_key SET revoked_at = ?1 WHERE revoked_at IS NULL", + [&now], + )?; + tx.execute( + "UPDATE device_authorization SET denied_at = ?1 \ + WHERE approved_at IS NULL AND denied_at IS NULL", + [&now], + )?; + tx.commit()?; + Ok(()) + }) + } + + // ========================================================================= + // Signing key + // ========================================================================= + + /// Load the active signing key, generating and persisting one on first use. + pub fn load_or_create_signing_key(&self) -> Result { + let existing: Option<(String, Vec, Vec)> = self.with_conn(|conn| { + Ok(conn + .query_row( + "SELECT kid, private_pem, public_pem FROM signing_key \ + WHERE retired_at IS NULL ORDER BY created_at DESC LIMIT 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional()?) + })?; + + if let Some((kid, private_der, public_raw)) = existing { + return SigningKey::from_der(kid, private_der, public_raw); + } + + let kid = Uuid::new_v4().to_string(); + let key = SigningKey::generate(kid.clone())?; + let now = Utc::now().to_rfc3339(); + self.with_conn(|conn| { + conn.execute( + "INSERT INTO signing_key (kid, private_pem, public_pem, created_at) \ + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![kid, key.private_der(), key.public_raw(), now], + )?; + Ok(()) + })?; + Ok(key) + } + + // ========================================================================= + // Browser sessions + // ========================================================================= + + /// Create a session, returning `(session_token, csrf_token, expires_at)`. + /// Only hashes are stored, so neither value can be read back out. + pub fn create_session(&self) -> Result<(String, String, DateTime)> { + let token = generate_secret()?; + let csrf = generate_secret()?; + let now = Utc::now(); + let expires = now + SESSION_TTL; + + self.with_conn(|conn| { + conn.execute( + "INSERT INTO session (id, token_hash, csrf_hash, created_at, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + Uuid::new_v4().to_string(), + hash_secret(&token), + hash_secret(&csrf), + now.to_rfc3339(), + expires.to_rfc3339(), + ], + )?; + Ok(()) + })?; + + Ok((token, csrf, expires)) + } + + /// Resolve a session cookie to a principal, refreshing `last_used_at`. + pub fn authenticate_session(&self, token: &str) -> Result> { + let hash = hash_secret(token); + let now = Utc::now(); + + self.with_conn(|conn| { + let row: Option<(String, String)> = conn + .query_row( + "SELECT id, expires_at FROM session \ + WHERE token_hash = ?1 AND revoked_at IS NULL", + [&hash], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + + let Some((id, expires_at)) = row else { + return Ok(None); + }; + let expires_at = parse_time(&expires_at)?; + if expires_at <= now { + return Ok(None); + } + + conn.execute( + "UPDATE session SET last_used_at = ?1 WHERE id = ?2", + rusqlite::params![now.to_rfc3339(), id], + )?; + + Ok(Some(Principal { + subject: ADMIN_SUBJECT.to_string(), + scopes: Scope::ALL.to_vec(), + kind: PrincipalKind::Session, + expires_at: Some(expires_at), + session_id: Some(id), + ticket_id: None, + step: None, + })) + }) + } + + /// Whether `csrf` matches the session's stored 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 + .query_row( + "SELECT csrf_hash FROM session WHERE id = ?1 AND revoked_at IS NULL", + [session_id], + |r| r.get(0), + ) + .optional()?; + Ok(stored.is_some_and(|s| crate::auth::secret::hashes_equal(&s, &provided))) + }) + } + + /// Issue a fresh CSRF token for an existing session, replacing the old one. + /// + /// 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. + 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", + rusqlite::params![hash, session_id], + )?; + Ok((n > 0).then_some(csrf)) + }) + } + + /// Revoke one session. + pub fn revoke_session(&self, session_id: &str) -> Result<()> { + let now = Utc::now().to_rfc3339(); + self.with_conn(|conn| { + conn.execute( + "UPDATE session SET revoked_at = ?1 WHERE id = ?2 AND revoked_at IS NULL", + rusqlite::params![now, session_id], + )?; + Ok(()) + }) + } + + /// All sessions, newest first. + pub fn list_sessions(&self, current_session_id: Option<&str>) -> Result> { + self.with_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT id, created_at, expires_at, last_used_at, revoked_at \ + FROM session ORDER BY created_at DESC", + )?; + let rows = stmt + .query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, Option>(4)?, + )) + })? + .collect::, _>>()?; + + rows.into_iter() + .map(|(id, created, expires, last_used, revoked)| { + Ok(SessionSummary { + current: current_session_id == Some(id.as_str()), + id, + created_at: parse_time(&created)?, + expires_at: parse_time(&expires)?, + last_used_at: parse_opt_time(last_used.as_deref())?, + revoked_at: parse_opt_time(revoked.as_deref())?, + }) + }) + .collect() + }) + } + + // ========================================================================= + // Refresh tokens + // ========================================================================= + + /// Start a refresh-token family and issue its first token. + pub fn create_refresh_family(&self, client_id: &str, scopes: &[Scope]) -> Result { + let now = Utc::now(); + let token = generate_secret()?; + let family_id = Uuid::new_v4().to_string(); + + self.with_conn(|conn| { + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO refresh_family \ + (id, client_id, scopes, created_at, absolute_expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + family_id, + client_id, + encode_scopes(scopes), + now.to_rfc3339(), + (now + REFRESH_ABSOLUTE_TTL).to_rfc3339(), + ], + )?; + tx.execute( + "INSERT INTO refresh_token (id, family_id, token_hash, created_at, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + Uuid::new_v4().to_string(), + family_id, + hash_secret(&token), + now.to_rfc3339(), + (now + REFRESH_IDLE_TTL).to_rfc3339(), + ], + )?; + tx.commit()?; + Ok(()) + })?; + + Ok(token) + } + + /// Redeem a refresh token, rotating it. + /// + /// Presenting an **already-consumed** token means two parties hold the same + /// credential — the legitimate client and a thief — and there is no way to + /// tell which is calling. The whole family is revoked rather than guessing: + /// a forced re-authentication is a far better outcome than silently serving + /// an attacker. + pub fn redeem_refresh_token(&self, token: &str, client_id: &str) -> Result { + let hash = hash_secret(token); + let now = Utc::now(); + let replacement = generate_secret()?; + + self.with_conn(|conn| { + let tx = conn.transaction()?; + + let row: Option<(String, String, Option, String)> = tx + .query_row( + "SELECT id, family_id, consumed_at, expires_at FROM refresh_token \ + WHERE token_hash = ?1", + [&hash], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .optional()?; + + let Some((token_id, family_id, consumed_at, expires_at)) = row else { + return Ok(RefreshOutcome::Invalid); + }; + + if consumed_at.is_some() { + tx.execute( + "UPDATE refresh_family SET revoked_at = ?1, revoked_reason = 'token reuse' \ + WHERE id = ?2 AND revoked_at IS NULL", + rusqlite::params![now.to_rfc3339(), family_id], + )?; + tx.commit()?; + return Ok(RefreshOutcome::Reused); + } + + let family: Option<(String, String, String, Option)> = tx + .query_row( + "SELECT client_id, scopes, absolute_expires_at, revoked_at FROM refresh_family \ + WHERE id = ?1", + [&family_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .optional()?; + + let Some((registered_client_id, scopes, absolute_expires_at, revoked_at)) = family + else { + return Ok(RefreshOutcome::Invalid); + }; + if registered_client_id != client_id { + return Ok(RefreshOutcome::Invalid); + } + + // Idle expiry, absolute expiry, and revocation each end the family. + if revoked_at.is_some() + || parse_time(&expires_at)? <= now + || parse_time(&absolute_expires_at)? <= now + { + return Ok(RefreshOutcome::Invalid); + } + + tx.execute( + "UPDATE refresh_token SET consumed_at = ?1 WHERE id = ?2", + rusqlite::params![now.to_rfc3339(), token_id], + )?; + tx.execute( + "INSERT INTO refresh_token (id, family_id, token_hash, created_at, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + Uuid::new_v4().to_string(), + family_id, + hash_secret(&replacement), + now.to_rfc3339(), + // Rotation resets the idle clock but never the absolute one. + (now + REFRESH_IDLE_TTL).to_rfc3339(), + ], + )?; + tx.execute( + "UPDATE refresh_family SET last_used_at = ?1 WHERE id = ?2", + rusqlite::params![now.to_rfc3339(), family_id], + )?; + tx.commit()?; + + Ok(RefreshOutcome::Rotated { + refresh_token: replacement, + scopes: decode_scopes(&scopes), + }) + }) + } + + /// Device-flow clients, for the security settings screen. + pub fn list_devices(&self) -> Result> { + self.with_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT id, client_id, scopes, created_at, absolute_expires_at, \ + last_used_at, revoked_at \ + FROM refresh_family ORDER BY created_at DESC", + )?; + let rows = stmt + .query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, Option>(5)?, + r.get::<_, Option>(6)?, + )) + })? + .collect::, _>>()?; + + rows.into_iter() + .map( + |(id, client_id, scopes, created, expires, last_used, revoked)| { + Ok(DeviceSummary { + id, + client_id, + scopes: decode_scopes(&scopes), + created_at: parse_time(&created)?, + expires_at: parse_time(&expires)?, + last_used_at: parse_opt_time(last_used.as_deref())?, + revoked_at: parse_opt_time(revoked.as_deref())?, + }) + }, + ) + .collect() + }) + } + + // ========================================================================= + // Device authorization + // ========================================================================= + + /// Create a device authorization, returning `(device_code, user_code)`. + pub fn create_device_authorization( + &self, + client_id: &str, + scopes: &[Scope], + ) -> Result<(String, String)> { + let device_code = generate_secret()?; + let user_code = generate_user_code()?; + let now = Utc::now(); + + self.with_conn(|conn| { + conn.execute( + "INSERT INTO device_authorization \ + (id, device_code_hash, user_code, client_id, scopes, created_at, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + Uuid::new_v4().to_string(), + hash_secret(&device_code), + user_code, + client_id, + encode_scopes(scopes), + now.to_rfc3339(), + (now + Duration::seconds(DEVICE_CODE_TTL_SECS as i64)).to_rfc3339(), + ], + )?; + Ok(()) + })?; + + Ok((device_code, user_code)) + } + + /// Approve a pending device authorization by its user code. + pub fn approve_device(&self, user_code: &str) -> Result)>> { + let now = Utc::now(); + self.with_conn(|conn| { + let row: Option<(String, String, String, String)> = conn + .query_row( + "SELECT id, client_id, scopes, expires_at FROM device_authorization \ + WHERE user_code = ?1 AND approved_at IS NULL AND denied_at IS NULL", + [user_code], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .optional()?; + + let Some((id, client_id, scopes, expires_at)) = row else { + return Ok(None); + }; + if parse_time(&expires_at)? <= now { + return Ok(None); + } + + conn.execute( + "UPDATE device_authorization SET approved_at = ?1 WHERE id = ?2", + rusqlite::params![now.to_rfc3339(), id], + )?; + Ok(Some((client_id, decode_scopes(&scopes)))) + }) + } + + /// Poll a device authorization, consuming it once approved. + /// + /// The poll interval is enforced here rather than trusted to the client, + /// which is the only way it actually bounds anything. + pub fn poll_device( + &self, + device_code: &str, + requesting_client_id: &str, + ) -> Result { + let hash = hash_secret(device_code); + let now = Utc::now(); + + self.with_conn(|conn| { + let tx = conn.transaction()?; + let row: Option = tx + .query_row( + "SELECT id, client_id, scopes, expires_at, approved_at, denied_at, \ + consumed_at, last_polled_at \ + FROM device_authorization WHERE device_code_hash = ?1", + [&hash], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + )) + }, + ) + .optional()?; + + let Some(( + id, + client_id, + scopes, + expires_at, + approved_at, + denied_at, + consumed_at, + last_polled_at, + )) = row + else { + return Ok(DevicePollOutcome::Expired); + }; + + if client_id != requesting_client_id { + return Ok(DevicePollOutcome::Expired); + } + + if consumed_at.is_some() || parse_time(&expires_at)? <= now { + return Ok(DevicePollOutcome::Expired); + } + if denied_at.is_some() { + return Ok(DevicePollOutcome::Denied); + } + + if let Some(last) = parse_opt_time(last_polled_at.as_deref())? { + if (now - last).num_seconds() < DEVICE_POLL_INTERVAL_SECS as i64 { + return Ok(DevicePollOutcome::SlowDown); + } + } + tx.execute( + "UPDATE device_authorization SET last_polled_at = ?1 WHERE id = ?2", + rusqlite::params![now.to_rfc3339(), id], + )?; + + if approved_at.is_none() { + tx.commit()?; + return Ok(DevicePollOutcome::Pending); + } + + // Consume on success so a device code is single-use. + tx.execute( + "UPDATE device_authorization SET consumed_at = ?1 WHERE id = ?2", + rusqlite::params![now.to_rfc3339(), id], + )?; + tx.commit()?; + + Ok(DevicePollOutcome::Approved { + client_id, + scopes: decode_scopes(&scopes), + }) + }) + } + + // ========================================================================= + // Service access keys + // ========================================================================= + + /// Create an access key, returning `(summary, secret)`. The secret is + /// returned once and is unrecoverable afterward. + pub fn create_access_key( + &self, + name: &str, + scopes: &[Scope], + expires_in_days: u64, + ) -> Result<(AccessKeySummary, String)> { + let name = name.trim(); + if name.is_empty() || name.len() > MAX_IDENTIFIER_LENGTH { + return Err(anyhow!("access key name must contain 1 to 128 characters")); + } + if scopes.is_empty() { + return Err(anyhow!("an access key must grant at least one scope")); + } + let unique_scopes: std::collections::HashSet<_> = scopes.iter().collect(); + if unique_scopes.len() != scopes.len() { + return Err(anyhow!("access key scopes must be unique")); + } + if expires_in_days == 0 || expires_in_days > MAX_ACCESS_KEY_EXPIRY_DAYS { + return Err(anyhow!("access key expiry must be between 1 and 365 days")); + } + + let secret = generate_prefixed_secret(ACCESS_KEY_PREFIX)?; + let id = Uuid::new_v4().to_string(); + let now = Utc::now(); + let expires = now + + Duration::try_days(i64::try_from(expires_in_days).unwrap_or(i64::MAX)) + .ok_or_else(|| anyhow!("expiry is too far in the future"))?; + + self.with_conn(|conn| { + conn.execute( + "INSERT INTO access_key (id, name, key_hash, scopes, created_at, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + id, + name, + hash_secret(&secret), + encode_scopes(scopes), + now.to_rfc3339(), + expires.to_rfc3339(), + ], + )?; + Ok(()) + })?; + + Ok(( + AccessKeySummary { + id, + name: name.to_string(), + scopes: scopes.to_vec(), + created_at: now, + expires_at: expires, + last_used_at: None, + revoked_at: None, + }, + secret, + )) + } + + /// Exchange an access key for its scopes, recording the use. + pub fn redeem_access_key(&self, secret: &str) -> Result>> { + let hash = hash_secret(secret); + let now = Utc::now(); + + self.with_conn(|conn| { + let row: Option<(String, String, String, Option)> = conn + .query_row( + "SELECT id, scopes, expires_at, revoked_at FROM access_key \ + WHERE key_hash = ?1", + [&hash], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .optional()?; + + let Some((id, scopes, expires_at, revoked_at)) = row else { + return Ok(None); + }; + if revoked_at.is_some() || parse_time(&expires_at)? <= now { + return Ok(None); + } + + conn.execute( + "UPDATE access_key SET last_used_at = ?1 WHERE id = ?2", + rusqlite::params![now.to_rfc3339(), id], + )?; + Ok(Some(decode_scopes(&scopes))) + }) + } + + /// Revoke an access key. + pub fn revoke_access_key(&self, id: &str) -> Result>> { + let now = Utc::now(); + self.with_conn(|conn| { + let n = conn.execute( + "UPDATE access_key SET revoked_at = ?1 WHERE id = ?2 AND revoked_at IS NULL", + rusqlite::params![now.to_rfc3339(), id], + )?; + Ok((n > 0).then_some(now)) + }) + } + + /// All access keys, newest first. + pub fn list_access_keys(&self) -> Result> { + self.with_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT id, name, scopes, created_at, expires_at, last_used_at, revoked_at \ + FROM access_key ORDER BY created_at DESC", + )?; + let rows = stmt + .query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, Option>(5)?, + r.get::<_, Option>(6)?, + )) + })? + .collect::, _>>()?; + + rows.into_iter() + .map(|(id, name, scopes, created, expires, last_used, revoked)| { + Ok(AccessKeySummary { + id, + name, + scopes: decode_scopes(&scopes), + created_at: parse_time(&created)?, + expires_at: parse_time(&expires)?, + last_used_at: parse_opt_time(last_used.as_deref())?, + revoked_at: parse_opt_time(revoked.as_deref())?, + }) + }) + .collect() + }) + } + + // ========================================================================= + // Rate limiting + // ========================================================================= + + /// Check a rate-limit bucket without recording an attempt. + pub fn check_rate_limit(&self, bucket: &str) -> Result { + let now = Utc::now(); + self.with_conn(|conn| { + let retry_after: Option> = conn + .query_row( + "SELECT retry_after FROM rate_limit WHERE bucket = ?1", + [bucket], + |r| r.get(0), + ) + .optional()?; + + let Some(Some(retry_after)) = retry_after else { + return Ok(RateLimitDecision::Allow); + }; + let retry_at = parse_time(&retry_after)?; + if retry_at <= now { + return Ok(RateLimitDecision::Allow); + } + Ok(RateLimitDecision::Backoff { + retry_after_secs: (retry_at - now).num_seconds().max(1) as u64, + }) + }) + } + + /// Record a failed attempt and extend the backoff. + /// + /// Delay grows exponentially and is capped. It never becomes a permanent + /// lockout: on a single-account system that would be a denial-of-service + /// against the only person who could undo it. + pub fn record_failure(&self, bucket: &str) -> Result<()> { + const MAX_BACKOFF_SECS: i64 = 15 * 60; + let now = Utc::now(); + + self.with_conn(|conn| { + let attempts: Option = conn + .query_row( + "SELECT attempts FROM rate_limit WHERE bucket = ?1", + [bucket], + |r| r.get(0), + ) + .optional()?; + + let attempts = attempts.unwrap_or(0) + 1; + // No delay for the first few attempts, then 2^n seconds, capped. + let delay = if attempts <= 3 { + 0 + } else { + (1i64 << (attempts - 3).min(20)).min(MAX_BACKOFF_SECS) + }; + let retry_after = (now + Duration::seconds(delay)).to_rfc3339(); + + conn.execute( + "INSERT INTO rate_limit (bucket, attempts, first_at, last_at, retry_after) \ + VALUES (?1, ?2, ?3, ?3, ?4) \ + ON CONFLICT(bucket) DO UPDATE SET \ + attempts = ?2, last_at = ?3, retry_after = ?4", + rusqlite::params![bucket, attempts, now.to_rfc3339(), retry_after], + )?; + Ok(()) + }) + } + + /// Clear a bucket after a success. + pub fn clear_rate_limit(&self, bucket: &str) -> Result<()> { + self.with_conn(|conn| { + conn.execute("DELETE FROM rate_limit WHERE bucket = ?1", [bucket])?; + Ok(()) + }) + } + + // ========================================================================= + // Audit + // ========================================================================= + + /// Append an audit record. Callers must never pass secret material. + pub fn audit(&self, event: &str, detail: Option<&str>, succeeded: bool) -> Result<()> { + self.with_conn(|conn| { + conn.execute( + "INSERT INTO audit_log (at, event, subject, detail, succeeded) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + Utc::now().to_rfc3339(), + event, + ADMIN_SUBJECT, + detail, + i64::from(succeeded), + ], + )?; + Ok(()) + }) + } + + /// Recent audit records, newest first, as `(at, event, detail, succeeded)`. + pub fn recent_audit(&self, limit: u32) -> Result, bool)>> { + self.with_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT at, event, detail, succeeded FROM audit_log \ + ORDER BY id DESC LIMIT ?1", + )?; + let rows = stmt + .query_map([limit], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, Option>(2)?, + r.get::<_, i64>(3)? != 0, + )) + })? + .collect::, _>>()?; + Ok(rows) + }) + } +} + +fn encode_scopes(scopes: &[Scope]) -> String { + scopes + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(" ") +} + +fn decode_scopes(raw: &str) -> Vec { + raw.split_whitespace() + .filter_map(|s| s.parse::().ok()) + .collect() +} + +fn parse_time(raw: &str) -> Result> { + DateTime::parse_from_rfc3339(raw) + .map(|t| t.with_timezone(&Utc)) + .with_context(|| format!("parsing timestamp {raw:?}")) +} + +fn parse_opt_time(raw: Option<&str>) -> Result>> { + raw.map(parse_time).transpose() +} + +#[cfg(test)] +mod tests { + use super::*; + + const GOOD_PASSWORD: &str = "correct horse battery staple"; + + fn store() -> AuthStore { + AuthStore::in_memory().unwrap() + } + + // --- bootstrap ---------------------------------------------------------- + + #[test] + fn test_bootstrap_state_progresses() { + let s = store(); + assert_eq!(s.bootstrap_state().unwrap(), BootstrapState::Uninitialized); + + s.create_admin(GOOD_PASSWORD, true).unwrap(); + assert_eq!( + s.bootstrap_state().unwrap(), + BootstrapState::AwaitingPassword + ); + + s.set_admin_password("a different long password").unwrap(); + assert_eq!(s.bootstrap_state().unwrap(), BootstrapState::Complete); + } + + #[test] + fn test_only_one_admin_can_ever_be_created() { + // The security property behind the bootstrap race: whoever gets there + // first wins, and every other attempt is refused rather than creating + // a second account. + let s = store(); + assert!(s.create_admin(GOOD_PASSWORD, false).unwrap()); + assert!(!s.create_admin("some other long password", false).unwrap()); + + // The first password still works; the loser did not overwrite it. + assert!(s.verify_admin_password(GOOD_PASSWORD).unwrap()); + assert!(!s.verify_admin_password("some other long password").unwrap()); + } + + #[test] + fn test_concurrent_bootstrap_yields_exactly_one_winner() { + let s = store(); + let wins: usize = std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|i| { + let s = s.clone(); + scope.spawn(move || { + s.create_admin(&format!("password number {i:02} long"), false) + .unwrap_or(false) + }) + }) + .collect(); + handles + .into_iter() + .filter_map(|h| h.join().ok()) + .filter(|won| *won) + .count() + }); + assert_eq!(wins, 1, "exactly one concurrent bootstrap must succeed"); + } + + #[test] + fn test_verify_password_on_an_uninitialized_store_is_false_not_an_error() { + let s = store(); + assert!(!s.verify_admin_password(GOOD_PASSWORD).unwrap()); + } + + #[test] + fn test_short_password_is_refused() { + let s = store(); + assert!(s.create_admin("short", false).is_err()); + assert_eq!(s.bootstrap_state().unwrap(), BootstrapState::Uninitialized); + } + + // --- signing key -------------------------------------------------------- + + #[test] + fn test_signing_key_is_generated_once_and_then_reused() { + // Regenerating on every open would invalidate every issued token on + // each restart. + let s = store(); + let first = s.load_or_create_signing_key().unwrap(); + let second = s.load_or_create_signing_key().unwrap(); + assert_eq!(first.kid, second.kid); + assert_eq!(first.public_raw(), second.public_raw()); + } + + // --- sessions ----------------------------------------------------------- + + #[test] + fn test_session_round_trip() { + let s = store(); + let (token, _csrf, _expires) = s.create_session().unwrap(); + let principal = s + .authenticate_session(&token) + .unwrap() + .expect("valid session"); + assert_eq!(principal.subject, ADMIN_SUBJECT); + assert_eq!(principal.kind, PrincipalKind::Session); + assert!(principal.session_id.is_some()); + } + + #[test] + fn test_unknown_or_revoked_session_does_not_authenticate() { + let s = store(); + assert!(s.authenticate_session("nonsense").unwrap().is_none()); + + let (token, _, _) = s.create_session().unwrap(); + let id = s + .authenticate_session(&token) + .unwrap() + .unwrap() + .session_id + .unwrap(); + s.revoke_session(&id).unwrap(); + assert!( + s.authenticate_session(&token).unwrap().is_none(), + "a revoked session must stop authenticating immediately" + ); + } + + #[test] + fn test_session_token_is_not_recoverable_from_the_database() { + let s = store(); + let (token, csrf, _) = s.create_session().unwrap(); + let stored: Vec<(String, String)> = s + .with_conn(|conn| { + let mut stmt = conn.prepare("SELECT token_hash, csrf_hash FROM session")?; + let rows = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::, _>>()?; + Ok(rows) + }) + .unwrap(); + for (token_hash, csrf_hash) in stored { + assert_ne!(token_hash, token); + assert_ne!(csrf_hash, csrf); + } + } + + #[test] + fn test_csrf_token_is_bound_to_its_own_session() { + let s = store(); + let (token_a, csrf_a, _) = s.create_session().unwrap(); + let (_token_b, csrf_b, _) = s.create_session().unwrap(); + let id_a = s + .authenticate_session(&token_a) + .unwrap() + .unwrap() + .session_id + .unwrap(); + + assert!(s.verify_csrf(&id_a, &csrf_a).unwrap()); + assert!( + !s.verify_csrf(&id_a, &csrf_b).unwrap(), + "another session's CSRF token must not satisfy this one" + ); + } + + #[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) + .unwrap() + .unwrap() + .session_id + .unwrap(); + + 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(), + "the superseded CSRF token must stop working" + ); + } + + #[test] + fn test_rotating_csrf_on_an_unknown_session_returns_none() { + let s = store(); + assert!(s.rotate_csrf("no-such-session").unwrap().is_none()); + } + + #[test] + fn test_list_sessions_marks_the_current_one() { + let s = store(); + let (token, _, _) = s.create_session().unwrap(); + let id = s + .authenticate_session(&token) + .unwrap() + .unwrap() + .session_id + .unwrap(); + s.create_session().unwrap(); + + let listed = s.list_sessions(Some(&id)).unwrap(); + assert_eq!(listed.len(), 2); + assert_eq!(listed.iter().filter(|x| x.current).count(), 1); + } + + // --- refresh tokens ----------------------------------------------------- + + #[test] + fn test_refresh_token_rotates_and_the_old_one_dies() { + let s = store(); + let first = s + .create_refresh_family("vscode", &[Scope::Read, Scope::Write]) + .unwrap(); + + let RefreshOutcome::Rotated { + refresh_token: second, + scopes, + } = s.redeem_refresh_token(&first, "vscode").unwrap() + else { + panic!("first redemption should rotate"); + }; + assert_ne!(first, second); + assert_eq!(scopes, vec![Scope::Read, Scope::Write]); + + // The replacement works. + assert!(matches!( + s.redeem_refresh_token(&second, "vscode").unwrap(), + RefreshOutcome::Rotated { .. } + )); + } + + #[test] + fn test_reusing_a_consumed_refresh_token_revokes_the_whole_family() { + // Two parties hold the same token and we cannot tell which is calling, + // so both are cut off rather than serving a possible thief. + let s = store(); + let first = s.create_refresh_family("vscode", &[Scope::Read]).unwrap(); + let RefreshOutcome::Rotated { + refresh_token: second, + .. + } = s.redeem_refresh_token(&first, "vscode").unwrap() + else { + panic!("expected rotation"); + }; + + assert!(matches!( + s.redeem_refresh_token(&first, "vscode").unwrap(), + RefreshOutcome::Reused + )); + + // The legitimate holder's current token is now dead too. + assert!( + matches!( + s.redeem_refresh_token(&second, "vscode").unwrap(), + RefreshOutcome::Invalid + ), + "reuse must revoke the family, not just the replayed token" + ); + } + + #[test] + fn test_unknown_refresh_token_is_invalid() { + let s = store(); + assert!(matches!( + s.redeem_refresh_token("nope", "vscode").unwrap(), + RefreshOutcome::Invalid + )); + } + + #[test] + fn test_refresh_token_is_bound_to_its_client() { + let s = store(); + let token = s.create_refresh_family("vscode", &[Scope::Read]).unwrap(); + + assert!(matches!( + s.redeem_refresh_token(&token, "other-client").unwrap(), + RefreshOutcome::Invalid + )); + assert!(matches!( + s.redeem_refresh_token(&token, "vscode").unwrap(), + RefreshOutcome::Rotated { .. } + )); + } + + #[test] + fn test_rotation_does_not_extend_the_absolute_deadline() { + // The idle clock resets on use; the absolute one is fixed at issuance, + // so a continuously refreshed client still re-authenticates eventually. + let s = store(); + let token = s.create_refresh_family("vscode", &[Scope::Read]).unwrap(); + let before: String = s + .with_conn(|conn| { + Ok( + conn.query_row("SELECT absolute_expires_at FROM refresh_family", [], |r| { + r.get(0) + })?, + ) + }) + .unwrap(); + + s.redeem_refresh_token(&token, "vscode").unwrap(); + + let after: String = s + .with_conn(|conn| { + Ok( + conn.query_row("SELECT absolute_expires_at FROM refresh_family", [], |r| { + r.get(0) + })?, + ) + }) + .unwrap(); + assert_eq!(before, after); + } + + // --- device flow -------------------------------------------------------- + + #[test] + fn test_device_flow_pending_then_approved_then_consumed() { + let s = store(); + let (device_code, user_code) = s + .create_device_authorization("vscode", &Scope::ALL) + .unwrap(); + + assert!(matches!( + s.poll_device(&device_code, "vscode").unwrap(), + DevicePollOutcome::Pending + )); + + let (client_id, scopes) = s.approve_device(&user_code).unwrap().expect("approved"); + assert_eq!(client_id, "vscode"); + assert_eq!(scopes, Scope::ALL.to_vec()); + + // Poll again immediately: the interval is enforced server-side. + assert!(matches!( + s.poll_device(&device_code, "vscode").unwrap(), + DevicePollOutcome::SlowDown + )); + } + + #[test] + fn test_a_device_code_cannot_be_redeemed_twice() { + let s = store(); + let (device_code, user_code) = s + .create_device_authorization("vscode", &[Scope::Read]) + .unwrap(); + s.approve_device(&user_code).unwrap().unwrap(); + + // Backdate the poll clock so the interval check does not mask this. + s.with_conn(|conn| { + conn.execute("UPDATE device_authorization SET last_polled_at = NULL", [])?; + Ok(()) + }) + .unwrap(); + + assert!(matches!( + s.poll_device(&device_code, "vscode").unwrap(), + DevicePollOutcome::Approved { .. } + )); + assert!( + matches!( + s.poll_device(&device_code, "vscode").unwrap(), + DevicePollOutcome::Expired + ), + "a device code must be single-use" + ); + } + + #[test] + fn test_approving_an_unknown_user_code_returns_none() { + let s = store(); + assert!(s.approve_device("ZZZZ-ZZZZ").unwrap().is_none()); + } + + #[test] + fn test_device_code_is_bound_to_its_client() { + let s = store(); + let (device_code, _) = s + .create_device_authorization("vscode", &[Scope::Read]) + .unwrap(); + + assert!(matches!( + s.poll_device(&device_code, "other-client").unwrap(), + DevicePollOutcome::Expired + )); + assert!(matches!( + s.poll_device(&device_code, "vscode").unwrap(), + DevicePollOutcome::Pending + )); + } + + #[test] + fn test_a_device_cannot_be_approved_twice() { + let s = store(); + let (_dc, user_code) = s + .create_device_authorization("vscode", &[Scope::Read]) + .unwrap(); + assert!(s.approve_device(&user_code).unwrap().is_some()); + assert!( + s.approve_device(&user_code).unwrap().is_none(), + "re-approving must not silently succeed" + ); + } + + // --- access keys -------------------------------------------------------- + + #[test] + fn test_access_key_round_trip() { + let s = store(); + let (summary, secret) = s + .create_access_key("ci", &[Scope::Read, Scope::Execute], 30) + .unwrap(); + assert!(secret.starts_with("opk_")); + assert_eq!(summary.scopes, vec![Scope::Read, Scope::Execute]); + + let scopes = s.redeem_access_key(&secret).unwrap().expect("valid key"); + assert_eq!(scopes, vec![Scope::Read, Scope::Execute]); + } + + #[test] + fn test_access_key_secret_is_not_recoverable() { + let s = store(); + let (_summary, secret) = s.create_access_key("ci", &[Scope::Read], 30).unwrap(); + let listed = s.list_access_keys().unwrap(); + let rendered = serde_json::to_string(&listed).unwrap(); + assert!( + !rendered.contains(&secret), + "listing keys must never expose the secret" + ); + } + + #[test] + fn test_revoked_access_key_stops_working() { + let s = store(); + let (summary, secret) = s.create_access_key("ci", &[Scope::Read], 30).unwrap(); + assert!(s.redeem_access_key(&secret).unwrap().is_some()); + + assert!(s.revoke_access_key(&summary.id).unwrap().is_some()); + assert!(s.redeem_access_key(&secret).unwrap().is_none()); + // Revoking twice is not an error, but reports no second revocation. + assert!(s.revoke_access_key(&summary.id).unwrap().is_none()); + } + + #[test] + fn test_access_key_records_last_use() { + let s = store(); + let (summary, secret) = s.create_access_key("ci", &[Scope::Read], 30).unwrap(); + assert!(s.list_access_keys().unwrap()[0].last_used_at.is_none()); + + s.redeem_access_key(&secret).unwrap(); + let listed = s.list_access_keys().unwrap(); + assert_eq!(listed[0].id, summary.id); + assert!( + listed[0].last_used_at.is_some(), + "last use must be tracked so an unused key is visible" + ); + } + + #[test] + fn test_access_key_expiry_is_mandatory_and_scopes_required() { + let s = store(); + assert!(s.create_access_key("ci", &[Scope::Read], 0).is_err()); + assert!(s.create_access_key("ci", &[], 30).is_err()); + } + + // --- revoke everything -------------------------------------------------- + + #[test] + fn test_revoke_all_credentials_cuts_off_every_kind_at_once() { + let s = store(); + let (session, _, _) = s.create_session().unwrap(); + let refresh = s.create_refresh_family("vscode", &[Scope::Read]).unwrap(); + let (_summary, key) = s.create_access_key("ci", &[Scope::Read], 30).unwrap(); + + s.revoke_all_credentials("password reset").unwrap(); + + assert!(s.authenticate_session(&session).unwrap().is_none()); + assert!(matches!( + s.redeem_refresh_token(&refresh, "vscode").unwrap(), + RefreshOutcome::Invalid + )); + assert!(s.redeem_access_key(&key).unwrap().is_none()); + } + + // --- rate limiting ------------------------------------------------------ + + #[test] + fn test_backoff_engages_only_after_a_few_failures() { + let s = store(); + for _ in 0..3 { + assert_eq!( + s.check_rate_limit("login").unwrap(), + RateLimitDecision::Allow + ); + s.record_failure("login").unwrap(); + } + // A typo or two should not cost the operator anything. + assert_eq!( + s.check_rate_limit("login").unwrap(), + RateLimitDecision::Allow + ); + + s.record_failure("login").unwrap(); + assert!(matches!( + s.check_rate_limit("login").unwrap(), + RateLimitDecision::Backoff { .. } + )); + } + + #[test] + fn test_backoff_is_capped_and_never_becomes_a_lockout() { + // On a single-account system a permanent lockout would be a + // denial-of-service against the only person who could undo it. + let s = store(); + for _ in 0..64 { + s.record_failure("login").unwrap(); + } + match s.check_rate_limit("login").unwrap() { + RateLimitDecision::Backoff { retry_after_secs } => { + assert!( + retry_after_secs <= 15 * 60, + "backoff must stay bounded, got {retry_after_secs}s" + ); + } + RateLimitDecision::Allow => panic!("expected backoff after many failures"), + } + } + + #[test] + fn test_success_clears_the_backoff() { + let s = store(); + for _ in 0..8 { + s.record_failure("login").unwrap(); + } + s.clear_rate_limit("login").unwrap(); + assert_eq!( + s.check_rate_limit("login").unwrap(), + RateLimitDecision::Allow + ); + } + + #[test] + fn test_rate_limit_buckets_are_independent() { + let s = store(); + for _ in 0..8 { + s.record_failure("login").unwrap(); + } + assert_eq!( + s.check_rate_limit("bootstrap").unwrap(), + RateLimitDecision::Allow, + "failing to log in must not throttle bootstrap" + ); + } + + // --- audit -------------------------------------------------------------- + + #[test] + fn test_audit_records_are_appended_newest_first() { + let s = store(); + s.audit("login", Some("failure"), false).unwrap(); + s.audit("login", None, true).unwrap(); + + let recent = s.recent_audit(10).unwrap(); + assert_eq!(recent.len(), 2); + assert!(recent[0].3, "newest record should be the successful login"); + assert_eq!(recent[1].2.as_deref(), Some("failure")); + } +} diff --git a/src/auth/tokens.rs b/src/auth/tokens.rs new file mode 100644 index 00000000..baf7b406 --- /dev/null +++ b/src/auth/tokens.rs @@ -0,0 +1,458 @@ +//! Access-token minting and verification (`EdDSA` / Ed25519). +//! +//! Access tokens are deliberately **not revocable individually** — checking a +//! revocation list on every request would put a database read in the hot path. +//! Their blast radius is bounded by a short expiry instead, which is why +//! [`ACCESS_TOKEN_TTL`] is 15 minutes and why anything longer-lived (sessions, +//! refresh tokens, access keys) is opaque and database-backed so it *can* be +//! revoked. + +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use serde::{Deserialize, Serialize}; + +/// Length of a raw Ed25519 public key. +const ED25519_PUBLIC_KEY_LEN: usize = 32; + +use crate::rest::dto::auth::Scope; + +/// Lifetime of a normal access token. +pub const ACCESS_TOKEN_TTL: Duration = Duration::minutes(15); + +/// Token issuer, and the audience for ordinary API access. +pub const ISSUER: &str = "operator"; +/// Audience for tokens used against the REST API and MCP. +pub const AUDIENCE_API: &str = "operator-api"; +/// Audience for single-purpose agent step-completion callbacks. +pub const AUDIENCE_CALLBACK: &str = "opr8r-callback"; + +/// Registered and Operator-specific claims. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + /// Issuer. + pub iss: String, + /// Audience. + pub aud: String, + /// Subject — the account the token acts as. + pub sub: String, + /// Space-separated scopes, per OAuth convention. + pub scope: String, + /// Expiry (seconds since epoch). + pub exp: i64, + /// Issued at (seconds since epoch). + pub iat: i64, + /// Token id, so a specific token can be named in an audit record. + pub jti: String, + /// Ticket a callback token is pinned to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ticket_id: Option, + /// Step a callback token is pinned to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub step: Option, + /// Agent session a callback token is pinned to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +impl Claims { + /// Parse the space-separated `scope` claim, ignoring unknown entries so a + /// token minted by a newer build does not fail closed on an unknown scope. + pub fn scopes(&self) -> Vec { + self.scope + .split_whitespace() + .filter_map(|s| s.parse::().ok()) + .collect() + } +} + +/// An Ed25519 keypair used to sign and verify tokens. +/// +/// Stored as DER rather than PEM because that is exactly what the underlying +/// signer wants: `jsonwebtoken`'s `EdDSA` path hands the private key straight to +/// `ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked`, and the +/// verifier wants the bare 32-byte public key. Keeping DER end-to-end avoids +/// hand-rolling ASN.1 to convert between the two. +pub struct SigningKey { + /// Key id, carried in the JWT header so a rotation can still verify tokens issued under the previous key. + pub kid: String, + /// PKCS#8 DER of the private key. + private_der: Vec, + /// Raw 32-byte public key. + public_raw: Vec, + encoding: EncodingKey, + decoding: DecodingKey, +} + +impl std::fmt::Debug for SigningKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print key material, even accidentally through a derived Debug. + f.debug_struct("SigningKey") + .field("kid", &self.kid) + .finish_non_exhaustive() + } +} + +impl SigningKey { + /// Generate a fresh Ed25519 keypair. + pub fn generate(kid: impl Into) -> Result { + let rng = SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng) + .map_err(|_| anyhow!("generating Ed25519 keypair failed"))?; + let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()) + .map_err(|_| anyhow!("freshly generated Ed25519 keypair did not parse"))?; + + Self::from_der( + kid, + pkcs8.as_ref().to_vec(), + pair.public_key().as_ref().to_vec(), + ) + } + + /// Rebuild from the DER material stored in the auth database. + pub fn from_der( + kid: impl Into, + private_der: Vec, + public_raw: Vec, + ) -> Result { + // Fail here rather than at first sign/verify, so a corrupt row surfaces + // at startup instead of as a mysterious 401 later. + Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private_der) + .map_err(|_| anyhow!("stored Ed25519 private key is not valid PKCS#8"))?; + if public_raw.len() != ED25519_PUBLIC_KEY_LEN { + return Err(anyhow!( + "stored Ed25519 public key must be {ED25519_PUBLIC_KEY_LEN} bytes, got {}", + public_raw.len() + )); + } + + Ok(Self { + kid: kid.into(), + encoding: EncodingKey::from_ed_der(&private_der), + decoding: DecodingKey::from_ed_der(&public_raw), + private_der, + public_raw, + }) + } + + /// PKCS#8 DER of the private key, for persistence. Handle as a secret. + pub fn private_der(&self) -> &[u8] { + &self.private_der + } + + /// Raw public key bytes, for persistence. + pub fn public_raw(&self) -> &[u8] { + &self.public_raw + } + + /// Mint a signed token. + pub fn sign(&self, claims: &Claims) -> Result { + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.kid.clone()); + jsonwebtoken::encode(&header, claims, &self.encoding).context("signing access token") + } + + /// Verify a token's signature, issuer, audience, and expiry. + /// + /// `audience` is required rather than optional: verifying without pinning + /// it would let a narrowly scoped agent callback token be replayed against + /// the full REST API. + pub fn verify(&self, token: &str, audience: &str) -> Result { + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_issuer(&[ISSUER]); + validation.set_audience(&[audience]); + validation.validate_exp = true; + + jsonwebtoken::decode::(token, &self.decoding, &validation) + .map(|data| data.claims) + .map_err(|e| anyhow!("token rejected: {e}")) + } +} + +/// Build the claims for an ordinary API access token. +pub fn api_claims(subject: &str, scopes: &[Scope], now: DateTime, jti: String) -> Claims { + Claims { + iss: ISSUER.to_string(), + aud: AUDIENCE_API.to_string(), + sub: subject.to_string(), + scope: scopes + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(" "), + exp: (now + ACCESS_TOKEN_TTL).timestamp(), + iat: now.timestamp(), + jti, + ticket_id: None, + step: None, + session_id: None, + } +} + +/// Build the claims for an agent step-completion callback token. +/// +/// The lifetime is the step's, not [`ACCESS_TOKEN_TTL`]: a step may legitimately +/// run for hours, and a callback that expired mid-run would strand the agent +/// with completed work it cannot report. What bounds this token is not time but +/// its claims — it carries only `execute`, is pinned to one ticket, step, and +/// session, and is issued for the `opr8r-callback` audience, so it is useless +/// against any other route. +pub fn callback_claims( + subject: &str, + ticket_id: &str, + step: &str, + session_id: &str, + ttl: Duration, + now: DateTime, + jti: String, +) -> Claims { + Claims { + iss: ISSUER.to_string(), + aud: AUDIENCE_CALLBACK.to_string(), + sub: subject.to_string(), + scope: Scope::Execute.as_str().to_string(), + exp: (now + ttl).timestamp(), + iat: now.timestamp(), + jti, + ticket_id: Some(ticket_id.to_string()), + step: Some(step.to_string()), + session_id: Some(session_id.to_string()), + } +} + +#[cfg(test)] +pub(crate) mod test_support { + use super::SigningKey; + + /// A freshly generated keypair for tests. Generating beats pinning PEM + /// literals: the test then exercises the same code path production uses to + /// create a key on first initialization. + pub fn key() -> SigningKey { + SigningKey::generate("test-kid").expect("generating a test keypair") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_scope_claim_uses_space_separated_oauth_form() { + let claims = api_claims( + "admin", + &[Scope::Read, Scope::Execute], + Utc::now(), + "jti".to_string(), + ); + assert_eq!(claims.scope, "read execute"); + assert_eq!(claims.scopes(), vec![Scope::Read, Scope::Execute]); + } + + #[test] + fn test_unknown_scopes_in_a_claim_are_ignored_not_fatal() { + // A token minted by a newer build may name a scope this build does not + // know. Dropping it is correct; failing the whole token is not. + let claims = Claims { + scope: "read future-scope execute".to_string(), + ..api_claims("admin", &[], Utc::now(), "jti".to_string()) + }; + assert_eq!(claims.scopes(), vec![Scope::Read, Scope::Execute]); + } + + #[test] + fn test_api_token_expires_in_fifteen_minutes() { + let now = Utc::now(); + let claims = api_claims("admin", &[Scope::Read], now, "jti".to_string()); + assert_eq!(claims.exp - claims.iat, 15 * 60); + } + + #[test] + fn test_callback_claims_are_pinned_and_execute_only() { + let claims = callback_claims( + "admin", + "FEAT-1", + "build", + "sess-9", + Duration::hours(24), + Utc::now(), + "jti".to_string(), + ); + assert_eq!(claims.aud, AUDIENCE_CALLBACK); + assert_eq!(claims.scope, "execute"); + assert_eq!(claims.ticket_id.as_deref(), Some("FEAT-1")); + assert_eq!(claims.step.as_deref(), Some("build")); + assert_eq!(claims.session_id.as_deref(), Some("sess-9")); + assert_eq!(claims.scopes(), vec![Scope::Execute]); + } + + #[test] + fn test_signing_key_debug_never_prints_key_material() { + let key = test_support::key(); + let rendered = format!("{key:?}"); + assert!(rendered.contains("test-kid")); + assert!(!rendered.contains("PRIVATE")); + assert!(!rendered.contains("BEGIN")); + } + + #[test] + fn test_round_trip_sign_and_verify() { + let key = test_support::key(); + let claims = api_claims( + "admin", + &[Scope::Read, Scope::Write], + Utc::now(), + "jti-1".to_string(), + ); + let token = key.sign(&claims).unwrap(); + + let verified = key.verify(&token, AUDIENCE_API).unwrap(); + assert_eq!(verified.sub, "admin"); + assert_eq!(verified.jti, "jti-1"); + assert_eq!(verified.scopes(), vec![Scope::Read, Scope::Write]); + } + + #[test] + fn test_header_carries_kid_and_eddsa() { + let key = test_support::key(); + let token = key + .sign(&api_claims("admin", &[], Utc::now(), "j".to_string())) + .unwrap(); + let header = jsonwebtoken::decode_header(&token).unwrap(); + assert_eq!(header.alg, Algorithm::EdDSA); + assert_eq!(header.kid.as_deref(), Some("test-kid")); + } + + #[test] + fn test_callback_token_is_rejected_against_the_api_audience() { + // The whole point of pinning `aud`: a narrowly scoped callback token + // must not be replayable against the full REST API. + let key = test_support::key(); + let token = key + .sign(&callback_claims( + "admin", + "FEAT-1", + "build", + "sess", + Duration::hours(1), + Utc::now(), + "j".to_string(), + )) + .unwrap(); + + assert!(key.verify(&token, AUDIENCE_CALLBACK).is_ok()); + assert!( + key.verify(&token, AUDIENCE_API).is_err(), + "a callback token must not authenticate ordinary API requests" + ); + } + + #[test] + fn test_expired_token_is_rejected() { + let key = test_support::key(); + let past = Utc::now() - Duration::hours(2); + let token = key + .sign(&api_claims("admin", &[Scope::Read], past, "j".to_string())) + .unwrap(); + assert!(key.verify(&token, AUDIENCE_API).is_err()); + } + + #[test] + fn test_tampered_token_fails_signature_verification() { + let key = test_support::key(); + let token = key + .sign(&api_claims( + "admin", + &[Scope::Read], + Utc::now(), + "j".to_string(), + )) + .unwrap(); + + // Flip a character in the payload segment. + let mut parts: Vec<&str> = token.split('.').collect(); + let payload = parts[1].to_string(); + let mutated = match payload.strip_prefix('e') { + Some(rest) => format!("f{rest}"), + None => format!("e{}", &payload[1..]), + }; + parts[1] = &mutated; + let tampered = parts.join("."); + + assert!(key.verify(&tampered, AUDIENCE_API).is_err()); + } + + #[test] + fn test_key_survives_a_persistence_round_trip() { + // The database stores DER; a token signed before a restart must still + // verify after one, or every issued credential dies on restart. + let original = SigningKey::generate("kid-1").unwrap(); + let token = original + .sign(&api_claims( + "admin", + &[Scope::Admin], + Utc::now(), + "j".to_string(), + )) + .unwrap(); + + let reloaded = SigningKey::from_der( + "kid-1", + original.private_der().to_vec(), + original.public_raw().to_vec(), + ) + .unwrap(); + + let claims = reloaded.verify(&token, AUDIENCE_API).unwrap(); + assert_eq!(claims.sub, "admin"); + } + + #[test] + fn test_each_generated_key_is_distinct() { + let a = SigningKey::generate("a").unwrap(); + let b = SigningKey::generate("b").unwrap(); + assert_ne!(a.public_raw(), b.public_raw()); + assert_ne!(a.private_der(), b.private_der()); + } + + #[test] + fn test_a_token_does_not_verify_under_a_different_key() { + // Re-bootstrapping generates a new key, which must invalidate every + // token issued under the old one. + let old_key = SigningKey::generate("old").unwrap(); + let new_key = SigningKey::generate("new").unwrap(); + let token = old_key + .sign(&api_claims( + "admin", + &[Scope::Read], + Utc::now(), + "j".to_string(), + )) + .unwrap(); + + assert!(old_key.verify(&token, AUDIENCE_API).is_ok()); + assert!(new_key.verify(&token, AUDIENCE_API).is_err()); + } + + #[test] + fn test_corrupt_stored_key_material_is_rejected_at_load() { + // Surfacing this at startup beats a mysterious 401 on first request. + assert!(SigningKey::from_der("kid", vec![0u8; 16], vec![0u8; 32]).is_err()); + + let good = SigningKey::generate("kid").unwrap(); + assert!( + SigningKey::from_der("kid", good.private_der().to_vec(), vec![0u8; 31]).is_err(), + "a public key of the wrong length must be rejected" + ); + } + + #[test] + fn test_token_from_a_different_issuer_is_rejected() { + let key = test_support::key(); + let mut claims = api_claims("admin", &[Scope::Read], Utc::now(), "j".to_string()); + claims.iss = "somebody-else".to_string(); + let token = key.sign(&claims).unwrap(); + assert!(key.verify(&token, AUDIENCE_API).is_err()); + } +} diff --git a/src/collections/fetch.rs b/src/collections/fetch.rs index 99141ee3..1efff618 100644 --- a/src/collections/fetch.rs +++ b/src/collections/fetch.rs @@ -108,12 +108,14 @@ pub struct FetchedCollection { } fn http_client(timeout_secs: u64) -> Result { - Ok(reqwest::Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .build()?) + crate::auth::egress::validated_client( + crate::auth::egress::EgressPolicy::default(), + Duration::from_secs(timeout_secs), + ) } async fn get_bytes(client: &reqwest::Client, url: &str) -> Result> { + crate::auth::egress::validate(url, &crate::auth::egress::EgressPolicy::default())?; let response = client.get(url).send().await?; let status = response.status(); if !status.is_success() { diff --git a/src/config.rs b/src/config.rs index 8b40c0c1..c88d6469 100644 --- a/src/config.rs +++ b/src/config.rs @@ -294,9 +294,15 @@ pub struct RestApiConfig { /// Port for the REST API server #[serde(default = "default_rest_port")] pub port: u16, - /// CORS allowed origins (empty = allow all) + /// CORS allowed origins. Empty means **same-origin only** #[serde(default)] pub cors_origins: Vec, + /// Externally reachable base URL (e.g. `https://operator.example.com`). + /// + /// OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, + /// which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. + #[serde(default)] + pub public_url: Option, } fn default_rest_enabled() -> bool { @@ -318,6 +324,7 @@ impl Default for RestApiConfig { host: default_rest_host(), port: default_rest_port(), cors_origins: Vec::new(), + public_url: None, } } } @@ -330,6 +337,15 @@ impl RestApiConfig { .parse() .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)) } + + /// The configured public base URL, trailing slash trimmed. + pub fn public_base_url(&self) -> Option { + self.public_url + .as_deref() + .map(str::trim) + .filter(|u| !u.is_empty()) + .map(|u| u.trim_end_matches('/').to_string()) + } } /// Model Context Protocol (MCP) server configuration @@ -662,6 +678,19 @@ pub struct RelayConfig { pub auto_inject_mcp: bool, } +/// Environment-variable source for `Config::load`. +/// +/// `prefix_separator` is explicit because `config`'s default for it is the +/// value of `separator`, not `_`: with `separator("__")` alone the source only +/// matches `OPERATOR__REST_API__HOST`, so every documented `OPERATOR_*__*` +/// variable is silently ignored. +fn env_source() -> config::Environment { + config::Environment::with_prefix("OPERATOR") + .prefix_separator("_") + .separator("__") + .try_parsing(true) +} + impl Config { /// Path to the operator config file within .tickets/ pub fn operator_config_path() -> PathBuf { @@ -699,11 +728,7 @@ impl Config { } // Environment variables with OPERATOR_ prefix - builder = builder.add_source( - config::Environment::with_prefix("OPERATOR") - .separator("__") - .try_parsing(true), - ); + builder = builder.add_source(env_source()); let config = builder.build().context("Failed to load configuration")?; let cfg: Self = config.try_deserialize().map_err(|e| { @@ -740,12 +765,14 @@ impl Config { } validate_targets(&cfg)?; + crate::git::identity::validate_config(&cfg)?; Ok(cfg) } /// Save config to .tickets/operator/config.toml pub fn save(&self) -> Result<()> { + crate::git::identity::validate_config(self)?; let config_path = Self::operator_config_path(); // Ensure parent directory exists @@ -757,7 +784,15 @@ impl Config { let toml_str = toml::to_string_pretty(self).context("Failed to serialize config to TOML")?; - std::fs::write(&config_path, toml_str).context("Failed to write config file")?; + // Write to a sibling temp file and rename over the target. A plain + // write truncates first, so a crash or a full disk mid-write leaves a + // half-written config.toml that will not parse. prevents startup failure later + let temp_path = config_path.with_extension(format!("toml.tmp.{}", uuid::Uuid::new_v4())); + std::fs::write(&temp_path, toml_str).context("Failed to write config file")?; + if let Err(e) = std::fs::rename(&temp_path, &config_path) { + let _ = std::fs::remove_file(&temp_path); + return Err(e).context("Failed to replace config file"); + } Ok(()) } @@ -949,6 +984,51 @@ mod tests { let dir = default_worktrees_dir(); assert!(dir.contains("worktrees")); } + + // --- Environment override mapping --- + // + // `env_source().source(Some(map))` feeds a fixed map instead of the process + // environment, so these run in parallel without touching real env vars. + + fn config_from_env(vars: &[(&str, &str)]) -> Config { + let map: std::collections::HashMap = vars + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + let defaults_json = serde_json::to_string(&Config::default()).unwrap(); + config::Config::builder() + .add_source(config::File::from_str( + &defaults_json, + config::FileFormat::Json, + )) + .add_source(env_source().source(Some(map))) + .build() + .unwrap() + .try_deserialize() + .unwrap() + } + + #[test] + fn test_env_override_applies_to_nested_rest_api_fields() { + let cfg = config_from_env(&[ + ("OPERATOR_REST_API__HOST", "0.0.0.0"), + ("OPERATOR_REST_API__PORT", "7099"), + ]); + assert_eq!(cfg.rest_api.host, "0.0.0.0"); + assert_eq!(cfg.rest_api.port, 7099); + } + + #[test] + fn test_env_override_applies_to_paths_worktrees() { + let cfg = config_from_env(&[("OPERATOR_PATHS__WORKTREES", "/op/.worktrees")]); + assert_eq!(cfg.paths.worktrees, "/op/.worktrees"); + } + + #[test] + fn test_env_override_leaves_unset_fields_at_default() { + let cfg = config_from_env(&[("OPERATOR_REST_API__HOST", "0.0.0.0")]); + assert_eq!(cfg.rest_api.port, default_rest_port()); + } } #[cfg(test)] diff --git a/src/config/agent_profile.rs b/src/config/agent_profile.rs index 917c0693..4bf59a6d 100644 --- a/src/config/agent_profile.rs +++ b/src/config/agent_profile.rs @@ -75,6 +75,9 @@ pub struct AgentProfile { #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] #[ts(export)] pub struct XOperator { + /// Optional Git identity, HTTPS credential reference, and runtime settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, /// Optional display name for UI. #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, @@ -93,7 +96,8 @@ pub struct XOperator { impl XOperator { /// Whether this bag carries any Operator-specific data worth serializing. fn is_empty(&self) -> bool { - self.display_name.is_none() + self.git.is_none() + && self.display_name.is_none() && self.model_properties.is_empty() && self.model_server.is_none() && self.launch_config.is_none() @@ -139,6 +143,7 @@ pub fn delegator_to_profile(d: &Delegator) -> AgentProfile { .unwrap_or_default(); let x_operator = XOperator { + git: d.git.clone(), display_name: d.display_name.clone(), model_properties: d.model_properties.clone(), model_server: d.model_server.clone(), @@ -186,6 +191,7 @@ pub fn profile_to_delegator(p: &AgentProfile) -> Delegator { }; Delegator { + git: x.git, name: p.name.clone(), llm_tool: p.provider.clone(), model: p.model.clone(), @@ -216,6 +222,7 @@ mod tests { let mut props = std::collections::HashMap::new(); props.insert("reasoning_effort".to_string(), "high".to_string()); Delegator { + git: None, name: "claude-opus-auto".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), @@ -270,6 +277,7 @@ mod tests { #[test] fn delegator_with_no_operator_data_has_no_x_operator() { let d = Delegator { + git: None, name: "bare".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), diff --git a/src/config/config_tests.rs b/src/config/config_tests.rs index c98fc930..3b45092a 100644 --- a/src/config/config_tests.rs +++ b/src/config/config_tests.rs @@ -23,6 +23,7 @@ fn test_dev_kanban_has_three_issue_types() { #[test] fn test_delegator_serde_roundtrip() { let delegator = Delegator { + git: None, name: "claude-opus-auto".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), diff --git a/src/config/git_config.rs b/src/config/git_config.rs index 06558ad0..1e4efb03 100644 --- a/src/config/git_config.rs +++ b/src/config/git_config.rs @@ -10,6 +10,13 @@ use crate::types::pr::GitProvider; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct GitConfig { + /// Default commit identity for delegated work. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity: Option, + #[serde(default)] + pub gitea: GiteaConfig, + #[serde(default)] + pub forgejo: ForgejoConfig, /// Active provider (auto-detected from remote URL if not specified) #[serde(default)] pub provider: Option, @@ -35,6 +42,9 @@ fn default_branch_format() -> String { impl Default for GitConfig { fn default() -> Self { Self { + identity: None, + gitea: GiteaConfig::default(), + forgejo: ForgejoConfig::default(), provider: None, github: GitHubConfig::default(), gitlab: GitLabConfig::default(), @@ -115,6 +125,151 @@ fn default_gitlab_token_env() -> String { "GITLAB_TOKEN".to_string() } +/// Commit identity template for delegated work. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema, PartialEq, Eq)] +#[ts(export)] +pub struct GitIdentityConfig { + pub name: String, + pub email: String, +} + +/// Supplied HTTPS credential, bound to a repository; contains no secret value. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema, PartialEq, Eq)] +#[ts(export)] +pub struct GitCredentialConfig { + pub repository_url: String, + pub username: String, + pub token_env: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema, PartialEq, Eq)] +#[ts(export)] +pub struct GitConfigEntry { + pub key: String, + pub value: String, +} + +/// Git settings owned by a named delegator. +#[derive( + Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema, PartialEq, Eq, +)] +#[ts(export)] +pub struct GitExecutionConfig { + #[serde(default)] + pub identity: Option, + #[serde(default)] + pub credentials: Option, + #[serde(default)] + pub settings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct GiteaConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_gitea_token_env")] + pub token_env: String, + /// HTTPS host or base URL; defaults to gitea.com. + #[serde(default)] + pub host: Option, + #[serde(default = "default_wip_prefix")] + pub wip_prefix: String, +} + +fn default_gitea_token_env() -> String { + "GITEA_TOKEN".into() +} + +impl Default for GiteaConfig { + fn default() -> Self { + Self { + enabled: false, + token_env: default_gitea_token_env(), + host: None, + wip_prefix: default_wip_prefix(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct ForgejoConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_forgejo_token_env")] + pub token_env: String, + /// HTTPS host or base URL; defaults to codeberg.org. + #[serde(default)] + pub host: Option, + #[serde(default = "default_wip_prefix")] + pub wip_prefix: String, +} + +fn default_forgejo_token_env() -> String { + "FORGEJO_TOKEN".into() +} + +impl Default for ForgejoConfig { + fn default() -> Self { + Self { + enabled: false, + token_env: default_forgejo_token_env(), + host: None, + wip_prefix: default_wip_prefix(), + } + } +} + +fn default_wip_prefix() -> String { + "WIP: ".into() +} + +#[cfg(test)] +mod delegation_tests { + use super::*; + #[test] + fn git_defaults_have_no_identity_and_provider_defaults_agree() { + assert!(GitConfig::default().identity.is_none()); + let gitea: GiteaConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(gitea.token_env, GiteaConfig::default().token_env); + assert!(!gitea.enabled); + let forgejo: ForgejoConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(forgejo.token_env, ForgejoConfig::default().token_env); + assert!(!forgejo.enabled); + } + #[test] + fn credentials_reject_embedded_tokens_and_managed_config_overrides() { + let credentials = GitCredentialConfig { + repository_url: "https://user:secret@git.example/a/b".into(), + username: "bot".into(), + token_env: "TOKEN".into(), + }; + assert!(GitExecutionConfig { + credentials: Some(credentials), + ..Default::default() + } + .validate() + .is_err()); + for key in [ + "credential.helper", + "user.name", + "http.extraHeader", + "include.path", + ] { + assert!(GitExecutionConfig { + settings: vec![GitConfigEntry { + key: key.into(), + value: "value".into() + }], + ..Default::default() + } + .validate() + .is_err()); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/config/llm_tools.rs b/src/config/llm_tools.rs index b099974c..00f78426 100644 --- a/src/config/llm_tools.rs +++ b/src/config/llm_tools.rs @@ -151,6 +151,9 @@ pub struct RemoteAgentRef { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct Delegator { + /// Optional Git identity, HTTPS credential reference, and runtime settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, /// Unique name for this delegator (e.g., "claude-opus-auto") pub name: String, /// LLM tool name (must match a detected tool, e.g., "claude", "codex") @@ -344,6 +347,7 @@ mod tests { #[test] fn delegator_serializes_omits_none_new_fields() { let d = Delegator { + git: None, name: "claude-opus".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), diff --git a/src/config/targets.rs b/src/config/targets.rs index 9f68f203..281d1060 100644 --- a/src/config/targets.rs +++ b/src/config/targets.rs @@ -445,6 +445,7 @@ template = "operator-agent" fn test_validate_targets_unknown_delegator_reference_error() { let mut config = config_with_targets(vec![]); config.delegators.push(crate::config::Delegator { + git: None, name: "heavy".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), @@ -480,6 +481,7 @@ template = "operator-agent" }); for name in ["local", "docker", "gpu-vm", "legacy-host"] { config.delegators = vec![crate::config::Delegator { + git: None, name: "d".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), diff --git a/src/docs_gen/llms.rs b/src/docs_gen/llms.rs index e70ef4b7..9c9485f9 100644 --- a/src/docs_gen/llms.rs +++ b/src/docs_gen/llms.rs @@ -61,6 +61,10 @@ const SECTIONS: &[Section] = &[ slug: "getting-started/agents", fallback_desc: "Supported coding agents, lifecycle, and modes.", }, + Link { + slug: "getting-started/platforms/kubernetes", + fallback_desc: "Run Operator in a cluster from the OCI Helm chart.", + }, Link { slug: "downloads", fallback_desc: "", @@ -132,6 +136,14 @@ const SECTIONS: &[Section] = &[ slug: "artifact-detection", fallback_desc: "Using produced files as step-completion signals.", }, + Link { + slug: "security", + fallback_desc: "Threat model, trust boundaries, and residual risks.", + }, + Link { + slug: "security/authentication", + fallback_desc: "Admin account, scopes, tokens, and recovery.", + }, ], extra: &[], }, @@ -208,8 +220,13 @@ fn render_item(title: &str, url: &str, desc: &str) -> String { /// /// Falls back to a title-cased slug when the page or its `title` is missing. fn read_front_matter(docs_root: &Path, slug: &str) -> (String, Option) { - let path = docs_root.join(slug).join("index.md"); - let content = std::fs::read_to_string(&path).unwrap_or_default(); + // A section slug resolves to either `/index.md` or `.md`; + // Jekyll's `permalink: pretty` serves both at `//`. + let index = docs_root.join(slug).join("index.md"); + let flat = docs_root.join(format!("{slug}.md")); + let content = std::fs::read_to_string(&index) + .or_else(|_| std::fs::read_to_string(&flat)) + .unwrap_or_default(); let mut title = None; let mut description = None; @@ -269,24 +286,29 @@ mod tests { /// `read_front_matter` swallows a missing file, so a slug pointing at a /// deleted or renamed page yields a title-cased slug and a live link to a /// 404 with no error anywhere. Assert every listed page actually exists. + /// + /// A slug resolves through either `docs//index.md` or the flat + /// `docs/.md`; `permalink: pretty` serves both at `//`, and the + /// generator reads front matter from whichever exists. #[test] fn test_every_listed_slug_resolves_to_a_real_page() { let docs_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs"); for section in super::SECTIONS { for link in section.links { - let path = docs_root.join(link.slug).join("index.md"); + let index = docs_root.join(link.slug).join("index.md"); + let flat = docs_root.join(format!("{}.md", link.slug)); + let path = if index.is_file() { &index } else { &flat }; assert!( path.is_file(), - "llms.txt lists '{}' under '{}', but {} does not exist. The generator \ - requires docs//index.md specifically — a flat .md yields \ - empty output and a broken link.", + "llms.txt lists '{}' under '{}', but neither {} nor {} exists.", link.slug, section.heading, - path.display() + index.display(), + flat.display() ); // Existing on disk is not enough: Jekyll skips `published: false` // pages entirely, so the link would still 404. - let content = std::fs::read_to_string(&path).expect("page reads"); + let content = std::fs::read_to_string(path).expect("page reads"); assert!( !content.contains("published: false"), "llms.txt lists '{}', but {} is `published: false` — Jekyll will not \ diff --git a/src/docs_gen/openapi.rs b/src/docs_gen/openapi.rs index 12d39c85..3f0bfcb1 100644 --- a/src/docs_gen/openapi.rs +++ b/src/docs_gen/openapi.rs @@ -1,6 +1,6 @@ //! OpenAPI specification documentation generator. //! -//! Generates OpenAPI 3.0 specification from utoipa annotations. +//! Generates the OpenAPI specification from utoipa annotations. use anyhow::Result; diff --git a/src/docs_gen/schema_index.rs b/src/docs_gen/schema_index.rs index 289fb7d9..43c921ff 100644 --- a/src/docs_gen/schema_index.rs +++ b/src/docs_gen/schema_index.rs @@ -26,7 +26,7 @@ impl DocGenerator for SchemaIndexDocGenerator { let mut output = format_header("Schema Reference", self.source()); output.push_str( - "This section documents all JSON schemas and type definitions used by Operator.\n\n", + "This section documents Operator's file schemas and public REST API contract.\n\n", ); // Documentation pages @@ -79,7 +79,7 @@ impl DocGenerator for SchemaIndexDocGenerator { ], vec![ "[openapi.json](openapi.json)".to_string(), - "OpenAPI 3.0".to_string(), + "OpenAPI 3.1".to_string(), "REST API specification (generated via utoipa)".to_string(), ], vec![ @@ -95,25 +95,15 @@ impl DocGenerator for SchemaIndexDocGenerator { ]; output.push_str(&table(json_headers, &json_rows)); - // TypeScript types - output.push_str(&heading(2, "TypeScript Types")); - output.push_str( - "TypeScript type definitions are available for frontend integration:\n\n\ - - Source: `shared/types.ts` (generated via ts-rs)\n\ - - API docs can be generated locally with `npm run docs:typescript`\n\n", - ); - // Regeneration instructions output.push_str(&heading(2, "Regenerating Schemas")); output.push_str( "Schemas are auto-generated from source code. To regenerate:\n\n\ ```bash\n\ - # Generate JSON schemas and TypeScript types\n\ + # Generate JSON schemas\n\ cargo run --bin generate_types\n\n\ # Generate documentation pages\n\ - cargo run -- docs\n\n\ - # Generate TypeScript API docs\n\ - npm run docs:typescript\n\ + cargo run -- docs\n\ ```\n", ); @@ -148,6 +138,8 @@ mod tests { assert!(result.contains("config.json")); assert!(result.contains("state.json")); assert!(result.contains("openapi.json")); + assert!(result.contains("OpenAPI 3.1")); + assert!(!result.contains("TypeScript Types")); // Should have regeneration instructions assert!(result.contains("cargo run --bin generate_types")); diff --git a/src/env_vars.rs b/src/env_vars.rs index 45e92567..da6e7b86 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -259,6 +259,14 @@ pub static ENV_VARS: &[EnvVar] = &[ default: Some(".tickets/operator"), example: Some("/var/lib/operator/state"), }, + EnvVar { + name: "OPERATOR_PATHS__WORKTREES", + description: "Directory for per-ticket git worktrees", + category: EnvVarCategory::Paths, + required: false, + default: Some("~/.operator/worktrees"), + example: Some("/op/.worktrees"), + }, // === UI === EnvVar { name: "OPERATOR_UI__REFRESH_RATE_MS", diff --git a/src/git/cli.rs b/src/git/cli.rs index 59f701b0..d3e7a41f 100644 --- a/src/git/cli.rs +++ b/src/git/cli.rs @@ -17,7 +17,9 @@ impl GitCli { async fn run_git(args: &[&str], cwd: &Path) -> Result { debug!(?args, ?cwd, "Running git command"); - let output = Command::new("git") + let mut command = Command::new("git"); + let _git_runtime = crate::git::runtime::configure_command(&mut command)?; + let output = command .args(args) .current_dir(cwd) .stdout(Stdio::piped()) diff --git a/src/git/identity.rs b/src/git/identity.rs new file mode 100644 index 00000000..59aa3405 --- /dev/null +++ b/src/git/identity.rs @@ -0,0 +1,233 @@ +use crate::config::{Config, GitExecutionConfig, GitIdentityConfig}; +use crate::queue::Ticket; +use anyhow::{bail, ensure, Result}; + +pub const IDENTITY_ENV_NAMES: &[&str] = &[ + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitIdentity { + pub name: String, + pub email: String, +} + +pub fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +impl GitIdentity { + pub fn to_export_block(&self) -> String { + use std::fmt::Write; + let mut exports = String::new(); + for key in IDENTITY_ENV_NAMES { + let value = if key.ends_with("NAME") { + &self.name + } else { + &self.email + }; + writeln!(exports, "export {key}={}", shell_quote(value)).expect("writing to String"); + } + exports + } +} + +fn interpolate( + template: &str, + ticket_id: &str, + project: &str, + ticket_type: &str, +) -> Result { + let mut result = String::new(); + let mut rest = template; + while let Some(start) = rest.find('{') { + ensure!( + !rest[..start].contains('}'), + "Invalid Git identity placeholder" + ); + result.push_str(&rest[..start]); + let end = rest[start..] + .find('}') + .ok_or_else(|| anyhow::anyhow!("Unclosed Git identity placeholder"))? + + start; + result.push_str(match &rest[start + 1..end] { + "ticket_id" => ticket_id, + "project" => project, + "ticket_type" => ticket_type, + _ => bail!("Unknown Git identity placeholder"), + }); + rest = &rest[end + 1..]; + } + ensure!(!rest.contains('}'), "Invalid Git identity placeholder"); + result.push_str(rest); + ensure!( + !result.trim().is_empty() && !result.contains(['\0', '\n', '\r']), + "Git identity must be nonempty and single-line" + ); + Ok(result) +} + +pub fn valid_env_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .enumerate() + .all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())) +} + +impl GitExecutionConfig { + pub fn validate(&self) -> Result<()> { + if let Some(identity) = &self.identity { + interpolate(&identity.name, "ticket", "project", "type")?; + interpolate(&identity.email, "ticket", "project", "type")?; + } + if let Some(credentials) = &self.credentials { + let url = credential_url(&credentials.repository_url)?; + ensure!( + url.path().trim_matches('/').contains('/'), + "Git credential URL must identify a repository" + ); + ensure!( + valid_env_name(&credentials.token_env), + "Invalid Git token environment variable name" + ); + ensure!( + !credentials.username.is_empty() + && !credentials.username.contains(['\0', '\n', '\r']), + "Invalid Git credential username" + ); + } + for entry in &self.settings { + let key = entry.key.to_ascii_lowercase(); + ensure!( + key.contains('.') + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-')), + "Invalid Git configuration key" + ); + ensure!( + !entry.value.contains('\0'), + "Invalid Git configuration value" + ); + ensure!( + !key.starts_with("credential.") + && !key.starts_with("url.") + && !key.starts_with("http.") + && !key.starts_with("include") + && !matches!( + key.as_str(), + "user.name" | "user.email" | "core.askpass" | "core.sshcommand" + ), + "Git configuration conflicts with managed identity or authentication" + ); + } + Ok(()) + } +} + +pub fn credential_url(raw: &str) -> Result { + let url = url::Url::parse(raw) + .map_err(|_| anyhow::anyhow!("Invalid Git credential repository URL"))?; + ensure!(url.scheme() == "https" && url.host_str().is_some() && url.username().is_empty() && url.password().is_none() && url.query().is_none() && url.fragment().is_none(), "Git credentials require an HTTPS repository URL without embedded credentials, query, or fragment"); + Ok(url) +} + +pub fn resolve_config( + config: &Config, + ticket: &Ticket, + delegator: Option<&str>, +) -> Result> { + let mut resolved = match delegator { + Some(name) => config + .delegators + .iter() + .find(|d| d.name == name) + .ok_or_else(|| anyhow::anyhow!("Unknown Git delegator: {name}"))? + .git + .clone() + .unwrap_or_default(), + None => GitExecutionConfig::default(), + }; + if resolved.identity.is_none() { + resolved.identity = config.git.identity.clone(); + } + resolved.validate()?; + if let Some(identity) = &mut resolved.identity { + *identity = GitIdentityConfig { + name: interpolate( + &identity.name, + &ticket.id, + &ticket.project, + &ticket.ticket_type, + )?, + email: interpolate( + &identity.email, + &ticket.id, + &ticket.project, + &ticket.ticket_type, + )?, + }; + } + Ok((resolved != GitExecutionConfig::default()).then_some(resolved)) +} + +pub fn resolve_identity(config: &Config, ticket: &Ticket) -> Result> { + Ok(resolve_config(config, ticket, None)? + .and_then(|c| c.identity) + .map(|i| GitIdentity { + name: i.name, + email: i.email, + })) +} + +pub fn validate_config(config: &Config) -> Result<()> { + crate::types::pr::ProviderHosts::from_config(&config.git)?; + GitExecutionConfig { + identity: config.git.identity.clone(), + ..Default::default() + } + .validate()?; + for delegator in &config.delegators { + if let Some(git) = &delegator.git { + git.validate()?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_sets_both_pairs_and_escapes_shell() { + let identity = GitIdentity { + name: "Agent O'Neil".into(), + email: "agent@example.org".into(), + }; + let exports = identity.to_export_block(); + assert!(exports.contains("export GIT_AUTHOR_NAME='Agent O'\\''Neil'")); + assert!(exports.contains("export GIT_COMMITTER_NAME='Agent O'\\''Neil'")); + assert!(exports.contains("export GIT_AUTHOR_EMAIL='agent@example.org'")); + assert!(exports.contains("export GIT_COMMITTER_EMAIL='agent@example.org'")); + } + + #[test] + fn interpolation_rejects_unknown_placeholders() { + assert_eq!( + interpolate( + "bot-{ticket_id}-{project}-{ticket_type}", + "42", + "demo", + "FIX" + ) + .unwrap(), + "bot-42-demo-FIX" + ); + assert!(interpolate("{typo}", "42", "demo", "FIX").is_err()); + } +} diff --git a/src/git/mod.rs b/src/git/mod.rs index 7bd73175..4fcd9abc 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -9,6 +9,8 @@ #![allow(unused_imports)] mod cli; +pub mod identity; +pub mod runtime; mod worktree; pub use cli::{GitCli, WorktreeEntry}; diff --git a/src/git/runtime.rs b/src/git/runtime.rs new file mode 100644 index 00000000..25f17649 --- /dev/null +++ b/src/git/runtime.rs @@ -0,0 +1,635 @@ +use std::{ + collections::BTreeMap, + fs, + future::Future, + io::Write, + path::{Path, PathBuf}, +}; + +use super::identity::{credential_url, shell_quote, GitIdentity}; +use crate::config::GitExecutionConfig; +use anyhow::{ensure, Context, Result}; + +tokio::task_local! { + static ACTIVE: Option; + static AUTH: Option; +} + +#[derive(Clone)] +pub struct ProviderAuth { + /// The provider this auth belongs to. Drives which env vars the CLI gets; + /// never infer that from the binary name. + pub provider: crate::types::pr::GitProvider, + pub token_env: String, + pub host: Option, +} + +/// The `(token, host)` env var names a provider's CLI reads, when it reads any. +/// `tea` (Gitea, Forgejo) authenticates through a private config file instead, +/// and the detect-only providers have no CLI stack yet. +fn auth_env_keys(provider: crate::types::pr::GitProvider) -> Option<(&'static str, &'static str)> { + use crate::types::pr::GitProvider; + match provider { + GitProvider::GitHub => Some(("GH_TOKEN", "GH_HOST")), + GitProvider::GitLab => Some(("GITLAB_TOKEN", "GITLAB_HOST")), + GitProvider::Gitea + | GitProvider::Forgejo + | GitProvider::Bitbucket + | GitProvider::AzureDevOps => None, + } +} + +pub async fn auth_scope(auth: Option, future: impl Future) -> T { + AUTH.scope(auth, future).await +} + +pub async fn scope(config: Option, future: impl Future) -> T { + ACTIVE.scope(config, future).await +} + +pub fn current() -> Option { + ACTIVE.try_with(Clone::clone).ok().flatten() +} + +pub fn validate_remote(config: &GitExecutionConfig, remote: &str) -> Result<()> { + if let Some(credentials) = &config.credentials { + let expected = credential_url(&credentials.repository_url)?; + let actual = credential_url(remote)?; + ensure!( + expected.origin() == actual.origin() + && repository_path(&expected) == repository_path(&actual), + "Git credential repository does not match origin" + ); + } + Ok(()) +} + +fn repository_path(url: &url::Url) -> &str { + url.path().trim_matches('/').trim_end_matches(".git") +} + +pub fn private_dir(path: &Path) -> Result<()> { + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder + .create(path) + .context("Creating private Git runtime directory")?; + Ok(()) +} + +pub fn private_file(path: &Path, contents: &[u8], executable: bool) -> Result<()> { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(if executable { 0o700 } else { 0o600 }); + } + options.open(path)?.write_all(contents)?; + Ok(()) +} + +pub struct GitRuntime { + pub path: PathBuf, + env: BTreeMap, + keep: bool, +} + +impl Drop for GitRuntime { + fn drop(&mut self) { + if !self.keep { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +impl GitRuntime { + pub fn create(config: &GitExecutionConfig) -> Result { + Self::create_with_token(config, None) + } + + pub fn create_with_token(config: &GitExecutionConfig, token: Option<&str>) -> Result { + config.validate()?; + let path = PathBuf::from("/tmp").join(format!("operator-git-{}", uuid::Uuid::new_v4())); + private_dir(&path)?; + let mut runtime = Self { + path, + env: BTreeMap::new(), + keep: false, + }; + runtime.populate(config, token)?; + Ok(runtime) + } + + fn populate( + &mut self, + config: &GitExecutionConfig, + supplied_token: Option<&str>, + ) -> Result<()> { + let mut settings = config + .settings + .iter() + .map(|e| (e.key.clone(), e.value.clone())) + .collect::>(); + if let Some(identity) = &config.identity { + for key in super::identity::IDENTITY_ENV_NAMES { + self.env.insert( + (*key).into(), + if key.ends_with("NAME") { + identity.name.clone() + } else { + identity.email.clone() + }, + ); + } + } + if let Some(credentials) = &config.credentials { + let token = match supplied_token { + Some(token) => token.to_owned(), + None => std::env::var(&credentials.token_env) + .context("Git credential environment variable is not set")?, + }; + ensure!( + !token.is_empty() && !token.contains(['\0', '\n', '\r']), + "Git token must be nonempty and single-line" + ); + let url = credential_url(&credentials.repository_url)?; + let authority = &url[url::Position::BeforeHost..url::Position::AfterPort]; + let repo_path = repository_path(&url); + private_file( + &self.path.join("credential"), + format!("username={}\npassword={}\n", credentials.username, token).as_bytes(), + false, + )?; + let helper = format!( + r#"#!/bin/sh +[ "$1" = get ] || exit 0 +protocol= host= path= +while IFS='=' read -r key value; do + case "$key" in protocol) protocol=$value;; host) host=$value;; path) path=$value;; esac +done +[ "$protocol" = https ] && [ "$host" = {host} ] || exit 0 +case "$path" in {repo}|{repo_git}) cat "$(dirname "$0")/credential";; esac +"#, + host = shell_quote(authority), + repo = shell_quote(repo_path), + repo_git = shell_quote(&format!("{repo_path}.git")) + ); + private_file(&self.path.join("helper"), helper.as_bytes(), true)?; + settings.extend([ + ("credential.helper".into(), String::new()), + ( + "credential.helper".into(), + self.path.join("helper").to_string_lossy().into_owned(), + ), + ("credential.useHttpPath".into(), "true".into()), + ("http.followRedirects".into(), "false".into()), + ("http.extraHeader".into(), String::new()), + ]); + self.env.insert("GIT_TERMINAL_PROMPT".into(), "0".into()); + self.env + .insert("GIT_ASKPASS".into(), "/usr/bin/false".into()); + // These are private subprocess exports, never command arguments. + for key in [ + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "GITLAB_TOKEN", + "GITLAB_ACCESS_TOKEN", + "OAUTH_TOKEN", + ] { + self.env.insert(key.into(), token.clone()); + } + self.env.insert("GH_HOST".into(), authority.into()); + self.env.insert("GITLAB_HOST".into(), authority.into()); + private_dir(&self.path.join("tea"))?; + let tea = serde_json::json!({"logins": [{"name": "operator", "url": url.origin().ascii_serialization(), "user": credentials.username, "token": token, "default": true}], "preferences": {}}); + private_file( + &self.path.join("tea/config.yml"), + serde_json::to_string(&tea)?.as_bytes(), + false, + )?; + private_dir(&self.path.join("bin"))?; + // `OPERATOR_TEA_BINARY` is resolved by env.sh *on the target*, before + // this directory joins PATH, so the shim never recurses into itself. + // It is empty when the target has no `tea` -- Operator installs no + // client binaries -- and `exec ""` would fail as an unreadable + // `exec: : not found` that blames the shim. Say what is missing + // instead; the launch preflight should have caught it first. + let tea_wrapper = format!( + "#!/bin/sh\nif [ -z \"${{OPERATOR_TEA_BINARY:-}}\" ]; then\n echo \"operator: tea not found on PATH; install it on this machine to use Gitea or Forgejo\" >&2\n exit 127\nfi\nXDG_CONFIG_HOME={} exec \"$OPERATOR_TEA_BINARY\" \"$@\"\n", + shell_quote(&self.path.to_string_lossy()) + ); + private_file(&self.path.join("bin/tea"), tea_wrapper.as_bytes(), true)?; + } + let inherited = std::env::var("GIT_CONFIG_COUNT").unwrap_or_default(); + let offset: usize = if inherited.is_empty() { + 0 + } else { + inherited + .parse() + .context("Invalid inherited GIT_CONFIG_COUNT")? + }; + ensure!( + offset <= 4096, + "Inherited Git runtime configuration is too large" + ); + for index in 0..offset { + for part in ["KEY", "VALUE"] { + let key = format!("GIT_CONFIG_{part}_{index}"); + self.env.insert( + key.clone(), + std::env::var(key).context("Incomplete inherited Git runtime configuration")?, + ); + } + } + for (index, (key, value)) in settings.into_iter().enumerate() { + self.env + .insert(format!("GIT_CONFIG_KEY_{}", offset + index), key); + self.env + .insert(format!("GIT_CONFIG_VALUE_{}", offset + index), value); + } + let count = self + .env + .keys() + .filter(|k| k.starts_with("GIT_CONFIG_KEY_")) + .count(); + self.env + .insert("GIT_CONFIG_COUNT".into(), count.to_string()); + use std::fmt::Write as _; + let mut exports = String::new(); + for (key, value) in &self.env { + writeln!(exports, "export {key}={}", shell_quote(value)).expect("writing to String"); + } + if config.credentials.is_some() { + exports.push_str(&format!( + "export OPERATOR_TEA_BINARY=$(command -v tea || true)\nexport PATH={}:\"$PATH\"\n", + shell_quote(&self.path.join("bin").to_string_lossy()) + )); + } + private_file(&self.path.join("env.sh"), exports.as_bytes(), false)?; + Ok(()) + } + + pub fn apply(&self, command: &mut tokio::process::Command) { + command.envs(&self.env); + } + + pub fn persist(mut self) -> PathBuf { + self.keep = true; + self.path.clone() + } +} + +pub fn configure_command(command: &mut tokio::process::Command) -> Result> { + let config = current(); + if config.as_ref().is_none_or(|c| c.credentials.is_none()) { + if let Some(auth) = AUTH.try_with(Clone::clone).ok().flatten() { + if let Some((token_key, host_key)) = auth_env_keys(auth.provider) { + if let Ok(token) = std::env::var(&auth.token_env) { + ensure!(!token.is_empty(), "Configured provider token is empty"); + command.env(token_key, token); + } + if let Some(host) = auth.host { + command.env(host_key, host); + } + } + } + } + let runtime = config.as_ref().map(GitRuntime::create).transpose()?; + if let Some(runtime) = &runtime { + runtime.apply(command); + } + Ok(runtime) +} + +pub fn identity_exports(config: &GitExecutionConfig) -> String { + config + .identity + .as_ref() + .map(|i| { + GitIdentity { + name: i.name.clone(), + email: i.email.clone(), + } + .to_export_block() + }) + .unwrap_or_default() +} + +pub fn managed_runtime_path(path: &Path) -> bool { + path.parent() == Some(Path::new("/tmp")) + && path + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_prefix("operator-git-")) + .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok()) +} + +pub fn abandoned_runtime_pointers(config: &crate::config::Config) -> Vec<(PathBuf, PathBuf)> { + const LAUNCH_GRACE: std::time::Duration = std::time::Duration::from_hours(1); + let Ok(state) = crate::state::State::load(config) else { + return Vec::new(); + }; + let active: std::collections::HashSet<_> = state + .agents + .iter() + .filter_map(|a| { + a.step_launch_context + .as_ref() + .and_then(|c| c.session_id.as_deref()) + }) + .collect(); + let Ok(entries) = fs::read_dir(config.tickets_path().join("operator/commands")) else { + return Vec::new(); + }; + entries + .filter_map(|entry| { + let pointer = entry.ok()?.path(); + if pointer.extension()?.to_str()? != "git-runtime" + || active.contains(pointer.file_stem()?.to_str()?) + { + return None; + } + if pointer.metadata().ok()?.modified().ok()?.elapsed().ok()? < LAUNCH_GRACE { + return None; + } + let path = PathBuf::from(fs::read_to_string(&pointer).ok()?); + managed_runtime_path(&path).then_some((pointer, path)) + }) + .collect() +} + +pub fn reconcile_local(config: &crate::config::Config) { + for (pointer, path) in abandoned_runtime_pointers(config) { + if pointer.with_extension("git-remote").exists() { + continue; + } + if fs::symlink_metadata(&path).is_ok_and(|m| !m.file_type().is_dir()) { + continue; + } + if let Ok(pid) = fs::read_to_string(path.join("pid")) { + let Ok(pid) = pid.trim().parse::() else { + continue; + }; + if pid == 0 + || std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + { + continue; + } + } + if !path.exists() || fs::remove_dir_all(path).is_ok() { + let _ = fs::remove_file(pointer); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{GitCredentialConfig, GitIdentityConfig}; + use std::process::{Command, Stdio}; + + fn credential_config() -> GitExecutionConfig { + GitExecutionConfig { + credentials: Some(GitCredentialConfig { + repository_url: "https://git.example/team/project.git".into(), + username: "agent".into(), + token_env: "OPERATOR_TEST_GIT_TOKEN".into(), + }), + ..Default::default() + } + } + + #[test] + fn helper_returns_credentials_only_for_bound_repository() { + let runtime = + GitRuntime::create_with_token(&credential_config(), Some("secret-test-token")).unwrap(); + for (host, path, expected) in [ + ("git.example", "team/project.git", true), + ("git.example", "team/other", false), + ("evil.example", "team/project", false), + ] { + let mut child = Command::new(runtime.path.join("helper")) + .arg("get") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(format!("protocol=https\nhost={host}\npath={path}\n\n").as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout) + .unwrap() + .contains("secret-test-token"), + expected + ); + } + } + + /// With `tea` absent from the target, `OPERATOR_TEA_BINARY` is empty and + /// the PATH shim used to `exec ""` -- an unreadable failure deep inside an + /// agent run. Operator installs no client binaries, so this path is + /// reachable whenever a target has not been provisioned with `tea`. + #[test] + fn tea_shim_reports_a_missing_binary_instead_of_execing_nothing() { + let runtime = + GitRuntime::create_with_token(&credential_config(), Some("secret-test-token")).unwrap(); + let shim = runtime.path.join("bin/tea"); + + let output = Command::new(&shim) + .arg("--version") + .env_remove("OPERATOR_TEA_BINARY") + .output() + .unwrap(); + + assert!(!output.status.success(), "shim must fail, not exec nothing"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("tea not found on PATH"), + "shim must say what is missing, got: {stderr}" + ); + // The bare `exec ""` failure; unreadable and blames the shim, not tea. + assert!( + !stderr.contains("exec: : not found"), + "shim still execs the empty string: {stderr}" + ); + assert!( + !stderr.contains("secret-test-token"), + "shim must not leak the token" + ); + } + + #[test] + fn runtime_is_private_and_removed_on_drop() { + use std::os::unix::fs::PermissionsExt; + let runtime = + GitRuntime::create_with_token(&credential_config(), Some("secret-test-token")).unwrap(); + let path = runtime.path.clone(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + for file in ["credential", "env.sh", "tea/config.yml"] { + assert_eq!( + fs::metadata(path.join(file)).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + drop(runtime); + assert!(!path.exists()); + } + + #[test] + fn remote_validation_rejects_ssh_and_other_repositories() { + let config = credential_config(); + assert!(validate_remote(&config, "git@git.example:team/project.git").is_err()); + assert!(validate_remote(&config, "https://git.example/team/other").is_err()); + assert!(validate_remote(&config, "https://git.example/team/project").is_ok()); + } + + /// Env injection must follow the provider the auth was built for, not the + /// spelling of the binary. Sniffing the program name handed every non-`gh` + /// tool a `GITLAB_TOKEN`, including `tea`. + #[tokio::test] + async fn provider_auth_injects_only_its_own_provider_env() { + use crate::types::pr::GitProvider; + std::env::set_var("OPERATOR_TEST_PROVIDER_TOKEN", "provider-secret"); + + async fn envs_for(provider: GitProvider, program: &str) -> Vec<(String, String)> { + let auth = ProviderAuth { + provider, + token_env: "OPERATOR_TEST_PROVIDER_TOKEN".into(), + host: Some("git.example".into()), + }; + auth_scope(Some(auth), async { + let mut command = tokio::process::Command::new(program); + let _runtime = configure_command(&mut command).unwrap(); + command + .as_std() + .get_envs() + .filter_map(|(k, v)| { + Some(( + k.to_string_lossy().into_owned(), + v?.to_string_lossy().into_owned(), + )) + }) + .collect() + }) + .await + } + + let github = envs_for(GitProvider::GitHub, "gh").await; + assert!(github + .iter() + .any(|(k, v)| k == "GH_TOKEN" && v == "provider-secret")); + assert!(github + .iter() + .any(|(k, v)| k == "GH_HOST" && v == "git.example")); + assert!(!github.iter().any(|(k, _)| k == "GITLAB_TOKEN")); + + let gitlab = envs_for(GitProvider::GitLab, "glab").await; + assert!(gitlab + .iter() + .any(|(k, v)| k == "GITLAB_TOKEN" && v == "provider-secret")); + assert!(!gitlab.iter().any(|(k, _)| k == "GH_TOKEN")); + + // `tea` authenticates through its own private config, never these vars. + let gitea = envs_for(GitProvider::Gitea, "tea").await; + assert!(!gitea.iter().any(|(k, _)| k == "GITLAB_TOKEN")); + assert!(!gitea.iter().any(|(k, _)| k == "GH_TOKEN")); + + std::env::remove_var("OPERATOR_TEST_PROVIDER_TOKEN"); + } + + #[tokio::test] + async fn parallel_worktree_commits_use_separate_author_and_committer() { + let root = tempfile::tempdir().unwrap(); + let repo = root.path().join("repo"); + fs::create_dir(&repo).unwrap(); + let git = |args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(&repo) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&["init"]); + git(&[ + "-c", + "user.name=Human", + "-c", + "user.email=human@example.org", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ]); + let one = root.path().join("one"); + let two = root.path().join("two"); + git(&["worktree", "add", "-b", "one", one.to_str().unwrap()]); + git(&["worktree", "add", "-b", "two", two.to_str().unwrap()]); + let original = fs::read(repo.join(".git/config")).unwrap(); + async fn commit(path: &Path, name: &str) { + let config = GitExecutionConfig { + identity: Some(GitIdentityConfig { + name: name.into(), + email: format!("{name}@example.org"), + }), + ..Default::default() + }; + scope(Some(config), async { + let mut command = tokio::process::Command::new("git"); + command + .args([ + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "agent", + ]) + .current_dir(path); + let _runtime = configure_command(&mut command).unwrap(); + assert!(command.output().await.unwrap().status.success()); + }) + .await; + let output = Command::new("git") + .args(["log", "-1", "--format=%an|%ae|%cn|%ce"]) + .current_dir(path) + .output() + .unwrap(); + assert_eq!( + String::from_utf8(output.stdout).unwrap().trim(), + format!("{name}|{name}@example.org|{name}|{name}@example.org") + ); + } + tokio::join!(commit(&one, "one"), commit(&two, "two")); + assert_eq!(fs::read(repo.join(".git/config")).unwrap(), original); + } +} diff --git a/src/integrations/catalog.rs b/src/integrations/catalog.rs index 810029be..bc6394f9 100644 --- a/src/integrations/catalog.rs +++ b/src/integrations/catalog.rs @@ -259,7 +259,15 @@ pub fn all_integrations() -> Vec { entry(Git, "bitbucket", "Bitbucket", None, None, false, Proto), entry(Git, "azure", "Azure DevOps", None, None, false, Proto), entry(Git, "forgejo", "Forgejo", None, None, false, Proto), - entry(Git, "gitea", "Gitea", None, None, false, Proto), + entry( + Git, + "gitea", + "Gitea", + Some("getting-started/git/gitea"), + Some("gitea"), + false, + Alpha, + ), // --- Session wrappers (mirror SessionWrapperType::ALL; vscode lives under Editor) --- entry( Session, @@ -363,6 +371,15 @@ pub fn all_integrations() -> Vec { true, Alpha, ), + entry( + Platform, + "kubernetes", + "Kubernetes", + Some("getting-started/platforms/kubernetes"), + Some("kubernetes"), + true, + Alpha, + ), // --- Integrations (documented, no README badge row) --- entry( Integration, diff --git a/src/lib.rs b/src/lib.rs index 28c35f08..285bbf40 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ // Public modules for type generation pub mod agents; pub mod api; +pub mod auth; pub mod collections; pub mod config; pub mod editors; diff --git a/src/main.rs b/src/main.rs index 0ead4d29..a1afe519 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; mod api; mod app; +mod auth; mod collections; mod config; mod editors; @@ -305,6 +306,48 @@ enum Commands { #[command(subcommand)] action: WorkflowAction, }, + + /// Local authentication administration and recovery + Auth { + #[command(subcommand)] + action: AuthAction, + }, +} + +#[derive(Subcommand)] +enum AuthAction { + /// Reset the admin password, revoking every issued credential. + /// + /// Local only, by design. A network-reachable password reset on a single-admin system has no compensating control. + /// no second factor to require and no second account to notify -- so recovery deliberately requires filesystem access. + ResetAdminPassword { + /// New password. Omit to read it from stdin without echoing to the + /// terminal; passing it as an argument leaves it in shell history. + #[arg(long)] + password: Option, + }, + + /// Show the bootstrap state and recent audit records. + Status { + /// How many audit records to show + #[arg(long, default_value_t = 20)] + limit: u32, + }, + + /// Bootstrap the admin account on a running server + Bootstrap { + /// Server base URL (e.g. ) + #[arg(long)] + server: String, + + /// Temporary password, when the server was started with a bootstrap secret + #[arg(long)] + temporary_password: Option, + + /// New admin password. Omit to read from stdin. + #[arg(long)] + password: Option, + }, } #[derive(Subcommand)] @@ -445,6 +488,9 @@ async fn main() -> Result<()> { skip_llm_detection, )?; } + Some(Commands::Auth { action }) => { + cmd_auth(&config, action).await?; + } Some(Commands::Workflow { action }) => { cmd_workflow(&config, action)?; } @@ -470,7 +516,7 @@ async fn run_tui( // Note: tmux availability is now checked in the setup wizard (TmuxOnboarding step) // when the user selects tmux as their session wrapper - let mut app = App::new(config, start_web, open_ui).await?; + let mut app = Box::pin(App::new(config, start_web, open_ui)).await?; let result = app.run().await; // Print log file path on exit if logs were written @@ -847,6 +893,96 @@ async fn cmd_create( Ok(()) } +/// Read a password from stdin without echoing it. +/// +/// Falls back to a plain read when stdin is not a terminal, so the command stays usable . +fn read_password_from_stdin(prompt: &str) -> Result { + use std::io::{BufRead, Write}; + + print!("{prompt}"); + std::io::stdout().flush()?; + + let mut line = String::new(); + std::io::stdin().lock().read_line(&mut line)?; + println!(); + Ok(line.trim_end_matches(['\n', '\r']).to_string()) +} + +/// `operator auth ...` +async fn cmd_auth(config: &Config, action: AuthAction) -> Result<()> { + use crate::auth::store::AuthStore; + + match action { + AuthAction::ResetAdminPassword { password } => { + let password = match password { + Some(p) => p, + None => read_password_from_stdin("New admin password: ")?, + }; + + let store = AuthStore::open(&config.state_path())?; + store.set_admin_password(&password)?; + // Everything issued under the old password is now suspect: the + // reason for a reset is usually that something leaked. + store.revoke_all_credentials("admin password reset")?; + store.audit("password reset", Some("via local CLI"), true)?; + + println!("Admin password reset."); + println!( + "Every session, refresh token, and access key has been revoked; \ + integrations need new keys." + ); + Ok(()) + } + + AuthAction::Status { limit } => { + let store = AuthStore::open(&config.state_path())?; + println!("Bootstrap state: {:?}", store.bootstrap_state()?); + + let keys = store.list_access_keys()?; + let active = keys.iter().filter(|k| k.revoked_at.is_none()).count(); + println!("Access keys: {active} active, {} total", keys.len()); + + println!("\nRecent audit records:"); + for (at, event, detail, succeeded) in store.recent_audit(limit)? { + let outcome = if succeeded { "ok" } else { "FAILED" }; + match detail { + Some(d) => println!(" {at} {outcome:<6} {event} ({d})"), + None => println!(" {at} {outcome:<6} {event}"), + } + } + Ok(()) + } + + AuthAction::Bootstrap { + server, + temporary_password, + password, + } => { + let password = match password { + Some(p) => p, + None => read_password_from_stdin("New admin password: ")?, + }; + + let url = format!("{}/api/v1/auth/bootstrap", server.trim_end_matches('/')); + let body = serde_json::json!({ + "temporary_password": temporary_password, + "new_password": password, + }); + + let response = reqwest::Client::new().post(&url).json(&body).send().await?; + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + + if status.is_success() { + println!("Admin account created on {server}."); + Ok(()) + } else { + anyhow::bail!("bootstrap failed ({status}): {text}") + } + } + } +} + fn cmd_workflow(config: &Config, action: WorkflowAction) -> Result<()> { match action { WorkflowAction::Export { diff --git a/src/mcp/descriptor.rs b/src/mcp/descriptor.rs index e56449e1..c2d39e35 100644 --- a/src/mcp/descriptor.rs +++ b/src/mcp/descriptor.rs @@ -73,7 +73,7 @@ pub async fn descriptor( ) -> Json { let base = format!("http://{host}"); - let stdio = if state.config.mcp.stdio_advertised { + let stdio = if state.config().mcp.stdio_advertised { let command = std::env::current_exe() .ok() .and_then(|p| p.to_str().map(str::to_string)) diff --git a/src/mcp/handler.rs b/src/mcp/handler.rs index 4dc7a595..ba7fa7b7 100644 --- a/src/mcp/handler.rs +++ b/src/mcp/handler.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::mcp::tools; +use crate::rest::dto::auth::Scope; use crate::rest::state::ApiState; #[derive(Debug, Deserialize)] @@ -35,7 +36,11 @@ pub struct JsonRpcError { pub message: String, } -pub async fn handle_jsonrpc(request: &JsonRpcRequest, state: &ApiState) -> JsonRpcResponse { +pub async fn handle_jsonrpc( + request: &JsonRpcRequest, + state: &ApiState, + scopes: &[Scope], +) -> JsonRpcResponse { let id = request.id.clone().unwrap_or(Value::Null); match request.method.as_str() { @@ -96,7 +101,7 @@ pub async fn handle_jsonrpc(request: &JsonRpcRequest, state: &ApiState) -> JsonR .cloned() .unwrap_or_else(|| json!({})); - match tools::execute_tool(tool_name, arguments, state).await { + match tools::execute_tool(tool_name, arguments, state, scopes).await { Ok(result) => { let text = serde_json::to_string_pretty(&result).unwrap_or_default(); JsonRpcResponse { @@ -199,7 +204,7 @@ mod tests { params: json!({}), }; - let response = handle_jsonrpc(&request, &state).await; + let response = handle_jsonrpc(&request, &state, &Scope::ALL).await; assert_eq!(response.jsonrpc, "2.0"); assert_eq!(response.id, json!(1)); @@ -222,7 +227,7 @@ mod tests { params: json!({}), }; - let response = handle_jsonrpc(&request, &state).await; + let response = handle_jsonrpc(&request, &state, &Scope::ALL).await; assert!(response.error.is_none()); let result = response.result.unwrap(); @@ -248,7 +253,7 @@ mod tests { }), }; - let response = handle_jsonrpc(&request, &state).await; + let response = handle_jsonrpc(&request, &state, &Scope::ALL).await; assert!(response.error.is_none()); let result = response.result.unwrap(); @@ -274,7 +279,7 @@ mod tests { }), }; - let response = handle_jsonrpc(&request, &state).await; + let response = handle_jsonrpc(&request, &state, &Scope::ALL).await; assert!(response.error.is_some()); assert!(response.error.unwrap().message.contains("Unknown tool")); @@ -290,7 +295,7 @@ mod tests { params: json!({}), }; - let response = handle_jsonrpc(&request, &state).await; + let response = handle_jsonrpc(&request, &state, &Scope::ALL).await; assert!(response.error.is_some()); let err = response.error.unwrap(); @@ -308,7 +313,7 @@ mod tests { params: json!({}), }; - let response = handle_jsonrpc(&request, &state).await; + let response = handle_jsonrpc(&request, &state, &Scope::ALL).await; assert!(response.error.is_none()); assert!(response.result.is_some()); diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index b192deab..e34c8180 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -39,3 +39,15 @@ impl FromRequestParts for Host { Ok(Host(host)) } } + +/// Base URL to advertise in descriptors and transport endpoints. +/// +/// Prefers the configured public URL over the request's `Host` header. The header is attacker-controlled and carries no scheme, +/// so behind TLS termination it yields a `http://` URL a client cannot use. Falling back to it is still correct for a loopback bind. +pub fn public_base_url(state: &crate::rest::state::ApiState, host: &str) -> String { + state + .config() + .rest_api + .public_base_url() + .unwrap_or_else(|| format!("http://{host}")) +} diff --git a/src/mcp/resources.rs b/src/mcp/resources.rs index 5c8f7b01..d40af138 100644 --- a/src/mcp/resources.rs +++ b/src/mcp/resources.rs @@ -10,7 +10,7 @@ use crate::queue::Queue; use crate::rest::state::ApiState; pub async fn list_resources(state: &ApiState) -> Result, String> { - let config = (*state.config).clone(); + let config = (*state.config()).clone(); tokio::task::spawn_blocking(move || -> Result, String> { let queue = Queue::new(&config).map_err(|e| e.to_string())?; let mut all = Vec::new(); @@ -43,7 +43,7 @@ pub async fn read_resource(uri: &str, state: &ApiState) -> Result Result { diff --git a/src/mcp/stdio.rs b/src/mcp/stdio.rs index b80d4ce9..6cdeb8a0 100644 --- a/src/mcp/stdio.rs +++ b/src/mcp/stdio.rs @@ -9,6 +9,7 @@ use std::io; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; +use crate::rest::dto::auth::Scope; use crate::rest::state::ApiState; /// Run the stdio MCP loop until stdin closes. @@ -32,7 +33,7 @@ where continue; } }; - let response = handle_jsonrpc(&request, &state).await; + let response = handle_jsonrpc(&request, &state, &Scope::ALL).await; let json = serde_json::to_string(&response).unwrap_or_else(|_| { r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"serialization failed"}}"# .to_string() diff --git a/src/mcp/tickets.rs b/src/mcp/tickets.rs index 75d612ed..fb187606 100644 --- a/src/mcp/tickets.rs +++ b/src/mcp/tickets.rs @@ -53,7 +53,7 @@ pub async fn list_tickets(args: Value, state: &ApiState) -> Result Result, String> { let queue = Queue::new(&config).map_err(|e| e.to_string())?; match status.as_str() { @@ -73,7 +73,7 @@ pub async fn list_tickets(args: Value, state: &ApiState) -> Result Result { let id = id.to_string(); let in_status = in_status.to_string(); - let config = (*state.config).clone(); + let config = (*state.config()).clone(); tokio::task::spawn_blocking(move || -> Result { let queue = Queue::new(&config).map_err(|e| e.to_string())?; let list = match in_status.as_str() { @@ -97,7 +97,7 @@ pub async fn claim_ticket(args: Value, state: &ApiState) -> Result Result<(), String> { @@ -116,7 +116,7 @@ pub async fn complete_ticket(args: Value, state: &ApiState) -> Result Result<(), String> { @@ -135,7 +135,7 @@ pub async fn return_to_queue(args: Value, state: &ApiState) -> Result Result<(), String> { @@ -169,7 +169,7 @@ pub async fn create_ticket(args: Value, state: &ApiState) -> Result Result { let creator = TicketCreator::new(&config); creator @@ -334,6 +334,7 @@ mod tests { "operator_claim_ticket", json!({ "id": "FEAT-0001" }), &state, + &crate::rest::dto::auth::Scope::ALL, ) .await .unwrap_err(); diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index b459bdd2..8309782c 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -9,6 +9,7 @@ use axum::Json; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use crate::rest::dto::auth::Scope; use crate::rest::dto::{LaunchTicketRequest, RejectReviewRequest}; use crate::rest::routes; use crate::rest::state::ApiState; @@ -251,19 +252,27 @@ pub fn all_tool_definitions() -> Vec { ] } -fn require_write_tools(state: &ApiState) -> Result<(), String> { - if state.config.mcp.expose_ticket_write_tools { - Ok(()) - } else { - Err( +/// Gate the ticket-mutating tools. +fn require_write_tools(state: &ApiState, scopes: &[Scope]) -> Result<(), String> { + if !scopes.contains(&Scope::Write) { + return Err("This tool requires the `write` scope".to_string()); + } + if !state.config().mcp.expose_ticket_write_tools { + return Err( "Ticket write tools disabled in config ([mcp].expose_ticket_write_tools = true to enable)" .to_string(), - ) + ); } + Ok(()) } /// Execute an MCP tool by name with the given arguments -pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result { +pub async fn execute_tool( + name: &str, + args: Value, + state: &ApiState, + scopes: &[Scope], +) -> Result { match name { "operator_health" => { let resp = routes::health::health(State(state.clone())).await; @@ -331,23 +340,23 @@ pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result crate::mcp::tickets::list_tickets(args, state).await, "operator_claim_ticket" => { - require_write_tools(state)?; + require_write_tools(state, scopes)?; crate::mcp::tickets::claim_ticket(args, state).await } "operator_complete_ticket" => { - require_write_tools(state)?; + require_write_tools(state, scopes)?; crate::mcp::tickets::complete_ticket(args, state).await } "operator_return_to_queue" => { - require_write_tools(state)?; + require_write_tools(state, scopes)?; crate::mcp::tickets::return_to_queue(args, state).await } "operator_create_ticket" => { - require_write_tools(state)?; + require_write_tools(state, scopes)?; crate::mcp::tickets::create_ticket(args, state).await } "operator_launch_ticket" => { - require_write_tools(state)?; + require_write_tools(state, scopes)?; let id = args .get("id") .and_then(|v| v.as_str()) @@ -383,7 +392,7 @@ pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result { - require_write_tools(state)?; + require_write_tools(state, scopes)?; let result = routes::queue::pause(State(state.clone())).await; match result { Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), @@ -391,7 +400,7 @@ pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result { - require_write_tools(state)?; + require_write_tools(state, scopes)?; let result = routes::queue::resume(State(state.clone())).await; match result { Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), @@ -399,7 +408,7 @@ pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result { - require_write_tools(state)?; + require_write_tools(state, scopes)?; let result = routes::queue::sync(State(state.clone())).await; match result { Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), @@ -407,7 +416,7 @@ pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result { - require_write_tools(state)?; + require_write_tools(state, scopes)?; let id = args .get("id") .and_then(|v| v.as_str()) @@ -420,7 +429,7 @@ pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result { - require_write_tools(state)?; + require_write_tools(state, scopes)?; let id = args .get("id") .and_then(|v| v.as_str()) @@ -522,7 +531,7 @@ mod tests { let config = Config::default(); let state = ApiState::new(config, PathBuf::from("/tmp/test")); - let result = execute_tool("operator_health", json!({}), &state).await; + let result = execute_tool("operator_health", json!({}), &state, &Scope::ALL).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -534,7 +543,7 @@ mod tests { let config = Config::default(); let state = ApiState::new(config, PathBuf::from("/tmp/test")); - let result = execute_tool("operator_status", json!({}), &state).await; + let result = execute_tool("operator_status", json!({}), &state, &Scope::ALL).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -546,7 +555,8 @@ mod tests { let config = Config::default(); let state = ApiState::new(config, PathBuf::from("/tmp/test")); - let result = execute_tool("operator_list_issue_types", json!({}), &state).await; + let result = + execute_tool("operator_list_issue_types", json!({}), &state, &Scope::ALL).await; assert!(result.is_ok()); let value = result.unwrap(); @@ -559,7 +569,7 @@ mod tests { let config = Config::default(); let state = ApiState::new(config, PathBuf::from("/tmp/test")); - let result = execute_tool("operator_get_issue_type", json!({}), &state).await; + let result = execute_tool("operator_get_issue_type", json!({}), &state, &Scope::ALL).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Missing required parameter")); } @@ -569,7 +579,7 @@ mod tests { let config = Config::default(); let state = ApiState::new(config, PathBuf::from("/tmp/test")); - let result = execute_tool("nonexistent_tool", json!({}), &state).await; + let result = execute_tool("nonexistent_tool", json!({}), &state, &Scope::ALL).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Unknown tool")); } @@ -581,7 +591,13 @@ mod tests { let config = Config::default(); let state = ApiState::new(config, PathBuf::from("/tmp/test")); - let result = execute_tool("operator_launch_ticket", json!({"id": "FEAT-1"}), &state).await; + let result = execute_tool( + "operator_launch_ticket", + json!({"id": "FEAT-1"}), + &state, + &Scope::ALL, + ) + .await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Ticket write tools disabled")); } @@ -591,7 +607,7 @@ mod tests { let config = Config::default(); let state = ApiState::new(config, PathBuf::from("/tmp/test")); - let result = execute_tool("operator_pause_queue", json!({}), &state).await; + let result = execute_tool("operator_pause_queue", json!({}), &state, &Scope::ALL).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Ticket write tools disabled")); } @@ -601,7 +617,7 @@ mod tests { let config = Config::default(); let state = ApiState::new(config, PathBuf::from("/tmp/test")); - let result = execute_tool("operator_resume_queue", json!({}), &state).await; + let result = execute_tool("operator_resume_queue", json!({}), &state, &Scope::ALL).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Ticket write tools disabled")); } @@ -611,7 +627,7 @@ mod tests { let config = Config::default(); let state = ApiState::new(config, PathBuf::from("/tmp/test")); - let result = execute_tool("operator_sync_kanban", json!({}), &state).await; + let result = execute_tool("operator_sync_kanban", json!({}), &state, &Scope::ALL).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Ticket write tools disabled")); } @@ -621,7 +637,13 @@ mod tests { let config = Config::default(); let state = ApiState::new(config, PathBuf::from("/tmp/test")); - let result = execute_tool("operator_approve_agent", json!({"id": "agent-1"}), &state).await; + let result = execute_tool( + "operator_approve_agent", + json!({"id": "agent-1"}), + &state, + &Scope::ALL, + ) + .await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Ticket write tools disabled")); } @@ -635,6 +657,7 @@ mod tests { "operator_reject_agent", json!({"id": "agent-1", "reason": "bad"}), &state, + &Scope::ALL, ) .await; assert!(result.is_err()); @@ -647,7 +670,7 @@ mod tests { config.mcp.expose_ticket_write_tools = true; let state = ApiState::new(config, PathBuf::from("/tmp/test")); - let result = execute_tool("operator_launch_ticket", json!({}), &state).await; + let result = execute_tool("operator_launch_ticket", json!({}), &state, &Scope::ALL).await; assert!(result.is_err()); assert!(result .unwrap_err() diff --git a/src/mcp/transport.rs b/src/mcp/transport.rs index 03dea8e3..e1abcbab 100644 --- a/src/mcp/transport.rs +++ b/src/mcp/transport.rs @@ -20,7 +20,9 @@ use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::StreamExt as _; use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; -use crate::rest::state::ApiState; +use crate::mcp::public_base_url; +use crate::rest::middleware::auth::Authenticated; +use crate::rest::state::{ApiState, McpSession}; /// Query parameters for the message endpoint #[derive(Debug, Deserialize)] @@ -33,21 +35,37 @@ pub struct MessageQuery { /// /// The client connects here first, receives the message endpoint URL, /// then sends JSON-RPC requests to that endpoint. +#[utoipa::path( + get, + path = "/api/v1/mcp/sse", + tag = "MCP", + operation_id = "mcp_sse", + responses((status = 200, description = "SSE stream carrying the message endpoint and JSON-RPC responses", content_type = "text/event-stream", body = String)) +)] pub async fn sse_handler( Host(host): Host, State(state): State, + Authenticated(principal): Authenticated, ) -> Sse>> { let session_id = uuid::Uuid::new_v4().to_string(); let (tx, rx) = mpsc::unbounded_channel::(); - // Register session - state - .mcp_sessions - .lock() - .await - .insert(session_id.clone(), tx); - - let message_url = format!("http://{host}/api/v1/mcp/message?sessionId={session_id}"); + // Bind the session to whoever opened it. The session id travels in a URL + // and is therefore a bearer credential; recording the principal means a + // leaked id is not enough on its own to drive the session. + state.mcp_sessions.lock().await.insert( + session_id.clone(), + McpSession { + tx, + subject: principal.subject.clone(), + scopes: principal.scopes.clone(), + }, + ); + + // Generated from the configured public URL, not the request `Host` header, + // which a caller controls and which is plain `http` behind TLS termination. + let base = public_base_url(&state, &host); + let message_url = format!("{base}/api/v1/mcp/message?sessionId={session_id}"); let session_id_cleanup = session_id.clone(); let sessions_cleanup = state.mcp_sessions.clone(); @@ -77,24 +95,46 @@ pub async fn sse_handler( } /// Message endpoint — receives JSON-RPC requests and sends responses via SSE +#[utoipa::path( + post, + path = "/api/v1/mcp/message", + tag = "MCP", + operation_id = "mcp_message", + params(("sessionId" = String, Query, description = "MCP SSE session id")), + request_body = serde_json::Value, + responses( + (status = 202, description = "JSON-RPC request accepted for delivery on the SSE stream"), + (status = 403, description = "Session belongs to another principal"), + (status = 404, description = "Session not found") + ) +)] pub async fn message_handler( Query(query): Query, State(state): State, + Authenticated(principal): Authenticated, Json(request): Json, ) -> impl IntoResponse { - // Clone the sender and drop the lock before async work - let tx = { + // Clone the sender and drop the lock before async work. + let (tx, scopes) = { let sessions = state.mcp_sessions.lock().await; - let Some(tx) = sessions.get(&query.session_id) else { + let Some(session) = sessions.get(&query.session_id) else { return ( axum::http::StatusCode::NOT_FOUND, Json(json!({"error": "Session not found"})), ); }; - tx.clone() + // The caller must be the principal that opened this stream. Without + // this, anyone who learns a session id inherits its authority. + if session.subject != principal.subject { + return ( + axum::http::StatusCode::FORBIDDEN, + Json(json!({"error": "Session belongs to a different principal"})), + ); + } + (session.tx.clone(), session.scopes.clone()) }; - let response = handle_jsonrpc(&request, &state).await; + let response = handle_jsonrpc(&request, &state, &scopes).await; // Send response through SSE channel if let Ok(json_str) = serde_json::to_string(&response) { diff --git a/src/notifications/webhook_integration.rs b/src/notifications/webhook_integration.rs index 25ec6e64..6a41b579 100644 --- a/src/notifications/webhook_integration.rs +++ b/src/notifications/webhook_integration.rs @@ -1,5 +1,8 @@ //! Webhook notification integration. +use std::time::Duration; + +use crate::auth::egress::EgressPolicy; use anyhow::Result; use async_trait::async_trait; use chrono::Utc; @@ -44,6 +47,12 @@ struct WebhookPayload { data: serde_json::Value, } +/// 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()) +} + impl WebhookIntegration { /// Create a new webhook integration from config. #[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate @@ -85,7 +94,7 @@ impl WebhookIntegration { auth, subscribed_events: config.events.clone().unwrap_or_default(), enabled: config.enabled, - client: Client::new(), + client: egress_client(), }) } @@ -98,7 +107,7 @@ impl WebhookIntegration { auth: WebhookAuth::None, subscribed_events: events, enabled: true, - client: Client::new(), + client: egress_client(), } } } @@ -129,6 +138,16 @@ impl NotificationIntegration for WebhookIntegration { data: serde_json::to_value(event)?, }; + // A webhook URL comes from configuration and this request carries the configured bearer or basic credential + if let Err(e) = crate::auth::egress::validate(&self.url, &EgressPolicy::default()) { + tracing::warn!( + webhook = %self.name, + error = %e, + "refusing to deliver webhook to a disallowed destination" + ); + return Ok(()); + } + // Build request let mut request = self.client.post(&self.url).json(&payload); diff --git a/src/projects.rs b/src/projects.rs index 1063b1d6..334a551b 100644 --- a/src/projects.rs +++ b/src/projects.rs @@ -583,6 +583,7 @@ mod tests { git_info: Some(GitRepoInfo { remote_url: Some("https://github.com/user/repo.git".to_string()), github_info: Some(GitHubRepoInfo { + host: None, provider: GitProvider::GitHub, owner: "user".to_string(), repo_name: "repo".to_string(), diff --git a/src/rest/dto/auth.rs b/src/rest/dto/auth.rs new file mode 100644 index 00000000..2d85f1d4 --- /dev/null +++ b/src/rest/dto/auth.rs @@ -0,0 +1,687 @@ +//! DTOs for authentication: bootstrap, sessions, OAuth device flow, and +//! service access keys. +//! +//! Two rules hold across every type in this module, and the tests at the +//! bottom enforce both: +//! +//! 1. **A secret crosses the wire at most once, outbound.** Bootstrap and +//! login accept a password inbound; access-key and token creation return a +//! secret exactly once at creation. No other type carries one. +//! 2. **No summary type ever carries a hash.** Metadata DTOs describe a +//! credential (created, expires, last used, revoked) so it can be managed +//! without ever exposing the material used to authenticate with it. + +use chrono::{DateTime, Utc}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +pub const MAX_ACCESS_KEY_EXPIRY_DAYS: u64 = 365; +pub const MAX_IDENTIFIER_LENGTH: usize = 128; + +// ============================================================================= +// Scopes +// ============================================================================= + +/// A typed authorization scope. +/// +/// Scopes are **not hierarchical**: `Write` does not imply `Read`. A credential +/// is granted each scope it needs explicitly, so an integration's authority is +/// legible from its scope list alone rather than requiring the reader to reason +/// about implication. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + ToSchema, + JsonSchema, + TS, +)] +#[ts(export)] +#[serde(rename_all = "lowercase")] +pub enum Scope { + /// Observe the queue, agents, tickets, projects, and issue types. + Read, + /// Mutate tickets, issue types, steps, and collections. + Write, + /// Launch agents, complete steps, probe providers, call MCP tools. + Execute, + /// Configuration, delegators, model servers, and auth administration. + Admin, +} + +impl Scope { + /// Every scope, in privilege-suggesting order. The single source of truth + /// for "all scopes" across the API, the CLI, and the clients. + /// + /// Unused in the binary until Phase 3 wires route authorization; the DTO + /// contract ships a phase ahead of its consumers so generated clients and + /// the OpenAPI spec are settled before any handler depends on them. + #[allow(dead_code)] + pub const ALL: [Scope; 4] = [Scope::Read, Scope::Write, Scope::Execute, Scope::Admin]; + + /// Wire representation, matching the `serde` rename. + pub fn as_str(self) -> &'static str { + match self { + Scope::Read => "read", + Scope::Write => "write", + Scope::Execute => "execute", + Scope::Admin => "admin", + } + } +} + +impl std::fmt::Display for Scope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for Scope { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "read" => Ok(Scope::Read), + "write" => Ok(Scope::Write), + "execute" => Ok(Scope::Execute), + "admin" => Ok(Scope::Admin), + _ => Err(()), + } + } +} + +// ============================================================================= +// Bootstrap +// ============================================================================= + +/// Where the deployment sits in the one-time admin-creation sequence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(rename_all = "snake_case")] +pub enum BootstrapState { + /// No admin account exists. The bootstrap endpoint accepts a submission. + Uninitialized, + /// A temporary password was supplied out of band; a new one must be set + /// before the account is usable. + AwaitingPassword, + /// The admin account is usable. Bootstrap is closed permanently. + Complete, +} + +/// Current bootstrap state, readable without authentication so a client can +/// route a first-time visitor to setup rather than to login. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct BootstrapStatusResponse { + /// The state this deployment is in. + pub state: BootstrapState, + /// Whether a temporary password was supplied out of band (a mounted + /// bootstrap secret). When true, submission must present it. + pub requires_temporary_password: bool, +} + +/// Claim the admin account and set its password. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct BootstrapSubmitRequest { + /// The out-of-band temporary password, when + /// `requires_temporary_password` is set. Never persisted or logged. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(write_only, format = Password, min_length = 12, max_length = 1024)] + pub temporary_password: Option, + /// The admin password to set. Never persisted in plaintext or logged. + #[schema(write_only, format = Password, min_length = 12, max_length = 1024)] + pub new_password: String, +} + +/// Result of a successful bootstrap. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct BootstrapSubmitResponse { + /// The state after submission — `Complete` on success. + pub state: BootstrapState, +} + +// ============================================================================= +// Browser sessions +// ============================================================================= + +/// Password login, exchanged for an opaque server-side session cookie. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LoginRequest { + /// The admin password. Never persisted in plaintext or logged. + #[schema(write_only, format = Password, min_length = 12, max_length = 1024)] + pub password: String, +} + +/// Successful login. The session itself rides in a `Set-Cookie` header, not in +/// this body — a body-borne session identifier would be readable by script. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LoginResponse { + /// Scopes the session holds. + pub scopes: Vec, + /// When the session expires. + pub expires_at: DateTime, + /// CSRF token to send on subsequent cookie-authenticated mutations. + #[schema(read_only, format = Password)] + pub csrf_token: String, +} + +/// Result of destroying the current session server-side. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LogoutResponse { + /// Always true; present so the response has a stable, non-empty shape. + pub ended: bool, +} + +/// The caller's current authenticated identity. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CurrentSessionResponse { + /// Account name — always `admin`, the single human account. + pub subject: String, + /// Scopes this credential holds. + pub scopes: Vec, + /// How the caller authenticated. + pub principal_kind: PrincipalKind, + /// When this credential expires, if it does. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, +} + +/// What kind of credential authenticated a request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(rename_all = "snake_case")] +pub enum PrincipalKind { + /// A browser session cookie. + Session, + /// A bearer access token from the device flow or a key exchange. + AccessToken, + /// The automatically issued loopback credential for a local process. + LocalProcess, + /// A single-purpose agent step-completion callback token. + AgentCallback, +} + +/// A freshly minted CSRF token for the current session. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CsrfTokenResponse { + /// Send as the CSRF header on cookie-authenticated mutations. + #[schema(read_only, format = Password)] + pub csrf_token: String, +} + +// ============================================================================= +// OAuth device authorization +// ============================================================================= + +/// Begin device authorization for a public client that cannot hold a secret. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DeviceAuthorizationRequest { + /// Identifier for the requesting client (e.g. `vscode`). + #[schema(min_length = 1, max_length = 128, pattern = "^[A-Za-z0-9._:-]+$")] + pub client_id: String, + /// Scopes requested. IDE clients request all four, because such a client + /// acts as the human admin. + #[serde(default)] + pub scopes: Vec, +} + +/// RFC 8628 device authorization response. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DeviceAuthorizationResponse { + /// Opaque code the client polls the token endpoint with. Never logged. + #[schema(read_only, format = Password, min_length = 43, max_length = 43)] + pub device_code: String, + /// Short code the human types into the approval screen. + #[schema( + read_only, + pattern = "^[A-HJ-KM-NP-TV-Z2-9]{4}-[A-HJ-KM-NP-TV-Z2-9]{4}$" + )] + pub user_code: String, + /// Where the human goes to approve. + pub verification_uri: String, + /// `verification_uri` with the user code pre-filled. + pub verification_uri_complete: String, + /// Seconds until the device code expires. + pub expires_in: u64, + /// Minimum seconds the client must wait between polls. + pub interval: u64, +} + +/// Approve a pending device authorization from an authenticated session. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DeviceApprovalRequest { + /// The user code shown on the requesting device. + #[schema(pattern = "^[A-HJ-KM-NP-TV-Z2-9]{4}-[A-HJ-KM-NP-TV-Z2-9]{4}$")] + pub user_code: String, +} + +/// Result of approving a device. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DeviceApprovalResponse { + /// Client that requested authorization, echoed so the approver can confirm. + pub client_id: String, + /// Scopes granted. + pub scopes: Vec, + /// Whether approval completed. + pub approved: bool, +} + +// ============================================================================= +// Token endpoint +// ============================================================================= + +/// Token endpoint request. The discriminator makes unrelated credential +/// combinations unrepresentable. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(tag = "grant_type")] +pub enum TokenRequest { + /// Poll for a previously approved device authorization. + #[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")] + DeviceCode { + #[schema(write_only, format = Password, min_length = 43, max_length = 43)] + device_code: String, + #[schema(min_length = 1, max_length = 128, pattern = "^[A-Za-z0-9._:-]+$")] + client_id: String, + }, + /// Redeem a rotating refresh token. + #[serde(rename = "refresh_token")] + RefreshToken { + #[schema(write_only, format = Password, min_length = 43, max_length = 43)] + refresh_token: String, + #[schema(min_length = 1, max_length = 128, pattern = "^[A-Za-z0-9._:-]+$")] + client_id: String, + }, + /// Exchange a service access key. + #[serde(rename = "operator:access-key")] + AccessKey { + #[schema(write_only, format = Password, min_length = 47, max_length = 47)] + access_key: String, + }, +} + +/// A newly issued access token, and a refresh token when the grant produces one. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct TokenResponse { + /// Signed, short-lived bearer token. + #[schema(read_only, format = Password)] + pub access_token: String, + /// Always `Bearer`. + pub token_type: String, + /// Seconds until `access_token` expires. + pub expires_in: u64, + /// Opaque rotating refresh token. Absent for access-key exchange, which is + /// re-exercised with the key itself rather than refreshed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(read_only, format = Password)] + pub refresh_token: Option, + /// Scopes the access token carries. + pub scopes: Vec, +} + +/// Standardized OAuth error, shaped per RFC 6749 §5.2 so stock clients can +/// interpret it — notably `authorization_pending` and `slow_down`, which a +/// device-flow client polls against. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct OAuthErrorResponse { + /// Machine-readable error code. + pub error: OAuthErrorCode, + /// Human-readable explanation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_description: Option, + /// Documentation link. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_uri: Option, +} + +/// OAuth error codes Operator emits. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(rename_all = "snake_case")] +pub enum OAuthErrorCode { + /// The device code is valid but the human has not approved yet — keep polling. + AuthorizationPending, + /// Polling faster than `interval`; back off. + SlowDown, + /// The device code expired before approval. + ExpiredToken, + /// The human declined. + AccessDenied, + /// The credential presented is invalid, expired, revoked, or already used. + InvalidGrant, + /// The request is missing a required field or is internally inconsistent. + InvalidRequest, + /// The client identifier is not recognized. + InvalidClient, + /// The requested scopes exceed what this credential may be granted. + InvalidScope, + /// The grant type is not supported. + UnsupportedGrantType, +} + +// ============================================================================= +// Service access keys +// ============================================================================= + +/// Create a service access key for an integration. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateAccessKeyRequest { + /// Human-readable label identifying what holds this key. + #[schema(min_length = 1, max_length = 128)] + pub name: String, + /// Scopes to grant. Only what the integration needs. + #[schema(min_items = 1, max_items = 4)] + pub scopes: Vec, + /// Days until the key expires. Expiry is mandatory — there is no + /// non-expiring key. + #[schema(minimum = 1, maximum = 365)] + pub expires_in_days: u64, +} + +/// A newly created access key. **The secret appears here and nowhere else, +/// ever** — only its hash is stored, so it cannot be shown again. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateAccessKeyResponse { + /// Metadata for the created key. + pub key: AccessKeySummary, + /// The key secret, returned exactly once. Store it now; it is unrecoverable. + #[schema(read_only, format = Password, min_length = 47, max_length = 47)] + pub secret: String, +} + +/// Access key metadata. Carries no secret and no hash. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct AccessKeySummary { + /// Stable identifier, safe to display and to reference for revocation. + #[schema(format = Uuid)] + pub id: String, + /// Human-readable label. + pub name: String, + /// Scopes granted. + pub scopes: Vec, + /// When the key was created. + pub created_at: DateTime, + /// When the key expires. + pub expires_at: DateTime, + /// When the key was last exchanged for a token; `None` if never used. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_used_at: Option>, + /// When the key was revoked; `None` while active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revoked_at: Option>, +} + +/// All access keys, active and revoked. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct AccessKeyListResponse { + /// The keys. + pub keys: Vec, +} + +/// Result of revoking an access key. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct RevokeAccessKeyResponse { + /// The revoked key's identifier. + #[schema(format = Uuid)] + pub id: String, + /// When revocation took effect. + pub revoked_at: DateTime, +} + +// ============================================================================= +// Session and device metadata +// ============================================================================= + +/// An active or expired browser session. Carries no session identifier — the +/// cookie value is never readable back out, only the session's `id` for +/// revocation. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SessionSummary { + /// Stable identifier, safe to display and to reference for revocation. + #[schema(format = Uuid)] + pub id: String, + /// When the session began. + pub created_at: DateTime, + /// When the session expires. + pub expires_at: DateTime, + /// When the session was last used. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_used_at: Option>, + /// When the session was revoked; `None` while active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revoked_at: Option>, + /// Whether this is the session making the request. + pub current: bool, +} + +/// A client authorized through the device flow. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DeviceSummary { + /// Stable identifier, safe to display and to reference for revocation. + #[schema(format = Uuid)] + pub id: String, + /// Client identifier supplied at authorization (e.g. `vscode`). + #[schema(min_length = 1, max_length = 128, pattern = "^[A-Za-z0-9._:-]+$")] + pub client_id: String, + /// Scopes granted to this device. + pub scopes: Vec, + /// When the device was approved. + pub created_at: DateTime, + /// When the device's refresh credential reaches its absolute deadline. + pub expires_at: DateTime, + /// When the device last refreshed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_used_at: Option>, + /// When the device was revoked; `None` while active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revoked_at: Option>, +} + +/// All sessions and devices for the admin account. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SessionListResponse { + /// Browser sessions. + pub sessions: Vec, + /// Device-flow clients. + pub devices: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) + } + + #[test] + fn test_scope_wire_format_is_lowercase() { + let json = serde_json::to_string(&Scope::ALL.to_vec()).unwrap(); + assert_eq!(json, r#"["read","write","execute","admin"]"#); + } + + #[test] + fn test_scope_roundtrips_through_str() { + for scope in Scope::ALL { + assert_eq!(scope.as_str().parse::(), Ok(scope)); + } + assert_eq!("".parse::(), Err(())); + assert_eq!("superuser".parse::(), Err(())); + } + + #[test] + fn test_scopes_are_not_hierarchical() { + // Ord exists for stable sorting and set membership, not for privilege + // implication: holding Write must never imply holding Read. Anything + // that grants access does so by explicit membership. + let granted = [Scope::Write]; + assert!(!granted.contains(&Scope::Read)); + assert!(!granted.contains(&Scope::Admin)); + } + + #[test] + fn test_access_key_summary_carries_no_secret_or_hash() { + // The management view describes a key without exposing anything usable + // to authenticate with it. + let summary = AccessKeySummary { + id: "ak_7f3a".to_string(), + name: "ci-pipeline".to_string(), + scopes: vec![Scope::Read, Scope::Execute], + created_at: ts("2026-01-01T00:00:00Z"), + expires_at: ts("2026-04-01T00:00:00Z"), + last_used_at: Some(ts("2026-01-05T12:00:00Z")), + revoked_at: None, + }; + let json = serde_json::to_string(&summary).unwrap(); + assert!(json.contains("\"id\":\"ak_7f3a\"")); + assert!(json.contains("\"scopes\":[\"read\",\"execute\"]")); + for forbidden in ["secret", "hash", "password", "token"] { + assert!( + !json.contains(forbidden), + "AccessKeySummary must not carry a `{forbidden}` field: {json}" + ); + } + } + + #[test] + fn test_create_access_key_response_returns_secret_exactly_once() { + // Creation is the one place a key secret is ever on the wire; the + // nested summary still carries none. + let resp = CreateAccessKeyResponse { + key: AccessKeySummary { + id: "ak_7f3a".to_string(), + name: "ci".to_string(), + scopes: vec![Scope::Read], + created_at: ts("2026-01-01T00:00:00Z"), + expires_at: ts("2026-04-01T00:00:00Z"), + last_used_at: None, + revoked_at: None, + }, + secret: "opk_live_abc123".to_string(), + }; + let json = serde_json::to_string(&resp).unwrap(); + assert_eq!(json.matches("opk_live_abc123").count(), 1); + } + + #[test] + fn test_session_and_device_summaries_carry_no_credential() { + let session = SessionSummary { + id: "sess_1".to_string(), + created_at: ts("2026-01-01T00:00:00Z"), + expires_at: ts("2026-01-02T00:00:00Z"), + last_used_at: None, + revoked_at: None, + current: true, + }; + let device = DeviceSummary { + id: "dev_1".to_string(), + client_id: "vscode".to_string(), + scopes: Scope::ALL.to_vec(), + created_at: ts("2026-01-01T00:00:00Z"), + expires_at: ts("2026-04-01T00:00:00Z"), + last_used_at: None, + revoked_at: None, + }; + for json in [ + serde_json::to_string(&session).unwrap(), + serde_json::to_string(&device).unwrap(), + ] { + for forbidden in ["secret", "hash", "cookie", "refresh_token"] { + assert!( + !json.contains(forbidden), + "metadata DTO must not carry `{forbidden}`: {json}" + ); + } + } + } + + #[test] + fn test_login_response_omits_session_identifier() { + // The session rides in Set-Cookie (HttpOnly); a body-borne identifier + // would be readable by script and defeat the cookie flags. + let resp = LoginResponse { + scopes: Scope::ALL.to_vec(), + expires_at: ts("2026-01-02T00:00:00Z"), + csrf_token: "csrf_abc".to_string(), + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(!json.contains("session_id")); + assert!(json.contains("csrf_token")); + } + + #[test] + fn test_oauth_error_codes_match_rfc_spelling() { + // A device-flow client branches on these exact strings while polling. + for (code, expected) in [ + ( + OAuthErrorCode::AuthorizationPending, + "authorization_pending", + ), + (OAuthErrorCode::SlowDown, "slow_down"), + (OAuthErrorCode::ExpiredToken, "expired_token"), + (OAuthErrorCode::AccessDenied, "access_denied"), + (OAuthErrorCode::InvalidGrant, "invalid_grant"), + ] { + assert_eq!( + serde_json::to_string(&code).unwrap(), + format!("\"{expected}\"") + ); + } + } + + #[test] + fn test_token_request_is_discriminated_by_grant() { + let req = TokenRequest::RefreshToken { + refresh_token: "rt_abc".to_string(), + client_id: "vscode".to_string(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("refresh_token")); + assert!(!json.contains("device_code")); + assert!(!json.contains("access_key")); + } + + #[test] + fn test_bootstrap_states_are_snake_case() { + for (state, expected) in [ + (BootstrapState::Uninitialized, "uninitialized"), + (BootstrapState::AwaitingPassword, "awaiting_password"), + (BootstrapState::Complete, "complete"), + ] { + assert_eq!( + serde_json::to_string(&state).unwrap(), + format!("\"{expected}\"") + ); + } + } +} diff --git a/src/rest/dto/configuration.rs b/src/rest/dto/configuration.rs index 2160ea54..e445c453 100644 --- a/src/rest/dto/configuration.rs +++ b/src/rest/dto/configuration.rs @@ -100,6 +100,9 @@ pub struct SkillsResponse { #[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct DelegatorResponse { + /// Optional Git identity, HTTPS credential reference, and runtime settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, /// Unique name pub name: String, /// LLM tool name (e.g., "claude") @@ -127,6 +130,9 @@ pub struct DelegatorResponse { #[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct CreateDelegatorRequest { + /// Optional Git identity, HTTPS credential reference, and runtime settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, /// Unique name for the delegator pub name: String, /// LLM tool name (must match a detected tool) @@ -213,6 +219,9 @@ pub struct DelegatorsResponse { #[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct CreateDelegatorFromToolRequest { + /// Optional Git identity, HTTPS credential reference, and runtime settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git: Option, /// Name of the detected tool (e.g., "claude", "codex", "gemini") pub tool_name: String, /// Model alias to use (e.g., "opus"). If omitted, uses the tool's first model alias. @@ -387,7 +396,7 @@ pub struct ModelServerModelsResponse { #[ts(export)] pub struct LlmToolsResponse { /// Detected CLI tools with model aliases and capabilities - pub tools: Vec, + pub tools: Vec, /// Total count pub total: usize, } @@ -412,6 +421,295 @@ pub struct DefaultLlmResponse { pub model: String, } +// ============================================================================= +// Public operational configuration +// ============================================================================= + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(deny_unknown_fields)] +pub struct AgentsConfiguration { + pub max_parallel: usize, + pub cores_reserved: usize, + pub max_agents_per_repo: usize, + pub health_check_interval: u64, + pub generation_timeout_secs: u64, + pub sync_interval: u64, + pub step_timeout: u64, + pub silence_threshold: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(deny_unknown_fields)] +pub struct QueueConfiguration { + pub auto_assign: bool, + pub priority_order: Vec, + pub poll_interval_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(deny_unknown_fields)] +pub struct PanelNamesConfiguration { + pub status: String, + pub queue: String, + pub in_progress: String, + pub completed: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(deny_unknown_fields)] +pub struct UiConfiguration { + pub refresh_rate_ms: u64, + pub completed_history_hours: u64, + pub summary_max_length: usize, + pub panel_names: PanelNamesConfiguration, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(rename_all = "lowercase")] +pub enum SessionWrapper { + Tmux, + Vscode, + Cmux, + Zellij, +} + +impl From for SessionWrapper { + fn from(value: crate::config::SessionWrapperType) -> Self { + match value { + crate::config::SessionWrapperType::Tmux => Self::Tmux, + crate::config::SessionWrapperType::Vscode => Self::Vscode, + crate::config::SessionWrapperType::Cmux => Self::Cmux, + crate::config::SessionWrapperType::Zellij => Self::Zellij, + } + } +} + +impl From for crate::config::SessionWrapperType { + fn from(value: SessionWrapper) -> Self { + match value { + SessionWrapper::Tmux => Self::Tmux, + SessionWrapper::Vscode => Self::Vscode, + SessionWrapper::Cmux => Self::Cmux, + SessionWrapper::Zellij => Self::Zellij, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(deny_unknown_fields)] +pub struct LaunchConfiguration { + pub confirm_autonomous: bool, + pub confirm_paired: bool, + pub launch_delay_ms: u64, + pub docker_enabled: bool, + pub docker_image: String, + pub yolo_enabled: bool, + pub session_wrapper: SessionWrapper, +} + +/// The deliberately supported, integration-safe configuration surface. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(deny_unknown_fields)] +pub struct ConfigurationResponse { + pub agents: AgentsConfiguration, + pub queue: QueueConfiguration, + pub ui: UiConfiguration, + pub launch: LaunchConfiguration, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(default, deny_unknown_fields)] +pub struct AgentsConfigurationPatch { + pub max_parallel: Option, + pub cores_reserved: Option, + pub max_agents_per_repo: Option, + pub health_check_interval: Option, + pub generation_timeout_secs: Option, + pub sync_interval: Option, + pub step_timeout: Option, + pub silence_threshold: Option, +} + +impl AgentsConfigurationPatch { + fn is_empty(&self) -> bool { + self.max_parallel.is_none() + && self.cores_reserved.is_none() + && self.max_agents_per_repo.is_none() + && self.health_check_interval.is_none() + && self.generation_timeout_secs.is_none() + && self.sync_interval.is_none() + && self.step_timeout.is_none() + && self.silence_threshold.is_none() + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(default, deny_unknown_fields)] +pub struct QueueConfigurationPatch { + pub auto_assign: Option, + pub priority_order: Option>, + pub poll_interval_ms: Option, +} + +impl QueueConfigurationPatch { + fn is_empty(&self) -> bool { + self.auto_assign.is_none() + && self.priority_order.is_none() + && self.poll_interval_ms.is_none() + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(default, deny_unknown_fields)] +pub struct PanelNamesConfigurationPatch { + pub status: Option, + pub queue: Option, + pub in_progress: Option, + pub completed: Option, +} + +impl PanelNamesConfigurationPatch { + fn is_empty(&self) -> bool { + self.status.is_none() + && self.queue.is_none() + && self.in_progress.is_none() + && self.completed.is_none() + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(default, deny_unknown_fields)] +pub struct UiConfigurationPatch { + pub refresh_rate_ms: Option, + pub completed_history_hours: Option, + pub summary_max_length: Option, + pub panel_names: Option, +} + +impl UiConfigurationPatch { + fn is_empty(&self) -> bool { + self.refresh_rate_ms.is_none() + && self.completed_history_hours.is_none() + && self.summary_max_length.is_none() + && self + .panel_names + .as_ref() + .is_none_or(PanelNamesConfigurationPatch::is_empty) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(default, deny_unknown_fields)] +pub struct LaunchConfigurationPatch { + pub confirm_autonomous: Option, + pub confirm_paired: Option, + pub launch_delay_ms: Option, + pub docker_enabled: Option, + pub docker_image: Option, + pub yolo_enabled: Option, + pub session_wrapper: Option, +} + +impl LaunchConfigurationPatch { + fn is_empty(&self) -> bool { + self.confirm_autonomous.is_none() + && self.confirm_paired.is_none() + && self.launch_delay_ms.is_none() + && self.docker_enabled.is_none() + && self.docker_image.is_none() + && self.yolo_enabled.is_none() + && self.session_wrapper.is_none() + } +} + +/// Field-level patch for the public operational configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(default, deny_unknown_fields)] +pub struct UpdateConfigurationRequest { + pub agents: Option, + pub queue: Option, + pub ui: Option, + pub launch: Option, +} + +impl UpdateConfigurationRequest { + pub fn is_empty(&self) -> bool { + self.agents + .as_ref() + .is_none_or(AgentsConfigurationPatch::is_empty) + && self + .queue + .as_ref() + .is_none_or(QueueConfigurationPatch::is_empty) + && self.ui.as_ref().is_none_or(UiConfigurationPatch::is_empty) + && self + .launch + .as_ref() + .is_none_or(LaunchConfigurationPatch::is_empty) + } +} + +/// Public view of a detected agent tool without local paths or command flags. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DetectedToolSummary { + pub name: String, + pub version: String, + pub min_version: Option, + pub version_ok: bool, + pub model_aliases: Vec, + pub capabilities: ToolCapabilitiesSummary, + pub health_ok: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ToolCapabilitiesSummary { + pub supports_sessions: bool, + pub supports_headless: bool, +} + +/// Execution target transport category. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(rename_all = "lowercase")] +pub enum ExecutionTargetKind { + Local, + Docker, + Coder, + Ssh, +} + +/// A named execution target without connection or credential plumbing. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ExecutionTargetSummary { + pub name: String, + pub display_name: Option, + pub kind: ExecutionTargetKind, + pub available: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ExecutionTargetsResponse { + pub targets: Vec, + pub total: usize, +} + #[cfg(test)] mod tests { use super::*; @@ -453,6 +751,7 @@ mod tests { #[test] fn test_delegator_response_roundtrip() { let resp = DelegatorResponse { + git: None, name: "claude-opus".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), diff --git a/src/rest/dto/kanban.rs b/src/rest/dto/kanban.rs index d9d76610..05fae9ae 100644 --- a/src/rest/dto/kanban.rs +++ b/src/rest/dto/kanban.rs @@ -119,6 +119,7 @@ pub struct JiraCredentials { /// Atlassian account email for Basic Auth pub email: String, /// API token / personal access token + #[schema(write_only, format = Password)] pub api_token: String, } @@ -127,6 +128,7 @@ pub struct JiraCredentials { #[ts(export)] pub struct LinearCredentials { /// Linear API key (prefixed `lin_api_`) + #[schema(write_only, format = Password)] pub api_key: String, } @@ -139,6 +141,7 @@ pub struct LinearCredentials { #[ts(export)] pub struct GithubCredentials { /// GitHub PAT, fine-grained PAT, or app installation token + #[schema(write_only, format = Password)] pub token: String, } @@ -397,6 +400,7 @@ pub struct WriteKanbanConfigResponse { pub struct JiraSessionEnv { pub domain: String, pub email: String, + #[schema(write_only, format = Password)] pub api_token: String, pub api_key_env: String, } @@ -405,6 +409,7 @@ pub struct JiraSessionEnv { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct LinearSessionEnv { + #[schema(write_only, format = Password)] pub api_key: String, pub api_key_env: String, } @@ -413,6 +418,7 @@ pub struct LinearSessionEnv { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct GithubSessionEnv { + #[schema(write_only, format = Password)] pub token: String, pub api_key_env: String, } diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs index 8ff2fa87..ed1119ba 100644 --- a/src/rest/dto/mod.rs +++ b/src/rest/dto/mod.rs @@ -5,8 +5,10 @@ //! - `kanban`: Kanban onboarding, board, and sync DTOs //! - `agents`: Agent lifecycle, launch, step execution, and review DTOs //! - `configuration`: `Delegator`, model server, LLM tool, and project DTOs +//! - `auth`: Bootstrap, session, OAuth device flow, and access-key DTOs pub mod agents; +pub mod auth; pub mod configuration; pub mod integrations; pub mod issue_types; @@ -16,6 +18,7 @@ pub mod tickets; pub mod workflow; pub use agents::*; +pub use auth::*; pub use configuration::*; pub use integrations::*; pub use issue_types::*; diff --git a/src/rest/error.rs b/src/rest/error.rs index 3443ace0..0110cbf7 100644 --- a/src/rest/error.rs +++ b/src/rest/error.rs @@ -1,15 +1,19 @@ //! API error types and responses. use axum::{ - http::StatusCode, + http::{header, StatusCode}, response::{IntoResponse, Response}, Json, }; + +/// Challenge returned with every `401`. Names both accepted schemes so a client that holds neither knows which to obtain. +const WWW_AUTHENTICATE_CHALLENGE: &str = r#"Bearer realm="operator", Cookie realm="operator""#; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; /// API error types #[derive(Debug)] +#[allow(dead_code)] // Auth variants are constructed by Phase 3 middleware/handlers. pub enum ApiError { /// Resource not found NotFound(String), @@ -23,6 +27,19 @@ pub enum ApiError { BadRequest(String), /// Cannot modify builtin resource BuiltinReadOnly(String), + // The three auth variants below are constructed by the authorization + // middleware and auth handlers, which land in Phase 3. The error contract + // ships first so clients can be generated against a settled shape. + /// No usable credential was presented. Carries a `WWW-Authenticate` + /// challenge so a client knows *how* to authenticate, not just that it must. + Unauthorized(String), + /// A valid credential that lacks the scope this route requires. Distinct + /// from `Unauthorized`: re-authenticating will not help, so a client must + /// not retry with the same credential. + Forbidden(String), + /// A cookie-authenticated mutation arrived without a valid CSRF token or + /// with a mismatched `Origin`. + CsrfFailed(String), } /// Error response body @@ -43,16 +60,29 @@ impl IntoResponse for ApiError { } ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg), ApiError::BuiltinReadOnly(msg) => (StatusCode::FORBIDDEN, "builtin_readonly", msg), + ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "unauthorized", msg), + ApiError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg), + ApiError::CsrfFailed(msg) => (StatusCode::FORBIDDEN, "csrf_failed", msg), }; - ( - status, - Json(ErrorResponse { - error: error.to_string(), - message, - }), - ) - .into_response() + let body = Json(ErrorResponse { + error: error.to_string(), + message, + }); + + // Only a 401 carries a challenge. A 403 means the credential was + // understood and refused, so advertising a scheme would invite a + // pointless retry. + if status == StatusCode::UNAUTHORIZED { + ( + status, + [(header::WWW_AUTHENTICATE, WWW_AUTHENTICATE_CHALLENGE)], + body, + ) + .into_response() + } else { + (status, body).into_response() + } } } @@ -106,4 +136,47 @@ mod tests { assert_eq!(response.status(), StatusCode::FORBIDDEN); } + + #[tokio::test] + async fn test_unauthorized_carries_a_challenge() { + let response = + ApiError::Unauthorized("no credential presented".to_string()).into_response(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let challenge = response + .headers() + .get(header::WWW_AUTHENTICATE) + .expect("401 must advertise how to authenticate") + .to_str() + .unwrap(); + assert!(challenge.contains("Bearer")); + assert!(challenge.contains("Cookie")); + } + + #[tokio::test] + async fn test_forbidden_does_not_invite_a_retry() { + // The credential was understood and refused; a challenge would suggest + // re-authenticating fixes it, which it does not. + let response = + ApiError::Forbidden("requires the `admin` scope".to_string()).into_response(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(response.headers().get(header::WWW_AUTHENTICATE).is_none()); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: ErrorResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(json.error, "forbidden"); + } + + #[tokio::test] + async fn test_csrf_failure_is_distinguishable_from_a_scope_denial() { + // A client retries these differently: refetch a CSRF token, versus + // obtain a credential with more scope. + let response = ApiError::CsrfFailed("missing CSRF token".to_string()).into_response(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: ErrorResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(json.error, "csrf_failed"); + } } diff --git a/src/rest/middleware/auth.rs b/src/rest/middleware/auth.rs new file mode 100644 index 00000000..f55ba15c --- /dev/null +++ b/src/rest/middleware/auth.rs @@ -0,0 +1,529 @@ +//! The authorization layer. +//! +//! One `middleware::from_fn_with_state` layer decides every request. It runs +//! over the *composed* router — the documented API routes, the Swagger UI, and +//! the config-gated MCP transport routes — so no surface can be mounted outside +//! its reach. +//! +//! The decision is: resolve a principal from the request's credentials, look up +//! what the matched route requires, and compare. A route the table does not +//! know is **denied**, because an unclassified route is a bug and failing open +//! would make it an exploitable one. + +use axum::extract::{Request, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; + +use crate::auth::scope::{required_access, Access, Principal}; +use crate::auth::store::RateLimitDecision; +use crate::auth::tokens::{AUDIENCE_API, AUDIENCE_CALLBACK}; +use crate::rest::dto::auth::{PrincipalKind, Scope}; +use crate::rest::error::ApiError; +use crate::rest::state::ApiState; + +/// Name of the browser session cookie. +/// +/// The `__Host-` prefix is enforced *by the browser*: it refuses the cookie +/// unless it is `Secure`, carries no `Domain`, and has `Path=/`. That makes it +/// impossible for a sibling subdomain to set or overwrite the session. +pub const SESSION_COOKIE: &str = "__Host-operator_session"; + +/// Header carrying the CSRF token on cookie-authenticated mutations. +pub const CSRF_HEADER: &str = "x-operator-csrf"; + +/// Paths served to an unauthenticated browser so it can render the login, bootstrap, and device-approval screens. +/// +/// This is the whole SPA bundle, unavoidably: the dashboard uses fragment +/// routing, so `#/login` and `#/config` are indistinguishable to the server — +/// it sees one request for `/` either way. The bundle carries no workspace data +/// or credentials; everything it displays arrives over authenticated API calls. +/// See `docs/security/#the-dashboard-bundle-is-public`. +fn is_public_asset(path: &str) -> bool { + !(path.starts_with("/api/") || path.starts_with("/swagger-ui") || path.starts_with("/api-docs")) +} + +/// Requirement for a path that matched no route. +/// +/// Swagger UI and the raw OpenAPI document are served by `SwaggerUi`, not by a +/// `routes!` entry, so they never produce a `MatchedPath` and cannot be listed +/// in `ROUTE_RULES`. They still need classifying: the spec enumerates every +/// endpoint this server exposes, which is not something to hand out +/// anonymously — but an authenticated admin should be able to open it. +fn unmatched_access(path: &str) -> Option { + if path.starts_with("/swagger-ui") || path.starts_with("/api-docs") { + return Some(Access::Scoped(Scope::Read)); + } + if is_public_asset(path) { + // The SPA bundle. See the note on `is_public_asset`. + return Some(Access::Public); + } + // An unknown /api/ path: deny, so a route mounted without a rule fails + // closed rather than silently open. + None +} + +/// Extract a cookie value from a `Cookie` header. +fn cookie_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(header::COOKIE)? + .to_str() + .ok()? + .split(';') + .filter_map(|part| part.split_once('=')) + .find(|(k, _)| k.trim() == name) + .map(|(_, v)| v.trim().to_string()) +} + +/// Extract a bearer token from an `Authorization` header. +fn bearer_token(headers: &HeaderMap) -> Option { + let raw = headers.get(header::AUTHORIZATION)?.to_str().ok()?; + let (scheme, token) = raw.split_once(' ')?; + scheme + .eq_ignore_ascii_case("bearer") + .then(|| token.trim().to_string()) + .filter(|t| !t.is_empty()) +} + +/// Resolve the caller's identity from whatever credential they presented. +/// +/// Order matters only in that a bearer token is checked before a cookie: an +/// explicit `Authorization` header is a deliberate act, while a cookie is sent +/// ambiently by the browser. +pub async fn resolve_principal(state: &ApiState, headers: &HeaderMap) -> Option { + if let Some(token) = bearer_token(headers) { + // The local-unlock token is a plain opaque string, not a JWT. + if let Some(local) = state.auth.local_token.as_deref() { + if crate::auth::local::matches(local, &token) { + return Some(Principal::local(crate::auth::store::ADMIN_SUBJECT)); + } + } + + if let Ok(claims) = state.auth.signing_key.verify(&token, AUDIENCE_API) { + return Some(Principal { + subject: claims.sub.clone(), + scopes: claims.scopes(), + kind: PrincipalKind::AccessToken, + expires_at: chrono::DateTime::from_timestamp(claims.exp, 0), + session_id: None, + ticket_id: None, + step: None, + }); + } + + // An agent step-completion token. It verifies under a different + // audience, so it can never satisfy an ordinary API route; the handler + // additionally matches its ticket/step claims against the request path. + if let Ok(claims) = state.auth.signing_key.verify(&token, AUDIENCE_CALLBACK) { + return Some(Principal { + subject: claims.sub.clone(), + scopes: claims.scopes(), + kind: PrincipalKind::AgentCallback, + expires_at: chrono::DateTime::from_timestamp(claims.exp, 0), + session_id: None, + ticket_id: claims.ticket_id, + step: claims.step, + }); + } + return None; + } + + if let Some(cookie) = cookie_value(headers, SESSION_COOKIE) { + let store = state.auth.store.clone(); + return tokio::task::spawn_blocking(move || store.authenticate_session(&cookie)) + .await + .ok()? + .ok()?; + } + + None +} + +/// Whether a request method mutates. +fn is_mutation(method: &axum::http::Method) -> bool { + !matches!( + *method, + axum::http::Method::GET | axum::http::Method::HEAD | axum::http::Method::OPTIONS + ) +} + +/// The `host[:port]` part of an origin, so `https://x.example:443` and a `Host` +/// header of `x.example:443` compare equal without guessing a scheme. +fn origin_authority(origin: &str) -> Option<&str> { + origin + .split_once("://") + .map(|(_scheme, rest)| rest) + .filter(|rest| !rest.is_empty()) +} + +/// Reject a cross-origin `Origin` on a cookie-authenticated mutation. +/// +/// Belt and braces alongside `SameSite=Strict`: the cookie should never be sent +/// cross-site in the first place, but `Origin` costs nothing to check and +/// covers flows where the cookie policy is weaker than expected. +/// +/// Three cases count as acceptable, and the first is easy to forget: a browser +/// sends `Origin` on a **same-origin** POST too. Checking only the configured +/// CORS allowlist therefore blocked the dashboard's own mutations, since that +/// list is empty by default — and a curl test never catches it, because curl +/// sends no `Origin` at all. +fn origin_is_acceptable(headers: &HeaderMap, allowed: &[String]) -> bool { + let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else { + // No Origin header: not a browser cross-site request. + return true; + }; + + // Same-origin: the Origin's authority matches the Host being addressed. + if let (Some(origin_authority), Some(host)) = ( + origin_authority(origin), + headers.get(header::HOST).and_then(|v| v.to_str().ok()), + ) { + if origin_authority.eq_ignore_ascii_case(host) { + return true; + } + } + + // Explicitly configured cross-origin caller. + allowed.iter().any(|a| a == origin) +} + +/// The authorization layer. +pub async fn authorize( + State(state): State, + request: Request, + next: Next, +) -> Result { + let method = request.method().clone(); + let path = request.uri().path().to_string(); + let headers = request.headers().clone(); + + // The route pattern axum matched (`/api/v1/tickets/{id}`), not the concrete + // path, so the table is written once per route rather than per request. + let matched = request + .extensions() + .get::() + .map(|m| m.as_str().to_string()); + + // Prefer the route table. Fall back to path-based classification when the + // table has no entry: `SwaggerUi` mounts its own wildcard route, so it does + // produce a `MatchedPath` — just not one that can appear in `ROUTE_RULES`. + // The fallback still denies any unclassified `/api/` path. + let access = matched + .as_deref() + .and_then(|pattern| required_access(method.as_str(), pattern)) + .or_else(|| unmatched_access(&path)); + + let Some(access) = access else { + // Unknown or unclassified: deny. `tests/route_scope_parity.rs` makes + // this unreachable for mounted routes. + return Err( + ApiError::Unauthorized("this endpoint requires authentication".to_string()) + .into_response(), + ); + }; + + let required = match access { + Access::Public => return Ok(next.run(request).await), + Access::Scoped(scope) => scope, + }; + + let Some(principal) = resolve_principal(&state, &headers).await else { + return Err( + ApiError::Unauthorized("no valid credential was presented".to_string()).into_response(), + ); + }; + + if !principal.has_scope(required) { + return Err( + ApiError::Forbidden(format!("this endpoint requires the `{required}` scope")) + .into_response(), + ); + } + + // A cookie is sent automatically by the browser, so a cookie-authenticated + // mutation needs proof the request was intended. A bearer token is never + // sent ambiently, so it needs no such proof. + if principal.kind == PrincipalKind::Session && is_mutation(&method) { + let config = state.config(); + if !origin_is_acceptable(&headers, &config.rest_api.cors_origins) { + return Err( + ApiError::CsrfFailed("request Origin is not allowed".to_string()).into_response(), + ); + } + + let Some(session_id) = principal.session_id.clone() else { + return Err( + ApiError::CsrfFailed("session is not identifiable".to_string()).into_response(), + ); + }; + let Some(csrf) = headers + .get(CSRF_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + else { + return Err(ApiError::CsrfFailed(format!( + "cookie-authenticated mutations require the `{CSRF_HEADER}` header" + )) + .into_response()); + }; + + let store = state.auth.store.clone(); + let ok = tokio::task::spawn_blocking(move || store.verify_csrf(&session_id, &csrf)) + .await + .map_err(|_| { + ApiError::InternalError("CSRF verification task failed".to_string()).into_response() + })? + .unwrap_or(false); + if !ok { + return Err(ApiError::CsrfFailed("CSRF token is invalid".to_string()).into_response()); + } + } + + let mut request = request; + request.extensions_mut().insert(principal); + Ok(next.run(request).await) +} + +/// Apply persisted backoff to a credential-issuing endpoint. +/// +/// Returns the `Retry-After` response when the caller must wait. +pub async fn enforce_backoff(state: &ApiState, bucket: &str) -> Option { + let store = state.auth.store.clone(); + let owned = bucket.to_string(); + let decision = tokio::task::spawn_blocking(move || store.check_rate_limit(&owned)) + .await + .ok()? + .ok()?; + + match decision { + RateLimitDecision::Allow => None, + RateLimitDecision::Backoff { retry_after_secs } => Some( + ( + StatusCode::TOO_MANY_REQUESTS, + [(header::RETRY_AFTER, retry_after_secs.to_string())], + axum::Json(serde_json::json!({ + "error": "rate_limited", + "message": format!("too many attempts; retry in {retry_after_secs}s"), + })), + ) + .into_response(), + ), + } +} + +/// Extractor for the principal the [`authorize`] layer attached. +/// +/// Infallible by construction: a handler only runs after `authorize` inserted a +/// principal, or the route was public. A public route that asks for one gets +/// the anonymous fallback rather than a 500. +pub struct Authenticated(pub Principal); + +impl axum::extract::FromRequestParts for Authenticated +where + S: Send + Sync, +{ + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> Result { + Ok(Authenticated( + parts + .extensions + .get::() + .cloned() + .unwrap_or_else(|| Principal { + subject: String::new(), + scopes: Vec::new(), + kind: PrincipalKind::AccessToken, + expires_at: None, + session_id: None, + ticket_id: None, + step: None, + }), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn headers(pairs: &[(&str, &str)]) -> HeaderMap { + let mut h = HeaderMap::new(); + for (k, v) in pairs { + h.insert( + axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(), + HeaderValue::from_str(v).unwrap(), + ); + } + h + } + + #[test] + fn test_bearer_token_parsing() { + assert_eq!( + bearer_token(&headers(&[("authorization", "Bearer abc123")])).as_deref(), + Some("abc123") + ); + // Scheme is case-insensitive per RFC 7235. + assert_eq!( + bearer_token(&headers(&[("authorization", "bearer abc123")])).as_deref(), + Some("abc123") + ); + assert!(bearer_token(&headers(&[("authorization", "Basic abc123")])).is_none()); + assert!(bearer_token(&headers(&[("authorization", "Bearer")])).is_none()); + assert!(bearer_token(&headers(&[("authorization", "Bearer ")])).is_none()); + assert!(bearer_token(&HeaderMap::new()).is_none()); + } + + #[test] + fn test_cookie_extraction_finds_the_named_cookie_among_others() { + let h = headers(&[( + "cookie", + "theme=dark; __Host-operator_session=sess-value; other=x", + )]); + assert_eq!( + cookie_value(&h, SESSION_COOKIE).as_deref(), + Some("sess-value") + ); + assert!(cookie_value(&h, "nonexistent").is_none()); + } + + #[test] + fn test_cookie_name_keeps_the_host_prefix() { + // The browser enforces Secure + no Domain + Path=/ on this prefix; + // renaming it silently drops those guarantees. + assert_eq!(SESSION_COOKIE, "__Host-operator_session"); + } + + #[test] + fn test_api_surfaces_are_never_treated_as_public_assets() { + for path in [ + "/api/v1/health", + "/api/v1/configuration", + "/swagger-ui", + "/swagger-ui/index.html", + "/api-docs/openapi.json", + ] { + assert!( + !is_public_asset(path), + "{path} must not be reachable as a static asset" + ); + } + } + + #[test] + fn test_spa_assets_are_public_so_the_login_screen_can_render() { + for path in ["/", "/index.html", "/assets/index-abc123.js"] { + assert!(is_public_asset(path)); + assert_eq!(unmatched_access(path), Some(Access::Public)); + } + } + + #[test] + fn test_swagger_needs_a_credential_but_is_reachable_with_one() { + // It is served by SwaggerUi rather than a `routes!` entry, so it never + // produces a MatchedPath and cannot live in ROUTE_RULES. Denying it + // outright would make the API docs unusable for the admin. + for path in [ + "/swagger-ui/", + "/swagger-ui/index.html", + "/api-docs/openapi.json", + ] { + assert_eq!( + unmatched_access(path), + Some(Access::Scoped(Scope::Read)), + "{path} should require a credential but remain reachable" + ); + } + } + + #[test] + fn test_an_unknown_api_path_fails_closed() { + // A route mounted without a ROUTE_RULES entry must be denied, not + // silently served. + assert_eq!(unmatched_access("/api/v1/not-a-route"), None); + } + + #[test] + fn test_mutation_classification() { + use axum::http::Method; + assert!(!is_mutation(&Method::GET)); + assert!(!is_mutation(&Method::HEAD)); + assert!(!is_mutation(&Method::OPTIONS)); + assert!(is_mutation(&Method::POST)); + assert!(is_mutation(&Method::PUT)); + assert!(is_mutation(&Method::DELETE)); + assert!(is_mutation(&Method::PATCH)); + } + + #[test] + fn test_absent_origin_is_accepted_but_a_foreign_one_is_not() { + let allowed = vec!["https://operator.example.com".to_string()]; + // A non-browser client sends no Origin at all. + assert!(origin_is_acceptable(&HeaderMap::new(), &allowed)); + assert!(origin_is_acceptable( + &headers(&[("origin", "https://operator.example.com")]), + &allowed + )); + assert!(!origin_is_acceptable( + &headers(&[("origin", "https://evil.example.com")]), + &allowed + )); + } + + #[test] + fn test_same_origin_mutation_is_accepted_with_no_configured_origins() { + // Regression: the dashboard's own POSTs were rejected because browsers + // send `Origin` on same-origin mutations too and the default + // `cors_origins` list is empty. curl never reproduced it — curl sends + // no Origin header, so the check passed there. + for (origin, host) in [ + ("http://127.0.0.1:7008", "127.0.0.1:7008"), + ("http://localhost:7008", "localhost:7008"), + ("https://operator.example.com", "operator.example.com"), + ] { + assert!( + origin_is_acceptable(&headers(&[("origin", origin), ("host", host)]), &[]), + "same-origin mutation from {origin} must be allowed" + ); + } + } + + #[test] + fn test_a_different_host_is_still_rejected_without_configuration() { + assert!(!origin_is_acceptable( + &headers(&[ + ("origin", "https://evil.example.com"), + ("host", "operator.example.com") + ]), + &[] + )); + // A port mismatch is a different origin. + assert!(!origin_is_acceptable( + &headers(&[ + ("origin", "http://127.0.0.1:9999"), + ("host", "127.0.0.1:7008") + ]), + &[] + )); + } + + #[test] + fn test_origin_authority_extraction() { + assert_eq!( + origin_authority("https://operator.example.com"), + Some("operator.example.com") + ); + assert_eq!( + origin_authority("http://127.0.0.1:7008"), + Some("127.0.0.1:7008") + ); + // `null` is what a sandboxed iframe or a file:// page sends; it has no + // authority and must not match anything. + assert_eq!(origin_authority("null"), None); + } +} diff --git a/src/rest/middleware/mod.rs b/src/rest/middleware/mod.rs new file mode 100644 index 00000000..e965b216 --- /dev/null +++ b/src/rest/middleware/mod.rs @@ -0,0 +1,3 @@ +//! Request middleware for the REST API. + +pub mod auth; diff --git a/src/rest/mod.rs b/src/rest/mod.rs index 1c49a881..2c5392b4 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -10,7 +10,7 @@ use axum::{ routing::{get, post}, Router, }; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::CorsLayer; use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; use tracing::Level; use utoipa::OpenApi; @@ -21,6 +21,7 @@ use utoipa_swagger_ui::SwaggerUi; pub mod directory; pub mod dto; pub mod error; +pub mod middleware; pub mod openapi; pub mod routes; pub mod server; @@ -58,6 +59,39 @@ pub use state::ApiState; #[allow(dead_code)] pub const DEFAULT_PORT: u16 = 7008; +/// Probe and authentication routes. +/// +/// Split out of [`documented_router`] both to keep that function legible and +/// because this is the security-relevant subset: every route here either needs +/// no credential or manages one. Merged in, so it still self-registers in the +/// OpenAPI spec exactly like the rest. +fn auth_router() -> OpenApiRouter { + OpenApiRouter::new() + // Kubernetes probes — public, and deliberately metadata-free. + .routes(routes!(routes::probes::livez)) + .routes(routes!(routes::probes::readyz)) + // Obtaining a credential. + .routes(routes!( + routes::auth::bootstrap_status, + routes::auth::bootstrap_submit + )) + .routes(routes!(routes::auth::login)) + .routes(routes!(routes::auth::device_code)) + .routes(routes!(routes::auth::token)) + // Managing credentials (authenticated). + .routes(routes!(routes::auth::logout)) + .routes(routes!(routes::auth::current_session)) + .routes(routes!(routes::auth::csrf_token)) + .routes(routes!(routes::auth::list_sessions)) + .routes(routes!(routes::auth::revoke_session)) + .routes(routes!(routes::auth::device_approve)) + .routes(routes!( + routes::auth::list_access_keys, + routes::auth::create_access_key + )) + .routes(routes!(routes::auth::revoke_access_key)) +} + /// Build the documented API surface as a `utoipa_axum::OpenApiRouter`. /// /// Every always-on route is mounted here via `routes!`, so mounting a route @@ -71,6 +105,7 @@ pub const DEFAULT_PORT: u16 = 7008; /// mounted handlers. fn documented_router() -> OpenApiRouter { OpenApiRouter::with_openapi(ApiDoc::openapi()) + .merge(auth_router()) // Health endpoints .routes(routes!(routes::health::health)) .routes(routes!(routes::health::status)) @@ -170,8 +205,9 @@ fn documented_router() -> OpenApiRouter { // Configuration endpoints .routes(routes!( routes::configuration::get_config, - routes::configuration::update_config + routes::configuration::patch_config )) + .routes(routes!(routes::configuration::execution_targets)) // Model server endpoints .routes(routes!( routes::model_servers::list, @@ -193,8 +229,8 @@ fn documented_router() -> OpenApiRouter { /// The canonical OpenAPI spec for the documented API surface. /// /// Built from [`documented_router`] so it always reflects the mounted routes. -/// Config-gated MCP transport routes are omitted (they carry no -/// `#[utoipa::path]` and only ever exist when `[mcp].http_enabled`). +/// Config-gated MCP transport routes remain in the contract so clients can +/// discover their wire format even when a particular deployment disables them. /// /// The `info.version` is stamped here from `CARGO_PKG_VERSION` — the compiled /// release version that CI writes into `Cargo.toml`/`VERSION` on every release. @@ -204,22 +240,59 @@ fn documented_router() -> OpenApiRouter { pub fn openapi_spec() -> utoipa::openapi::OpenApi { let mut spec = documented_router().split_for_parts().1; spec.info.version = env!("CARGO_PKG_VERSION").to_string(); - spec + openapi::apply_contract_metadata(spec) } /// Build the API router with all routes +/// Build the CORS layer from configuration. +/// +/// Replaces a blanket `allow_origin(Any)`, which let any website on the +/// internet call this API. `Any` is also incompatible with credentials: a +/// browser refuses to send cookies to a wildcard origin, so the permissive +/// version could not have supported an authenticated dashboard anyway. +/// +/// An empty `cors_origins` means **same-origin only** — no `Access-Control-Allow-Origin` +/// is emitted, the same-origin dashboard still works, and no other site can +/// read a response. +fn cors_layer(config: &crate::config::Config) -> CorsLayer { + let origins: Vec = config + .rest_api + .cors_origins + .iter() + .filter_map(|o| o.parse().ok()) + .collect(); + + if origins.is_empty() { + return CorsLayer::new(); + } + + CorsLayer::new() + .allow_origin(origins) + .allow_methods(vec![ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::PUT, + axum::http::Method::PATCH, + axum::http::Method::DELETE, + ]) + .allow_headers(vec![ + axum::http::header::CONTENT_TYPE, + axum::http::header::AUTHORIZATION, + axum::http::HeaderName::from_static(crate::rest::middleware::auth::CSRF_HEADER), + ]) + // Required for the dashboard's session cookie to be sent at all. + .allow_credentials(true) +} + pub fn build_router(state: ApiState) -> Router { - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); + let config = state.config(); + let cors = cors_layer(&config); - let mcp_enabled = state.config.mcp.http_enabled; + let mcp_enabled = config.mcp.http_enabled; let (mut router, _api) = documented_router().split_for_parts(); - // MCP transport endpoints — gated by [mcp].http_enabled and intentionally - // undocumented (no OpenAPI schema for the SSE/JSON-RPC transport). + // MCP transport endpoints are gated by [mcp].http_enabled. if mcp_enabled { router = router .route("/api/v1/mcp/sse", get(crate::mcp::transport::sse_handler)) @@ -229,7 +302,26 @@ pub fn build_router(state: ApiState) -> Router { ); } - let router = router + // Swagger UI and its spec are merged in here, and the SPA fallback is + // registered here, so that the auth layer below covers both. + // + // Ordering is load-bearing: `Router::fallback` registered *after* `.layer` + // is not wrapped by that layer. With the fallback added last, an unknown + // `/api/...` path bypassed authorization entirely and was answered with the + // SPA shell instead of a 401 — which also meant a route mounted without a + // `ROUTE_RULES` entry would silently serve HTML rather than fail closed. + let router = + router.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi_spec())); + + #[cfg(feature = "embed-ui")] + let router = router.fallback(web_ui::spa_handler); + + router + // Authorization runs over the composed router: documented routes, the config-gated MCP transport, Swagger, and the SPA fallback. + .layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::auth::authorize, + )) .layer( TraceLayer::new_for_http() .on_request(DefaultOnRequest::new().level(Level::INFO)) @@ -237,20 +329,13 @@ pub fn build_router(state: ApiState) -> Router { ) .layer(cors) .with_state(state) - // Serve the version-stamped spec (not the raw `_api` half) so swagger-ui - // reports the release version, matching /api/v1/health. - .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi_spec())); - - #[cfg(feature = "embed-ui")] - let router = router.fallback(web_ui::spa_handler); - - router } /// Start the REST API server (standalone mode with session file and logging) pub async fn serve(state: ApiState, port: u16) -> Result<()> { let tickets_path = state.tickets_path.clone(); - let host_ip = state.config.rest_api.host_ip(); + let state_path = state.config().state_path(); + let host_ip = state.config().rest_api.host_ip(); let app = build_router(state); let addr = SocketAddr::new(host_ip, port); @@ -258,7 +343,7 @@ pub async fn serve(state: ApiState, port: u16) -> Result<()> { tracing::info!("Swagger UI available at http://{}/swagger-ui", addr); // Write session file for client discovery - write_session_file(&tickets_path, port)?; + write_session_file(&tickets_path, &state_path, port)?; let listener = tokio::net::TcpListener::bind(addr).await?; @@ -274,7 +359,11 @@ pub async fn serve(state: ApiState, port: u16) -> Result<()> { } /// Write API session file for client discovery (standalone mode) -fn write_session_file(tickets_path: &std::path::Path, port: u16) -> Result<()> { +fn write_session_file( + tickets_path: &std::path::Path, + state_path: &std::path::Path, + port: u16, +) -> Result<()> { let operator_dir = tickets_path.join("operator"); std::fs::create_dir_all(&operator_dir)?; @@ -284,6 +373,7 @@ fn write_session_file(tickets_path: &std::path::Path, port: u16) -> Result<()> { pid: std::process::id(), started_at: chrono::Utc::now().to_rfc3339(), version: env!("CARGO_PKG_VERSION").to_string(), + state_dir: state_path.to_path_buf(), }; let json = serde_json::to_string_pretty(&session)?; diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index 05c2cd6e..fd536208 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -1,25 +1,47 @@ //! OpenAPI specification builder using utoipa. -use utoipa::OpenApi; +use std::collections::BTreeSet; +use utoipa::openapi::{ + content::Content, + extensions::Extensions, + header::Header, + path::{Operation, Parameter, ParameterIn}, + response::Response, + schema::{Object, Type}, + security::{ + ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityRequirement, SecurityScheme, + }, + Ref, RefOr, Required, +}; +use utoipa::{Modify, OpenApi}; + +use crate::auth::scope::{required_access, Access}; use crate::mcp::descriptor::McpDescriptorResponse; use crate::rest::dto::{ - ActiveAgentResponse, ActiveAgentsResponse, AgentDetailResponse, AssessTicketResponse, - CollectionResponse, CreateAlertRequest, CreateAlertResponse, CreateDelegatorFromToolRequest, - CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, CreateModelServerRequest, - CreateStepRequest, CreateTicketRequest, CreateTicketResponse, DefaultLlmResponse, - DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, ExternalIssueTypeSummary, - FieldResponse, HealthResponse, IntegrationCatalogEntryDto, IssueTypeResponse, IssueTypeSummary, - KanbanBoardResponse, KanbanIssueTypeResponse, KanbanProviderCatalogEntry, KanbanSyncResponse, - KanbanTicketCard, LaunchTicketRequest, LaunchTicketResponse, ListKanbanProjectsRequest, - ListKanbanProjectsResponse, ListKanbanStatusesRequest, ListKanbanStatusesResponse, ModelEntry, - ModelServerKindEntry, ModelServerModelsResponse, ModelServerResponse, ModelServersResponse, - NextStepInfo, OperatorOutput, ProjectSummary, QueueByType, QueueControlResponse, - QueueStatusResponse, RejectReviewRequest, ReviewResponse, SectionDto, SectionRowDto, - SetDefaultLlmRequest, SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, SkillEntry, - SkillsResponse, StatusResponse, StepCompleteRequest, StepCompleteResponse, StepResponse, - SyncKanbanIssueTypesResponse, TicketDetailResponse, UpdateIssueTypeRequest, - UpdateModelServerRequest, UpdateStepRequest, UpdateTicketStatusRequest, + AccessKeyListResponse, AccessKeySummary, ActiveAgentResponse, ActiveAgentsResponse, + AgentDetailResponse, AssessTicketResponse, BootstrapState, BootstrapStatusResponse, + BootstrapSubmitRequest, BootstrapSubmitResponse, CollectionResponse, CreateAccessKeyRequest, + CreateAccessKeyResponse, CreateAlertRequest, CreateAlertResponse, + CreateDelegatorFromToolRequest, CreateDelegatorRequest, CreateFieldRequest, + CreateIssueTypeRequest, CreateModelServerRequest, CreateStepRequest, CreateTicketRequest, + CreateTicketResponse, CsrfTokenResponse, CurrentSessionResponse, DefaultLlmResponse, + DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, DeviceApprovalRequest, + DeviceApprovalResponse, DeviceAuthorizationRequest, DeviceAuthorizationResponse, DeviceSummary, + ExternalIssueTypeSummary, FieldResponse, HealthResponse, IntegrationCatalogEntryDto, + IssueTypeResponse, IssueTypeSummary, KanbanBoardResponse, KanbanIssueTypeResponse, + KanbanProviderCatalogEntry, KanbanSyncResponse, KanbanTicketCard, LaunchTicketRequest, + LaunchTicketResponse, ListKanbanProjectsRequest, ListKanbanProjectsResponse, + ListKanbanStatusesRequest, ListKanbanStatusesResponse, LoginRequest, LoginResponse, + LogoutResponse, ModelEntry, ModelServerKindEntry, ModelServerModelsResponse, + ModelServerResponse, ModelServersResponse, NextStepInfo, OAuthErrorCode, OAuthErrorResponse, + OperatorOutput, PrincipalKind, ProjectSummary, QueueByType, QueueControlResponse, + QueueStatusResponse, RejectReviewRequest, ReviewResponse, RevokeAccessKeyResponse, Scope, + SectionDto, SectionRowDto, SessionListResponse, SessionSummary, SetDefaultLlmRequest, + SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, SkillEntry, SkillsResponse, + StatusResponse, StepCompleteRequest, StepCompleteResponse, StepResponse, + SyncKanbanIssueTypesResponse, TicketDetailResponse, TokenRequest, TokenResponse, + UpdateIssueTypeRequest, UpdateModelServerRequest, UpdateStepRequest, UpdateTicketStatusRequest, UpdateTicketStatusResponse, ValidateKanbanCredentialsRequest, ValidateKanbanCredentialsResponse, WorkflowExportResponse, WorkflowFormatDto, WorkflowHintsDto, WorkflowPreviewResponse, WriteKanbanConfigRequest, WriteKanbanConfigResponse, @@ -150,8 +172,41 @@ use crate::rest::error::ErrorResponse; WriteKanbanConfigResponse, SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, + // Authentication + Scope, + PrincipalKind, + BootstrapState, + BootstrapStatusResponse, + BootstrapSubmitRequest, + BootstrapSubmitResponse, + LoginRequest, + LoginResponse, + LogoutResponse, + CurrentSessionResponse, + CsrfTokenResponse, + SessionSummary, + DeviceSummary, + SessionListResponse, + DeviceAuthorizationRequest, + DeviceAuthorizationResponse, + DeviceApprovalRequest, + DeviceApprovalResponse, + TokenRequest, + TokenResponse, + OAuthErrorCode, + OAuthErrorResponse, + CreateAccessKeyRequest, + CreateAccessKeyResponse, + AccessKeySummary, + AccessKeyListResponse, + RevokeAccessKeyResponse, ) ), + modifiers(&SecurityAddon), + paths( + crate::mcp::transport::sse_handler, + crate::mcp::transport::message_handler, + ), tags( (name = "Health", description = "Health check and status endpoints"), (name = "Status", description = "Canonical status sections (TUI / VS Code parity)"), @@ -170,10 +225,274 @@ use crate::rest::error::ErrorResponse; (name = "Projects", description = "Project discovery and ticket assessment"), (name = "Configuration", description = "Operator configuration read/write"), (name = "Kanban", description = "Kanban provider issue types and onboarding"), + (name = "Auth", description = "Bootstrap, sessions, OAuth device flow, and access keys"), ) )] pub struct ApiDoc; +/// Registers the two accepted authentication schemes on the generated spec. +pub struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + // `components` is always present: the derive registers schemas above. + let components = openapi + .components + .as_mut() + .expect("ApiDoc registers component schemas, so components exists"); + + components.add_security_scheme( + "bearerAuth", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some( + "Short-lived signed access token. Obtain one at the token \ + endpoint with a refresh token or a service access key.", + )) + .build(), + ), + ); + + components.add_security_scheme( + "sessionCookie", + SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::with_description( + "__Host-operator_session", + "Opaque server-side browser session. Cookie-authenticated \ + mutations additionally require a CSRF token and a matching Origin.", + ))), + ); + + let mut unauthorized = error_response("Unauthorized"); + unauthorized.headers.insert( + "WWW-Authenticate".to_string(), + response_header( + "Authentication challenge naming the accepted schemes.", + Type::String, + ), + ); + components + .responses + .insert(UNAUTHORIZED_RESPONSE_NAME.to_string(), unauthorized.into()); + components.responses.insert( + FORBIDDEN_RESPONSE_NAME.to_string(), + error_response("Forbidden").into(), + ); + } +} + +const ERROR_SCHEMA_NAME: &str = "ErrorResponse"; +const FORBIDDEN_RESPONSE_NAME: &str = "Forbidden"; +const JSON_MEDIA_TYPE: &str = "application/json"; +const UNAUTHORIZED_RESPONSE_NAME: &str = "Unauthorized"; + +fn error_content() -> Content { + Content::new(Some(Ref::from_schema_name(ERROR_SCHEMA_NAME))) +} + +fn error_response(description: &str) -> Response { + let mut response = Response::new(description); + response + .content + .insert(JSON_MEDIA_TYPE.to_string(), error_content()); + response +} + +fn response_header(description: &str, schema_type: Type) -> Header { + let mut header = Header::new(Object::with_type(schema_type)); + header.description = Some(description.to_string()); + header +} + +fn document_error_responses(operation: &mut Operation) { + for (status, response) in &mut operation.responses.responses { + if !(status.starts_with('4') || status.starts_with('5')) { + continue; + } + + let RefOr::T(response) = response else { + continue; + }; + response + .content + .entry(JSON_MEDIA_TYPE.to_string()) + .or_insert_with(error_content); + + if status == "401" { + response.headers.insert( + "WWW-Authenticate".to_string(), + response_header( + "Authentication challenge naming the accepted schemes.", + Type::String, + ), + ); + } + if status == "429" { + response.headers.insert( + "Retry-After".to_string(), + response_header( + "Seconds the client must wait before retrying.", + Type::Integer, + ), + ); + } + } +} + +fn document_set_cookie(operation: &mut Operation, description: &str) { + let Some(RefOr::T(response)) = operation.responses.responses.get_mut("200") else { + return; + }; + response.headers.insert( + "Set-Cookie".to_string(), + response_header(description, Type::String), + ); +} + +fn collect_schema_refs(value: &serde_json::Value, refs: &mut BTreeSet) { + match value { + serde_json::Value::Object(object) => { + if let Some(name) = object + .get("$ref") + .and_then(serde_json::Value::as_str) + .and_then(|reference| reference.strip_prefix("#/components/schemas/")) + { + refs.insert(name.to_string()); + } + for nested in object.values() { + collect_schema_refs(nested, refs); + } + } + serde_json::Value::Array(items) => { + for item in items { + collect_schema_refs(item, refs); + } + } + _ => {} + } +} + +fn prune_unreachable_schemas(spec: &mut utoipa::openapi::OpenApi) { + let Some(components) = spec.components.as_mut() else { + return; + }; + let schemas = components.schemas.clone(); + let mut reachable = BTreeSet::new(); + collect_schema_refs( + &serde_json::to_value(&spec.paths).expect("serialize OpenAPI paths"), + &mut reachable, + ); + collect_schema_refs( + &serde_json::to_value(&components.responses).expect("serialize OpenAPI responses"), + &mut reachable, + ); + + let mut pending: Vec = reachable.iter().cloned().collect(); + while let Some(name) = pending.pop() { + let Some(schema) = schemas.get(&name) else { + continue; + }; + let mut nested = BTreeSet::new(); + collect_schema_refs( + &serde_json::to_value(schema).expect("serialize component schema"), + &mut nested, + ); + for reference in nested { + if reachable.insert(reference.clone()) { + pending.push(reference); + } + } + } + + components + .schemas + .retain(|name, _| reachable.contains(name)); +} + +fn csrf_parameter() -> Parameter { + let mut parameter = Parameter::new(crate::rest::middleware::auth::CSRF_HEADER); + parameter.parameter_in = ParameterIn::Header; + parameter.required = Required::False; + parameter.description = Some( + "Required for cookie-authenticated mutations; omit when using bearer authentication." + .to_string(), + ); + parameter.schema = Some(Object::with_type(Type::String).into()); + parameter +} + +fn enrich_operation(method: &str, path: &str, operation: Option<&mut Operation>) { + let Some(operation) = operation else { + return; + }; + let access = required_access(method, path) + .unwrap_or_else(|| panic!("documented route {method} {path} is missing from ROUTE_RULES")); + + match access { + Access::Public => operation.security = Some(Vec::new()), + Access::Scoped(scope) => { + operation.security = Some(vec![ + SecurityRequirement::new("bearerAuth", Vec::::new()), + SecurityRequirement::new("sessionCookie", Vec::::new()), + ]); + operation + .extensions + .get_or_insert_with(Extensions::default) + .insert( + "x-operator-scope".to_string(), + serde_json::json!(scope.as_str()), + ); + operation + .responses + .responses + .entry("401".to_string()) + .or_insert_with(|| Ref::from_response_name(UNAUTHORIZED_RESPONSE_NAME).into()); + operation + .responses + .responses + .entry("403".to_string()) + .or_insert_with(|| Ref::from_response_name(FORBIDDEN_RESPONSE_NAME).into()); + + if !matches!(method, "GET" | "HEAD" | "OPTIONS") { + operation + .parameters + .get_or_insert_with(Vec::new) + .push(csrf_parameter()); + } + } + } + + document_error_responses(operation); + + match (method, path) { + ("POST", "/api/v1/auth/login") => { + document_set_cookie(operation, "Sets the opaque HttpOnly browser session cookie"); + } + ("POST", "/api/v1/auth/logout") => { + document_set_cookie(operation, "Expires the browser session cookie"); + } + _ => {} + } +} + +/// Apply the external contract metadata after `utoipa_axum` has merged paths. +pub fn apply_contract_metadata(mut openapi: utoipa::openapi::OpenApi) -> utoipa::openapi::OpenApi { + for (path, item) in &mut openapi.paths.paths { + enrich_operation("GET", path, item.get.as_mut()); + enrich_operation("PUT", path, item.put.as_mut()); + enrich_operation("POST", path, item.post.as_mut()); + enrich_operation("DELETE", path, item.delete.as_mut()); + enrich_operation("PATCH", path, item.patch.as_mut()); + enrich_operation("OPTIONS", path, item.options.as_mut()); + enrich_operation("HEAD", path, item.head.as_mut()); + enrich_operation("TRACE", path, item.trace.as_mut()); + } + + prune_unreachable_schemas(&mut openapi); + openapi +} + impl ApiDoc { /// Generate the OpenAPI specification as a JSON string. /// @@ -199,6 +518,10 @@ impl ApiDoc { mod tests { use super::*; + fn parsed_spec() -> serde_json::Value { + serde_json::from_str(&ApiDoc::json().expect("generate spec")).expect("spec is JSON") + } + #[test] fn test_openapi_spec_generates() { let spec = ApiDoc::json().expect("Failed to generate OpenAPI spec"); @@ -207,6 +530,202 @@ mod tests { assert!(spec.contains("/api/v1/issuetypes")); } + #[test] + fn test_openapi_declares_both_security_schemes() { + // The schemes are added by a `Modify` addon, which is easy to drop from + // the derive without noticing — the spec still builds, just without any + // way for a client to learn how to authenticate. + let spec = ApiDoc::json().expect("generate spec"); + let parsed: serde_json::Value = serde_json::from_str(&spec).expect("spec is JSON"); + let schemes = parsed + .get("components") + .and_then(|c| c.get("securitySchemes")) + .expect("spec must declare securitySchemes"); + + let bearer = schemes.get("bearerAuth").expect("bearerAuth scheme"); + assert_eq!( + bearer.get("scheme").and_then(|v| v.as_str()), + Some("bearer") + ); + assert_eq!( + bearer.get("bearerFormat").and_then(|v| v.as_str()), + Some("JWT") + ); + + let cookie = schemes.get("sessionCookie").expect("sessionCookie scheme"); + assert_eq!(cookie.get("in").and_then(|v| v.as_str()), Some("cookie")); + assert_eq!( + cookie.get("name").and_then(|v| v.as_str()), + Some("__Host-operator_session"), + "the cookie name must keep its __Host- prefix, which the browser enforces" + ); + } + + #[test] + fn test_openapi_security_matches_route_rules() { + let spec = parsed_spec(); + for rule in crate::auth::scope::ROUTE_RULES { + let operation = &spec["paths"][rule.path][rule.method.to_ascii_lowercase()]; + assert!( + operation.is_object(), + "{} {} must be documented", + rule.method, + rule.path + ); + match rule.access { + Access::Public => assert_eq!(operation["security"], serde_json::json!([])), + Access::Scoped(scope) => { + assert_eq!( + operation["security"], + serde_json::json!([ + { "bearerAuth": [] }, + { "sessionCookie": [] } + ]) + ); + assert_eq!(operation["x-operator-scope"], scope.as_str()); + } + } + } + } + + #[test] + fn test_protected_mutations_document_csrf_header() { + let spec = parsed_spec(); + for rule in crate::auth::scope::ROUTE_RULES { + if matches!(rule.access, Access::Public) + || matches!(rule.method, "GET" | "HEAD" | "OPTIONS") + { + continue; + } + let parameters = spec["paths"][rule.path][rule.method.to_ascii_lowercase()] + ["parameters"] + .as_array() + .expect("protected mutation parameters"); + assert!(parameters.iter().any(|parameter| { + parameter["name"] == crate::rest::middleware::auth::CSRF_HEADER + && parameter["in"] == "header" + && parameter["required"] == false + })); + } + } + + #[test] + fn test_error_responses_have_standard_bodies_and_headers() { + let spec = parsed_spec(); + for item in spec["paths"].as_object().expect("paths").values() { + for operation in item.as_object().expect("path item").values() { + let Some(responses) = operation + .get("responses") + .and_then(|value| value.as_object()) + else { + continue; + }; + for (status, response) in responses { + let response = response + .get("$ref") + .and_then(serde_json::Value::as_str) + .and_then(|reference| reference.rsplit('/').next()) + .map_or(response, |name| &spec["components"]["responses"][name]); + if status.starts_with('4') || status.starts_with('5') { + assert!( + response["content"][JSON_MEDIA_TYPE]["schema"].is_object(), + "error response {status} must declare a JSON schema" + ); + } + if status == "401" { + assert!(response["headers"]["WWW-Authenticate"].is_object()); + } + if status == "429" { + assert!(response["headers"]["Retry-After"].is_object()); + } + } + } + } + } + + #[test] + fn test_only_operation_reachable_schemas_are_published() { + let spec = parsed_spec(); + let schemas = spec["components"]["schemas"] + .as_object() + .expect("component schemas"); + let mut reachable = BTreeSet::new(); + collect_schema_refs(&spec["paths"], &mut reachable); + collect_schema_refs(&spec["components"]["responses"], &mut reachable); + let mut pending: Vec<_> = reachable.iter().cloned().collect(); + while let Some(name) = pending.pop() { + let schema = schemas + .get(&name) + .unwrap_or_else(|| panic!("missing referenced schema {name}")); + let mut nested = BTreeSet::new(); + collect_schema_refs(schema, &mut nested); + for reference in nested { + if reachable.insert(reference.clone()) { + pending.push(reference); + } + } + } + assert_eq!(schemas.keys().cloned().collect::>(), reachable); + } + + #[test] + fn test_configuration_and_token_contracts_are_explicit() { + let spec = parsed_spec(); + let schemas = &spec["components"]["schemas"]; + assert!(schemas.get("Config").is_none()); + assert!(schemas["ConfigurationResponse"]["properties"]["agents"].is_object()); + assert!(schemas["UpdateConfigurationRequest"]["properties"]["launch"].is_object()); + assert_eq!( + schemas["TokenRequest"]["oneOf"].as_array().map(Vec::len), + Some(3) + ); + for variant in schemas["TokenRequest"]["oneOf"] + .as_array() + .expect("token variants") + { + let credential = variant["properties"] + .as_object() + .expect("token properties") + .iter() + .find(|(name, _)| { + name.ends_with("token") || *name == "device_code" || *name == "access_key" + }) + .map(|(_, schema)| schema) + .expect("credential property"); + assert_eq!(credential["writeOnly"], true); + assert_eq!(credential["format"], "password"); + } + } + + #[test] + fn test_openapi_registers_auth_schemas() { + // Phase 2 ships the contract before any handler exists, so these types + // are reachable only through `components(schemas(...))`. Dropping one + // would silently remove it from the spec and from every generated client. + let spec = ApiDoc::json().expect("generate spec"); + for schema in [ + "Scope", + "BootstrapState", + "BootstrapStatusResponse", + "BootstrapSubmitRequest", + "LoginRequest", + "LoginResponse", + "CurrentSessionResponse", + "DeviceAuthorizationResponse", + "TokenRequest", + "TokenResponse", + "OAuthErrorResponse", + "CreateAccessKeyResponse", + "AccessKeySummary", + "SessionListResponse", + ] { + assert!( + spec.contains(&format!("\"{schema}\"")), + "spec should register the {schema} component schema" + ); + } + } + #[test] fn test_openapi_has_all_tags() { let spec = ApiDoc::json().expect("Failed to generate OpenAPI spec"); diff --git a/src/rest/routes/agents.rs b/src/rest/routes/agents.rs index 2e4cda53..eb75618c 100644 --- a/src/rest/routes/agents.rs +++ b/src/rest/routes/agents.rs @@ -30,7 +30,7 @@ use crate::state::State as OperatorState; )] pub async fn active(State(state): State) -> Result, ApiError> { // Load operator state from state.json - let operator_state = OperatorState::load(&state.config) + let operator_state = OperatorState::load(&state.config()) .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; // Map AgentState to ActiveAgentResponse @@ -85,7 +85,7 @@ pub async fn get_detail( State(state): State, Path(agent_id): Path, ) -> Result, ApiError> { - let operator_state = OperatorState::load(&state.config) + let operator_state = OperatorState::load(&state.config()) .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; let agent = operator_state @@ -137,7 +137,7 @@ pub async fn approve_review( State(state): State, Path(agent_id): Path, ) -> Result, ApiError> { - let mut operator_state = OperatorState::load(&state.config) + let mut operator_state = OperatorState::load(&state.config()) .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; // Find the agent @@ -197,7 +197,7 @@ pub async fn reject_review( Path(agent_id): Path, Json(request): Json, ) -> Result, ApiError> { - let mut operator_state = OperatorState::load(&state.config) + let mut operator_state = OperatorState::load(&state.config()) .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; // Find the agent @@ -256,7 +256,7 @@ pub async fn focus_session( State(state): State, Path(agent_id): Path, ) -> Result<(), ApiError> { - let operator_state = OperatorState::load(&state.config) + let operator_state = OperatorState::load(&state.config()) .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; let agent = operator_state @@ -274,7 +274,7 @@ pub async fn focus_session( "Agent '{agent_id}' has no cmux session refs to focus" ))); } - let cmux_config = state.config.sessions.cmux.clone(); + let cmux_config = state.config().sessions.cmux.clone(); // cmux focusing shells out to the cmux binary; run it off the async // worker so a slow subprocess can't stall the runtime. tokio::task::spawn_blocking(move || -> Result<(), crate::agents::cmux::CmuxError> { diff --git a/src/rest/routes/auth.rs b/src/rest/routes/auth.rs new file mode 100644 index 00000000..55c5cb74 --- /dev/null +++ b/src/rest/routes/auth.rs @@ -0,0 +1,807 @@ +//! Authentication endpoints: bootstrap, login, sessions, device flow, keys. +//! +//! Everything here that issues or accepts a credential is rate-limited with +//! persisted backoff, so restarting the process does not reset an attacker's +//! budget. + +use axum::extract::{Path, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use chrono::Utc; + +use crate::auth::store::{ + AuthStore, DevicePollOutcome, RefreshOutcome, ADMIN_SUBJECT, DEVICE_CODE_TTL_SECS, + DEVICE_POLL_INTERVAL_SECS, +}; +use crate::auth::tokens::{api_claims, ACCESS_TOKEN_TTL}; +use crate::rest::dto::auth::{ + AccessKeyListResponse, BootstrapState, BootstrapStatusResponse, BootstrapSubmitRequest, + BootstrapSubmitResponse, CreateAccessKeyRequest, CreateAccessKeyResponse, CsrfTokenResponse, + CurrentSessionResponse, DeviceApprovalRequest, DeviceApprovalResponse, + DeviceAuthorizationRequest, DeviceAuthorizationResponse, LoginRequest, LoginResponse, + LogoutResponse, OAuthErrorCode, OAuthErrorResponse, RevokeAccessKeyResponse, Scope, + SessionListResponse, TokenRequest, TokenResponse, +}; +use crate::rest::error::ApiError; +use crate::rest::middleware::auth::{enforce_backoff, Authenticated, SESSION_COOKIE}; +use crate::rest::state::ApiState; + +/// Rate-limit bucket names. +const BUCKET_BOOTSTRAP: &str = "bootstrap"; +const BUCKET_LOGIN: &str = "login"; +const BUCKET_DEVICE_CODE: &str = "device_code"; +const BUCKET_TOKEN: &str = "token"; + +/// Env var naming a file holding the out-of-band bootstrap password. +/// +/// A file rather than a plain env var: an env var is visible in `/proc`, in +/// `docker inspect`, and to every child process Operator spawns — including the +/// agent processes, which is precisely the thing that must not read it. +pub const BOOTSTRAP_PASSWORD_FILE_ENV: &str = "OPERATOR_BOOTSTRAP_PASSWORD_FILE"; + +/// Read the mounted bootstrap password, if one was supplied. +fn mounted_bootstrap_password() -> Option { + let path = std::env::var(BOOTSTRAP_PASSWORD_FILE_ENV).ok()?; + std::fs::read_to_string(path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +async fn blocking(f: F) -> Result +where + F: FnOnce() -> anyhow::Result + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(f) + .await + .map_err(|e| ApiError::InternalError(format!("auth task failed: {e}")))? + .map_err(|e| ApiError::InternalError(e.to_string())) +} + +fn store(state: &ApiState) -> AuthStore { + state.auth.store.clone() +} + +fn oauth_error(status: StatusCode, code: OAuthErrorCode, message: &str) -> Response { + ( + status, + Json(OAuthErrorResponse { + error: code, + error_description: Some(message.to_string()), + error_uri: None, + }), + ) + .into_response() +} + +fn valid_client_id(client_id: &str) -> bool { + !client_id.is_empty() + && client_id.len() <= crate::rest::dto::auth::MAX_IDENTIFIER_LENGTH + && client_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} + +// ============================================================================= +// Bootstrap +// ============================================================================= + +/// Bootstrap status +#[utoipa::path( + operation_id = "auth_bootstrap_status", + get, + path = "/api/v1/auth/bootstrap", + tag = "Auth", + responses((status = 200, description = "Bootstrap state", body = BootstrapStatusResponse)) +)] +pub async fn bootstrap_status( + State(state): State, +) -> Result, ApiError> { + let s = store(&state); + let bootstrap_state = blocking(move || s.bootstrap_state()).await?; + Ok(Json(BootstrapStatusResponse { + state: bootstrap_state, + requires_temporary_password: mounted_bootstrap_password().is_some(), + })) +} + +/// Claim the admin account +#[utoipa::path( + operation_id = "auth_bootstrap_submit", + post, + path = "/api/v1/auth/bootstrap", + tag = "Auth", + request_body = BootstrapSubmitRequest, + responses( + (status = 200, description = "Admin account created", body = BootstrapSubmitResponse), + (status = 409, description = "Already bootstrapped"), + (status = 429, description = "Too many attempts"), + ) +)] +pub async fn bootstrap_submit( + State(state): State, + Json(req): Json, +) -> Result, Response> { + if let Some(limited) = enforce_backoff(&state, BUCKET_BOOTSTRAP).await { + return Err(limited); + } + + let s = store(&state); + let current = blocking(move || s.bootstrap_state()) + .await + .map_err(IntoResponse::into_response)?; + + let mounted = mounted_bootstrap_password(); + + match current { + BootstrapState::Complete => Err(ApiError::Conflict( + "the admin account already exists; use login".to_string(), + ) + .into_response()), + + BootstrapState::Uninitialized => { + // When a bootstrap secret is mounted, it must be presented. That is + // what closes the race where whoever reaches the endpoint first + // claims the account. + if let Some(expected) = mounted.as_deref() { + let provided = req.temporary_password.as_deref().unwrap_or_default(); + if !crate::auth::local::matches(expected, provided) { + let s = store(&state); + let _ = blocking(move || { + s.record_failure(BUCKET_BOOTSTRAP)?; + s.audit("bootstrap", Some("bad temporary password"), false) + }) + .await; + return Err(ApiError::Unauthorized( + "the temporary password is incorrect".to_string(), + ) + .into_response()); + } + } + + let password = req.new_password.clone(); + let s = store(&state); + let created = blocking(move || s.create_admin(&password, false)) + .await + .map_err(IntoResponse::into_response)?; + + let s = store(&state); + if !created { + let _ = blocking(move || s.audit("bootstrap", Some("lost the race"), false)).await; + return Err(ApiError::Conflict( + "the admin account was created concurrently".to_string(), + ) + .into_response()); + } + let _ = blocking(move || { + s.clear_rate_limit(BUCKET_BOOTSTRAP)?; + s.audit("bootstrap", None, true) + }) + .await; + + Ok(Json(BootstrapSubmitResponse { + state: BootstrapState::Complete, + })) + } + + BootstrapState::AwaitingPassword => { + // A temporary password is on the account; replacing it requires + // proving possession of it. + let provided = req.temporary_password.clone().unwrap_or_default(); + let s = store(&state); + let ok = blocking(move || s.verify_admin_password(&provided)) + .await + .map_err(IntoResponse::into_response)?; + if !ok { + let s = store(&state); + let _ = blocking(move || { + s.record_failure(BUCKET_BOOTSTRAP)?; + s.audit("bootstrap", Some("bad temporary password"), false) + }) + .await; + return Err(ApiError::Unauthorized( + "the temporary password is incorrect".to_string(), + ) + .into_response()); + } + + let password = req.new_password.clone(); + let s = store(&state); + blocking(move || { + s.set_admin_password(&password)?; + s.clear_rate_limit(BUCKET_BOOTSTRAP)?; + s.audit("bootstrap", Some("password set"), true) + }) + .await + .map_err(IntoResponse::into_response)?; + + Ok(Json(BootstrapSubmitResponse { + state: BootstrapState::Complete, + })) + } + } +} + +// ============================================================================= +// Login / logout / session +// ============================================================================= + +/// Build the `Set-Cookie` value for a session. +/// +/// `__Host-` requires `Secure`, no `Domain`, and `Path=/`; the browser rejects +/// the cookie otherwise. `SameSite=Strict` keeps it off cross-site requests +/// entirely, and `HttpOnly` keeps it away from script. +fn session_cookie(token: &str) -> String { + format!("{SESSION_COOKIE}={token}; HttpOnly; Secure; SameSite=Strict; Path=/") +} + +/// Log in +#[utoipa::path( + operation_id = "auth_login", + post, + path = "/api/v1/auth/login", + tag = "Auth", + request_body = LoginRequest, + responses( + (status = 200, description = "Logged in", body = LoginResponse), + (status = 401, description = "Bad password"), + (status = 429, description = "Too many attempts"), + ) +)] +pub async fn login( + State(state): State, + Json(req): Json, +) -> Result { + if let Some(limited) = enforce_backoff(&state, BUCKET_LOGIN).await { + return Err(limited); + } + + let password = req.password.clone(); + let s = store(&state); + let ok = blocking(move || s.verify_admin_password(&password)) + .await + .map_err(IntoResponse::into_response)?; + + if !ok { + let s = store(&state); + let _ = blocking(move || { + s.record_failure(BUCKET_LOGIN)?; + s.audit("login", Some("bad password"), false) + }) + .await; + return Err(ApiError::Unauthorized("incorrect password".to_string()).into_response()); + } + + let s = store(&state); + let (token, csrf, expires_at) = blocking(move || { + let session = s.create_session()?; + s.clear_rate_limit(BUCKET_LOGIN)?; + s.audit("login", None, true)?; + Ok(session) + }) + .await + .map_err(IntoResponse::into_response)?; + + let body = LoginResponse { + scopes: Scope::ALL.to_vec(), + expires_at, + csrf_token: csrf, + }; + + Ok(( + StatusCode::OK, + [(header::SET_COOKIE, session_cookie(&token))], + Json(body), + ) + .into_response()) +} + +/// Log out +#[utoipa::path( + operation_id = "auth_logout", + post, + path = "/api/v1/auth/logout", + tag = "Auth", + responses((status = 200, description = "Logged out", body = LogoutResponse)) +)] +pub async fn logout( + State(state): State, + Authenticated(principal): Authenticated, +) -> Result { + if let Some(session_id) = principal.session_id.clone() { + let s = store(&state); + blocking(move || { + s.revoke_session(&session_id)?; + s.audit("logout", None, true) + }) + .await?; + } + + // Clear the cookie client-side too. The server-side revocation above is + // what actually ends the session; this only tidies the browser. + // Attributes must match the cookie being cleared, `Secure` included, or the + // browser treats this as a different cookie and leaves the original. + let cleared = + format!("{SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0"); + + Ok(( + StatusCode::OK, + [(header::SET_COOKIE, cleared)], + Json(LogoutResponse { ended: true }), + ) + .into_response()) +} + +/// Current session +#[utoipa::path( + operation_id = "auth_current_session", + get, + path = "/api/v1/auth/session", + tag = "Auth", + responses((status = 200, description = "Current principal", body = CurrentSessionResponse)) +)] +pub async fn current_session( + Authenticated(principal): Authenticated, +) -> Json { + Json(CurrentSessionResponse { + subject: principal.subject.clone(), + scopes: principal.scopes.clone(), + principal_kind: principal.kind, + expires_at: principal.expires_at, + }) +} + +/// Issue a CSRF token for the current session +#[utoipa::path( + operation_id = "auth_csrf_token", + get, + path = "/api/v1/auth/csrf", + tag = "Auth", + responses((status = 200, description = "CSRF token", body = CsrfTokenResponse)) +)] +pub async fn csrf_token( + State(state): State, + Authenticated(principal): Authenticated, +) -> Result, ApiError> { + // Only a cookie session needs one: a bearer token is never sent ambiently, + // so there is nothing for a third-party site to forge. + let Some(session_id) = principal.session_id.clone() else { + return Err(ApiError::BadRequest( + "CSRF tokens apply only to cookie-authenticated sessions".to_string(), + )); + }; + + let s = store(&state); + let rotated = blocking(move || s.rotate_csrf(&session_id)).await?; + match rotated { + Some(csrf_token) => Ok(Json(CsrfTokenResponse { csrf_token })), + None => Err(ApiError::Unauthorized( + "session is no longer valid".to_string(), + )), + } +} + +/// List sessions and devices +#[utoipa::path( + operation_id = "auth_list_sessions", + get, + path = "/api/v1/auth/sessions", + tag = "Auth", + responses((status = 200, description = "Sessions and devices", body = SessionListResponse)) +)] +pub async fn list_sessions( + State(state): State, + Authenticated(principal): Authenticated, +) -> Result, ApiError> { + let current = principal.session_id.clone(); + let s = store(&state); + let sessions = blocking(move || s.list_sessions(current.as_deref())).await?; + let s = store(&state); + let devices = blocking(move || s.list_devices()).await?; + Ok(Json(SessionListResponse { sessions, devices })) +} + +/// Revoke a session +#[utoipa::path( + operation_id = "auth_revoke_session", + delete, + path = "/api/v1/auth/sessions/{id}", + tag = "Auth", + params(("id" = String, Path, description = "Session id")), + responses((status = 200, description = "Revoked", body = LogoutResponse)) +)] +pub async fn revoke_session( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let s = store(&state); + blocking(move || { + s.revoke_session(&id)?; + s.audit("session revoked", None, true) + }) + .await?; + Ok(Json(LogoutResponse { ended: true })) +} + +// ============================================================================= +// Device authorization +// ============================================================================= + +/// Begin device authorization +#[utoipa::path( + operation_id = "auth_device_code", + post, + path = "/api/v1/auth/device/code", + tag = "Auth", + request_body = DeviceAuthorizationRequest, + responses( + (status = 200, description = "Device code issued", body = DeviceAuthorizationResponse), + (status = 429, description = "Too many attempts"), + ) +)] +pub async fn device_code( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, Response> { + if let Some(limited) = enforce_backoff(&state, BUCKET_DEVICE_CODE).await { + return Err(limited); + } + if !valid_client_id(&req.client_id) { + return Err(oauth_error( + StatusCode::BAD_REQUEST, + OAuthErrorCode::InvalidClient, + "client_id must contain 1 to 128 letters, numbers, periods, underscores, colons, or hyphens", + )); + } + + // An IDE client acts as the human admin, so it receives every scope. A + // client asking for less is honored; asking for more than exists is not. + let scopes = if req.scopes.is_empty() { + Scope::ALL.to_vec() + } else { + req.scopes.clone() + }; + + let client_id = req.client_id.clone(); + let s = store(&state); + let (device_code, user_code) = blocking(move || { + let pair = s.create_device_authorization(&client_id, &scopes)?; + s.audit("device code issued", None, true)?; + Ok(pair) + }) + .await + .map_err(IntoResponse::into_response)?; + + let host = headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost"); + let base = crate::mcp::public_base_url(&state, host); + + Ok(Json(DeviceAuthorizationResponse { + verification_uri: format!("{base}/#/device"), + verification_uri_complete: format!("{base}/#/device?user_code={user_code}"), + device_code, + user_code, + expires_in: DEVICE_CODE_TTL_SECS, + interval: DEVICE_POLL_INTERVAL_SECS, + })) +} + +/// Approve a device +#[utoipa::path( + operation_id = "auth_device_approve", + post, + path = "/api/v1/auth/device/approve", + tag = "Auth", + request_body = DeviceApprovalRequest, + responses( + (status = 200, description = "Device approved", body = DeviceApprovalResponse), + (status = 404, description = "Unknown or expired user code"), + ) +)] +pub async fn device_approve( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let user_code = req.user_code.trim().to_uppercase(); + let s = store(&state); + let approved = blocking(move || s.approve_device(&user_code)).await?; + + let Some((client_id, scopes)) = approved else { + return Err(ApiError::NotFound( + "no pending device authorization for that code".to_string(), + )); + }; + + let s = store(&state); + let detail = client_id.clone(); + let _ = blocking(move || s.audit("device approved", Some(&detail), true)).await; + + Ok(Json(DeviceApprovalResponse { + client_id, + scopes, + approved: true, + })) +} + +// ============================================================================= +// Token endpoint +// ============================================================================= + +/// Exchange a credential for an access token +#[utoipa::path( + operation_id = "auth_token", + post, + path = "/api/v1/auth/token", + tag = "Auth", + request_body = TokenRequest, + responses( + (status = 200, description = "Access token issued", body = TokenResponse), + (status = 400, description = "OAuth error", body = OAuthErrorResponse), + (status = 429, description = "Too many attempts"), + ) +)] +pub async fn token( + State(state): State, + Json(req): Json, +) -> Result, Response> { + if let Some(limited) = enforce_backoff(&state, BUCKET_TOKEN).await { + return Err(limited); + } + + let (scopes, refresh_token) = match req { + TokenRequest::DeviceCode { + device_code: code, + client_id, + } => { + if !valid_client_id(&client_id) { + return Err(oauth_error( + StatusCode::BAD_REQUEST, + OAuthErrorCode::InvalidClient, + "client_id is invalid", + )); + } + let s = store(&state); + let outcome = blocking(move || s.poll_device(&code, &client_id)) + .await + .map_err(IntoResponse::into_response)?; + + match outcome { + DevicePollOutcome::Approved { client_id, scopes } => { + let s = store(&state); + let owned = scopes.clone(); + let refresh = blocking(move || s.create_refresh_family(&client_id, &owned)) + .await + .map_err(IntoResponse::into_response)?; + (scopes, Some(refresh)) + } + DevicePollOutcome::Pending => { + return Err(oauth_error( + StatusCode::BAD_REQUEST, + OAuthErrorCode::AuthorizationPending, + "the user has not yet approved this device", + )) + } + DevicePollOutcome::SlowDown => { + return Err(oauth_error( + StatusCode::BAD_REQUEST, + OAuthErrorCode::SlowDown, + "polling faster than the advertised interval", + )) + } + DevicePollOutcome::Denied => { + return Err(oauth_error( + StatusCode::BAD_REQUEST, + OAuthErrorCode::AccessDenied, + "the user declined this device", + )) + } + DevicePollOutcome::Expired => { + return Err(oauth_error( + StatusCode::BAD_REQUEST, + OAuthErrorCode::ExpiredToken, + "the device code has expired or was already used", + )) + } + } + } + + TokenRequest::RefreshToken { + refresh_token: token, + client_id, + } => { + if !valid_client_id(&client_id) { + return Err(oauth_error( + StatusCode::BAD_REQUEST, + OAuthErrorCode::InvalidClient, + "client_id is invalid", + )); + } + let s = store(&state); + let outcome = blocking(move || s.redeem_refresh_token(&token, &client_id)) + .await + .map_err(IntoResponse::into_response)?; + + match outcome { + RefreshOutcome::Rotated { + refresh_token, + scopes, + } => (scopes, Some(refresh_token)), + RefreshOutcome::Reused => { + let s = store(&state); + let _ = blocking(move || { + s.audit("refresh token reuse", Some("family revoked"), false) + }) + .await; + return Err(oauth_error( + StatusCode::BAD_REQUEST, + OAuthErrorCode::InvalidGrant, + "this refresh token was already used; the token family has been revoked", + )); + } + RefreshOutcome::Invalid => { + return Err(oauth_error( + StatusCode::BAD_REQUEST, + OAuthErrorCode::InvalidGrant, + "the refresh token is invalid, expired, or revoked", + )) + } + } + } + + TokenRequest::AccessKey { access_key: key } => { + let s = store(&state); + let scopes = blocking(move || s.redeem_access_key(&key)) + .await + .map_err(IntoResponse::into_response)?; + let Some(scopes) = scopes else { + let s = store(&state); + let _ = blocking(move || { + s.record_failure(BUCKET_TOKEN)?; + s.audit("access key exchange", Some("rejected"), false) + }) + .await; + return Err(oauth_error( + StatusCode::BAD_REQUEST, + OAuthErrorCode::InvalidGrant, + "the access key is invalid, expired, or revoked", + )); + }; + // An access key is re-presented on each exchange, so it produces no + // refresh token — there is nothing to refresh. + (scopes, None) + } + }; + + let claims = api_claims( + ADMIN_SUBJECT, + &scopes, + Utc::now(), + uuid::Uuid::new_v4().to_string(), + ); + let access_token = state + .auth + .signing_key + .sign(&claims) + .map_err(|e| ApiError::InternalError(e.to_string()).into_response())?; + + let s = store(&state); + let _ = blocking(move || s.clear_rate_limit(BUCKET_TOKEN)).await; + + Ok(Json(TokenResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: ACCESS_TOKEN_TTL.num_seconds().max(0) as u64, + refresh_token, + scopes, + })) +} + +// ============================================================================= +// Access keys +// ============================================================================= + +/// List access keys +#[utoipa::path( + operation_id = "auth_list_access_keys", + get, + path = "/api/v1/auth/keys", + tag = "Auth", + responses((status = 200, description = "Access keys", body = AccessKeyListResponse)) +)] +pub async fn list_access_keys( + State(state): State, +) -> Result, ApiError> { + let s = store(&state); + let keys = blocking(move || s.list_access_keys()).await?; + Ok(Json(AccessKeyListResponse { keys })) +} + +/// Create an access key +#[utoipa::path( + operation_id = "auth_create_access_key", + post, + path = "/api/v1/auth/keys", + tag = "Auth", + request_body = CreateAccessKeyRequest, + responses((status = 200, description = "Key created; secret returned once", body = CreateAccessKeyResponse)) +)] +pub async fn create_access_key( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let name = req.name.trim(); + if name.is_empty() || name.len() > crate::rest::dto::auth::MAX_IDENTIFIER_LENGTH { + return Err(ApiError::ValidationError( + "access key name must contain 1 to 128 characters".to_string(), + )); + } + if req.expires_in_days == 0 + || req.expires_in_days > crate::rest::dto::auth::MAX_ACCESS_KEY_EXPIRY_DAYS + { + return Err(ApiError::ValidationError( + "access key expiry must be between 1 and 365 days".to_string(), + )); + } + let unique_scopes: std::collections::HashSet<_> = req.scopes.iter().collect(); + if unique_scopes.is_empty() || unique_scopes.len() != req.scopes.len() { + return Err(ApiError::ValidationError( + "access key scopes must contain 1 to 4 unique values".to_string(), + )); + } + let s = store(&state); + let name = name.to_string(); + let scopes = req.scopes.clone(); + let days = req.expires_in_days; + // Validation failures here (no scopes, zero expiry) are the caller's fault, + // so they must surface as 400 rather than 500. + let created = tokio::task::spawn_blocking(move || { + let created = s.create_access_key(&name, &scopes, days)?; + s.audit("access key created", Some(&name), true)?; + anyhow::Ok(created) + }) + .await + .map_err(|e| ApiError::InternalError(format!("auth task failed: {e}")))?; + let (key, secret) = created.map_err(|e| ApiError::ValidationError(e.to_string()))?; + + Ok(Json(CreateAccessKeyResponse { key, secret })) +} + +/// Revoke an access key +#[utoipa::path( + operation_id = "auth_revoke_access_key", + delete, + path = "/api/v1/auth/keys/{id}", + tag = "Auth", + params(("id" = String, Path, description = "Access key id")), + responses( + (status = 200, description = "Revoked", body = RevokeAccessKeyResponse), + (status = 404, description = "Unknown or already revoked"), + ) +)] +pub async fn revoke_access_key( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let s = store(&state); + let owned = id.clone(); + let revoked = blocking(move || { + let at = s.revoke_access_key(&owned)?; + if at.is_some() { + s.audit("access key revoked", Some(&owned), true)?; + } + Ok(at) + }) + .await?; + + match revoked { + Some(revoked_at) => Ok(Json(RevokeAccessKeyResponse { id, revoked_at })), + None => Err(ApiError::NotFound( + "no active access key with that id".to_string(), + )), + } +} diff --git a/src/rest/routes/configuration.rs b/src/rest/routes/configuration.rs index f9cb3123..7aa0eb59 100644 --- a/src/rest/routes/configuration.rs +++ b/src/rest/routes/configuration.rs @@ -1,65 +1,294 @@ -//! Configuration read/write endpoints. +//! Deliberate public projection of operational configuration. use axum::extract::State; -use axum::http::StatusCode; use axum::Json; -use crate::config::Config; +use crate::config::{Config, TargetKind, TARGET_DOCKER, TARGET_LOCAL}; +use crate::rest::dto::{ + AgentsConfiguration, ConfigurationResponse, ExecutionTargetKind, ExecutionTargetSummary, + ExecutionTargetsResponse, LaunchConfiguration, PanelNamesConfiguration, QueueConfiguration, + UiConfiguration, UpdateConfigurationRequest, +}; +use crate::rest::error::ApiError; use crate::rest::state::ApiState; -/// Get the current configuration -/// -/// Returns the full operator configuration as a JSON object. The body is left -/// opaque in the OpenAPI spec because the `Config` tree is large and no client -/// consumes its OpenAPI schema (the TS `Config` type is generated separately by -/// ts-rs). +fn response(config: &Config) -> ConfigurationResponse { + ConfigurationResponse { + agents: AgentsConfiguration { + max_parallel: config.agents.max_parallel, + cores_reserved: config.agents.cores_reserved, + max_agents_per_repo: config.agents.max_agents_per_repo, + health_check_interval: config.agents.health_check_interval, + generation_timeout_secs: config.agents.generation_timeout_secs, + sync_interval: config.agents.sync_interval, + step_timeout: config.agents.step_timeout, + silence_threshold: config.agents.silence_threshold, + }, + queue: QueueConfiguration { + auto_assign: config.queue.auto_assign, + priority_order: config.queue.priority_order.clone(), + poll_interval_ms: config.queue.poll_interval_ms, + }, + ui: UiConfiguration { + refresh_rate_ms: config.ui.refresh_rate_ms, + completed_history_hours: config.ui.completed_history_hours, + summary_max_length: config.ui.summary_max_length, + panel_names: PanelNamesConfiguration { + status: config.ui.panel_names.status.clone(), + queue: config.ui.panel_names.queue.clone(), + in_progress: config.ui.panel_names.in_progress.clone(), + completed: config.ui.panel_names.completed.clone(), + }, + }, + launch: LaunchConfiguration { + confirm_autonomous: config.launch.confirm_autonomous, + confirm_paired: config.launch.confirm_paired, + launch_delay_ms: config.launch.launch_delay_ms, + docker_enabled: config.launch.docker.enabled, + docker_image: config.launch.docker.image.clone(), + yolo_enabled: config.launch.yolo.enabled, + session_wrapper: config.sessions.wrapper.into(), + }, + } +} + +fn contains_null(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Null => true, + serde_json::Value::Array(values) => values.iter().any(contains_null), + serde_json::Value::Object(values) => values.values().any(contains_null), + _ => false, + } +} + +fn apply_patch(config: &mut Config, patch: UpdateConfigurationRequest) { + if let Some(agents) = patch.agents { + if let Some(value) = agents.max_parallel { + config.agents.max_parallel = value; + } + if let Some(value) = agents.cores_reserved { + config.agents.cores_reserved = value; + } + if let Some(value) = agents.max_agents_per_repo { + config.agents.max_agents_per_repo = value; + } + if let Some(value) = agents.health_check_interval { + config.agents.health_check_interval = value; + } + if let Some(value) = agents.generation_timeout_secs { + config.agents.generation_timeout_secs = value; + } + if let Some(value) = agents.sync_interval { + config.agents.sync_interval = value; + } + if let Some(value) = agents.step_timeout { + config.agents.step_timeout = value; + } + if let Some(value) = agents.silence_threshold { + config.agents.silence_threshold = value; + } + } + if let Some(queue) = patch.queue { + if let Some(value) = queue.auto_assign { + config.queue.auto_assign = value; + } + if let Some(value) = queue.priority_order { + config.queue.priority_order = value; + } + if let Some(value) = queue.poll_interval_ms { + config.queue.poll_interval_ms = value; + } + } + if let Some(ui) = patch.ui { + if let Some(value) = ui.refresh_rate_ms { + config.ui.refresh_rate_ms = value; + } + if let Some(value) = ui.completed_history_hours { + config.ui.completed_history_hours = value; + } + if let Some(value) = ui.summary_max_length { + config.ui.summary_max_length = value; + } + if let Some(names) = ui.panel_names { + if let Some(value) = names.status { + config.ui.panel_names.status = value; + } + if let Some(value) = names.queue { + config.ui.panel_names.queue = value; + } + if let Some(value) = names.in_progress { + config.ui.panel_names.in_progress = value; + } + if let Some(value) = names.completed { + config.ui.panel_names.completed = value; + } + } + } + if let Some(launch) = patch.launch { + if let Some(value) = launch.confirm_autonomous { + config.launch.confirm_autonomous = value; + } + if let Some(value) = launch.confirm_paired { + config.launch.confirm_paired = value; + } + if let Some(value) = launch.launch_delay_ms { + config.launch.launch_delay_ms = value; + } + if let Some(value) = launch.docker_enabled { + config.launch.docker.enabled = value; + } + if let Some(value) = launch.docker_image { + config.launch.docker.image = value; + } + if let Some(value) = launch.yolo_enabled { + config.launch.yolo.enabled = value; + } + if let Some(value) = launch.session_wrapper { + config.sessions.wrapper = value.into(); + } + } +} + #[utoipa::path( get, path = "/api/v1/configuration", tag = "Configuration", operation_id = "configuration_get", - responses( - (status = 200, description = "Current configuration as a JSON object", body = serde_json::Value) - ) + responses((status = 200, description = "Supported operational configuration", body = ConfigurationResponse)) )] -pub async fn get_config(State(state): State) -> Json { - Json((*state.config).clone()) +pub async fn get_config(State(state): State) -> Json { + Json(response(&state.config())) } -/// Update configuration and save to disk #[utoipa::path( - put, + patch, path = "/api/v1/configuration", tag = "Configuration", - operation_id = "configuration_update", - request_body = serde_json::Value, + operation_id = "configuration_patch", + request_body = UpdateConfigurationRequest, responses( - (status = 200, description = "Updated configuration as a JSON object", body = serde_json::Value), - (status = 500, description = "Failed to save configuration") + (status = 200, description = "Updated operational configuration", body = ConfigurationResponse), + (status = 400, description = "Invalid, empty, or null-valued patch") ) )] -pub async fn update_config( +pub async fn patch_config( State(state): State, - Json(incoming): Json, -) -> Result, (StatusCode, String)> { - incoming - .save() - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let _ = &state; - Ok(Json(incoming)) + Json(raw): Json, +) -> Result, ApiError> { + if contains_null(&raw) { + return Err(ApiError::ValidationError( + "configuration patch values cannot be null".to_string(), + )); + } + let patch: UpdateConfigurationRequest = serde_json::from_value(raw) + .map_err(|error| ApiError::ValidationError(error.to_string()))?; + if patch.is_empty() { + return Err(ApiError::ValidationError( + "configuration patch must change at least one section".to_string(), + )); + } + + let updated = state + .mutate_config(move |config| { + apply_patch(config, patch); + Ok(response(config)) + }) + .await?; + Ok(Json(updated)) +} + +#[utoipa::path( + get, + path = "/api/v1/execution-targets", + tag = "Configuration", + operation_id = "configuration_execution_targets", + responses((status = 200, description = "Safe execution-target summaries", body = ExecutionTargetsResponse)) +)] +pub async fn execution_targets(State(state): State) -> Json { + let config = state.config(); + let mut targets = vec![ + ExecutionTargetSummary { + name: TARGET_LOCAL.to_string(), + display_name: Some("Local".to_string()), + kind: ExecutionTargetKind::Local, + available: true, + }, + ExecutionTargetSummary { + name: TARGET_DOCKER.to_string(), + display_name: Some("Docker".to_string()), + kind: ExecutionTargetKind::Docker, + available: config.launch.docker.enabled, + }, + ]; + targets.extend(config.targets.iter().map(|target| ExecutionTargetSummary { + name: target.name.clone(), + display_name: target.display_name.clone(), + kind: match &target.kind { + TargetKind::Local => ExecutionTargetKind::Local, + TargetKind::Docker(_) => ExecutionTargetKind::Docker, + TargetKind::Coder(_) => ExecutionTargetKind::Coder, + TargetKind::Ssh(_) => ExecutionTargetKind::Ssh, + }, + available: true, + })); + targets.extend(config.hosts.iter().map(|host| ExecutionTargetSummary { + name: host.name.clone(), + display_name: host.display_name.clone(), + kind: ExecutionTargetKind::Ssh, + available: true, + })); + let total = targets.len(); + Json(ExecutionTargetsResponse { targets, total }) } #[cfg(test)] mod tests { use super::*; - use std::path::PathBuf; - #[tokio::test] - async fn test_get_config() { - let config = Config::default(); - let state = ApiState::new(config, PathBuf::from("/tmp/test")); + #[test] + fn response_omits_internal_configuration() { + let json = serde_json::to_value(response(&Config::default())).unwrap(); + for internal in [ + "paths", + "rest_api", + "notifications", + "mcp", + "hosts", + "targets", + ] { + assert!( + json.get(internal).is_none(), + "{internal} must not be public" + ); + } + } + + #[test] + fn patch_preserves_unmentioned_fields_and_replaces_arrays() { + let mut config = Config::default(); + let original_timeout = config.agents.step_timeout; + let patch: UpdateConfigurationRequest = serde_json::from_value(serde_json::json!({ + "queue": { "priority_order": ["high", "normal"] } + })) + .unwrap(); + apply_patch(&mut config, patch); + assert_eq!(config.queue.priority_order, ["high", "normal"]); + assert_eq!(config.agents.step_timeout, original_timeout); + } - let Json(cfg) = get_config(State(state)).await; - assert!(!cfg.projects.is_empty() || cfg.projects.is_empty()); + #[test] + fn patch_rejects_unknown_and_null_fields() { + assert!( + serde_json::from_value::(serde_json::json!({ + "paths": {} + })) + .is_err() + ); + assert!(contains_null( + &serde_json::json!({"queue": {"auto_assign": null}}) + )); + let empty_nested: UpdateConfigurationRequest = + serde_json::from_value(serde_json::json!({"ui": {"panel_names": {}}})).unwrap(); + assert!(empty_nested.is_empty()); } } diff --git a/src/rest/routes/delegators.rs b/src/rest/routes/delegators.rs index 03552143..1acd2d12 100644 --- a/src/rest/routes/delegators.rs +++ b/src/rest/routes/delegators.rs @@ -10,7 +10,7 @@ use axum::{ use crate::config::{ agent_profile::{delegator_to_profile, profile_to_delegator, AgentProfile}, - Config, Delegator, DelegatorLaunchConfig, + Delegator, DelegatorLaunchConfig, }; use crate::rest::dto::{ CreateDelegatorFromToolRequest, CreateDelegatorRequest, DelegatorLaunchConfigDto, @@ -31,7 +31,7 @@ use crate::rest::state::ApiState; )] pub async fn list(State(state): State) -> Json { let delegators: Vec = state - .config + .config() .delegators .iter() .map(delegator_to_response) @@ -58,8 +58,8 @@ pub async fn get_one( State(state): State, Path(name): Path, ) -> Result, ApiError> { - let delegator = state - .config + let config = state.config(); + let delegator = config .delegators .iter() .find(|d| d.name == name) @@ -84,15 +84,8 @@ pub async fn create( State(state): State, Json(req): Json, ) -> Result, ApiError> { - // Check for duplicate name - if state.config.delegators.iter().any(|d| d.name == req.name) { - return Err(ApiError::Conflict(format!( - "Delegator '{}' already exists", - req.name - ))); - } - let delegator = Delegator { + git: req.git, name: req.name, llm_tool: req.llm_tool, model: req.model, @@ -106,14 +99,24 @@ pub async fn create( unmapped_core: None, }; - // Read current config, add delegator, save - let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); - config.delegators.push(delegator.clone()); - config - .save() - .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + let response = state + .mutate_config(move |config| { + if config + .delegators + .iter() + .any(|existing| existing.name == delegator.name) + { + return Err(ApiError::Conflict(format!( + "Delegator '{}' already exists", + delegator.name + ))); + } + config.delegators.push(delegator.clone()); + Ok(delegator_to_response(&delegator)) + }) + .await?; - Ok(Json(delegator_to_response(&delegator))) + Ok(Json(response)) } /// Delete a delegator by name @@ -134,21 +137,17 @@ pub async fn delete( State(state): State, Path(name): Path, ) -> Result, ApiError> { - // Find the delegator first for the response - let delegator = state - .config - .delegators - .iter() - .find(|d| d.name == name) - .ok_or_else(|| ApiError::NotFound(format!("Delegator '{name}' not found")))?; - let response = delegator_to_response(delegator); - - // Read current config, remove delegator, save - let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); - config.delegators.retain(|d| d.name != name); - config - .save() - .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + let response = state + .mutate_config(move |config| { + let position = config + .delegators + .iter() + .position(|delegator| delegator.name == name) + .ok_or_else(|| ApiError::NotFound(format!("Delegator '{name}' not found")))?; + let delegator = config.delegators.remove(position); + Ok(delegator_to_response(&delegator)) + }) + .await?; Ok(Json(response)) } @@ -190,6 +189,7 @@ fn launch_config_to_dto(lc: &DelegatorLaunchConfig) -> DelegatorLaunchConfigDto /// Convert a Delegator config to a `DelegatorResponse` DTO fn delegator_to_response(d: &Delegator) -> DelegatorResponse { DelegatorResponse { + git: d.git.clone(), name: d.name.clone(), llm_tool: d.llm_tool.clone(), model: d.model.clone(), @@ -220,57 +220,53 @@ pub async fn create_from_tool( State(state): State, Json(req): Json, ) -> Result, ApiError> { - // Find the detected tool - let tool = state - .config - .llm_tools - .detected - .iter() - .find(|t| t.name == req.tool_name) - .ok_or_else(|| ApiError::NotFound(format!("Tool '{}' not detected", req.tool_name)))?; - - // Resolve model (explicit or first alias or "default") - let model = req.model.unwrap_or_else(|| { - tool.model_aliases - .first() - .cloned() - .unwrap_or_else(|| "default".to_string()) - }); - - // Auto-generate name if not provided - let name = req - .name - .unwrap_or_else(|| format!("{}-{}", tool.name, model)); - - // Check for duplicate - if state.config.delegators.iter().any(|d| d.name == name) { - return Err(ApiError::Conflict(format!( - "Delegator '{name}' already exists" - ))); - } + let response = state + .mutate_config(move |config| { + let tool = config + .llm_tools + .detected + .iter() + .find(|tool| tool.name == req.tool_name) + .ok_or_else(|| { + ApiError::NotFound(format!("Tool '{}' not detected", req.tool_name)) + })?; + let model = req.model.unwrap_or_else(|| { + tool.model_aliases + .first() + .cloned() + .unwrap_or_else(|| "default".to_string()) + }); + let name = req.name.unwrap_or_else(|| format!("{}-{model}", tool.name)); + if config + .delegators + .iter() + .any(|delegator| delegator.name == name) + { + return Err(ApiError::Conflict(format!( + "Delegator '{name}' already exists" + ))); + } + + let delegator = Delegator { + git: req.git, + name, + llm_tool: tool.name.clone(), + model, + display_name: req.display_name, + model_properties: std::collections::HashMap::new(), + model_server: req.model_server, + launch_config: req.launch_config.map(dto_to_launch_config), + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, + }; + config.delegators.push(delegator.clone()); + Ok(delegator_to_response(&delegator)) + }) + .await?; - let delegator = Delegator { - name, - llm_tool: tool.name.clone(), - model, - display_name: req.display_name, - model_properties: std::collections::HashMap::new(), - model_server: req.model_server.clone(), - launch_config: req.launch_config.map(dto_to_launch_config), - remote_agent: None, - x_agnt: None, - x_openai: None, - unmapped_core: None, - }; - - // Save to config - let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); - config.delegators.push(delegator.clone()); - config - .save() - .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; - - Ok(Json(delegator_to_response(&delegator))) + Ok(Json(response)) } /// Update an existing delegator @@ -293,39 +289,33 @@ pub async fn update( Path(name): Path, Json(req): Json, ) -> Result, ApiError> { - // Verify the delegator exists, and capture its opaque AGNT carry-fields so an - // update through the (AGNT-unaware) request DTO doesn't drop them. - let existing = state - .config - .delegators - .iter() - .find(|d| d.name == name) - .ok_or_else(|| ApiError::NotFound(format!("Delegator '{name}' not found")))?; - - let updated = Delegator { - name: name.clone(), - llm_tool: req.llm_tool, - model: req.model, - display_name: req.display_name, - model_properties: req.model_properties, - model_server: req.model_server, - launch_config: req.launch_config.map(dto_to_launch_config), - remote_agent: req.remote_agent, - x_agnt: existing.x_agnt.clone(), - x_openai: existing.x_openai.clone(), - unmapped_core: existing.unmapped_core.clone(), - }; + let response = state + .mutate_config(move |config| { + let existing = config + .delegators + .iter_mut() + .find(|delegator| delegator.name == name) + .ok_or_else(|| ApiError::NotFound(format!("Delegator '{name}' not found")))?; + let updated = Delegator { + git: req.git, + name, + llm_tool: req.llm_tool, + model: req.model, + display_name: req.display_name, + model_properties: req.model_properties, + model_server: req.model_server, + launch_config: req.launch_config.map(dto_to_launch_config), + remote_agent: req.remote_agent, + x_agnt: existing.x_agnt.clone(), + x_openai: existing.x_openai.clone(), + unmapped_core: existing.unmapped_core.clone(), + }; + *existing = updated; + Ok(delegator_to_response(existing)) + }) + .await?; - // Replace in config and save - let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); - if let Some(existing) = config.delegators.iter_mut().find(|d| d.name == name) { - *existing = updated.clone(); - } - config - .save() - .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; - - Ok(Json(delegator_to_response(&updated))) + Ok(Json(response)) } /// Export a delegator as a portable `AgentProfile` (`agent-profile.json`). @@ -350,8 +340,8 @@ pub async fn export_profile( State(state): State, Path(name): Path, ) -> Result, ApiError> { - let delegator = state - .config + let config = state.config(); + let delegator = config .delegators .iter() .find(|d| d.name == name) @@ -379,27 +369,26 @@ pub async fn import_profile( State(state): State, Json(profile): Json, ) -> Result, ApiError> { - if state - .config - .delegators - .iter() - .any(|d| d.name == profile.name) - { - return Err(ApiError::Conflict(format!( - "Delegator '{}' already exists", - profile.name - ))); - } - let delegator = profile_to_delegator(&profile); - let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); - config.delegators.push(delegator.clone()); - config - .save() - .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + let response = state + .mutate_config(move |config| { + if config + .delegators + .iter() + .any(|existing| existing.name == delegator.name) + { + return Err(ApiError::Conflict(format!( + "Delegator '{}' already exists", + delegator.name + ))); + } + config.delegators.push(delegator.clone()); + Ok(delegator_to_response(&delegator)) + }) + .await?; - Ok(Json(delegator_to_response(&delegator))) + Ok(Json(response)) } #[cfg(test)] @@ -422,6 +411,7 @@ mod tests { async fn test_list_with_delegators() { let mut config = Config::default(); config.delegators.push(Delegator { + git: None, name: "test-delegator".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), @@ -454,6 +444,7 @@ mod tests { async fn test_get_one_found() { let mut config = Config::default(); config.delegators.push(Delegator { + git: None, name: "my-delegator".to_string(), llm_tool: "codex".to_string(), model: "gpt-4o".to_string(), @@ -485,6 +476,7 @@ mod tests { async fn test_get_one_with_extended_launch_config() { let mut config = Config::default(); config.delegators.push(Delegator { + git: None, name: "full-config".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), @@ -532,6 +524,7 @@ mod tests { let state = ApiState::new(config, PathBuf::from("/tmp/test")); let req = crate::rest::dto::CreateDelegatorFromToolRequest { + git: None, tool_name: "nonexistent".to_string(), model: None, name: None, @@ -548,6 +541,7 @@ mod tests { async fn export_profile_returns_agent_profile() { let mut config = Config::default(); config.delegators.push(Delegator { + git: None, name: "claude-opus".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), @@ -582,6 +576,7 @@ mod tests { // covered by the unit tests in `config::agent_profile`. let mut config = Config::default(); config.delegators.push(Delegator { + git: None, name: "dup".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), diff --git a/src/rest/routes/kanban.rs b/src/rest/routes/kanban.rs index d7325c96..b55e8a3b 100644 --- a/src/rest/routes/kanban.rs +++ b/src/rest/routes/kanban.rs @@ -59,7 +59,7 @@ pub async fn provider_catalog( ) -> Result>, ApiError> { // Reload config from disk so freshly onboarded providers are reflected in // the `configured` flags without requiring a server restart. - let fresh_config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + let fresh_config = Config::load(None).unwrap_or_else(|_| (*state.config()).clone()); Ok(Json(build_provider_catalog(&fresh_config.kanban))) } @@ -88,7 +88,7 @@ pub async fn external_issue_types( ) -> Result>, ApiError> { // Try reading from persisted catalog first let service = KanbanIssueTypeService::from_tickets_path(std::path::Path::new( - &state.config.paths.tickets, + &state.config().paths.tickets, )); let catalog_types = service .list_kanban_types(&provider_name, &project_key) @@ -109,7 +109,7 @@ pub async fn external_issue_types( // Fall back to live provider fetch. Reload config from disk so freshly // onboarded providers are visible without requiring a server restart. - let fresh_config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + let fresh_config = Config::load(None).unwrap_or_else(|_| (*state.config()).clone()); let provider = get_provider_from_config(&fresh_config.kanban, &provider_name, &project_key) .map_err(|e| ApiError::BadRequest(e.to_string()))?; @@ -158,7 +158,7 @@ pub async fn project_statuses( ) -> Result, ApiError> { // Reload config from disk so freshly onboarded providers are visible // without requiring a server restart. - let fresh_config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + let fresh_config = Config::load(None).unwrap_or_else(|_| (*state.config()).clone()); let provider = get_provider_from_config(&fresh_config.kanban, &provider_name, &project_key) .map_err(|e| ApiError::BadRequest(e.to_string()))?; @@ -194,12 +194,12 @@ pub async fn sync_issue_types( ) -> Result, ApiError> { // Reload config from disk so freshly onboarded providers are visible // without requiring a server restart. - let fresh_config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + let fresh_config = Config::load(None).unwrap_or_else(|_| (*state.config()).clone()); let provider = get_provider_from_config(&fresh_config.kanban, &provider_name, &project_key) .map_err(|e| ApiError::BadRequest(e.to_string()))?; let service = KanbanIssueTypeService::from_tickets_path(std::path::Path::new( - &state.config.paths.tickets, + &state.config().paths.tickets, )); let synced_types = service diff --git a/src/rest/routes/kanban_onboarding.rs b/src/rest/routes/kanban_onboarding.rs index 3696ad2f..59276c7e 100644 --- a/src/rest/routes/kanban_onboarding.rs +++ b/src/rest/routes/kanban_onboarding.rs @@ -100,11 +100,20 @@ pub async fn list_statuses( ) )] pub async fn write_config( - State(_state): State, + State(state): State, Json(req): Json, ) -> Result, ApiError> { - // Pass `None` so the service uses the production config path. - let resp = kanban_onboarding::write_config(req, None)?; + let resp = state + .mutate_config(move |config| { + let section_header = kanban_onboarding::apply_config_request(config, req)?; + Ok(WriteKanbanConfigResponse { + written_path: crate::config::Config::operator_config_path() + .display() + .to_string(), + section_header, + }) + }) + .await?; Ok(Json(resp)) } diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index 494a4529..4b5baf31 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -13,11 +13,13 @@ use axum::{ use crate::agents::delegator_resolution::{self, AgentContext}; use crate::agents::{LaunchOptions, Launcher, PreparedLaunch, ProofRunner, RelaunchOptions}; use crate::queue::Queue; +use crate::rest::dto::auth::PrincipalKind; use crate::rest::dto::{ LaunchTicketRequest, LaunchTicketResponse, NextStepInfo, StepCompleteRequest, StepCompleteResponse, }; use crate::rest::error::ApiError; +use crate::rest::middleware::auth::Authenticated; use crate::rest::state::ApiState; /// If the sub-agent identified by `request.session_id` (or by ticket fallback) @@ -32,7 +34,7 @@ fn handle_multi_agent_completion( step_name: &str, request: &StepCompleteRequest, ) -> Result, ApiError> { - let mut app_state = crate::state::State::load(&state.config) + let mut app_state = crate::state::State::load(&state.config()) .map_err(|e| ApiError::InternalError(e.to_string()))?; // Resolve the sub-agent: prefer session-id lookup, fall back to ticket. @@ -159,7 +161,7 @@ pub async fn launch_ticket( Json(request): Json, ) -> Result, ApiError> { // Create a queue to find the ticket - let queue = Queue::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; + let queue = Queue::new(&state.config()).map_err(|e| ApiError::InternalError(e.to_string()))?; // Find the ticket by ID let ticket = queue @@ -189,14 +191,14 @@ pub async fn launch_ticket( // Check if ticket is in-progress directory let in_progress_path = state - .config + .config() .tickets_path() .join("in-progress") .join(&ticket.filename); // Create launcher let launcher = - Launcher::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; + Launcher::new(&state.config()).map_err(|e| ApiError::InternalError(e.to_string()))?; // Non-local targets (docker/coder/ssh) execute SERVER-SIDE: workspace // lifecycle and remote session orchestration belong to the server, and a @@ -250,9 +252,9 @@ fn apply_request_target( options: &mut LaunchOptions, ) -> Result<(), ApiError> { if let Some(ref name) = request.target { - let target = delegator_resolution::resolve_named_target(&state.config, name) + let target = delegator_resolution::resolve_named_target(&state.config(), name) .map_err(|e| ApiError::BadRequest(e.to_string()))?; - delegator_resolution::apply_target_to_options(options, target, &state.config) + delegator_resolution::apply_target_to_options(options, target, &state.config()) .map_err(|e| ApiError::BadRequest(e.to_string()))?; } Ok(()) @@ -264,7 +266,7 @@ fn server_side_response( state: &ApiState, ticket: &crate::queue::Ticket, ) -> Result { - let app_state = crate::state::State::load(&state.config) + let app_state = crate::state::State::load(&state.config()) .map_err(|e| ApiError::InternalError(e.to_string()))?; let agent = app_state .agents @@ -279,7 +281,7 @@ fn server_side_response( ticket_id: ticket.id.clone(), working_directory: agent.worktree_path.clone().unwrap_or_else(|| { state - .config + .config() .projects_path() .join(&ticket.project) .to_string_lossy() @@ -304,7 +306,7 @@ fn build_launch_options( agent_context: Option<&AgentContext>, ) -> Result { delegator_resolution::resolve_launch_options( - &state.config, + &state.config(), request.delegator.as_deref(), request.provider.as_deref(), request.model.as_deref(), @@ -364,7 +366,7 @@ fn build_next_step_command( ) -> anyhow::Result { use crate::agents::launcher::step_command::{self, StepLaunchContext}; - let config: &crate::config::Config = &state.config; + let config: &crate::config::Config = &state.config(); let app_state = crate::state::State::load(config)?; let agent = find_completing_agent(&app_state.agents, &ticket.id, request.session_id.as_deref()); @@ -468,7 +470,7 @@ fn record_step_transition( if let Err(e) = advanced.set_session_id(&next_step.name, next_session_id) { tracing::warn!(ticket = %ticket.id, error = %e, "Failed to store next step session id"); } - match crate::state::State::load(&state.config) { + match crate::state::State::load(&state.config()) { Ok(mut app_state) => { let matched = find_completing_agent(&app_state.agents, &ticket.id, request.session_id.as_deref()); @@ -504,7 +506,7 @@ fn set_proof_status_message( session_id: Option<&str>, message: &str, ) { - let mut app_state = match crate::state::State::load(&state.config) { + let mut app_state = match crate::state::State::load(&state.config()) { Ok(s) => s, Err(e) => { tracing::warn!(ticket = %ticket.id, error = %e, "Failed to load state for proof status message"); @@ -547,13 +549,13 @@ async fn run_proof_review_hook( let project_fallback = || { state - .config + .config() .projects_path() .join(&ticket.project) .to_string_lossy() .to_string() }; - let worktree_root = match crate::state::State::load(&state.config) { + let worktree_root = match crate::state::State::load(&state.config()) { Ok(app_state) => { let agent = find_completing_agent(&app_state.agents, &ticket.id, request.session_id.as_deref()); @@ -626,10 +628,25 @@ async fn run_proof_review_hook( pub async fn complete_step( State(state): State, Path((ticket_id, step_name)): Path<(String, String)>, + Authenticated(principal): Authenticated, Json(request): Json, ) -> Result, ApiError> { + // A callback token is pinned to one ticket and step. Presenting a valid but + // *different* one here would let an agent working on one ticket drive + // another ticket's workflow forward, so the claims are matched against the + // path rather than trusted for having verified at all. + if principal.kind == PrincipalKind::AgentCallback { + let matches_ticket = principal.ticket_id.as_deref() == Some(ticket_id.as_str()); + let matches_step = principal.step.as_deref() == Some(step_name.as_str()); + if !matches_ticket || !matches_step { + return Err(ApiError::Forbidden( + "this callback token is issued for a different ticket or step".to_string(), + )); + } + } + // Create a queue to find the ticket - let queue = Queue::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; + let queue = Queue::new(&state.config()).map_err(|e| ApiError::InternalError(e.to_string()))?; // Find the ticket by ID let ticket = queue @@ -653,6 +670,19 @@ pub async fn complete_step( )) })?; + // Clone what the rest of the function needs from the registry, then drop + // the read guard before any `.await`. The proof hook below runs an + // assertion command synchronously (up to its configured timeout, default + // 120s) — holding `registry.read()` across that would stall every + // `registry.write()` caller (issuetypes/collections/steps routes) for + // the duration of each Proof-reviewed step completion. + let current_step = current_step.clone(); + let next_step_schema = current_step + .next_step + .as_ref() + .and_then(|n| issue_type.get_step(n).cloned()); + drop(registry); + // Multi-agent branch: if the calling sub-agent belongs to a group, // write its individual output file and return a group_* status. // The sync loop owns aggregation, advancement, and artifact writing. @@ -667,7 +697,7 @@ pub async fn complete_step( if request.exit_code == 0 && current_step.review_type == crate::templates::schema::ReviewType::Proof { - run_proof_review_hook(&state, &ticket, current_step, &request).await; + run_proof_review_hook(&state, &ticket, ¤t_step, &request).await; } // Determine status based on exit code and validation @@ -694,13 +724,11 @@ pub async fn complete_step( } // Find next step info - let next_step_info = current_step.next_step.as_ref().and_then(|next_name| { - issue_type.get_step(next_name).map(|step| NextStepInfo { - name: step.name.clone(), - display_name: step.display_name.clone().unwrap_or(step.name.clone()), - review_type: format!("{:?}", step.review_type).to_lowercase(), - prompt: Some(step.prompt.clone()), - }) + let next_step_info = next_step_schema.as_ref().map(|step| NextStepInfo { + name: step.name.clone(), + display_name: step.display_name.clone().unwrap_or(step.name.clone()), + review_type: format!("{:?}", step.review_type).to_lowercase(), + prompt: Some(step.prompt.clone()), }); // Determine if we should auto-proceed @@ -713,11 +741,7 @@ pub async fn complete_step( // Never target-wrapped — exec() happens inside the already-wrapped // environment (see step_command module docs). let next_command = if auto_proceed { - match current_step - .next_step - .as_ref() - .and_then(|n| issue_type.get_step(n).cloned()) - { + match next_step_schema { Some(next_schema) => { match build_next_step_command(&state, &ticket, &next_schema, &request) { Ok(built) => { @@ -895,6 +919,7 @@ mod tests { fn test_build_launch_options_delegator_propagates_all_fields() { let mut config = Config::default(); config.delegators.push(crate::config::Delegator { + git: None, name: "full-delegator".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), @@ -951,6 +976,7 @@ mod tests { fn test_build_launch_options_delegator_none_overrides_inherit() { let mut config = Config::default(); config.delegators.push(crate::config::Delegator { + git: None, name: "minimal".to_string(), llm_tool: "claude".to_string(), model: "sonnet".to_string(), @@ -1001,6 +1027,7 @@ mod tests { fn make_delegator(name: &str, tool: &str, model: &str) -> crate::config::Delegator { crate::config::Delegator { + git: None, name: name.to_string(), llm_tool: tool.to_string(), model: model.to_string(), @@ -1159,6 +1186,7 @@ mod tests { #[test] fn test_build_launch_options_step_agent_applies_launch_config() { let state = make_state_with_delegators(vec![crate::config::Delegator { + git: None, name: "codex-auto".to_string(), llm_tool: "codex".to_string(), model: "o3".to_string(), @@ -1289,7 +1317,7 @@ mod tests { // Build a group with 2 expected sub-agents; launch one (mark_launched). let (agent_id, session_name) = { - let mut state = State::load(&api_state.config).unwrap(); + let mut state = State::load(&api_state.config()).unwrap(); let group_id = state .create_multi_agent_group( &ticket.id, @@ -1360,7 +1388,7 @@ mod tests { // 2 sub-agents, both launched; the FIRST has already recorded its output. let (second_agent_id, session_name) = { - let mut state = State::load(&api_state.config).unwrap(); + let mut state = State::load(&api_state.config()).unwrap(); let group_id = state .create_multi_agent_group( &ticket.id, @@ -1501,7 +1529,7 @@ mod tests { let content = format!("---\nid: {id}\nstatus: running\nstep: {step}\n---\n\n# Chain ticket\n"); let path = state - .config + .config() .tickets_path() .join("in-progress") .join(filename); @@ -1541,7 +1569,7 @@ mod tests { /// Add an agent for `ticket` carrying the given persisted launch context. fn add_chain_agent(state: &ApiState, ticket: &Ticket, model: &str, session_id: &str) -> String { - let mut app_state = State::load(&state.config).unwrap(); + let mut app_state = State::load(&state.config()).unwrap(); let agent_id = app_state .add_agent_with_options( ticket.id.clone(), @@ -1559,7 +1587,7 @@ mod tests { } fn persisted_context_session_id(state: &ApiState, agent_id: &str) -> Option { - State::load(&state.config) + State::load(&state.config()) .unwrap() .agents .iter() @@ -1577,6 +1605,7 @@ mod tests { let response = complete_step( State(fixture.state.clone()), Path((ticket.id.clone(), "scan".to_string())), + Authenticated(crate::auth::scope::Principal::local("admin")), Json(make_chain_complete_request("session-current")), ) .await @@ -1652,6 +1681,7 @@ mod tests { let first = complete_step( State(fixture.state.clone()), Path((ticket.id.clone(), "scan".to_string())), + Authenticated(crate::auth::scope::Principal::local("admin")), Json(make_chain_complete_request("session-scan")), ) .await @@ -1668,6 +1698,7 @@ mod tests { let second = complete_step( State(fixture.state.clone()), Path((ticket.id.clone(), "scan".to_string())), + Authenticated(crate::auth::scope::Principal::local("admin")), Json(make_chain_complete_request("session-scan")), ) .await @@ -1716,7 +1747,7 @@ mod tests { add_chain_agent(&fixture.state, &other, "opus", "shared-session"); let target_agent = add_chain_agent(&fixture.state, &target, "sonnet", "target-session"); - let app_state = State::load(&fixture.state.config).unwrap(); + let app_state = State::load(&fixture.state.config()).unwrap(); let found = find_completing_agent(&app_state.agents, &target.id, Some("shared-session")) .expect("falls back to a ticket match"); assert_eq!( @@ -1736,6 +1767,7 @@ mod tests { let first = complete_step( State(fixture.state.clone()), Path((ticket.id.clone(), "scan".to_string())), + Authenticated(crate::auth::scope::Principal::local("admin")), Json(make_chain_complete_request("session-scan")), ) .await @@ -1759,6 +1791,7 @@ mod tests { let second = complete_step( State(fixture.state.clone()), Path((ticket.id.clone(), "validate".to_string())), + Authenticated(crate::auth::scope::Principal::local("admin")), Json(make_chain_complete_request(&validate_session)), ) .await @@ -1771,13 +1804,6 @@ mod tests { ); } - // ─── Proof review hook (Task B3) ──────────────────────────────────── - // - // Registers a synthetic "PROOF" issue type directly into the registry - // (IssueType::validate doesn't check proof_config — that's B1's - // TemplateSchema-level check for filesystem-loaded types — so this can - // also model the "runtime template bypassed validation" case). - use crate::agents::ProofResult; use crate::issuetypes::schema::IssueTypeSource; use crate::issuetypes::IssueType; @@ -1826,7 +1852,7 @@ mod tests { let content = format!("---\nid: {id}\nstatus: running\nstep: {step}\n---\n\n# Proof ticket\n"); let path = state - .config + .config() .tickets_path() .join("in-progress") .join(filename); @@ -1835,7 +1861,7 @@ mod tests { } fn agent_last_message(state: &ApiState, agent_id: &str) -> Option { - State::load(&state.config) + State::load(&state.config()) .unwrap() .agents .iter() @@ -1861,7 +1887,7 @@ mod tests { let ticket = write_typed_ticket(&fixture.state, "PROOF-9001", "PROOF", "run"); let agent_id = add_chain_agent(&fixture.state, &ticket, "sonnet", "session-proof-1"); - State::load(&fixture.state.config) + State::load(&fixture.state.config()) .unwrap() .update_agent_worktree_path(&agent_id, &worktree.to_string_lossy()) .unwrap(); @@ -1869,6 +1895,7 @@ mod tests { let response = complete_step( State(fixture.state.clone()), Path((ticket.id.clone(), "run".to_string())), + Authenticated(crate::auth::scope::Principal::local("admin")), Json(make_chain_complete_request("session-proof-1")), ) .await @@ -1910,7 +1937,7 @@ mod tests { let ticket = write_typed_ticket(&fixture.state, "PROOF-9002", "PROOF", "run"); let agent_id = add_chain_agent(&fixture.state, &ticket, "sonnet", "session-proof-2"); - State::load(&fixture.state.config) + State::load(&fixture.state.config()) .unwrap() .update_agent_worktree_path(&agent_id, &worktree.to_string_lossy()) .unwrap(); @@ -1918,6 +1945,7 @@ mod tests { let response = complete_step( State(fixture.state.clone()), Path((ticket.id.clone(), "run".to_string())), + Authenticated(crate::auth::scope::Principal::local("admin")), Json(make_chain_complete_request("session-proof-2")), ) .await @@ -1957,7 +1985,7 @@ mod tests { let ticket = write_typed_ticket(&fixture.state, "PROOF-9003", "PROOF", "run"); let agent_id = add_chain_agent(&fixture.state, &ticket, "sonnet", "session-proof-3"); - State::load(&fixture.state.config) + State::load(&fixture.state.config()) .unwrap() .update_agent_worktree_path(&agent_id, &worktree.to_string_lossy()) .unwrap(); @@ -1968,6 +1996,7 @@ mod tests { let response = complete_step( State(fixture.state.clone()), Path((ticket.id.clone(), "run".to_string())), + Authenticated(crate::auth::scope::Principal::local("admin")), Json(request), ) .await @@ -1998,7 +2027,7 @@ mod tests { let ticket = write_typed_ticket(&fixture.state, "PROOF-9004", "PROOF", "run"); let agent_id = add_chain_agent(&fixture.state, &ticket, "sonnet", "session-proof-4"); - State::load(&fixture.state.config) + State::load(&fixture.state.config()) .unwrap() .update_agent_worktree_path(&agent_id, &worktree.to_string_lossy()) .unwrap(); @@ -2006,6 +2035,7 @@ mod tests { let response = complete_step( State(fixture.state.clone()), Path((ticket.id.clone(), "run".to_string())), + Authenticated(crate::auth::scope::Principal::local("admin")), Json(make_chain_complete_request("session-proof-4")), ) .await diff --git a/src/rest/routes/llm_tools.rs b/src/rest/routes/llm_tools.rs index f240c49b..a719cd14 100644 --- a/src/rest/routes/llm_tools.rs +++ b/src/rest/routes/llm_tools.rs @@ -6,7 +6,6 @@ use axum::extract::State; use axum::Json; -use crate::config::Config; use crate::rest::dto::{DefaultLlmResponse, LlmToolsResponse, SetDefaultLlmRequest}; use crate::rest::error::ApiError; use crate::rest::state::ApiState; @@ -22,7 +21,24 @@ use crate::rest::state::ApiState; ) )] pub async fn list(State(state): State) -> Json { - let tools = state.config.llm_tools.detected.clone(); + let tools = state + .config() + .llm_tools + .detected + .iter() + .map(|tool| crate::rest::dto::DetectedToolSummary { + name: tool.name.clone(), + version: tool.version.clone(), + min_version: tool.min_version.clone(), + version_ok: tool.version_ok, + model_aliases: tool.model_aliases.clone(), + capabilities: crate::rest::dto::ToolCapabilitiesSummary { + supports_sessions: tool.capabilities.supports_sessions, + supports_headless: tool.capabilities.supports_headless, + }, + health_ok: tool.health_ok, + }) + .collect::>(); let total = tools.len(); Json(LlmToolsResponse { tools, total }) } @@ -40,13 +56,13 @@ pub async fn list(State(state): State) -> Json { pub async fn get_default(State(state): State) -> Json { Json(DefaultLlmResponse { tool: state - .config + .config() .llm_tools .default_tool .clone() .unwrap_or_default(), model: state - .config + .config() .llm_tools .default_model .clone() @@ -70,30 +86,29 @@ pub async fn set_default( State(state): State, Json(req): Json, ) -> Result, ApiError> { - if !state - .config - .llm_tools - .detected - .iter() - .any(|t| t.name == req.tool) - { - return Err(ApiError::NotFound(format!( - "Tool '{}' not detected", - req.tool - ))); - } - - let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); - config.llm_tools.default_tool = Some(req.tool.clone()); - config.llm_tools.default_model = Some(req.model.clone()); - config - .save() - .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + let response = state + .mutate_config(move |config| { + if !config + .llm_tools + .detected + .iter() + .any(|tool| tool.name == req.tool) + { + return Err(ApiError::NotFound(format!( + "Tool '{}' not detected", + req.tool + ))); + } + config.llm_tools.default_tool = Some(req.tool.clone()); + config.llm_tools.default_model = Some(req.model.clone()); + Ok(DefaultLlmResponse { + tool: req.tool, + model: req.model, + }) + }) + .await?; - Ok(Json(DefaultLlmResponse { - tool: req.tool, - model: req.model, - })) + Ok(Json(response)) } #[cfg(test)] diff --git a/src/rest/routes/mod.rs b/src/rest/routes/mod.rs index 859ede32..6507ecd0 100644 --- a/src/rest/routes/mod.rs +++ b/src/rest/routes/mod.rs @@ -1,6 +1,7 @@ //! Route handlers for the REST API. pub mod agents; +pub mod auth; pub mod collections; pub mod configuration; pub mod delegators; @@ -12,6 +13,7 @@ pub mod kanban_onboarding; pub mod launch; pub mod llm_tools; pub mod model_servers; +pub mod probes; pub mod projects; pub mod queue; pub mod sections; diff --git a/src/rest/routes/model_servers.rs b/src/rest/routes/model_servers.rs index 510a2f3d..c436f7c8 100644 --- a/src/rest/routes/model_servers.rs +++ b/src/rest/routes/model_servers.rs @@ -57,7 +57,7 @@ fn server_to_response(s: &ModelServer, user_declared: bool) -> ModelServerRespon )] pub async fn list(State(state): State) -> Json { let mut servers: Vec = state - .config + .config() .model_servers .iter() .map(|s| server_to_response(s, true)) @@ -92,7 +92,7 @@ pub async fn get_one( State(state): State, Path(name): Path, ) -> Result, ApiError> { - if let Some(server) = state.config.model_servers.iter().find(|s| s.name == name) { + 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 { @@ -122,17 +122,6 @@ pub async fn create( State(state): State, Json(req): Json, ) -> Result, ApiError> { - if state - .config - .model_servers - .iter() - .any(|s| s.name == req.name) - { - return Err(ApiError::Conflict(format!( - "Model server '{}' already exists", - req.name - ))); - } if IMPLICIT_TOOL_NAMES .iter() .any(|t| implicit_model_server_for_tool(t).name == req.name) @@ -152,13 +141,24 @@ pub async fn create( display_name: req.display_name, }; - let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); - config.model_servers.push(server.clone()); - config - .save() - .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + let response = state + .mutate_config(move |config| { + if config + .model_servers + .iter() + .any(|existing| existing.name == server.name) + { + return Err(ApiError::Conflict(format!( + "Model server '{}' already exists", + server.name + ))); + } + config.model_servers.push(server.clone()); + Ok(server_to_response(&server, true)) + }) + .await?; - Ok(Json(server_to_response(&server, true))) + Ok(Json(response)) } /// Delete a user-declared model server by name @@ -191,21 +191,17 @@ pub async fn delete( ))); } - let server = state - .config - .model_servers - .iter() - .find(|s| s.name == name) - .ok_or_else(|| ApiError::NotFound(format!("Model server '{name}' not found")))? - .clone(); - - let response = server_to_response(&server, true); - - let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); - config.model_servers.retain(|s| s.name != name); - config - .save() - .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + let response = state + .mutate_config(move |config| { + let position = config + .model_servers + .iter() + .position(|server| server.name == name) + .ok_or_else(|| ApiError::NotFound(format!("Model server '{name}' not found")))?; + let server = config.model_servers.remove(position); + Ok(server_to_response(&server, true)) + }) + .await?; Ok(Json(response)) } @@ -240,25 +236,23 @@ pub async fn update( ))); } - let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); - let server = config - .model_servers - .iter_mut() - .find(|s| s.name == name) - .ok_or_else(|| ApiError::NotFound(format!("Model server '{name}' not found")))?; - - server.kind = req.kind; - server.base_url = req.base_url; - server.api_key_env = req.api_key_env; - server.extra_env = req.extra_env; - server.display_name = req.display_name; - let updated = server.clone(); - - config - .save() - .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; - - Ok(Json(server_to_response(&updated, true))) + let updated = state + .mutate_config(move |config| { + let server = config + .model_servers + .iter_mut() + .find(|server| server.name == name) + .ok_or_else(|| ApiError::NotFound(format!("Model server '{name}' not found")))?; + server.kind = req.kind; + server.base_url = req.base_url; + server.api_key_env = req.api_key_env; + server.extra_env = req.extra_env; + server.display_name = req.display_name; + Ok(server_to_response(server, true)) + }) + .await?; + + Ok(Json(updated)) } /// List the models a server offers, via a live probe of its inference endpoint. @@ -282,10 +276,14 @@ pub async fn models( State(state): State, Path(name): Path, ) -> Result, ApiError> { - let (server, _) = find_server(&state.config, &name) + let (server, _) = find_server(&state.config(), &name) .ok_or_else(|| ApiError::NotFound(format!("Model server '{name}' not found")))?; - let outcome = probe_models(&server).await; + let outcome = probe_models( + &server, + &crate::auth::egress::EgressPolicy::from_config(&state.config()), + ) + .await; Ok(Json(ModelServerModelsResponse { server: name, reachable: outcome.reachable, @@ -362,7 +360,7 @@ pub async fn kind_models( // Prefer a user-declared instance of this kind; otherwise probe from the // kind's built-in defaults (the probe fills in base_url/api_key_env). let server = state - .config + .config() .model_servers .iter() .find(|s| s.kind == slug) @@ -376,7 +374,11 @@ pub async fn kind_models( display_name: None, }); - let outcome = probe_models(&server).await; + let outcome = probe_models( + &server, + &crate::auth::egress::EgressPolicy::from_config(&state.config()), + ) + .await; Ok(Json(ModelServerModelsResponse { server: kind.slug().to_string(), reachable: outcome.reachable, diff --git a/src/rest/routes/probes.rs b/src/rest/routes/probes.rs new file mode 100644 index 00000000..008479b5 --- /dev/null +++ b/src/rest/routes/probes.rs @@ -0,0 +1,56 @@ +//! Kubernetes liveness and readiness probes. +//! +//! These exist as a separate, public pair precisely so `/api/v1/health` does +//! not have to be. That endpoint reports the workspace directory name and a +//! directory identifier — workspace identity, which an unauthenticated probe +//! should not disclose. These two carry no metadata at all: the HTTP status is +//! the entire signal. + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; + +use crate::rest::state::ApiState; + +/// Liveness probe +/// +/// Answers only "is the process serving HTTP". It deliberately does not touch +/// the database: a liveness failure restarts the pod, and restarting will not +/// fix a corrupt database — it would just crash-loop. +#[utoipa::path( + operation_id = "livez", + get, + path = "/livez", + tag = "Health", + responses((status = 200, description = "Process is alive")) +)] +pub async fn livez() -> impl IntoResponse { + (StatusCode::OK, "ok") +} + +/// Readiness probe +/// +/// Answers "can this instance serve requests", which additionally requires the auth database to be reachable +/// An uninitialized deployment awaiting bootstrap is **ready** +#[utoipa::path( + operation_id = "readyz", + get, + path = "/readyz", + tag = "Health", + responses( + (status = 200, description = "Ready to serve"), + (status = 503, description = "Not ready"), + ) +)] +pub async fn readyz(State(state): State) -> impl IntoResponse { + let store = state.auth.store.clone(); + let reachable = tokio::task::spawn_blocking(move || store.bootstrap_state()) + .await + .is_ok_and(|r| r.is_ok()); + + if reachable { + (StatusCode::OK, "ready") + } else { + (StatusCode::SERVICE_UNAVAILABLE, "auth store unavailable") + } +} diff --git a/src/rest/routes/projects.rs b/src/rest/routes/projects.rs index 501a6ead..ad7ca11f 100644 --- a/src/rest/routes/projects.rs +++ b/src/rest/routes/projects.rs @@ -23,7 +23,7 @@ use crate::templates::TemplateType; ) )] pub async fn list(State(state): State) -> Json> { - let config = &state.config; + let config = &state.config(); let projects_path = config.projects_path(); let project_names = &config.projects; @@ -141,7 +141,7 @@ pub async fn assess( State(state): State, Path(name): Path, ) -> Result, ApiError> { - let config = &state.config; + let config = &state.config(); // Validate project exists in config if !config.projects.contains(&name) { diff --git a/src/rest/routes/queue.rs b/src/rest/routes/queue.rs index b5043b2f..c86f89bd 100644 --- a/src/rest/routes/queue.rs +++ b/src/rest/routes/queue.rs @@ -50,7 +50,7 @@ fn ticket_to_card(ticket: &Ticket) -> KanbanTicketCard { )] pub async fn kanban(State(state): State) -> Result, ApiError> { // Create a queue from the config - let queue = Queue::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; + let queue = Queue::new(&state.config()).map_err(|e| ApiError::InternalError(e.to_string()))?; // Load tickets from each directory let queued_tickets = queue @@ -152,7 +152,7 @@ pub async fn kanban(State(state): State) -> Result) -> Result, ApiError> { // Create a queue from the config - let queue = Queue::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; + let queue = Queue::new(&state.config()).map_err(|e| ApiError::InternalError(e.to_string()))?; // Load tickets from each directory let queued_tickets = queue @@ -249,7 +249,7 @@ pub async fn status(State(state): State) -> Result) -> Result, ApiError> { - let mut operator_state = OperatorState::load(&state.config) + let mut operator_state = OperatorState::load(&state.config()) .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; operator_state @@ -275,7 +275,7 @@ pub async fn pause(State(state): State) -> Result) -> Result, ApiError> { - let mut operator_state = OperatorState::load(&state.config) + let mut operator_state = OperatorState::load(&state.config()) .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; operator_state @@ -304,7 +304,7 @@ pub async fn resume(State(state): State) -> Result) -> Result, ApiError> { use crate::services::KanbanSyncService; - let sync_service = KanbanSyncService::new(&state.config); + let sync_service = KanbanSyncService::new(&state.config()); let result = sync_service .sync_all() @@ -342,7 +342,7 @@ pub async fn sync_collection( ) -> Result, ApiError> { use crate::services::KanbanSyncService; - let sync_service = KanbanSyncService::new(&state.config); + let sync_service = KanbanSyncService::new(&state.config()); let result = sync_service .sync_collection(&provider, &project_key) diff --git a/src/rest/routes/sections.rs b/src/rest/routes/sections.rs index fa916004..63af4849 100644 --- a/src/rest/routes/sections.rs +++ b/src/rest/routes/sections.rs @@ -28,11 +28,11 @@ pub async fn list(State(state): State) -> Json> { // so report live connection facts the config-only snapshot can't know. let live = LiveConnectionStatus { api_running: true, - port: state.config.rest_api.port, - mcp_http_enabled: state.config.mcp.http_enabled, + port: state.config().rest_api.port, + mcp_http_enabled: state.config().mcp.http_enabled, mcp_active_sessions: state.mcp_sessions.lock().await.len(), }; - Json(provider(&state.config, ®istry, &live)) + Json(provider(&state.config(), ®istry, &live)) } None => Json(Vec::new()), } diff --git a/src/rest/routes/skills.rs b/src/rest/routes/skills.rs index b1ae49fb..3a9a3923 100644 --- a/src/rest/routes/skills.rs +++ b/src/rest/routes/skills.rs @@ -19,7 +19,7 @@ use crate::rest::state::ApiState; ) )] pub async fn list(State(state): State) -> Json { - let config = state.config; + let config = state.config(); let tool_configs = load_all_tool_configs(); let mut skills = Vec::new(); diff --git a/src/rest/routes/tickets.rs b/src/rest/routes/tickets.rs index 5fe4e8d7..66a470b0 100644 --- a/src/rest/routes/tickets.rs +++ b/src/rest/routes/tickets.rs @@ -61,7 +61,7 @@ pub async fn get_one( State(state): State, Path(ticket_id): Path, ) -> Result, ApiError> { - let queue = Queue::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; + let queue = Queue::new(&state.config()).map_err(|e| ApiError::InternalError(e.to_string()))?; let ticket = find_ticket_anywhere(&queue, &ticket_id)?; let step_display_name = ticket.current_step_display_name(); @@ -126,7 +126,7 @@ pub async fn update_status( ))); } - let queue = Queue::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; + let queue = Queue::new(&state.config()).map_err(|e| ApiError::InternalError(e.to_string()))?; let ticket = find_ticket_anywhere(&queue, &ticket_id)?; @@ -134,7 +134,7 @@ pub async fn update_status( let target_status = request.status.as_str(); // Determine target directory - let tickets_path = state.config.tickets_path(); + let tickets_path = state.config().tickets_path(); let dst_dir = match target_status { "queued" => tickets_path.join("queue"), "running" | "awaiting" => tickets_path.join("in-progress"), @@ -229,7 +229,7 @@ async fn create_ticket_from_values( template_type: TemplateType, values: HashMap, ) -> Result<(Ticket, std::path::PathBuf), ApiError> { - let config = (*state.config).clone(); + let config = (*state.config()).clone(); let path = tokio::task::spawn_blocking(move || -> Result { let creator = TicketCreator::new(&config); let project = values.get("project").cloned().unwrap_or_default(); diff --git a/src/rest/routes/workflow.rs b/src/rest/routes/workflow.rs index 8052e74d..3d48d734 100644 --- a/src/rest/routes/workflow.rs +++ b/src/rest/routes/workflow.rs @@ -52,7 +52,7 @@ pub async fn export( Path(ticket_id): Path, Query(query): Query, ) -> Result, ApiError> { - let queue = Queue::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; + let queue = Queue::new(&state.config()).map_err(|e| ApiError::InternalError(e.to_string()))?; let ticket = find_ticket_anywhere(&queue, &ticket_id)?; let registry = state.registry.read().await; @@ -60,7 +60,7 @@ pub async fn export( &ticket, ®istry, None, - &state.config, + &state.config(), query.format, ) .map_err(|e| ApiError::NotFound(e.to_string()))?; diff --git a/src/rest/server.rs b/src/rest/server.rs index 234d48e3..f725b23b 100644 --- a/src/rest/server.rs +++ b/src/rest/server.rs @@ -20,10 +20,18 @@ pub struct ApiSessionInfo { pub pid: u32, pub started_at: String, pub version: String, + /// State directory holding `local-token`, so a same-host client (the VS + /// Code extension) can find the local credential when `paths.state` is + /// not the default. + pub state_dir: PathBuf, } /// Write API session file for client discovery -fn write_session_file(tickets_path: &Path, port: u16) -> std::io::Result { +fn write_session_file( + tickets_path: &Path, + state_path: &Path, + port: u16, +) -> std::io::Result { let operator_dir = tickets_path.join("operator"); std::fs::create_dir_all(&operator_dir)?; @@ -33,6 +41,7 @@ fn write_session_file(tickets_path: &Path, port: u16) -> std::io::Result return ExternalApiProbe::Unreachable, }; - let response = match client.get(&url).send().await { + // `/api/v1/health` now requires a credential, so the probe presents the + // local-unlock token from *this* project's state directory. + let mut request = client.get(&url); + if let Some(token) = crate::auth::local::read(&self.config.state_path()) { + request = request.bearer_auth(token); + } + + let response = match request.send().await { Ok(r) => r, Err(_) => return ExternalApiProbe::Unreachable, }; + if response.status() == reqwest::StatusCode::UNAUTHORIZED + || response.status() == reqwest::StatusCode::FORBIDDEN + { + // An operator server we cannot authenticate to is, for adoption + // purposes, someone else's workspace. + return ExternalApiProbe::DifferentProject { + found_name: String::new(), + }; + } + // A 2xx that doesn't deserialize into a health shape is "not operator". let health = match response.json::().await { Ok(h) => h, @@ -244,6 +270,7 @@ impl RestApiServer { let host_ip = self.config.rest_api.host_ip(); let status = self.status.clone(); let tickets_path = self.tickets_path.clone(); + let state_path = self.config.state_path(); let api_state_handle = self.api_state.clone(); *status.lock().unwrap() = RestApiStatus::Starting; @@ -258,7 +285,7 @@ impl RestApiServer { tracing::info!("REST API listening on http://{}", addr); // Write session file for client discovery - if let Err(e) = write_session_file(&tickets_path, port) { + if let Err(e) = write_session_file(&tickets_path, &state_path, port) { tracing::warn!(error = %e, "Failed to write API session file"); } @@ -517,7 +544,8 @@ mod tests { let temp_dir = tempfile::TempDir::new().unwrap(); let port = 7008u16; - let result = write_session_file(temp_dir.path(), port); + let state_dir = temp_dir.path().join("custom-state"); + let result = write_session_file(temp_dir.path(), &state_dir, port); assert!(result.is_ok()); let session_file = temp_dir.path().join("operator").join("api-session.json"); @@ -529,6 +557,10 @@ mod tests { assert_eq!(session.port, port); assert!(!session.version.is_empty()); assert!(session.pid > 0); + assert_eq!( + session.state_dir, state_dir, + "clients locate local-token through the advertised state dir" + ); } #[test] @@ -539,7 +571,7 @@ mod tests { let operator_dir = temp_dir.path().join("operator"); assert!(!operator_dir.exists()); - let result = write_session_file(temp_dir.path(), 7008); + let result = write_session_file(temp_dir.path(), &operator_dir, 7008); assert!(result.is_ok()); // Should have created the operator directory diff --git a/src/rest/state.rs b/src/rest/state.rs index bb51b98a..d65a3622 100644 --- a/src/rest/state.rs +++ b/src/rest/state.rs @@ -4,11 +4,17 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use std::sync::RwLock as StdRwLock; + use tokio::sync::{Mutex, RwLock}; use crate::api::kanban_sync::KanbanBidirectionalSync; +use crate::auth::store::AuthStore; +use crate::auth::tokens::SigningKey; use crate::config::Config; use crate::issuetypes::IssueTypeRegistry; +use crate::rest::dto::auth::Scope; +use crate::rest::error::ApiError; use crate::startup::templates::load_registry; /// Shared state for the REST API @@ -16,16 +22,69 @@ use crate::startup::templates::load_registry; pub struct ApiState { /// Issue type registry (thread-safe read-write access) pub registry: Arc>, - /// Application configuration (reserved for future CORS/auth settings) - #[allow(dead_code)] - pub config: Arc, + /// Live application configuration. + config: Arc>>, + /// Serializes read-modify-write configuration updates. + config_update: Arc>, /// Path to tickets directory for persistence pub tickets_path: PathBuf, - /// Active MCP SSE sessions (`session_id` -> message sender) - pub mcp_sessions: Arc>>>, + /// Active MCP SSE sessions, each bound to the principal that opened it. + pub mcp_sessions: Arc>>, /// Bidirectional kanban sync service (present only when at least one project has /// `bidirectional: true` in its sync config). pub kanban_sync: Option>, + /// Authentication store and signing key. + pub auth: Arc, +} + +/// An open MCP SSE session. +/// +/// The principal is captured when the stream is established and re-checked on +/// every message: the session id alone is a bearer credential, and binding it +/// to an identity means a leaked id cannot be used by anyone else. +pub struct McpSession { + /// Channel that relays JSON-RPC responses back over the SSE stream. + pub tx: tokio::sync::mpsc::UnboundedSender, + /// Who opened the stream. + pub subject: String, + /// Scopes that principal held. + pub scopes: Vec, +} + +/// Authentication state shared by every surface. +pub struct AuthContext { + /// The credential database. + pub store: AuthStore, + /// Active token signing key. + pub signing_key: SigningKey, + /// The local-unlock token, when the server is bound to loopback. + pub local_token: Option, +} + +impl AuthContext { + /// Open the auth database and load or create the signing key. + /// + /// `bind_addr` decides whether a local-unlock token is issued: loopback + /// gets one, anything else does not and must bootstrap. + pub fn initialize(state_path: PathBuf, bind_addr: std::net::IpAddr) -> anyhow::Result { + let store = AuthStore::open(&state_path)?; + let signing_key = store.load_or_create_signing_key()?; + + let local_token = if crate::auth::local::is_loopback(bind_addr) { + Some(crate::auth::local::issue(&state_path)?) + } else { + // Leaving a stale token behind would hand a local credential to a + // publicly bound server. + crate::auth::local::revoke(&state_path); + None + }; + + Ok(Self { + store, + signing_key, + local_token, + }) + } } impl ApiState { @@ -36,8 +95,17 @@ impl ApiState { /// 2. If empty, initialize default templates from embedded files /// 3. Fallback to embedded builtins if filesystem loading fails pub fn new(config: Config, tickets_path: PathBuf) -> Self { - // Shared loader — keeps the API's issue-type resolution identical to the - // CLI/TUI so `workflow export` produces the same output on every surface. + let state_path = config.state_path(); + let bind_addr = config.rest_api.host_ip(); + let auth = AuthContext::initialize(state_path, bind_addr) + .expect("auth store must be available; without it nothing can authenticate"); + Self::with_auth(config, tickets_path, Arc::new(auth)) + } + + /// Build with a caller-supplied auth context, so tests and the TUI can share + /// one already-open store instead of racing to open the same database file. + pub fn with_auth(config: Config, tickets_path: PathBuf, auth: Arc) -> Self { + // Shared loader; keeps the API's issue-type resolution identical to the CLI/TUI `workflow export` produces the same output on every surface. let registry = load_registry(&tickets_path); let config_arc = Arc::new(config); @@ -52,13 +120,53 @@ impl ApiState { Self { registry: Arc::new(RwLock::new(registry)), - config: config_arc, + config: Arc::new(StdRwLock::new(config_arc)), + config_update: Arc::new(Mutex::new(())), tickets_path, mcp_sessions: Arc::new(Mutex::new(HashMap::new())), kanban_sync, + auth, + } + } + + /// A snapshot of the live configuration. + /// + /// Clones the inner `Arc` so a handler reads a consistent view for the duration of a request. + pub fn config(&self) -> Arc { + match self.config.read() { + Ok(guard) => Arc::clone(&guard), + Err(poisoned) => Arc::clone(&poisoned.into_inner()), } } + /// Replace the live configuration after a successful write to disk. + /// + /// This is the other half of fixing the stale-snapshot bug: writing + /// `config.toml` without calling this leaves every handler reading the + /// values the process started with. + pub fn replace_config(&self, config: Config) { + let next = Arc::new(config); + match self.config.write() { + Ok(mut guard) => *guard = next, + Err(poisoned) => *poisoned.into_inner() = next, + } + } + + /// Mutate and persist the latest live configuration as one serialized operation. + pub async fn mutate_config( + &self, + mutate: impl FnOnce(&mut Config) -> Result, + ) -> Result { + let _guard = self.config_update.lock().await; + let mut config = (*self.config()).clone(); + let result = mutate(&mut config)?; + config + .save() + .map_err(|error| ApiError::InternalError(format!("Failed to save config: {error}")))?; + self.replace_config(config); + Ok(result) + } + /// Get the templates directory path pub fn templates_path(&self) -> PathBuf { self.tickets_path.join("templates") diff --git a/src/services/kanban_onboarding.rs b/src/services/kanban_onboarding.rs index 4e6bfd98..b8c1c74e 100644 --- a/src/services/kanban_onboarding.rs +++ b/src/services/kanban_onboarding.rs @@ -321,6 +321,7 @@ pub async fn list_statuses( /// `Config::operator_config_path()` (which is what production uses). /// When `Some`, the config is loaded from and saved to that path instead /// (used by unit tests). +#[allow(dead_code)] pub fn write_config( req: WriteKanbanConfigRequest, config_override_path: Option<&PathBuf>, @@ -333,6 +334,32 @@ pub fn write_config( None => Config::load(None).unwrap_or_default(), }; + let section_header = apply_config_request(&mut config, req)?; + + let written_path = if let Some(p) = config_override_path { + save_config_to_path(&config, p) + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + p.display().to_string() + } else { + config + .save() + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + Config::operator_config_path().display().to_string() + }; + + info!(section = %section_header, "Wrote kanban config section"); + + Ok(WriteKanbanConfigResponse { + written_path, + section_header, + }) +} + +/// Apply a validated onboarding request to an existing configuration. +pub fn apply_config_request( + config: &mut Config, + req: WriteKanbanConfigRequest, +) -> Result { let section_header = match req.provider { KanbanProviderKind::Jira => { let body = req.jira.ok_or_else(|| { @@ -386,27 +413,11 @@ pub fn write_config( format!("[kanban.openspec.\"{}\"]", body.instance) } }; - - let written_path = if let Some(p) = config_override_path { - save_config_to_path(&config, p) - .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; - p.display().to_string() - } else { - config - .save() - .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; - Config::operator_config_path().display().to_string() - }; - - info!(section = %section_header, "Wrote kanban config section"); - - Ok(WriteKanbanConfigResponse { - written_path, - section_header, - }) + Ok(section_header) } /// Test-only helper: load a Config from an explicit TOML path. +#[allow(dead_code)] fn load_config_from_path(path: &PathBuf) -> anyhow::Result { let raw = std::fs::read_to_string(path)?; let cfg: Config = toml::from_str(&raw)?; @@ -414,6 +425,7 @@ fn load_config_from_path(path: &PathBuf) -> anyhow::Result { } /// Test-only helper: save a Config to an explicit TOML path. +#[allow(dead_code)] fn save_config_to_path(config: &Config, path: &PathBuf) -> anyhow::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; diff --git a/src/services/pr_monitor.rs b/src/services/pr_monitor.rs index 7124936b..abbd170a 100644 --- a/src/services/pr_monitor.rs +++ b/src/services/pr_monitor.rs @@ -22,6 +22,7 @@ const DEFAULT_POLL_INTERVAL: Duration = Duration::from_mins(1); /// Tracked PR information #[derive(Debug, Clone)] pub struct TrackedPr { + pub git_context: Option, /// Repository info (provider-agnostic) pub repo_info: RepoInfo, /// PR number @@ -74,11 +75,6 @@ pub struct PrMonitorService { } impl PrMonitorService { - /// Create a new PR monitor service, routing per-PR by provider - pub fn new(event_tx: mpsc::UnboundedSender) -> Self { - Self::with_service(Arc::new(PrServiceRouter::new()), event_tx) - } - /// Create a new PR monitor service with a custom provider pub fn with_service( pr_service: Arc, @@ -111,8 +107,14 @@ impl PrMonitorService { } /// Generate a key for a tracked PR - fn pr_key(repo_info: &RepoInfo, pr_number: i64) -> String { - format!("{}#{}", repo_info.full_name(), pr_number) + pub(crate) fn pr_key(repo_info: &RepoInfo, pr_number: i64) -> String { + format!( + "{}:{}:{}#{}", + repo_info.provider, + repo_info.host.as_deref().unwrap_or_default(), + repo_info.full_name(), + pr_number + ) } /// Start tracking a PR @@ -131,6 +133,7 @@ impl PrMonitorService { .context("Failed to fetch initial PR state")?; let tracked = TrackedPr { + git_context: crate::git::runtime::current(), repo_info: repo_info.clone(), pr_number, last_state: pr.state, @@ -214,11 +217,13 @@ impl PrMonitorService { /// Poll a single PR and handle status changes async fn poll_single_pr(&self, tracked: &TrackedPr) -> Result<()> { - let pr = self - .pr_service - .get_pr(&tracked.repo_info, tracked.pr_number) - .await - .context("Failed to fetch PR")?; + let pr = crate::git::runtime::scope( + tracked.git_context.clone(), + self.pr_service + .get_pr(&tracked.repo_info, tracked.pr_number), + ) + .await + .context("Failed to fetch PR")?; // Check for state changes let mut events = Vec::new(); @@ -308,24 +313,38 @@ mod tests { #[test] fn test_pr_key_format() { let repo = RepoInfo { + host: None, provider: GitProvider::GitHub, owner: "owner".to_string(), repo_name: "repo".to_string(), }; - assert_eq!(PrMonitorService::pr_key(&repo, 42), "owner/repo#42"); + assert_eq!(PrMonitorService::pr_key(&repo, 42), "github::owner/repo#42"); } #[tokio::test] async fn test_create_service() { let (tx, _rx) = mpsc::unbounded_channel(); - let service = PrMonitorService::new(tx); + let service = PrMonitorService::with_service( + Arc::new(PrServiceRouter::with_config( + &crate::config::Config::default(), + None, + )), + tx, + ); assert_eq!(service.tracked_count().await, 0); } #[tokio::test] async fn test_poll_interval_config() { let (tx, _rx) = mpsc::unbounded_channel(); - let service = PrMonitorService::new(tx).with_poll_interval(Duration::from_secs(30)); + let service = PrMonitorService::with_service( + Arc::new(PrServiceRouter::with_config( + &crate::config::Config::default(), + None, + )), + tx, + ) + .with_poll_interval(Duration::from_secs(30)); assert_eq!(service.poll_interval, Duration::from_secs(30)); } } diff --git a/src/startup/mod.rs b/src/startup/mod.rs index cd544979..68dde61a 100644 --- a/src/startup/mod.rs +++ b/src/startup/mod.rs @@ -80,6 +80,15 @@ pub static SETUP_STEPS: &[SetupStepInfo] = &[ without branch conflicts.", navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", }, + SetupStepInfo { + name: "Web UI Password", + description: "Optionally set the admin password for the web dashboard", + help_text: "Operator has a single human account, `admin`.\n\n\ + This terminal and the CLI need no password: a loopback process authenticates with an owner-only token file in the state directory. A browser cannot read that file, so the web dashboard stays locked until an admin password exists.\n\n\ + Leave both fields blank to skip. You can set one later with `operator auth bootstrap` or from the /setup page.\n\n\ + The password must be at least 12 characters. This step is hidden when an admin account already exists.", + navigation: "Tab to switch fields, Enter to continue (blank to skip), Esc to go back", + }, SetupStepInfo { name: "Tmux Onboarding", description: @@ -154,15 +163,11 @@ pub static SETUP_STEPS: &[SetupStepInfo] = &[ navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", }, SetupStepInfo { - name: "Custom Collection", - description: "Select individual issue types (only shown if Custom Selection chosen)", - help_text: "Toggle individual issue types to include:\n\ - - **TASK**: Focused task that executes one specific thing\n\ - - **FEAT**: New feature or enhancement\n\ - - **FIX**: Bug fix, follow-up work, tech debt\n\ - - **SPIKE**: Research or exploration (paired mode)\n\ - - **INV**: Incident investigation (paired mode)\n\n\ - At least one issue type must be selected to proceed.", + 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", }, SetupStepInfo { @@ -239,12 +244,11 @@ mod tests { #[test] fn test_setup_steps_count_matches_enum() { - // 15 steps: Welcome, SessionWrapperChoice, WorktreePreference, - // TmuxOnboarding, VSCodeSetup, CmuxSetup, ZellijSetup, - // KanbanInfo, KanbanProviderSetup, - // CollectionSource, CustomCollection, TaskFieldConfig, + // 16 steps: Welcome, SessionWrapperChoice, WorktreePreference, + // AdminPassword, TmuxOnboarding, VSCodeSetup, CmuxSetup, ZellijSetup, + // KanbanInfo, KanbanProviderSetup, CollectionSource, HostedCollectionFetch, TaskFieldConfig, // AcceptanceCriteria, StartupTickets, Confirm - assert_eq!(SETUP_STEPS.len(), 15); + assert_eq!(SETUP_STEPS.len(), 16); } #[test] diff --git a/src/state.rs b/src/state.rs index 759da91e..d5254819 100644 --- a/src/state.rs +++ b/src/state.rs @@ -40,6 +40,9 @@ pub struct State { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct AgentState { + /// Non-secret Git configuration captured at launch. + #[serde(default)] + pub git_context: Option, pub id: String, pub ticket_id: String, pub ticket_type: String, @@ -309,6 +312,7 @@ impl State { let now = Utc::now(); self.agents.push(AgentState { + git_context: None, id: id.clone(), ticket_id, ticket_type, @@ -366,6 +370,7 @@ impl State { let now = Utc::now(); self.agents.push(AgentState { + git_context: None, id: id.clone(), ticket_id, ticket_type, @@ -533,6 +538,17 @@ impl State { } /// Persist the launch context used for multi-step exec-chain transitions + pub fn update_agent_git_context( + &mut self, + id: &str, + context: Option, + ) -> Result<()> { + if let Some(agent) = self.agents.iter_mut().find(|a| a.id == id) { + agent.git_context = context; + } + self.save() + } + pub fn update_agent_step_launch_context( &mut self, agent_id: &str, diff --git a/src/types/pr.rs b/src/types/pr.rs index b2e6b8b8..81731d76 100644 --- a/src/types/pr.rs +++ b/src/types/pr.rs @@ -71,29 +71,116 @@ impl GitProvider { /// Detect provider from a remote URL pub fn from_remote_url(remote_url: &str) -> Option { - let url_lower = remote_url.to_lowercase(); - if url_lower.contains("github.com") { - Some(GitProvider::GitHub) - } else if url_lower.contains("gitlab.com") || url_lower.contains("gitlab.") { - Some(GitProvider::GitLab) - } else if url_lower.contains("bitbucket.org") { - Some(GitProvider::Bitbucket) - } else if url_lower.contains("dev.azure.com") || url_lower.contains("visualstudio.com") { - Some(GitProvider::AzureDevOps) - } else if url_lower.contains("codeberg.org") { - Some(GitProvider::Forgejo) - } else if url_lower.contains("gitea.com") { - Some(GitProvider::Gitea) + Self::from_remote_url_with_hosts(remote_url, &ProviderHosts::default()) + } + + pub fn from_remote_url_with_hosts(remote_url: &str, hosts: &ProviderHosts) -> Option { + let (host, _) = remote_parts(remote_url).ok()?; + hosts + .entries + .get(&host.to_ascii_lowercase()) + .copied() + .or_else(|| match host.to_ascii_lowercase().as_str() { + "github.com" => Some(Self::GitHub), + "gitlab.com" => Some(Self::GitLab), + "bitbucket.org" => Some(Self::Bitbucket), + "dev.azure.com" => Some(Self::AzureDevOps), + "codeberg.org" => Some(Self::Forgejo), + "gitea.com" => Some(Self::Gitea), + host if host.starts_with("gitlab.") => Some(Self::GitLab), + host if host.ends_with(".visualstudio.com") || host == "visualstudio.com" => { + Some(Self::AzureDevOps) + } + _ => None, + }) + } +} + +#[derive(Debug, Clone, Default)] +pub struct ProviderHosts { + entries: std::collections::BTreeMap, +} + +pub fn provider_base_url(host: Option<&str>, default: &str) -> anyhow::Result { + let raw = host.unwrap_or(default); + let raw = if raw.contains("://") { + raw.to_owned() + } else { + format!("https://{raw}") + }; + let url = crate::git::identity::credential_url(&raw)?; + anyhow::ensure!( + url.path() == "/", + "Git provider host must be an HTTPS origin without a path" + ); + Ok(url) +} + +impl ProviderHosts { + pub fn from_config(config: &crate::config::GitConfig) -> anyhow::Result { + let mut hosts = Self::default(); + for (host, provider) in [ + (config.gitlab.host.as_deref(), GitProvider::GitLab), + (config.gitea.host.as_deref(), GitProvider::Gitea), + (config.forgejo.host.as_deref(), GitProvider::Forgejo), + ] { + if let Some(host) = host { + let parsed = provider_base_url(Some(host), "")?; + let hostname = parsed + .host_str() + .ok_or_else(|| anyhow::anyhow!("Missing provider host"))? + .to_ascii_lowercase(); + anyhow::ensure!( + hosts.entries.insert(hostname, provider).is_none(), + "Ambiguous Git provider host mapping" + ); + } + } + Ok(hosts) + } +} + +fn remote_parts(raw: &str) -> Result<(String, String), RepoInfoError> { + let normalized = if raw.contains("://") { + raw.to_owned() + } else if let Some((authority, path)) = raw.split_once(':') { + if authority.contains('@') { + format!("ssh://{authority}/{path}") } else { - None + format!("https://{raw}") } + } else { + format!("https://{raw}") + }; + let url = url::Url::parse(&normalized) + .map_err(|_| RepoInfoError::InvalidUrl("Invalid repository remote".into()))?; + if !matches!(url.scheme(), "https" | "http" | "ssh" | "git") + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(RepoInfoError::InvalidUrl( + "Invalid repository remote".into(), + )); } + Ok(( + url.host_str() + .ok_or_else(|| RepoInfoError::InvalidUrl("Missing repository host".into()))? + .to_owned(), + url.path() + .trim_matches('/') + .trim_end_matches(".git") + .to_owned(), + )) } /// Repository info parsed from remote URL (provider-agnostic) #[derive(Debug, Clone, Serialize, Deserialize, TS, JsonSchema)] #[ts(export)] pub struct RepoInfo { + /// Repository hostname, retained for routing and monitor isolation. + #[serde(default)] + pub host: Option, /// Git hosting provider #[serde(default)] pub provider: GitProvider, @@ -111,6 +198,7 @@ impl RepoInfo { repo_name: impl Into, ) -> Self { Self { + host: None, provider, owner: owner.into(), repo_name: repo_name.into(), @@ -127,12 +215,31 @@ impl RepoInfo { /// /// Similar formats supported for GitLab, Bitbucket, and Azure DevOps. pub fn from_remote_url(remote_url: &str) -> Result { - let provider = GitProvider::from_remote_url(remote_url) - .ok_or_else(|| RepoInfoError::UnknownProvider(remote_url.to_string()))?; - - let (owner, repo_name) = parse_owner_repo(remote_url, provider)?; - + Self::from_remote_url_with_hosts(remote_url, &ProviderHosts::default()) + } + + pub fn from_remote_url_with_hosts( + remote_url: &str, + hosts: &ProviderHosts, + ) -> Result { + let provider = GitProvider::from_remote_url_with_hosts(remote_url, hosts) + .ok_or_else(|| RepoInfoError::UnknownProvider("Unconfigured repository host".into()))?; + let (host, path) = remote_parts(remote_url)?; + let (owner, repo_name) = if provider == GitProvider::AzureDevOps { + parse_owner_repo(remote_url, provider)? + } else { + let (owner, repo) = path + .rsplit_once('/') + .ok_or_else(|| RepoInfoError::InvalidUrl("Repository needs owner/name".into()))?; + if owner.is_empty() || repo.is_empty() { + return Err(RepoInfoError::InvalidUrl( + "Repository needs owner/name".into(), + )); + } + (owner.to_owned(), repo.to_owned()) + }; Ok(Self { + host: Some(host), provider, owner, repo_name, @@ -632,4 +739,28 @@ mod tests { fn test_export_bindings_gitprovider() { let _ = GitProvider::export_to_string(&ts_rs::Config::default()); } + #[test] + fn configured_gitea_host_is_exact_and_supports_ssh_and_https() { + let mut config = crate::config::GitConfig::default(); + config.gitea.host = Some("gitea.kube.untra.casa".into()); + let hosts = ProviderHosts::from_config(&config).unwrap(); + for remote in [ + "https://GITEA.KUBE.UNTRA.CASA/team/repo.git", + "git@gitea.kube.untra.casa:team/repo.git", + "ssh://git@gitea.kube.untra.casa:2222/team/repo.git", + ] { + let repo = RepoInfo::from_remote_url_with_hosts(remote, &hosts).unwrap(); + assert_eq!(repo.provider, GitProvider::Gitea); + assert_eq!(repo.full_name(), "team/repo"); + } + assert!(GitProvider::from_remote_url_with_hosts( + "https://gitea.kube.untra.casa.evil/team/repo", + &hosts + ) + .is_none()); + assert!(GitProvider::from_remote_url("https://evil.example/github.com/repo").is_none()); + assert!(GitProvider::from_remote_url("https://notgitlab.com/team/repo").is_none()); + config.forgejo.host = config.gitea.host.clone(); + assert!(ProviderHosts::from_config(&config).is_err()); + } } diff --git a/src/ui/dialogs/git_token.rs b/src/ui/dialogs/git_token.rs index 836538c7..466489da 100644 --- a/src/ui/dialogs/git_token.rs +++ b/src/ui/dialogs/git_token.rs @@ -9,6 +9,7 @@ use ratatui::{ }; use super::centered_rect; +use crate::ui::masked_input::MaskedInput; /// Dialog for collecting a git personal access token with masked input. pub struct GitTokenDialog { @@ -23,8 +24,8 @@ pub struct GitTokenDialog { pub placeholder: String, /// Inline error message (shown below input on validation failure). pub error: Option, - token: String, - cursor_position: usize, + /// The masked field itself; shared with the setup wizard's password step. + input: MaskedInput, } impl GitTokenDialog { @@ -36,8 +37,7 @@ impl GitTokenDialog { pat_url: String::new(), placeholder: String::new(), error: None, - token: String::new(), - cursor_position: 0, + input: MaskedInput::new(), } } @@ -53,22 +53,27 @@ impl GitTokenDialog { self.provider_display = provider_display.to_string(); self.pat_url = pat_url.to_string(); self.placeholder = placeholder.to_string(); - self.token.clear(); - self.cursor_position = 0; + self.input.clear(); self.error = None; self.visible = true; } pub fn hide(&mut self) { self.visible = false; - self.token.clear(); - self.cursor_position = 0; + self.input.clear(); self.error = None; } /// Get the current token value. pub fn token(&self) -> &str { - &self.token + self.input.value() + } + + /// Cursor position, in characters. Exposed for tests, which assert cursor + /// behavior as part of the dialog's contract. + #[cfg(test)] + fn cursor_position(&self) -> usize { + self.input.cursor() } /// Set an inline error message. @@ -77,44 +82,40 @@ impl GitTokenDialog { } pub fn handle_char(&mut self, c: char) { - self.token.insert(self.cursor_position, c); - self.cursor_position += 1; + self.input.handle_char(c); self.error = None; // clear error on new input } pub fn handle_backspace(&mut self) { - if self.cursor_position > 0 { - self.cursor_position -= 1; - self.token.remove(self.cursor_position); + // Only clear the error when something was actually removed, matching + // the original guard. + if self.input.cursor() > 0 { + self.input.handle_backspace(); self.error = None; } } pub fn handle_delete(&mut self) { - if self.cursor_position < self.token.len() { - self.token.remove(self.cursor_position); + if self.input.cursor() < self.input.char_count() { + self.input.handle_delete(); self.error = None; } } pub fn cursor_left(&mut self) { - if self.cursor_position > 0 { - self.cursor_position -= 1; - } + self.input.cursor_left(); } pub fn cursor_right(&mut self) { - if self.cursor_position < self.token.len() { - self.cursor_position += 1; - } + self.input.cursor_right(); } pub fn cursor_home(&mut self) { - self.cursor_position = 0; + self.input.cursor_home(); } pub fn cursor_end(&mut self) { - self.cursor_position = self.token.len(); + self.input.cursor_end(); } pub fn render(&self, frame: &mut Frame) { @@ -169,24 +170,9 @@ impl GitTokenDialog { )]); frame.render_widget(Paragraph::new(prompt), chunks[0]); - // Masked input - let display_text = if self.token.is_empty() { - Span::styled(&self.placeholder, Style::default().fg(Color::DarkGray)) - } else { - let masked: String = "•".repeat(self.token.len()); - Span::styled(masked, Style::default().fg(Color::White)) - }; - - let input = Paragraph::new(display_text) - .block(Block::default().borders(Borders::ALL).border_style( - Style::default().fg(if has_error { Color::Red } else { Color::Cyan }), - )) - .wrap(Wrap { trim: false }); - frame.render_widget(input, chunks[1]); - - // Cursor - let input_inner = Block::default().borders(Borders::ALL).inner(chunks[1]); - frame.set_cursor_position((input_inner.x + self.cursor_position as u16, input_inner.y)); + // Masked input (draws its own border and places the cursor). + self.input + .render(frame, chunks[1], &self.placeholder, true, has_error); // Error message (if present) if has_error { @@ -221,7 +207,7 @@ mod tests { let dialog = GitTokenDialog::new(); assert!(!dialog.visible); assert!(dialog.token().is_empty()); - assert_eq!(dialog.cursor_position, 0); + assert_eq!(dialog.cursor_position(), 0); assert!(dialog.error.is_none()); } @@ -257,7 +243,7 @@ mod tests { dialog.handle_char('p'); assert_eq!(dialog.token(), "ghp"); - assert_eq!(dialog.cursor_position, 3); + assert_eq!(dialog.cursor_position(), 3); } #[test] @@ -270,7 +256,7 @@ mod tests { dialog.handle_backspace(); assert_eq!(dialog.token(), "a"); - assert_eq!(dialog.cursor_position, 1); + assert_eq!(dialog.cursor_position(), 1); } #[test] @@ -280,7 +266,7 @@ mod tests { dialog.handle_backspace(); assert!(dialog.token().is_empty()); - assert_eq!(dialog.cursor_position, 0); + assert_eq!(dialog.cursor_position(), 0); } #[test] @@ -292,16 +278,16 @@ mod tests { dialog.handle_char('c'); dialog.cursor_left(); - assert_eq!(dialog.cursor_position, 2); + assert_eq!(dialog.cursor_position(), 2); dialog.cursor_right(); - assert_eq!(dialog.cursor_position, 3); + assert_eq!(dialog.cursor_position(), 3); dialog.cursor_home(); - assert_eq!(dialog.cursor_position, 0); + assert_eq!(dialog.cursor_position(), 0); dialog.cursor_end(); - assert_eq!(dialog.cursor_position, 3); + assert_eq!(dialog.cursor_position(), 3); } #[test] diff --git a/src/ui/in_progress_panel.rs b/src/ui/in_progress_panel.rs index 95e62ce9..c8bacdfe 100644 --- a/src/ui/in_progress_panel.rs +++ b/src/ui/in_progress_panel.rs @@ -66,9 +66,21 @@ impl InProgressPanel { match a.review_state.as_deref() { Some("pending_plan") => ("\u{1f4cb}", Color::Yellow), // 📋 Plan review Some("pending_visual") => ("\u{1f441}", Color::Magenta), // 👁 Visual review + Some("pending_proof") => ( + "\u{1f52c}", // 🔬 Proof review + // last_message is prefixed "Proof passed —" / "Proof FAILED (...) —" by sync/launch + if a.last_message + .as_deref() + .is_some_and(|m| m.starts_with("Proof FAILED")) + { + Color::Red + } else { + Color::Green + }, + ), Some("pending_pr_creation") => ("\u{1f504}", Color::Blue), // 🔄 Creating PR Some("pending_pr_merge") => ("\u{1f517}", Color::Cyan), // 🔗 Awaiting merge - _ => ("⏸", Color::Yellow), // Standard awaiting + _ => ("⏸", Color::Yellow), // Standard awaiting } } else { match a.status.as_str() { @@ -227,6 +239,7 @@ impl InProgressPanel { let hint = match a.review_state.as_deref() { Some("pending_plan") => Some("[a]pprove [r]eject plan"), Some("pending_visual") => Some("[a]pprove [r]eject visual"), + Some("pending_proof") => Some("[a]pprove [r]eject proof"), Some("pending_pr_creation") => Some("Creating PR..."), Some("pending_pr_merge") => { if a.pr_url.is_some() { @@ -329,6 +342,7 @@ mod tests { fn make_agent(id: &str, status: &str) -> AgentState { AgentState { + git_context: None, id: id.to_string(), ticket_id: format!("FEAT-{id}"), ticket_type: "FEAT".to_string(), diff --git a/src/ui/masked_input.rs b/src/ui/masked_input.rs new file mode 100644 index 00000000..4eb2648c --- /dev/null +++ b/src/ui/masked_input.rs @@ -0,0 +1,299 @@ +//! A single-line text field rendered as bullets. +//! +//! Extracted from [`crate::ui::dialogs::git_token::GitTokenDialog`], which owned +//! the only copy of this logic, so the setup wizard's password step can reuse it +//! rather than hand-roll a third `String` + cursor pair. +//! +//! The cursor is a **character** index, not a byte index. The original code +//! mixed the two — incrementing the cursor per character while indexing the +//! `String` by byte — so any multi-byte character panicked on the next edit. +//! Passwords are exactly where someone types an accented character or an emoji, +//! and `validate_password` counts characters too +//! (`crate::auth::password::validate_password`), so characters are the unit +//! throughout. + +use ratatui::{ + layout::Rect, + style::{Color, Style}, + text::Span, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +/// A masked single-line input. +#[derive(Debug, Default, Clone)] +pub struct MaskedInput { + value: String, + /// Cursor position in **characters** from the start. + cursor: usize, +} + +impl MaskedInput { + pub fn new() -> Self { + Self::default() + } + + /// The current value. + pub fn value(&self) -> &str { + &self.value + } + + /// Whether nothing has been typed. + pub fn is_empty(&self) -> bool { + self.value.is_empty() + } + + /// Length in characters — what the cursor and any length rule count in. + pub fn char_count(&self) -> usize { + self.value.chars().count() + } + + /// Cursor position, in characters. + pub fn cursor(&self) -> usize { + self.cursor + } + + /// Clear the value and reset the cursor. + pub fn clear(&mut self) { + self.value.clear(); + self.cursor = 0; + } + + /// Byte offset of a character index, for `String::insert`/`remove`. + fn byte_offset(&self, char_index: usize) -> usize { + self.value + .char_indices() + .nth(char_index) + .map_or(self.value.len(), |(offset, _)| offset) + } + + pub fn handle_char(&mut self, c: char) { + let offset = self.byte_offset(self.cursor); + self.value.insert(offset, c); + self.cursor += 1; + } + + pub fn handle_backspace(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + let offset = self.byte_offset(self.cursor); + self.value.remove(offset); + } + } + + pub fn handle_delete(&mut self) { + if self.cursor < self.char_count() { + let offset = self.byte_offset(self.cursor); + self.value.remove(offset); + } + } + + pub fn cursor_left(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + } + } + + pub fn cursor_right(&mut self) { + if self.cursor < self.char_count() { + self.cursor += 1; + } + } + + pub fn cursor_home(&mut self) { + self.cursor = 0; + } + + pub fn cursor_end(&mut self) { + self.cursor = self.char_count(); + } + + /// Render the bordered field, and place the terminal cursor when focused. + /// + /// Only the focused field positions the cursor: a terminal has one, so two + /// fields both claiming it would leave it wherever the later call put it. + pub fn render( + &self, + frame: &mut Frame, + area: Rect, + placeholder: &str, + focused: bool, + has_error: bool, + ) { + let display = if self.value.is_empty() { + Span::styled( + placeholder.to_string(), + Style::default().fg(Color::DarkGray), + ) + } else { + Span::styled( + "•".repeat(self.char_count()), + Style::default().fg(Color::White), + ) + }; + + let border = if has_error { + Color::Red + } else if focused { + Color::Cyan + } else { + Color::DarkGray + }; + + let input = Paragraph::new(display) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(input, area); + + if focused { + let inner = Block::default().borders(Borders::ALL).inner(area); + // Clamp so a value wider than the box cannot draw the cursor + // outside it (the original cast was unbounded). + let max_x = inner.width.saturating_sub(1); + let offset = u16::try_from(self.cursor).unwrap_or(u16::MAX).min(max_x); + frame.set_cursor_position((inner.x + offset, inner.y)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn typed(text: &str) -> MaskedInput { + let mut input = MaskedInput::new(); + for c in text.chars() { + input.handle_char(c); + } + input + } + + #[test] + fn test_new_is_empty_with_cursor_at_start() { + let input = MaskedInput::new(); + assert_eq!(input.value(), ""); + assert_eq!(input.cursor(), 0); + assert!(input.is_empty()); + } + + #[test] + fn test_char_input_appends_and_advances() { + let input = typed("ghp_abc"); + assert_eq!(input.value(), "ghp_abc"); + assert_eq!(input.cursor(), 7); + } + + #[test] + fn test_backspace_removes_before_cursor() { + let mut input = typed("abc"); + input.handle_backspace(); + assert_eq!(input.value(), "ab"); + assert_eq!(input.cursor(), 2); + } + + #[test] + fn test_backspace_at_start_is_a_noop() { + let mut input = typed("abc"); + input.cursor_home(); + input.handle_backspace(); + assert_eq!(input.value(), "abc"); + assert_eq!(input.cursor(), 0); + } + + #[test] + fn test_delete_removes_at_cursor() { + let mut input = typed("abc"); + input.cursor_home(); + input.handle_delete(); + assert_eq!(input.value(), "bc"); + assert_eq!(input.cursor(), 0); + } + + #[test] + fn test_delete_at_end_is_a_noop() { + let mut input = typed("abc"); + input.handle_delete(); + assert_eq!(input.value(), "abc"); + } + + #[test] + fn test_cursor_movement_is_bounded() { + let mut input = typed("abc"); + input.cursor_left(); + assert_eq!(input.cursor(), 2); + input.cursor_home(); + assert_eq!(input.cursor(), 0); + input.cursor_left(); + assert_eq!(input.cursor(), 0, "cursor must not go below zero"); + input.cursor_end(); + assert_eq!(input.cursor(), 3); + input.cursor_right(); + assert_eq!(input.cursor(), 3, "cursor must not pass the end"); + } + + #[test] + fn test_insert_in_the_middle() { + let mut input = typed("ac"); + input.cursor_left(); + input.handle_char('b'); + assert_eq!(input.value(), "abc"); + assert_eq!(input.cursor(), 2); + } + + #[test] + fn test_clear_resets_value_and_cursor() { + let mut input = typed("secret"); + input.clear(); + assert_eq!(input.value(), ""); + assert_eq!(input.cursor(), 0); + } + + // --- the byte-vs-char bug the extraction fixes -------------------------- + + #[test] + fn test_multibyte_input_does_not_panic() { + // The original indexed the String by byte while counting the cursor in + // characters, so the second edit here panicked on a char boundary. + let mut input = typed("héllo"); + assert_eq!(input.value(), "héllo"); + assert_eq!(input.cursor(), 5); + + input.handle_backspace(); + assert_eq!(input.value(), "héll"); + + input.cursor_home(); + input.handle_delete(); + assert_eq!(input.value(), "éll"); + } + + #[test] + fn test_multibyte_insert_in_the_middle_lands_on_a_char_boundary() { + let mut input = typed("aé"); + input.cursor_left(); + input.handle_char('b'); + assert_eq!(input.value(), "abé"); + } + + #[test] + fn test_char_count_counts_characters_not_bytes() { + // A password rule counts characters, so the mask length must too — + // otherwise "éé" would render four bullets for two typed characters. + let input = typed("éé🔐"); + assert_eq!(input.char_count(), 3); + assert!(input.value().len() > 3, "and it really is multi-byte"); + } + + #[test] + fn test_cursor_end_on_multibyte_value() { + let mut input = typed("🔐🔐"); + input.cursor_home(); + input.cursor_end(); + assert_eq!(input.cursor(), 2); + input.handle_backspace(); + assert_eq!(input.value(), "🔐"); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 20d1569a..a507e743 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -8,6 +8,7 @@ pub mod form_field; pub mod in_progress_panel; pub mod kanban_view; pub mod keybindings; +pub mod masked_input; pub mod paginated_list; mod panels; pub mod projects_dialog; diff --git a/src/ui/session_preview.rs b/src/ui/session_preview.rs index 92ea394a..02c8c4bf 100644 --- a/src/ui/session_preview.rs +++ b/src/ui/session_preview.rs @@ -319,6 +319,7 @@ mod tests { fn make_test_agent() -> AgentState { AgentState { + git_context: None, id: "agent-1".to_string(), ticket_id: "FEAT-1234".to_string(), ticket_type: "FEAT".to_string(), diff --git a/src/ui/setup/mod.rs b/src/ui/setup/mod.rs index 6f229825..f139ea0e 100644 --- a/src/ui/setup/mod.rs +++ b/src/ui/setup/mod.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use crate::agents::{SystemTmuxClient, TmuxClient, TmuxError}; use crate::config::{CollectionPreset, SessionWrapperType}; +use crate::ui::masked_input::MaskedInput; use ratatui::{widgets::ListState, Frame}; pub mod steps; @@ -92,6 +93,19 @@ pub struct SetupScreen { pub use_worktrees: bool, /// List state for worktree option selection pub(crate) worktree_state: ListState, + // ─── Admin Password State ───────────────────────────────────────────────── + /// The admin password field. + pub(crate) password: MaskedInput, + /// The confirmation field. + pub(crate) password_confirm: MaskedInput, + /// Which field Tab currently targets. + pub(crate) password_field_focused: PasswordField, + /// Inline validation message; `Some` keeps the step from advancing. + pub(crate) password_error: Option, + /// The accepted password, applied at initialization. `None` means skipped. + pub admin_password: Option, + /// Whether an admin account already exists, in which case the step is skipped. + pub admin_password_configured: bool, } impl SetupScreen { @@ -161,6 +175,12 @@ impl SetupScreen { vscode_status: VSCodeDetectionStatus::NotChecked, // Git worktree state use_worktrees: false, + password: MaskedInput::new(), + password_confirm: MaskedInput::new(), + password_field_focused: PasswordField::default(), + password_error: None, + admin_password: None, + admin_password_configured: false, worktree_state, } } @@ -342,6 +362,9 @@ impl SetupScreen { } } } + SetupStep::AdminPassword => { + self.password_field_focused = self.password_field_focused.toggled(); + } SetupStep::Confirm => { self.confirm_selected = !self.confirm_selected; } @@ -456,6 +479,74 @@ impl SetupScreen { } /// Proceed to next step or confirm (Enter key) + fn enter_wrapper_step(&mut self) { + match self.selected_wrapper { + SessionWrapperType::Tmux => { + // Check tmux availability when entering TmuxOnboarding + self.check_tmux_availability(); + self.step = SetupStep::TmuxOnboarding; + } + SessionWrapperType::Vscode => { + self.step = SetupStep::VSCodeSetup; + } + SessionWrapperType::Cmux => { + self.step = SetupStep::CmuxSetup; + } + SessionWrapperType::Zellij => { + self.step = SetupStep::ZellijSetup; + } + } + } + + /// Validate the password fields. + /// + /// `Ok(None)` means the step was skipped — both fields empty. The step is + /// optional, so an empty pair is a deliberate choice, not an error. + /// `Err(message)` is shown inline and keeps the wizard on this step. + fn validate_admin_password(&self) -> Result, String> { + let password = self.password.value(); + let confirm = self.password_confirm.value(); + + if self.password.is_empty() && self.password_confirm.is_empty() { + return Ok(None); + } + if password != confirm { + return Err("Passwords do not match".to_string()); + } + // Reuse the server's rule rather than restating a length here + crate::auth::password::validate_password(password).map_err(|e| e.to_string())?; + + Ok(Some(password.to_string())) + } + + /// Route a key to the focused password field. + /// + /// Called only for `SetupStep::AdminPassword`; see the guard in + /// `app::keyboard`, which otherwise consumes `i`, `c`, `j`, `k`, and space + /// as wizard commands before any character reaches a text field. + pub fn handle_password_key(&mut self, code: ratatui::crossterm::event::KeyCode) { + use ratatui::crossterm::event::KeyCode; + + let field = match self.password_field_focused { + PasswordField::Password => &mut self.password, + PasswordField::Confirm => &mut self.password_confirm, + }; + + match code { + KeyCode::Char(c) => field.handle_char(c), + KeyCode::Backspace => field.handle_backspace(), + KeyCode::Delete => field.handle_delete(), + KeyCode::Left => field.cursor_left(), + KeyCode::Right => field.cursor_right(), + KeyCode::Home => field.cursor_home(), + KeyCode::End => field.cursor_end(), + _ => return, + } + + // Any edit invalidates the previous complaint. + self.password_error = None; + } + pub fn confirm(&mut self) -> SetupResult { match self.step { SetupStep::Welcome => { @@ -564,22 +655,25 @@ impl SetupScreen { self.use_worktrees = options[i].to_use_worktrees(); } } - // Navigate to the appropriate next step based on wrapper choice - match self.selected_wrapper { - SessionWrapperType::Tmux => { - // Check tmux availability when entering TmuxOnboarding - self.check_tmux_availability(); - self.step = SetupStep::TmuxOnboarding; - } - SessionWrapperType::Vscode => { - self.step = SetupStep::VSCodeSetup; - } - SessionWrapperType::Cmux => { - self.step = SetupStep::CmuxSetup; - } - SessionWrapperType::Zellij => { - self.step = SetupStep::ZellijSetup; + // The wrapper fan-out now lives on the AdminPassword arm, so + // the password step sits between this one and the wrapper step. + if self.admin_password_configured { + self.enter_wrapper_step(); + } else { + self.step = SetupStep::AdminPassword; + } + SetupResult::Continue + } + SetupStep::AdminPassword => { + match self.validate_admin_password() { + Ok(password) => { + self.admin_password = password; + self.password_error = None; + self.enter_wrapper_step(); } + // `SetupResult` has no "stay and report" variant, so the + // message lives on the screen and the step does not change. + Err(message) => self.password_error = Some(message), } SetupResult::Continue } @@ -673,20 +767,40 @@ impl SetupScreen { self.step = SetupStep::SessionWrapperChoice; SetupResult::Continue } - SetupStep::TmuxOnboarding => { + SetupStep::AdminPassword => { self.step = SetupStep::WorktreePreference; SetupResult::Continue } + SetupStep::TmuxOnboarding => { + self.step = if self.admin_password_configured { + SetupStep::WorktreePreference + } else { + SetupStep::AdminPassword + }; + SetupResult::Continue + } SetupStep::VSCodeSetup => { - self.step = SetupStep::WorktreePreference; + self.step = if self.admin_password_configured { + SetupStep::WorktreePreference + } else { + SetupStep::AdminPassword + }; SetupResult::Continue } SetupStep::CmuxSetup => { - self.step = SetupStep::WorktreePreference; + self.step = if self.admin_password_configured { + SetupStep::WorktreePreference + } else { + SetupStep::AdminPassword + }; SetupResult::Continue } SetupStep::ZellijSetup => { - self.step = SetupStep::WorktreePreference; + self.step = if self.admin_password_configured { + SetupStep::WorktreePreference + } else { + SetupStep::AdminPassword + }; SetupResult::Continue } SetupStep::AcceptanceCriteria => { @@ -731,6 +845,7 @@ impl SetupScreen { SetupStep::KanbanProviderSetup { provider_index } => { self.render_kanban_provider_setup_step(frame, provider_index); } + SetupStep::AdminPassword => self.render_admin_password_step(frame), SetupStep::AcceptanceCriteria => self.render_acceptance_criteria_step(frame), SetupStep::StartupTickets => self.render_startup_tickets_step(frame), SetupStep::Confirm => self.render_confirm_step(frame), diff --git a/src/ui/setup/steps/admin_password.rs b/src/ui/setup/steps/admin_password.rs new file mode 100644 index 00000000..aba13aa3 --- /dev/null +++ b/src/ui/setup/steps/admin_password.rs @@ -0,0 +1,153 @@ +//! Optional admin-password step. +//! +//! Local use needs no password — the TUI, the CLI, and `opr8r` authenticate +//! with the owner-only local token file. A browser cannot read that file, so +//! this step exists solely to unlock the web dashboard, which is why it is +//! skippable and why the copy says so. + +use crate::ui::dialogs::centered_rect; +use crate::ui::setup::{PasswordField, SetupScreen}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +impl SetupScreen { + pub(crate) fn render_admin_password_step(&self, frame: &mut Frame) { + let area = centered_rect(70, 70, frame.area()); + frame.render_widget(Clear, area); + + let block = Block::default() + .title(Line::from(vec![ + Span::raw(" "), + Span::styled( + "Operator!", + Style::default() + .fg(Color::LightRed) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" Setup - Web UI Password "), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let has_error = self.password_error.is_some(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), // Heading + Constraint::Length(4), // Explanation + Constraint::Length(1), // Password label + Constraint::Length(3), // Password field + Constraint::Length(1), // Confirm label + Constraint::Length(3), // Confirm field + Constraint::Length(2), // Error + Constraint::Min(0), // Spacer + Constraint::Length(2), // Instructions + ]) + .split(inner); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Set a password for the web dashboard", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ))), + chunks[0], + ); + + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled( + "This terminal and the CLI already authenticate automatically.", + Style::default().fg(Color::Gray), + )), + Line::from(Span::styled( + "A browser cannot, so set a password to use the web dashboard.", + Style::default().fg(Color::Gray), + )), + Line::from(Span::styled( + "Leave both fields blank to skip - you can set one later with", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + "`operator auth bootstrap` or the /setup page.", + Style::default().fg(Color::DarkGray), + )), + ]) + .wrap(Wrap { trim: false }), + chunks[1], + ); + + let focused_password = self.password_field_focused == PasswordField::Password; + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Password", + Style::default().fg(if focused_password { + Color::Cyan + } else { + Color::Gray + }), + ))), + chunks[2], + ); + self.password.render( + frame, + chunks[3], + "at least 12 characters", + focused_password, + has_error, + ); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Confirm password", + Style::default().fg(if focused_password { + Color::Gray + } else { + Color::Cyan + }), + ))), + chunks[4], + ); + self.password_confirm.render( + frame, + chunks[5], + "repeat the password", + !focused_password, + has_error, + ); + + if let Some(error) = &self.password_error { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + error.as_str(), + Style::default().fg(Color::Red), + ))), + chunks[6], + ); + } + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Tab", Style::default().fg(Color::Yellow)), + Span::raw(" switch field "), + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(" continue (blank = skip) "), + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::raw(" back"), + ])) + .alignment(Alignment::Center), + chunks[8], + ); + } +} diff --git a/src/ui/setup/steps/mod.rs b/src/ui/setup/steps/mod.rs index e6e67d66..c5207255 100644 --- a/src/ui/setup/steps/mod.rs +++ b/src/ui/setup/steps/mod.rs @@ -1,6 +1,7 @@ //! Render methods for each setup step mod acceptance; +mod admin_password; mod collection; mod confirm; mod hosted; diff --git a/src/ui/setup/tests.rs b/src/ui/setup/tests.rs index ebaa28ae..f1e4fb19 100644 --- a/src/ui/setup/tests.rs +++ b/src/ui/setup/tests.rs @@ -191,7 +191,10 @@ fn test_setup_navigation_tmux_path() { screen.step = SetupStep::WorktreePreference; screen.selected_wrapper = SessionWrapperType::Tmux; - // WorktreePreference -> TmuxOnboarding (when tmux selected) + // WorktreePreference -> AdminPassword -> TmuxOnboarding (tmux selected). + // The optional password step now sits between them; skipping it reaches the same wrapper step. + screen.confirm(); + assert_eq!(screen.step, SetupStep::AdminPassword); screen.confirm(); assert_eq!(screen.step, SetupStep::TmuxOnboarding); } @@ -202,7 +205,9 @@ fn test_setup_navigation_vscode_path() { screen.step = SetupStep::WorktreePreference; screen.selected_wrapper = SessionWrapperType::Vscode; - // WorktreePreference -> VSCodeSetup (when vscode selected) + // WorktreePreference -> AdminPassword -> VSCodeSetup (vscode selected). + screen.confirm(); + assert_eq!(screen.step, SetupStep::AdminPassword); screen.confirm(); assert_eq!(screen.step, SetupStep::VSCodeSetup); } @@ -221,9 +226,9 @@ fn test_setup_tmux_onboarding_go_back() { let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); screen.step = SetupStep::TmuxOnboarding; - // TmuxOnboarding -> WorktreePreference + // TmuxOnboarding -> AdminPassword (the step it now came from) screen.go_back(); - assert_eq!(screen.step, SetupStep::WorktreePreference); + assert_eq!(screen.step, SetupStep::AdminPassword); } #[test] @@ -231,9 +236,9 @@ fn test_setup_vscode_setup_go_back() { let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); screen.step = SetupStep::VSCodeSetup; - // VSCodeSetup -> WorktreePreference + // VSCodeSetup -> AdminPassword (the step it now came from) screen.go_back(); - assert_eq!(screen.step, SetupStep::WorktreePreference); + assert_eq!(screen.step, SetupStep::AdminPassword); } #[test] @@ -430,3 +435,230 @@ fn test_session_wrapper_option_to_wrapper_type() { SessionWrapperType::Vscode ); } + +// ============================================================================= +// Admin password step +// ============================================================================= + +/// Drive the two password fields the way the key handler does. +fn type_password(screen: &mut SetupScreen, password: &str, confirm: &str) { + use ratatui::crossterm::event::KeyCode; + + screen.password_field_focused = PasswordField::Password; + for c in password.chars() { + screen.handle_password_key(KeyCode::Char(c)); + } + screen.password_field_focused = PasswordField::Confirm; + for c in confirm.chars() { + screen.handle_password_key(KeyCode::Char(c)); + } +} + +fn at_admin_password() -> SetupScreen { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::AdminPassword; + screen.selected_wrapper = SessionWrapperType::Vscode; + screen +} + +#[test] +fn test_admin_password_step_follows_worktree_preference() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::WorktreePreference; + + screen.confirm(); + assert_eq!(screen.step, SetupStep::AdminPassword); +} + +#[test] +fn test_admin_password_skipped_when_admin_exists() { + // Forward: straight past the step to the wrapper. + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.admin_password_configured = true; + screen.step = SetupStep::WorktreePreference; + screen.selected_wrapper = SessionWrapperType::Vscode; + screen.confirm(); + assert_eq!( + screen.step, + SetupStep::VSCodeSetup, + "an existing admin must not be offered a second bootstrap" + ); + + // Backward: the wrapper step returns past it too, or Esc would strand the + // operator on a step that cannot be completed. + screen.go_back(); + assert_eq!(screen.step, SetupStep::WorktreePreference); +} + +#[test] +fn test_admin_password_empty_enter_skips() { + let mut screen = at_admin_password(); + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::VSCodeSetup); + assert!( + screen.admin_password.is_none(), + "blank fields mean skip, not an empty password" + ); + assert!(screen.password_error.is_none()); +} + +#[test] +fn test_admin_password_too_short_shows_error_and_stays() { + let mut screen = at_admin_password(); + type_password(&mut screen, "short", "short"); + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::AdminPassword, "must not advance"); + let error = screen.password_error.as_deref().unwrap_or_default(); + assert!( + error.contains("12"), + "error should name the length rule, got {error:?}" + ); + assert!(screen.admin_password.is_none()); +} + +#[test] +fn test_admin_password_mismatch_shows_error_and_stays() { + let mut screen = at_admin_password(); + type_password( + &mut screen, + "a properly long password", + "a properly long passwerd", + ); + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::AdminPassword); + assert_eq!( + screen.password_error.as_deref(), + Some("Passwords do not match") + ); + assert!(screen.admin_password.is_none()); +} + +#[test] +fn test_admin_password_mismatch_is_reported_before_length() { + // A mismatched pair that is also too short should say "do not match" — + // telling someone their password is too short when they simply mistyped the + // confirmation sends them to fix the wrong thing. + let mut screen = at_admin_password(); + type_password(&mut screen, "short", "shorter"); + + screen.confirm(); + + assert_eq!( + screen.password_error.as_deref(), + Some("Passwords do not match") + ); +} + +#[test] +fn test_admin_password_valid_advances_and_records_value() { + let mut screen = at_admin_password(); + type_password( + &mut screen, + "a properly long password", + "a properly long password", + ); + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::VSCodeSetup); + assert_eq!( + screen.admin_password.as_deref(), + Some("a properly long password") + ); + assert!(screen.password_error.is_none()); +} + +#[test] +fn test_admin_password_go_back_returns_to_worktree_preference() { + let mut screen = at_admin_password(); + + screen.go_back(); + + assert_eq!(screen.step, SetupStep::WorktreePreference); +} + +#[test] +fn test_admin_password_reaches_every_wrapper_step() { + // The wrapper fan-out moved from WorktreePreference onto this step, so all + // four destinations must still be reachable. + for (wrapper, expected) in [ + (SessionWrapperType::Tmux, SetupStep::TmuxOnboarding), + (SessionWrapperType::Vscode, SetupStep::VSCodeSetup), + (SessionWrapperType::Cmux, SetupStep::CmuxSetup), + (SessionWrapperType::Zellij, SetupStep::ZellijSetup), + ] { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::AdminPassword; + screen.selected_wrapper = wrapper; + + screen.confirm(); + + assert_eq!(screen.step, expected, "wrapper {wrapper:?} lost its step"); + } +} + +#[test] +fn test_tab_switches_password_fields() { + let mut screen = at_admin_password(); + assert_eq!(screen.password_field_focused, PasswordField::Password); + + screen.toggle_selection(); + assert_eq!(screen.password_field_focused, PasswordField::Confirm); + + screen.toggle_selection(); + assert_eq!(screen.password_field_focused, PasswordField::Password); +} + +#[test] +fn test_editing_clears_a_previous_error() { + use ratatui::crossterm::event::KeyCode; + + let mut screen = at_admin_password(); + type_password(&mut screen, "short", "short"); + screen.confirm(); + assert!(screen.password_error.is_some()); + + screen.password_field_focused = PasswordField::Password; + screen.handle_password_key(KeyCode::Char('x')); + + assert!( + screen.password_error.is_none(), + "a stale complaint must not sit under freshly typed input" + ); +} + +#[test] +fn test_password_keys_reach_the_focused_field_only() { + use ratatui::crossterm::event::KeyCode; + + let mut screen = at_admin_password(); + screen.password_field_focused = PasswordField::Password; + screen.handle_password_key(KeyCode::Char('a')); + screen.password_field_focused = PasswordField::Confirm; + screen.handle_password_key(KeyCode::Char('b')); + + assert_eq!(screen.password.value(), "a"); + assert_eq!(screen.password_confirm.value(), "b"); +} + +#[test] +fn test_wizard_command_characters_are_typable_in_a_password() { + use ratatui::crossterm::event::KeyCode; + + // `i` initializes, `c` quits, and `j`/`k`/space navigate when the wizard + // handles them. Reaching the field they must be plain characters instead. + let mut screen = at_admin_password(); + screen.password_field_focused = PasswordField::Password; + for c in "ick j".chars() { + screen.handle_password_key(KeyCode::Char(c)); + } + + assert_eq!(screen.password.value(), "ick j"); + assert_eq!(screen.step, SetupStep::AdminPassword, "still on the step"); +} diff --git a/src/ui/setup/types.rs b/src/ui/setup/types.rs index b718ad45..ffe3ecba 100644 --- a/src/ui/setup/types.rs +++ b/src/ui/setup/types.rs @@ -339,6 +339,8 @@ pub enum SetupStep { SessionWrapperChoice, /// Git worktree preference (use worktrees vs in-place branches) WorktreePreference, + /// Optional admin password for the web dashboard. Skipped entirely when an admin account already exists. + AdminPassword, /// Tmux onboarding/help (only shown if tmux selected) TmuxOnboarding, /// VS Code extension setup (only shown if vscode selected) @@ -358,3 +360,21 @@ pub enum SetupStep { /// Confirm initialization Confirm, } + +/// Which of the two password fields has focus. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PasswordField { + #[default] + Password, + Confirm, +} + +impl PasswordField { + /// The other field — Tab toggles between exactly two. + pub fn toggled(self) -> Self { + match self { + PasswordField::Password => PasswordField::Confirm, + PasswordField::Confirm => PasswordField::Password, + } + } +} diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index e5435ac2..7d80d895 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -757,8 +757,14 @@ impl StatusSnapshot { let git_provider = config.git.provider.as_ref().map(|p| format!("{p:?}")); let git_token_set = match config.git.provider { Some(GitProviderConfig::GitLab) => std::env::var(&config.git.gitlab.token_env).is_ok(), - // GitHub is the default for all other providers (including None). - _ => std::env::var(&config.git.github.token_env).is_ok(), + Some(GitProviderConfig::Gitea) => std::env::var(&config.git.gitea.token_env).is_ok(), + Some(GitProviderConfig::Forgejo) => { + std::env::var(&config.git.forgejo.token_env).is_ok() + } + Some(GitProviderConfig::GitHub) | None => { + std::env::var(&config.git.github.token_env).is_ok() + } + _ => false, }; // Managed projects — names from config, resolved against the projects base dir. diff --git a/src/version.rs b/src/version.rs index 4c39d278..a48395ef 100644 --- a/src/version.rs +++ b/src/version.rs @@ -43,9 +43,13 @@ pub async fn check_for_updates(config: &VersionCheckConfig) -> Option { /// Fetches the latest version from the specified URL with a timeout. async fn fetch_latest_version(url: &str, timeout_secs: u64) -> Result { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .build()?; + // The update-check URL is configurable, so its destination is validated + // like any other config-controlled outbound request. + crate::auth::egress::validate(url, &crate::auth::egress::EgressPolicy::default())?; + let client = crate::auth::egress::validated_client( + crate::auth::egress::EgressPolicy::default(), + Duration::from_secs(timeout_secs), + )?; let response = client.get(url).send().await?; let status = response.status(); diff --git a/src/workflow_gen/agnt.rs b/src/workflow_gen/agnt.rs index 24b9744c..d8404648 100644 --- a/src/workflow_gen/agnt.rs +++ b/src/workflow_gen/agnt.rs @@ -643,6 +643,7 @@ mod tests { fn config_with_remote_delegator(name: &str, platform: &str, id: &str) -> Config { let mut config = Config::default(); config.delegators.push(crate::config::Delegator { + git: None, name: name.to_string(), llm_tool: "anthropic".to_string(), model: "claude-3-5-sonnet".to_string(), @@ -720,6 +721,7 @@ mod tests { let it = issuetype_with_step_agent("local-claude"); let mut config = Config::default(); config.delegators.push(crate::config::Delegator { + git: None, name: "local-claude".to_string(), llm_tool: "claude".to_string(), model: "opus".to_string(), diff --git a/tests/distribution_bundling.rs b/tests/distribution_bundling.rs index a82daddb..368f6781 100644 --- a/tests/distribution_bundling.rs +++ b/tests/distribution_bundling.rs @@ -20,14 +20,42 @@ fn read(path: &Path) -> String { fs::read_to_string(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())) } +/// True if some `COPY` line stages `src` at `dest`, regardless of any flags +/// (`--chown`, `--chmod`, `--from`) between the instruction and its operands. +fn copies(content: &str, src: &str, dest: &str) -> bool { + content.lines().any(|line| { + let line = line.trim(); + let Some(rest) = line.strip_prefix("COPY ") else { + return false; + }; + let operands: Vec<&str> = rest + .split_whitespace() + .filter(|t| !t.starts_with("--")) + .collect(); + operands == [src, dest] + }) +} + #[test] fn test_dockerfile_stages_opr8r() { let content = read(&repo_root().join("Dockerfile")); assert!( - content.contains("COPY opr8r-linux-${TARGETARCH} /usr/local/bin/opr8r"), + copies( + &content, + "opr8r-linux-${TARGETARCH}", + "/usr/local/bin/opr8r" + ), "Dockerfile must COPY opr8r-linux-${{TARGETARCH}} alongside operator-linux-${{TARGETARCH}}" ); + assert!( + copies( + &content, + "operator-linux-${TARGETARCH}", + "/usr/local/bin/operator" + ), + "Dockerfile must COPY operator-linux-${{TARGETARCH}} to /usr/local/bin/operator" + ); assert!( content.contains(r#"RUN ["/usr/local/bin/opr8r", "--version"]"#), "Dockerfile must smoke-test the staged opr8r binary at build time, like it does for operator" diff --git a/tests/gitprovider_integration.rs b/tests/gitprovider_integration.rs new file mode 100644 index 00000000..b75c190c --- /dev/null +++ b/tests/gitprovider_integration.rs @@ -0,0 +1,428 @@ +//! Live integration tests for the `PrService` stack (GitHub, GitLab, Gitea) +//! +//! These tests drive the real provider CLIs (`gh`, `glab`, `tea`) through +//! `pr_service_for`/`PrService` against designated, real, external test +//! repositories. They are strictly read-only: no create/write operations +//! against any provider are performed anywhere in this file. +//! +//! ## Environment Variables +//! +//! - `OPERATOR_GITPROVIDER_TEST_ENABLED=true`: Required to run any test in +//! this file. +//! - `OPERATOR_GITPROVIDER_TEST_REPO_GITHUB`: Full remote URL of a GitHub +//! test repo (e.g. `https://github.com/owner/repo`). +//! - `OPERATOR_GITPROVIDER_TEST_PR_GITHUB`: Number of an open GitHub PR on +//! that repo with at least one comment. +//! - `OPERATOR_GITPROVIDER_TEST_REPO_GITLAB`: Full remote URL of a GitLab +//! test repo. +//! - `OPERATOR_GITPROVIDER_TEST_PR_GITLAB`: Number of an open GitLab MR on +//! that repo with at least one comment. +//! - `OPERATOR_GITPROVIDER_TEST_REPO_GITEA`: Full remote URL of a Gitea test +//! repo. Gitea is self-hosted, so its origin also registers the Gitea host. +//! - `OPERATOR_GITPROVIDER_TEST_PR_GITEA`: Number of an open Gitea PR on that +//! repo with at least one comment. +//! +//! Each row additionally needs its CLI (`gh`, `glab`, `tea`) installed and +//! authenticated. A row skips itself (with `eprintln!`) rather than failing +//! the suite when its repo env var is unset or its CLI is unavailable. +//! +//! A row env var counts as unset when it is missing **or blank**: CI wires +//! these from repository variables that export an empty string when +//! undefined, and a blank value must skip the row rather than be parsed. +//! +//! ## Running Tests +//! +//! ```bash +//! # Every row +//! OPERATOR_GITPROVIDER_TEST_ENABLED=true \ +//! OPERATOR_GITPROVIDER_TEST_REPO_GITHUB=https://github.com/owner/repo \ +//! OPERATOR_GITPROVIDER_TEST_PR_GITHUB=1 \ +//! OPERATOR_GITPROVIDER_TEST_REPO_GITLAB=https://gitlab.com/owner/repo \ +//! OPERATOR_GITPROVIDER_TEST_PR_GITLAB=1 \ +//! OPERATOR_GITPROVIDER_TEST_REPO_GITEA=https://gitea.example/owner/repo \ +//! OPERATOR_GITPROVIDER_TEST_PR_GITEA=1 \ +//! cargo test --test gitprovider_integration -- --nocapture +//! +//! # Table-invariant unit test only (no env vars, no CLIs needed) +//! cargo test --test gitprovider_integration provider_case_table +//! ``` + +use operator::api::pr_service::pr_service_for; +use operator::api::PrService; +use operator::config::GitConfig; +use operator::types::pr::{GitProvider, PrState, RepoInfo}; +use std::collections::HashSet; +use std::env; +use std::sync::Arc; + +// ─── Configuration Helpers ─────────────────────────────────────────────────── + +/// Check if git-provider tests are enabled +fn gitprovider_tests_enabled() -> bool { + env::var("OPERATOR_GITPROVIDER_TEST_ENABLED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false) +} + +/// A set-but-blank value reads as unset -- see the blank-value note in the +/// module docs. +fn non_blank(value: String) -> Option { + (!value.trim().is_empty()).then_some(value) +} + +/// The value of `var`, or `None` when it is unset or blank. +fn configured(var: &str) -> Option { + env::var(var).ok().and_then(non_blank) +} + +/// Macro to skip test if git-provider tests are not configured +macro_rules! skip_if_not_configured { + () => { + if !gitprovider_tests_enabled() { + eprintln!("Skipping test: OPERATOR_GITPROVIDER_TEST_ENABLED not set to true"); + return; + } + }; +} + +// ─── Provider Table ────────────────────────────────────────────────────────── + +/// One row of the live provider test table. Adding a new operational +/// provider (bitbucket, azure, forgejo) is just adding a row here +/// plus a `#[tokio::test]` that calls `run_provider_case` for it -- see the +/// template comment below `PROVIDER_CASES`. +struct ProviderCase { + /// Matches `GitProvider::slug()` for this row's provider. + slug: &'static str, + /// The CLI binary this row's `PrService` shells out to. + cli: &'static str, + /// Env var holding the full remote URL of the live test repo. + repo_env: &'static str, + /// Env var holding the PR/MR number to exercise (must have >=1 comment). + pr_env: &'static str, + /// Registers the test repo's origin as this provider's host. `None` for + /// providers resolved by well-known domain; `Some` for self-hosted ones, + /// which are unresolvable until their host is configured. + apply_host: Option, + /// Builds the `PrService` under test for this row. + service: fn(&GitConfig) -> Arc, +} + +const PROVIDER_CASES: &[ProviderCase] = &[ + ProviderCase { + slug: "github", + cli: "gh", + repo_env: "OPERATOR_GITPROVIDER_TEST_REPO_GITHUB", + pr_env: "OPERATOR_GITPROVIDER_TEST_PR_GITHUB", + apply_host: None, + service: |git| pr_service_for(GitProvider::GitHub, git).expect("github is operational"), + }, + ProviderCase { + slug: "gitlab", + cli: "glab", + repo_env: "OPERATOR_GITPROVIDER_TEST_REPO_GITLAB", + pr_env: "OPERATOR_GITPROVIDER_TEST_PR_GITLAB", + apply_host: None, + service: |git| pr_service_for(GitProvider::GitLab, git).expect("gitlab is operational"), + }, + ProviderCase { + slug: "gitea", + cli: "tea", + repo_env: "OPERATOR_GITPROVIDER_TEST_REPO_GITEA", + pr_env: "OPERATOR_GITPROVIDER_TEST_PR_GITEA", + apply_host: Some(|git, origin| git.gitea.host = Some(origin)), + service: |git| pr_service_for(GitProvider::Gitea, git).expect("gitea is operational"), + }, + // Template for a future row (documentation only, NOT dead code -- copy + // this into a real `ProviderCase` once the provider gets an operational + // `PrService` impl in `src/api/pr_service.rs::pr_service_for`): + // + // ProviderCase { + // slug: "bitbucket", // GitProvider::Bitbucket.slug() + // cli: "bb" or whatever CLI backs BitbucketService, + // repo_env: "OPERATOR_GITPROVIDER_TEST_REPO_BITBUCKET", + // pr_env: "OPERATOR_GITPROVIDER_TEST_PR_BITBUCKET", + // apply_host: None, + // service: |git| pr_service_for(GitProvider::Bitbucket, git).expect("operational"), + // }, + // + // Azure DevOps and Forgejo follow the same shape once `pr_service_for` + // stops returning `UnsupportedProviderError` for them. A self-hosted row + // also needs `apply_host`, as the `gitea` row above does. +]; + +/// Look up a row by slug (stable against reordering `PROVIDER_CASES`). +fn case_by_slug(slug: &str) -> &'static ProviderCase { + PROVIDER_CASES + .iter() + .find(|case| case.slug == slug) + .unwrap_or_else(|| panic!("no ProviderCase for slug {slug}")) +} + +// ─── Shared Row Runner ──────────────────────────────────────────────────────── + +/// The `GitConfig` a row's service and URL parsing both resolve against. +/// Self-hosted rows register the live repo's own origin as their host. +fn git_config_for(case: &ProviderCase, repo_url: &str) -> GitConfig { + let mut git = GitConfig::default(); + if let Some(apply_host) = case.apply_host { + let origin = url::Url::parse(repo_url) + .unwrap_or_else(|e| { + panic!( + "{}: {} must be an absolute URL: {e}", + case.slug, case.repo_env + ) + }) + .origin() + .ascii_serialization(); + apply_host(&mut git, origin); + } + git +} + +/// Drive one row of `PROVIDER_CASES` through the live `PrService` stack. +/// Skips (with `eprintln!`) rather than fails when the row isn't configured +/// or its CLI isn't installed/authed -- a missing row must not fail the +/// suite. +async fn run_provider_case(case: &ProviderCase) { + skip_if_not_configured!(); + + let Some(repo_url) = configured(case.repo_env) else { + eprintln!("Skipping {} row: {} not set", case.slug, case.repo_env); + return; + }; + + let git = git_config_for(case, &repo_url); + let service = (case.service)(&git); + + // 1. check_available + match service.check_available().await { + Ok(true) => {} + Ok(false) => { + eprintln!( + "Skipping {} row: {} CLI not installed or not authenticated", + case.slug, case.cli + ); + return; + } + Err(e) => { + eprintln!("Skipping {} row: check_available errored: {e}", case.slug); + return; + } + } + + // 2. get_authenticated_user + let user = service + .get_authenticated_user() + .await + .unwrap_or_else(|e| panic!("{}: get_authenticated_user failed: {e}", case.slug)); + assert!( + !user.is_empty(), + "{}: authenticated user name should not be empty", + case.slug + ); + eprintln!("{}: authenticated as {user}", case.slug); + + // 3. RepoInfo::from_remote_url + let hosts = operator::types::pr::ProviderHosts::from_config(&git).unwrap(); + let repo_info = RepoInfo::from_remote_url_with_hosts(&repo_url, &hosts) + .unwrap_or_else(|e| panic!("{}: failed to parse repo url {repo_url}: {e}", case.slug)); + assert_eq!( + repo_info.provider.slug(), + case.slug, + "{}: parsed provider should match row slug", + case.slug + ); + + let Some(pr_number_str) = configured(case.pr_env) else { + eprintln!( + "Skipping remaining {} assertions: {} not set", + case.slug, case.pr_env + ); + return; + }; + let pr_number: i64 = pr_number_str + .parse() + .unwrap_or_else(|e| panic!("{}: {} must be an integer: {e}", case.slug, case.pr_env)); + + // 4. get_pr + let pr = service + .get_pr(&repo_info, pr_number) + .await + .unwrap_or_else(|e| panic!("{}: get_pr({pr_number}) failed: {e}", case.slug)); + assert!( + matches!(pr.state, PrState::Open | PrState::Merged | PrState::Closed), + "{}: PR state should be a valid variant", + case.slug + ); + eprintln!( + "{}: PR #{pr_number} state={:?} title={:?}", + case.slug, pr.state, pr.title + ); + + // 5. get_review_state -- external review state drifts, any variant is fine + match service.get_review_state(&repo_info, pr_number).await { + Ok(state) => eprintln!("{}: review state = {state:?}", case.slug), + Err(e) => panic!("{}: get_review_state failed: {e}", case.slug), + } + + // 6. get_all_comments + let comments = service + .get_all_comments(&repo_info, pr_number) + .await + .unwrap_or_else(|e| panic!("{}: get_all_comments failed: {e}", case.slug)); + eprintln!("{}: {} comments found", case.slug, comments.len()); + assert!( + !comments.is_empty(), + "{}: {} promises PR #{pr_number} has >=1 comment, found none", + case.slug, + case.pr_env + ); + + // 7. find_pr_for_branch -- `PullRequestInfo` doesn't expose the source + // branch, so it isn't cheaply obtainable from `get_pr`'s result above. + // Per the tolerant-tests rule, skip this assertion rather than guess. + eprintln!( + "{}: skipping find_pr_for_branch assertion -- PullRequestInfo has no branch field", + case.slug + ); +} + +// ─── Live Tests (one per row) ───────────────────────────────────────────────── + +#[tokio::test] +async fn test_github_provider_live() { + run_provider_case(case_by_slug("github")).await; +} + +#[tokio::test] +async fn test_gitlab_provider_live() { + run_provider_case(case_by_slug("gitlab")).await; +} + +#[tokio::test] +async fn test_gitea_provider_live() { + run_provider_case(case_by_slug("gitea")).await; +} + +// ─── CLI Flag Contract (no credentials, no network) ────────────────────────── + +/// Every long flag an argv builder emits must appear in the installed CLI's +/// own `--help`. This needs the binary but no auth and no repository, so it +/// runs anywhere the CLI is installed. +/// +/// This is the check that catches a flag the binary does not accept -- the +/// class of bug that made `gh pr create --json` fail on flag parse while +/// every unit test still passed. +#[test] +fn provider_cli_flags_are_accepted_by_the_installed_binary() { + use operator::api::argv::ProviderCommand; + use operator::types::pr::CreatePrRequest; + + let request = CreatePrRequest { + title: "Contract check".into(), + body: Some("Body".into()), + head_branch: "optest/source".into(), + base_branch: "main".into(), + draft: Some(true), + }; + + let cases: Vec = vec![ + operator::api::GhCli::create_pr_argv( + &RepoInfo::new(GitProvider::GitHub, "owner", "repo"), + &request, + ), + operator::api::GlabCli::create_pr_argv( + &RepoInfo::new(GitProvider::GitLab, "owner", "repo"), + &request, + ), + ]; + + let mut checked = 0; + for cmd in cases { + let help = match std::process::Command::new(cmd.program) + .args(cmd.subcommand()) + .arg("--help") + .output() + { + Ok(out) if out.status.success() => { + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + } + _ => { + eprintln!( + "Skipping {} {}: CLI not installed", + cmd.program, + cmd.subcommand().join(" ") + ); + continue; + } + }; + checked += 1; + for flag in cmd.long_flags() { + assert!( + help.contains(flag), + "`{} {}` does not accept `{flag}` -- it is absent from that \ + subcommand's --help", + cmd.program, + cmd.subcommand().join(" ") + ); + } + } + eprintln!("verified argv flags against {checked} installed CLI(s)"); +} + +// ─── Table Invariants (plain `cargo test`, no gating) ──────────────────────── + +/// CI wires every row with `${{ vars.X || '' }}`, which exports an empty +/// string when the variable is undefined -- a blank value must read as unset. +#[test] +fn blank_env_values_are_treated_as_unset() { + for blank in ["", " ", "\n"] { + assert_eq!( + non_blank(blank.to_string()), + None, + "blank value {blank:?} should read as unset" + ); + } + assert_eq!( + non_blank("https://github.com/owner/repo".to_string()).as_deref(), + Some("https://github.com/owner/repo") + ); +} + +#[test] +fn provider_case_table_env_names_unique_and_slugs_match() { + let mut repo_envs = HashSet::new(); + let mut pr_envs = HashSet::new(); + + for case in PROVIDER_CASES { + assert!( + repo_envs.insert(case.repo_env), + "duplicate repo_env in PROVIDER_CASES: {}", + case.repo_env + ); + assert!( + pr_envs.insert(case.pr_env), + "duplicate pr_env in PROVIDER_CASES: {}", + case.pr_env + ); + + assert!( + GitProvider::ALL.iter().any(|p| p.slug() == case.slug), + "{}: no GitProvider matches this row's slug", + case.slug + ); + + let service = (case.service)(&GitConfig::default()); + assert_eq!( + service.provider_name(), + case.slug, + "{}: PrService::provider_name() should match the row's slug", + case.slug + ); + } +} diff --git a/tests/model_server_integration.rs b/tests/model_server_integration.rs index cfc0d666..b7071fa5 100644 --- a/tests/model_server_integration.rs +++ b/tests/model_server_integration.rs @@ -32,6 +32,7 @@ //! ``` use operator::api::providers::model_server::{probe_models, ProbeOutcome}; +use operator::auth::egress::EgressPolicy; use operator::config::ModelServer; use std::env; use tokio::sync::OnceCell; @@ -119,7 +120,11 @@ async fn openrouter_outcome() -> ProbeOutcome { } async fn probe_models_owned(kind: &'static str, api_key_env: &'static str) -> ProbeOutcome { - probe_models(&server_for(kind, Some(api_key_env))).await + probe_models( + &server_for(kind, Some(api_key_env)), + &EgressPolicy::default(), + ) + .await } // ─── Keyless OpenRouter baseline (no secret, runs every CI run) ─────────────── @@ -130,7 +135,7 @@ mod openrouter_keyless { #[tokio::test] async fn test_public_models_list_is_text_filtered() { // No api_key_env and no OPENROUTER_API_KEY needed — the list is public. - let outcome = probe_models(&server_for("openrouter", None)).await; + let outcome = probe_models(&server_for("openrouter", None), &EgressPolicy::default()).await; if !outcome.reachable { // Treat a network/outage failure as a skip so offline `cargo test` diff --git a/tests/rest_api_integration.rs b/tests/rest_api_integration.rs index 3349c0bb..8600845b 100644 --- a/tests/rest_api_integration.rs +++ b/tests/rest_api_integration.rs @@ -28,6 +28,7 @@ use std::env; use std::fs; +use std::sync::atomic::{AtomicU16, Ordering}; use std::time::Duration; use serde::Deserialize; @@ -55,6 +56,22 @@ macro_rules! skip_if_not_configured { }; } +/// How long a server gets to come up before the suite gives up. +const READY_TIMEOUT: Duration = Duration::from_secs(30); + +/// How often readiness is rechecked while waiting. +const POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// Ports for this suite start here, high enough to avoid anything in use. +const API_TEST_PORT_BASE: u16 = 17000; + +/// Hands out a distinct port per context. Ports used to be hardcoded at each +static NEXT_PORT: AtomicU16 = AtomicU16::new(API_TEST_PORT_BASE); + +fn next_port() -> u16 { + NEXT_PORT.fetch_add(1, Ordering::SeqCst) +} + // ─── Test Context ───────────────────────────────────────────────────────────── /// Test context holding temporary directories and configuration @@ -65,8 +82,14 @@ struct RestApiTestContext { } impl RestApiTestContext { - /// Create a new test context with isolated directories - fn new(test_name: &str, port: u16) -> Self { + /// Create a new test context on its own freshly allocated port. + fn new(test_name: &str) -> Self { + Self::with_port(test_name, next_port()) + } + + /// Create a context bound to a specific port. Only for the port-conflict + /// test, which needs two contexts deliberately sharing one port. + fn with_port(test_name: &str, port: u16) -> Self { let temp_dir = TempDir::new().expect("Failed to create temp dir"); // Create directory structure @@ -126,15 +149,70 @@ impl RestApiTestContext { self.session_file_path().exists() } - /// Make HTTP request to health endpoint + /// Block until the server is fully up: listening, session file written, and + /// the health endpoint answering. + /// + /// A fixed `sleep` is a guess, and a cold Windows or arm64 CI runner can + /// outlast any constant -- which is what made this suite flaky across the + /// matrix. Polling waits exactly as long as needed and no longer. + async fn wait_until_ready(&self, server: &RestApiServer) { + self.poll_until("server to become ready", || async { + server.is_running() && self.session_file_exists() && self.check_health().await.is_ok() + }) + .await; + } + + /// Block until the server has fully shut down and cleaned up after itself. + async fn wait_until_stopped(&self, server: &RestApiServer) { + self.poll_until("server to stop", || async { + !server.is_running() && !self.session_file_exists() + }) + .await; + } + + /// Poll `condition` until it holds, or panic after `READY_TIMEOUT`. + async fn poll_until(&self, what: &str, condition: F) + where + F: Fn() -> Fut, + Fut: std::future::Future, + { + let deadline = std::time::Instant::now() + READY_TIMEOUT; + while std::time::Instant::now() < deadline { + if condition().await { + return; + } + tokio::time::sleep(POLL_INTERVAL).await; + } + panic!( + "timed out after {READY_TIMEOUT:?} waiting for {what} on port {}", + self.port + ); + } + + /// The local-unlock token this server issued into its own state directory + /// when it bound to loopback (see `rest::state`). `/api/v1/health` requires credentials + fn local_token(&self) -> Option { + operator::auth::local::read(&self.config.state_path()) + } + + /// Make an authenticated HTTP request to the health endpoint async fn check_health(&self) -> Result { + self.get_health(self.local_token().as_deref()).await + } + + /// 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() .timeout(Duration::from_secs(5)) .build() .map_err(|e| e.to_string())?; - let response = client.get(&url).send().await.map_err(|e| e.to_string())?; + let mut request = client.get(&url); + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request.send().await.map_err(|e| e.to_string())?; if response.status().is_success() { response @@ -163,15 +241,13 @@ struct HealthResponse { async fn test_api_server_starts_and_responds() { skip_if_not_configured!(); - let ctx = RestApiTestContext::new("starts_and_responds", 17001); + let ctx = RestApiTestContext::new("starts_and_responds"); let server = RestApiServer::new(ctx.config.clone(), ctx.port); // Start server let result = server.start(); assert!(result.is_ok(), "Server should start successfully"); - - // Give the server time to start - tokio::time::sleep(Duration::from_millis(200)).await; + ctx.wait_until_ready(&server).await; // Verify server is running assert!(server.is_running(), "Server should report as running"); @@ -190,9 +266,7 @@ async fn test_api_server_starts_and_responds() { // Stop server server.stop(); - - // Give it time to stop - tokio::time::sleep(Duration::from_millis(100)).await; + ctx.wait_until_stopped(&server).await; // Verify server is stopped assert!(!server.is_running(), "Server should report as stopped"); @@ -202,7 +276,7 @@ async fn test_api_server_starts_and_responds() { async fn test_api_writes_session_file() { skip_if_not_configured!(); - let ctx = RestApiTestContext::new("writes_session_file", 17002); + let ctx = RestApiTestContext::new("writes_session_file"); let server = RestApiServer::new(ctx.config.clone(), ctx.port); // Verify session file doesn't exist yet @@ -214,9 +288,7 @@ async fn test_api_writes_session_file() { // Start server let result = server.start(); assert!(result.is_ok(), "Server should start successfully"); - - // Give the server time to start and write session file - tokio::time::sleep(Duration::from_millis(200)).await; + ctx.wait_until_ready(&server).await; // Verify session file exists assert!( @@ -251,12 +323,12 @@ async fn test_api_writes_session_file() { async fn test_api_removes_session_file_on_stop() { skip_if_not_configured!(); - let ctx = RestApiTestContext::new("removes_session_file", 17003); + let ctx = RestApiTestContext::new("removes_session_file"); let server = RestApiServer::new(ctx.config.clone(), ctx.port); // Start server server.start().expect("Server should start"); - tokio::time::sleep(Duration::from_millis(200)).await; + ctx.wait_until_ready(&server).await; // Verify session file exists assert!( @@ -266,7 +338,7 @@ async fn test_api_removes_session_file_on_stop() { // Stop server server.stop(); - tokio::time::sleep(Duration::from_millis(100)).await; + ctx.wait_until_stopped(&server).await; // Verify session file is removed assert!( @@ -279,12 +351,12 @@ async fn test_api_removes_session_file_on_stop() { async fn test_api_session_file_matches_health_endpoint() { skip_if_not_configured!(); - let ctx = RestApiTestContext::new("session_matches_health", 17004); + let ctx = RestApiTestContext::new("session_matches_health"); let server = RestApiServer::new(ctx.config.clone(), ctx.port); // Start server server.start().expect("Server should start"); - tokio::time::sleep(Duration::from_millis(200)).await; + ctx.wait_until_ready(&server).await; // Get session file info let session = ctx.read_session_file().expect("Should read session file"); @@ -305,20 +377,47 @@ async fn test_api_session_file_matches_health_endpoint() { server.stop(); } +#[tokio::test] +async fn test_api_health_requires_a_credential() { + skip_if_not_configured!(); + + let ctx = RestApiTestContext::new("health_requires_credential"); + let server = RestApiServer::new(ctx.config.clone(), ctx.port); + + server.start().expect("Server should start"); + ctx.wait_until_ready(&server).await; + + let anonymous = ctx.get_health(None).await; + assert!( + anonymous.as_ref().err().is_some_and(|e| e.contains("401")), + "unauthenticated health must be rejected, got: {anonymous:?}" + ); + + let token = ctx + .local_token() + .expect("server should issue a local token on loopback bind"); + assert!( + ctx.get_health(Some(&token)).await.is_ok(), + "the issued local token must be accepted" + ); + + server.stop(); +} + #[tokio::test] async fn test_api_port_in_use_detection() { skip_if_not_configured!(); - let port = 17005; + let port = next_port(); // Start first server - let ctx1 = RestApiTestContext::new("port_in_use_1", port); + let ctx1 = RestApiTestContext::with_port("port_in_use_1", port); let server1 = RestApiServer::new(ctx1.config.clone(), port); server1.start().expect("First server should start"); - tokio::time::sleep(Duration::from_millis(200)).await; + ctx1.wait_until_ready(&server1).await; // Try to start second server on same port - let ctx2 = RestApiTestContext::new("port_in_use_2", port); + let ctx2 = RestApiTestContext::with_port("port_in_use_2", port); let server2 = RestApiServer::new(ctx2.config.clone(), port); // Check if port is in use @@ -333,7 +432,7 @@ async fn test_api_port_in_use_detection() { async fn test_api_creates_operator_directory() { skip_if_not_configured!(); - let ctx = RestApiTestContext::new("creates_operator_dir", 17006); + let ctx = RestApiTestContext::new("creates_operator_dir"); // Verify operator directory doesn't exist yet let operator_dir = ctx.temp_dir.path().join("tickets").join("operator"); @@ -346,7 +445,7 @@ async fn test_api_creates_operator_directory() { // Start server server.start().expect("Server should start"); - tokio::time::sleep(Duration::from_millis(200)).await; + ctx.wait_until_ready(&server).await; // Verify operator directory was created assert!( diff --git a/tests/route_scope_parity.rs b/tests/route_scope_parity.rs new file mode 100644 index 00000000..54d77fce --- /dev/null +++ b/tests/route_scope_parity.rs @@ -0,0 +1,159 @@ +//! Every mounted route must declare what it requires. +//! +//! Authorization is decided by matching a request against +//! `operator::auth::scope::ROUTE_RULES`. A route that is mounted but missing +//! from that table is denied at runtime — which fails safe, but as a 401 on a +//! working endpoint rather than as anything a developer would notice locally. +//! This suite turns that into a build failure instead, and pins the public +//! allowlist so widening it cannot happen quietly. + +use std::collections::BTreeSet; + +use operator::auth::scope::{Access, ROUTE_RULES}; + +/// Routes mounted outside the OpenAPI-documented router. +/// +/// The MCP SSE/message pair is config-gated on `[mcp].http_enabled` and carries no `#[utoipa::path]`, so it never appears in the spec +const UNDOCUMENTED_MOUNTED_ROUTES: &[(&str, &str)] = + &[("GET", "/api/v1/mcp/sse"), ("POST", "/api/v1/mcp/message")]; + +/// The complete set of routes reachable without a credential. +const EXPECTED_PUBLIC: &[(&str, &str)] = &[ + // Kubernetes probes — no workspace metadata. + ("GET", "/livez"), + ("GET", "/readyz"), + // The endpoints needed to *obtain* a credential. + ("GET", "/api/v1/auth/bootstrap"), + ("POST", "/api/v1/auth/bootstrap"), + ("POST", "/api/v1/auth/login"), + ("POST", "/api/v1/auth/device/code"), + ("POST", "/api/v1/auth/token"), +]; + +/// Every `(METHOD, path)` the generated OpenAPI spec documents. +fn documented_routes() -> BTreeSet<(String, String)> { + let spec = operator::rest::ApiDoc::json().expect("generate OpenAPI spec"); + let parsed: serde_json::Value = serde_json::from_str(&spec).expect("spec is JSON"); + let paths = parsed + .get("paths") + .and_then(|p| p.as_object()) + .expect("spec has paths"); + + let mut out = BTreeSet::new(); + for (path, item) in paths { + let Some(methods) = item.as_object() else { + continue; + }; + for method in methods.keys() { + // Skip OpenAPI path-level keys that are not operations. + if matches!(method.as_str(), "parameters" | "summary" | "description") { + continue; + } + out.insert((method.to_uppercase(), path.clone())); + } + } + out +} + +fn table_routes() -> BTreeSet<(String, String)> { + ROUTE_RULES + .iter() + .map(|r| (r.method.to_string(), r.path.to_string())) + .collect() +} + +#[test] +fn test_every_documented_route_declares_a_scope() { + let table = table_routes(); + let missing: Vec<_> = documented_routes() + .into_iter() + .filter(|r| !table.contains(r)) + .collect(); + + assert!( + missing.is_empty(), + "these routes are mounted but absent from ROUTE_RULES, so they would be \ + denied at runtime. Classify each one in src/auth/scope.rs:\n{missing:#?}" + ); +} + +#[test] +fn test_undocumented_mounted_routes_declare_a_scope() { + // The MCP transport pair never reaches the OpenAPI spec, so the check above + // cannot see it. It executes tools, which makes it the last thing that + // should slip through unclassified. + let table = table_routes(); + for (method, path) in UNDOCUMENTED_MOUNTED_ROUTES { + assert!( + table.contains(&(method.to_string(), path.to_string())), + "{method} {path} is mounted by build_router but has no ROUTE_RULES entry" + ); + } +} + +#[test] +fn test_route_table_has_no_entries_for_routes_that_do_not_exist() { + // A stale entry is not a security hole, but it is a lie about the surface + // and it hides a genuine miss behind noise. + let documented = documented_routes(); + let undocumented: BTreeSet<(String, String)> = UNDOCUMENTED_MOUNTED_ROUTES + .iter() + .map(|(m, p)| ((*m).to_string(), (*p).to_string())) + .collect(); + + let stale: Vec<_> = table_routes() + .into_iter() + .filter(|r| !documented.contains(r) && !undocumented.contains(r)) + .collect(); + + assert!( + stale.is_empty(), + "ROUTE_RULES names routes that are not mounted — remove them:\n{stale:#?}" + ); +} + +#[test] +fn test_public_routes_are_exactly_the_expected_allowlist() { + let actual: BTreeSet<(String, String)> = ROUTE_RULES + .iter() + .filter(|r| r.access == Access::Public) + .map(|r| (r.method.to_string(), r.path.to_string())) + .collect(); + let expected: BTreeSet<(String, String)> = EXPECTED_PUBLIC + .iter() + .map(|(m, p)| ((*m).to_string(), (*p).to_string())) + .collect(); + + let added: Vec<_> = actual.difference(&expected).collect(); + let removed: Vec<_> = expected.difference(&actual).collect(); + + assert!( + added.is_empty(), + "these routes became public. That is a change to the security boundary, \ + not a routing detail — update docs/security/ and this allowlist \ + deliberately:\n{added:#?}" + ); + assert!( + removed.is_empty(), + "these routes stopped being public; bootstrap or login may now be \ + unreachable:\n{removed:#?}" + ); +} + +#[test] +fn test_the_endpoints_that_disclose_workspace_identity_are_not_public() { + // /api/v1/health and /status report the workspace directory name and id. + // /livez and /readyz exist so a probe never needs them. + let public: BTreeSet<(String, String)> = ROUTE_RULES + .iter() + .filter(|r| r.access == Access::Public) + .map(|r| (r.method.to_string(), r.path.to_string())) + .collect(); + + for path in ["/api/v1/health", "/api/v1/status"] { + assert!( + !public.contains(&("GET".to_string(), path.to_string())), + "{path} discloses workspace identity and must not be public" + ); + } +} diff --git a/tests/version_parity.rs b/tests/version_parity.rs index 1d1a5ece..af1264ab 100644 --- a/tests/version_parity.rs +++ b/tests/version_parity.rs @@ -81,6 +81,7 @@ const MANAGED: &[(&str, ExtractKind)] = &[ ExtractKind::TomlPackageVersion, ), ("docs/_config.yml", ExtractKind::YamlVersion), + ("charts/operator/Chart.yaml", ExtractKind::YamlVersion), ("vscode-extension/package.json", ExtractKind::JsonDotVersion), ( "vscode-extension/src/webhook-server.ts", @@ -119,4 +120,11 @@ fn test_all_managed_manifests_match_version_file() { "version drift from VERSION={expected:?}:\n{}\nRun ./bump-version.sh or correct the files above; regenerate docs/schemas/openapi.json with `cargo run -- docs --only openapi`.", mismatches.join("\n") ); + + let chart = read(&root.join("charts/operator/Chart.yaml")); + let app_version = chart + .lines() + .find(|line| line.trim_start().starts_with("appVersion:")) + .and_then(|line| between_quotes_after(line, ":")); + assert_eq!(app_version.as_deref(), Some(expected.as_str())); } diff --git a/ui/README.md b/ui/README.md index 1360a556..7e188005 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,10 +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 `CLAUDE.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 diff --git a/ui/src/api-client.ts b/ui/src/api-client.ts index 38350201..f9dec554 100644 --- a/ui/src/api-client.ts +++ b/ui/src/api-client.ts @@ -17,7 +17,10 @@ import type { UpdateIssueTypeRequest } from '@operator/bindings/UpdateIssueTypeR import type { LaunchTicketRequest } from '@operator/bindings/LaunchTicketRequest'; import type { LaunchTicketResponse } from '@operator/bindings/LaunchTicketResponse'; import type { QueueControlResponse } from '@operator/bindings/QueueControlResponse'; -import type { Config } from '@operator/bindings/Config'; +import type { ConfigurationResponse } from '@operator/bindings/ConfigurationResponse'; +import type { UpdateConfigurationRequest } from '@operator/bindings/UpdateConfigurationRequest'; +import type { ExecutionTargetsResponse } from '@operator/bindings/ExecutionTargetsResponse'; +import type { LlmToolsResponse } from '@operator/bindings/LlmToolsResponse'; import type { AgentDetailResponse } from '@operator/bindings/AgentDetailResponse'; import type { WorkflowExportResponse } from '@operator/bindings/WorkflowExportResponse'; import type { WorkflowPreviewResponse } from '@operator/bindings/WorkflowPreviewResponse'; @@ -31,7 +34,29 @@ import type { DelegatorsResponse } from '@operator/bindings/DelegatorsResponse'; import type { DelegatorResponse } from '@operator/bindings/DelegatorResponse'; import type { CreateDelegatorRequest } from '@operator/bindings/CreateDelegatorRequest'; +import type { AccessKeyListResponse } from '@operator/bindings/AccessKeyListResponse'; +import type { BootstrapStatusResponse } from '@operator/bindings/BootstrapStatusResponse'; +import type { BootstrapSubmitRequest } from '@operator/bindings/BootstrapSubmitRequest'; +import type { BootstrapSubmitResponse } from '@operator/bindings/BootstrapSubmitResponse'; +import type { CreateAccessKeyRequest } from '@operator/bindings/CreateAccessKeyRequest'; +import type { CreateAccessKeyResponse } from '@operator/bindings/CreateAccessKeyResponse'; +import type { CsrfTokenResponse } from '@operator/bindings/CsrfTokenResponse'; +import type { CurrentSessionResponse } from '@operator/bindings/CurrentSessionResponse'; +import type { DeviceApprovalRequest } from '@operator/bindings/DeviceApprovalRequest'; +import type { DeviceApprovalResponse } from '@operator/bindings/DeviceApprovalResponse'; +import type { LoginRequest } from '@operator/bindings/LoginRequest'; +import type { LoginResponse } from '@operator/bindings/LoginResponse'; +import type { LogoutResponse } from '@operator/bindings/LogoutResponse'; +import type { RevokeAccessKeyResponse } from '@operator/bindings/RevokeAccessKeyResponse'; +import type { SessionListResponse } from '@operator/bindings/SessionListResponse'; + export type { + AccessKeyListResponse, + BootstrapStatusResponse, + CreateAccessKeyResponse, + CurrentSessionResponse, + LoginResponse, + SessionListResponse, HealthResponse, StatusResponse, SectionDto, @@ -50,7 +75,10 @@ export type { LaunchTicketRequest, LaunchTicketResponse, QueueControlResponse, - Config, + ConfigurationResponse, + UpdateConfigurationRequest, + ExecutionTargetsResponse, + LlmToolsResponse, AgentDetailResponse, WorkflowExportResponse, WorkflowPreviewResponse, @@ -72,21 +100,98 @@ export class ApiError extends Error { } } -async function request(base: string, path: string, init?: RequestInit): Promise { - const res = await fetch(`${base}${path}`, init); +/** + * The API is authenticated, so every call goes through here. + * + * Three things are added centrally rather than per call site: + * + * - `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. + * - 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'; + +/** In-memory only: a CSRF token in localStorage outlives the session it belongs to. */ +let csrfToken: string | null = null; + +export function setCsrfToken(token: string | null): void { + csrfToken = token; +} + +export function getCsrfToken(): string | null { + return csrfToken; +} + +function isMutation(method: string | undefined): boolean { + const m = (method ?? 'GET').toUpperCase(); + return m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS'; +} + +/** Send the user somewhere they can actually authenticate. */ +async function redirectToAuth(base: string): Promise { + let target = '#/login'; + try { + const res = await fetch(`${base}/api/v1/auth/bootstrap`, { credentials: 'same-origin' }); + if (res.ok) { + const status = (await res.json()) as { state?: string }; + if (status.state !== 'complete') { + target = '#/setup'; + } + } + } catch { + // Unreachable server: login is still the right place to land. + } + if (window.location.hash !== target) { + window.location.hash = target; + } +} + +/** + * `JSON.stringify` for request bodies that may contain `BigInt`. + * + * Rust `u64` fields generate as TypeScript `bigint`, and `JSON.stringify` + * *throws* on a BigInt rather than serializing it — so a body containing one + * fails before the request is ever sent, with no server-side trace. Numbers of + * this kind (a day count, a seconds value) are far inside the safe-integer + * range, so emitting them as JSON numbers is both correct and what the server + * expects. + */ +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 { + const headers = new Headers(init?.headers); + if (isMutation(init?.method) && csrfToken) { + headers.set(CSRF_HEADER, csrfToken); + } + return { ...init, headers, credentials: 'same-origin' }; +} + +async function send(base: string, path: string, init?: RequestInit): Promise { + const res = await fetch(`${base}${path}`, authInit(init)); + if (res.status === 401) { + await redirectToAuth(base); + } if (!res.ok) { const body = await res.json().catch(() => ({ message: `HTTP ${res.status}` })); throw new ApiError(res.status, body.message ?? body.error ?? `HTTP ${res.status}`); } + return res; +} + +async function request(base: string, path: string, init?: RequestInit): Promise { + const res = await send(base, path, init); return res.json() as Promise; } async function requestVoid(base: string, path: string, init?: RequestInit): Promise { - const res = await fetch(`${base}${path}`, init); - if (!res.ok) { - const body = await res.json().catch(() => ({ message: `HTTP ${res.status}` })); - throw new ApiError(res.status, body.message ?? body.error ?? `HTTP ${res.status}`); - } + await send(base, path, init); } export class OperatorApi { @@ -96,6 +201,86 @@ export class OperatorApi { this.base = host.baseUrl(); } + // --- Auth --- + + bootstrapStatus(): Promise { + return request(this.base, '/api/v1/auth/bootstrap'); + } + + bootstrap(body: BootstrapSubmitRequest): Promise { + return request(this.base, '/api/v1/auth/bootstrap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: toJson(body), + }); + } + + async login(password: string): Promise { + const res = await request(this.base, '/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: toJson({ password } satisfies LoginRequest), + }); + // Every later mutation needs this, so capture it at the one point it is issued. + setCsrfToken(res.csrf_token); + return res; + } + + async logout(): Promise { + const res = await request(this.base, '/api/v1/auth/logout', { + method: 'POST', + }); + setCsrfToken(null); + return res; + } + + currentSession(): Promise { + 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'); + } + + revokeSession(id: string): Promise { + return request(this.base, `/api/v1/auth/sessions/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + } + + listAccessKeys(): Promise { + return request(this.base, '/api/v1/auth/keys'); + } + + createAccessKey(body: CreateAccessKeyRequest): Promise { + return request(this.base, '/api/v1/auth/keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: toJson(body), + }); + } + + revokeAccessKey(id: string): Promise { + return request(this.base, `/api/v1/auth/keys/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + } + + approveDevice(userCode: string): Promise { + return request(this.base, '/api/v1/auth/device/approve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: toJson({ user_code: userCode } satisfies DeviceApprovalRequest), + }); + } + // --- Health --- health(): Promise { @@ -154,7 +339,7 @@ export class OperatorApi { return requestVoid(this.base, `/api/v1/agents/${encodeURIComponent(agentId)}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ reason }), + body: toJson({ reason }), }); } @@ -175,7 +360,7 @@ export class OperatorApi { return request(this.base, `/api/v1/tickets/${encodeURIComponent(ticketId)}/launch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(options), + body: toJson(options), }); } @@ -199,7 +384,7 @@ export class OperatorApi { return request(this.base, '/api/v1/issuetypes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(req), + body: toJson(req), }); } @@ -207,7 +392,7 @@ export class OperatorApi { return request(this.base, `/api/v1/issuetypes/${encodeURIComponent(key)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(req), + body: toJson(req), }); } @@ -231,18 +416,26 @@ export class OperatorApi { // --- Configuration --- - getConfiguration(): Promise { + getConfiguration(): Promise { return request(this.base, '/api/v1/configuration'); } - updateConfiguration(config: Partial): Promise { + updateConfiguration(config: UpdateConfigurationRequest): Promise { return request(this.base, '/api/v1/configuration', { - method: 'PUT', + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config), + body: toJson(config), }); } + executionTargets(): Promise { + return request(this.base, '/api/v1/execution-targets'); + } + + listLlmTools(): Promise { + return request(this.base, '/api/v1/llm-tools'); + } + // --- Model providers --- /** The catalog of supported model providers (kinds). */ @@ -265,7 +458,7 @@ export class OperatorApi { return request(this.base, '/api/v1/model-servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(req), + body: toJson(req), }); } @@ -279,7 +472,15 @@ export class OperatorApi { return request(this.base, '/api/v1/delegators', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(req), + body: toJson(req), + }); + } + + updateDelegator(name: string, req: CreateDelegatorRequest): Promise { + return request(this.base, `/api/v1/delegators/${encodeURIComponent(name)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: toJson(req), }); } diff --git a/ui/src/components/TicketDetailPanel.tsx b/ui/src/components/TicketDetailPanel.tsx index 821c2f0d..516c621e 100644 --- a/ui/src/components/TicketDetailPanel.tsx +++ b/ui/src/components/TicketDetailPanel.tsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import type { KanbanTicketCard } from '@operator/bindings/KanbanTicketCard'; -import type { Config } from '@operator/bindings/Config'; +import type { ConfigurationResponse } from '@operator/bindings/ConfigurationResponse'; +import type { DelegatorResponse } from '@operator/bindings/DelegatorResponse'; import type { LaunchTicketResponse } from '@operator/bindings/LaunchTicketResponse'; import { OperatorApi } from '../api-client'; import { useHost } from '../host'; @@ -30,7 +31,9 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { const [target, setTarget] = useState(''); // '' = delegator's target const [yolo, setYolo] = useState(false); - const [config, setConfig] = useState(null); + const [config, setConfig] = useState(null); + const [delegators, setDelegators] = useState([]); + const [targets, setTargets] = useState([]); const [workflow, setWorkflow] = useState(null); const [workflowError, setWorkflowError] = useState(null); @@ -46,9 +49,13 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { // Config (delegator names + the configured control wrapper) for the dropdowns. useEffect(() => { let cancelled = false; - api - .getConfiguration() - .then((c) => !cancelled && setConfig(c)) + Promise.all([api.getConfiguration(), api.listDelegators(), api.executionTargets()]) + .then(([configuration, delegatorResponse, targetResponse]) => { + if (cancelled) return; + setConfig(configuration); + setDelegators(delegatorResponse.delegators); + setTargets(targetResponse.targets.filter((item) => item.available).map((item) => item.name)); + }) .catch(() => !cancelled && setConfig(null)); return () => { cancelled = true; @@ -70,20 +77,7 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { }; }, [api, ticket.ticket_type]); - const defaultWrapperLabel = config?.sessions.wrapper ?? 'configured'; - const delegators = useMemo(() => config?.delegators ?? [], [config]); - - // Named execution targets: explicit [[targets]] entries, [[hosts]] synths, - // and the synthesized docker target when an image is configured. - const targets = useMemo(() => { - if (!config) return [] as string[]; - const names = [ - ...(config.targets ?? []).map((t) => t.name), - ...(config.hosts ?? []).map((h) => h.name), - ]; - if (config.launch.docker.image) names.push('docker'); - return names; - }, [config]); + const defaultWrapperLabel = config?.launch.session_wrapper ?? 'configured'; const onLaunch = () => { setLaunching(true); diff --git a/ui/src/main.tsx b/ui/src/main.tsx index bb2299f5..8396c240 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -13,6 +13,10 @@ import { StatusPage } from './routes/StatusPage'; import { SectionPage } from './routes/SectionPage'; import { AgentDetailPage } from './routes/AgentDetailPage'; import { ModelProvidersPage } from './routes/ModelProvidersPage'; +import { LoginPage } from './routes/LoginPage'; +import { SetupPage } from './routes/SetupPage'; +import { DevicePage } from './routes/DevicePage'; +import { SecurityPage } from './routes/SecurityPage'; const host = createBrowserHost(); @@ -21,7 +25,13 @@ createRoot(document.getElementById('root')!).render( + {/* 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/routes/AuthPage.module.css b/ui/src/routes/AuthPage.module.css new file mode 100644 index 00000000..06461e8d --- /dev/null +++ b/ui/src/routes/AuthPage.module.css @@ -0,0 +1,101 @@ +/* Shared styling for the unauthenticated screens (login, setup, device + approval). They render outside the app Layout, so they carry their own + centering rather than inheriting the sidebar shell. */ + +.screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + background: var(--bg); +} + +.card { + width: 100%; + max-width: 26rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 2rem; +} + +.title { + margin: 0 0 0.5rem; + font-size: 1.25rem; +} + +.subtitle { + margin: 0 0 1.5rem; + color: var(--text-muted); + font-size: 0.875rem; + line-height: 1.5; +} + +.field { + display: block; + margin-bottom: 1rem; +} + +.label { + display: block; + margin-bottom: 0.375rem; + font-size: 0.8125rem; + font-weight: 600; +} + +.input { + width: 100%; + padding: 0.5rem 0.625rem; + font: inherit; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + box-sizing: border-box; +} + +.input:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; +} + +.hint { + margin-top: 0.375rem; + font-size: 0.75rem; + color: var(--text-muted); +} + +.button { + width: 100%; + padding: 0.5rem; + font: inherit; + font-weight: 600; + color: var(--bg); + background: var(--accent); + border: none; + border-radius: 4px; + cursor: pointer; +} + +.button:disabled { + opacity: 0.6; + cursor: default; +} + +.error { + margin: 0 0 1rem; + padding: 0.5rem 0.625rem; + font-size: 0.8125rem; + color: var(--danger); + border: 1px solid var(--danger); + border-radius: 4px; +} + +.notice { + margin: 0 0 1rem; + padding: 0.5rem 0.625rem; + font-size: 0.8125rem; + border: 1px solid var(--border); + border-radius: 4px; +} diff --git a/ui/src/routes/DevicePage.tsx b/ui/src/routes/DevicePage.tsx new file mode 100644 index 00000000..05a370b2 --- /dev/null +++ b/ui/src/routes/DevicePage.tsx @@ -0,0 +1,89 @@ +// Device-approval screen for the OAuth device flow. +// +// The human lands here from the URL an IDE client printed, sees which client is +// 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 } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useHost } from '../host'; +import { OperatorApi, ApiError } from '../api-client'; +import styles from './AuthPage.module.css'; + +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); + + // 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]); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setBusy(true); + try { + const res = await new OperatorApi(host).approveDevice(userCode.trim()); + setApproved(res.client_id); + } catch (e) { + setError( + e instanceof ApiError && e.status === 404 + ? 'That code is unknown or has expired. Start the connection again from your editor.' + : 'Approval failed.', + ); + } finally { + setBusy(false); + } + } + + if (approved) { + return ( +
+
+

Device approved

+

+ {approved} now has access. You can close this page and + return to your editor. +

+
+
+ ); + } + + return ( +
+
+

Approve a device

+

+ Enter the code shown in the application requesting access. +

+ + {error &&

{error}

} + + + + +
+
+ ); +} diff --git a/ui/src/routes/LoginPage.tsx b/ui/src/routes/LoginPage.tsx new file mode 100644 index 00000000..acc9da54 --- /dev/null +++ b/ui/src/routes/LoginPage.tsx @@ -0,0 +1,79 @@ +// Password login. Rendered outside the app Layout: an unauthenticated visitor +// has no sections to show in the sidebar, and every API call the shell makes +// would 401. + +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useHost } from '../host'; +import { OperatorApi, ApiError } from '../api-client'; +import styles from './AuthPage.module.css'; + +export function LoginPage() { + const host = useHost(); + const navigate = useNavigate(); + 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') navigate('/setup', { replace: true }); + }) + .catch(() => { + /* Unreachable server: let the login attempt report it. */ + }); + }, [host, navigate]); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setBusy(true); + try { + await new OperatorApi(host).login(password); + navigate('/', { replace: true }); + } catch (e) { + // 429 carries a wait, not a wrong password; saying "incorrect" would + // send the operator hunting for a password problem they do not have. + const status = e instanceof ApiError ? e.status : 0; + setError( + status === 429 + ? 'Too many attempts. Wait a moment and try again.' + : 'Incorrect password.', + ); + } finally { + setBusy(false); + } + } + + return ( +
+
+

Sign in to Operator

+

This workspace requires the admin password.

+ + {error &&

{error}

} + + + + +
+
+ ); +} diff --git a/ui/src/routes/ModelProvidersPage.tsx b/ui/src/routes/ModelProvidersPage.tsx index 62e553a6..51878826 100644 --- a/ui/src/routes/ModelProvidersPage.tsx +++ b/ui/src/routes/ModelProvidersPage.tsx @@ -8,9 +8,10 @@ import { OperatorApi } from '../api-client'; import type { ModelServerKindEntry, ModelServerModelsResponse, - Config, + LlmToolsResponse, DelegatorResponse, } from '../api-client'; +import type { GitExecutionConfig } from '@operator/bindings/GitExecutionConfig'; import { useHost } from '../host'; import { CONCEPTS } from '../concepts'; import { PageHeader } from '../components/PageHeader'; @@ -47,12 +48,12 @@ export function ModelProvidersPage() { // Load the catalog + detected tools, then probe each provider for connection. useEffect(() => { let cancelled = false; - Promise.all([api.listProviderKinds(), api.getConfiguration()]) - .then(([catalog, config]: [ModelServerKindEntry[], Config]) => { + Promise.all([api.listProviderKinds(), api.listLlmTools()]) + .then(([catalog, tools]: [ModelServerKindEntry[], LlmToolsResponse]) => { if (cancelled) return; setKinds(catalog); setDetectedTools( - config.llm_tools.detected.map((t) => ({ name: t.name, healthOk: t.health_ok })), + 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) { @@ -154,6 +155,7 @@ export function ModelProvidersPage() { {d.llm_tool}:{d.model} {d.model_server ? ` @ ${d.model_server}` : ''} + ))} @@ -243,6 +245,7 @@ function CreateDelegatorForm({ const [provider, setProvider] = useState(''); const [model, setModel] = useState(''); const [name, setName] = useState(''); + const [git, setGit] = useState(null); const [submitting, setSubmitting] = useState(false); // Default the tool once detection lands, preferring one that can actually launch. @@ -270,6 +273,7 @@ function CreateDelegatorForm({ display_name: null, model_properties: {}, model_server: provider, + git, launch_config: null, remote_agent: null, }); @@ -362,6 +366,7 @@ function CreateDelegatorForm({ /> + @@ -369,3 +374,64 @@ function CreateDelegatorForm({ ); } + +function GitFields({ value, onChange, disabled }: { + value: GitExecutionConfig | null; + onChange: (value: GitExecutionConfig | null) => void; + disabled: boolean; +}) { + const config: GitExecutionConfig = value ?? { identity: null, credentials: null, settings: [] }; + const identity = config.identity ?? { name: '', email: '' }; + const credentials = config.credentials ?? { repository_url: '', username: '', token_env: '' }; + return
+ Git identity and credentials + + {value && <> + + {config.identity && <> + + +

Templates support {'{ticket_id}'}, {'{project}'}, and {'{ticket_type}'}.

+ } + + {config.credentials && <> + + + +

Enter the variable name configured on Operator, such as AGENT_GIT_TOKEN.

+ } + {config.settings.map((entry, index) =>
+ + + +
)} + + } +
; +} + +function DelegatorGitEditor({ api, delegator, onSaved }: { api: OperatorApi; delegator: DelegatorResponse; onSaved: () => void }) { + const [git, setGit] = useState(delegator.git ?? null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + useEffect(() => setGit(delegator.git ?? null), [delegator]); + const save = async () => { + setBusy(true); + setError(''); + try { + await api.updateDelegator(delegator.name, { + 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, + }); + onSaved(); + } catch (e) { setError(e instanceof Error ? e.message : 'Failed to save Git settings'); } + finally { setBusy(false); } + }; + return
Git settings + + {error &&

{error}

} + +
; +} diff --git a/ui/src/routes/SecurityPage.module.css b/ui/src/routes/SecurityPage.module.css new file mode 100644 index 00000000..d8906640 --- /dev/null +++ b/ui/src/routes/SecurityPage.module.css @@ -0,0 +1,119 @@ +.page { padding: 1.5rem; } + +.section { margin-bottom: 2.5rem; } + +.sectionTitle { + margin: 0 0 0.25rem; + font-size: 1rem; +} + +.sectionHint { + margin: 0 0 1rem; + font-size: 0.8125rem; + color: var(--text-muted); +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +.table th, +.table td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +.table th { + font-weight: 600; + color: var(--text-muted); +} + +.revoked { color: var(--text-muted); text-decoration: line-through; } + +.current { + font-size: 0.6875rem; + padding: 0.0625rem 0.375rem; + border: 1px solid var(--border); + border-radius: 999px; + margin-left: 0.375rem; +} + +.danger { + font: inherit; + font-size: 0.75rem; + color: var(--danger); + background: none; + border: 1px solid var(--danger); + border-radius: 4px; + padding: 0.125rem 0.5rem; + cursor: pointer; +} + +.danger:disabled { opacity: 0.4; cursor: default; } + +.form { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: flex-end; + margin-bottom: 1rem; +} + +.form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.75rem; } + +.form input, +.form select { + font: inherit; + padding: 0.375rem 0.5rem; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; +} + +.scopes { display: flex; gap: 0.75rem; align-items: center; } + +.scopes label { flex-direction: row; align-items: center; gap: 0.25rem; } + +.primary { + font: inherit; + font-weight: 600; + color: var(--bg); + background: var(--accent); + border: none; + border-radius: 4px; + padding: 0.4375rem 0.875rem; + cursor: pointer; +} + +.primary:disabled { opacity: 0.5; cursor: default; } + +.secretBox { + margin-bottom: 1rem; + padding: 0.75rem; + border: 1px solid var(--accent); + border-radius: 4px; + font-size: 0.8125rem; +} + +.secret { + display: block; + margin-top: 0.5rem; + padding: 0.5rem; + font-family: var(--font-mono, monospace); + word-break: break-all; + background: var(--bg); + border-radius: 4px; +} + +.error { + margin-bottom: 1rem; + padding: 0.5rem 0.625rem; + font-size: 0.8125rem; + color: var(--danger); + border: 1px solid var(--danger); + border-radius: 4px; +} diff --git a/ui/src/routes/SecurityPage.tsx b/ui/src/routes/SecurityPage.tsx new file mode 100644 index 00000000..3162f53a --- /dev/null +++ b/ui/src/routes/SecurityPage.tsx @@ -0,0 +1,263 @@ +// Security settings: browser sessions, connected devices, and service access keys. + +import { useCallback, useEffect, useState } from 'react'; +import { useHost } from '../host'; +import { OperatorApi, ApiError } from '../api-client'; +import type { + AccessKeyListResponse, + SessionListResponse, +} from '../api-client'; +import type { Scope } from '@operator/bindings/Scope'; +import { PageHeader } from '../components/PageHeader'; +import styles from './SecurityPage.module.css'; + +const ALL_SCOPES: Scope[] = ['read', 'write', 'execute', 'admin']; + +function formatDate(iso: string | null | undefined): string { + if (!iso) return '—'; + return new Date(iso).toLocaleString(); +} + +export function SecurityPage() { + const host = useHost(); + const [sessions, setSessions] = useState(null); + const [keys, setKeys] = useState(null); + const [error, setError] = useState(null); + + // Shown exactly once, right after creation: the server stores only a hash, + // so there is no second chance to display it. + const [newSecret, setNewSecret] = useState(null); + + const [keyName, setKeyName] = useState(''); + const [keyScopes, setKeyScopes] = useState(['read']); + const [keyDays, setKeyDays] = useState(90); + const [busy, setBusy] = useState(false); + + 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); + setError(null); + } catch (e) { + setError(e instanceof ApiError ? e.message : 'Failed to load security settings.'); + } + }, [host]); + + useEffect(() => { + void load(); + }, [load]); + + async function createKey(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + try { + const res = await new OperatorApi(host).createAccessKey({ + name: keyName, + scopes: keyScopes, + expires_in_days: BigInt(keyDays), + }); + setNewSecret(res.secret); + setKeyName(''); + await load(); + } catch (e) { + setError(e instanceof ApiError ? e.message : 'Failed to create the access key.'); + } finally { + setBusy(false); + } + } + + async function revokeKey(id: string) { + try { + await new OperatorApi(host).revokeAccessKey(id); + await load(); + } catch (e) { + setError(e instanceof ApiError ? e.message : 'Failed to revoke the key.'); + } + } + + async function revokeSession(id: string) { + try { + await new OperatorApi(host).revokeSession(id); + await load(); + } catch (e) { + setError(e instanceof ApiError ? e.message : 'Failed to revoke the session.'); + } + } + + function toggleScope(scope: Scope) { + setKeyScopes((current) => + current.includes(scope) ? current.filter((s) => s !== scope) : [...current, scope], + ); + } + + return ( +
+ + + {error &&

{error}

} + +
+

Browser sessions

+

+ Signed-in browsers. Revoking a session signs it out immediately. +

+ + + + + + + + + + {sessions?.sessions.map((s) => ( + + + + + + + ))} + +
StartedExpiresLast used +
+ {formatDate(s.created_at)} + {s.current && this browser} + {formatDate(s.expires_at)}{formatDate(s.last_used_at)} + +
+
+ +
+

Connected devices

+

+ Editors and other clients authorized through the device flow. +

+ + + + + + + + + + + {sessions?.devices.map((d) => ( + + + + + + + ))} + +
ClientScopesApprovedLast used
{d.client_id}{d.scopes.join(', ')}{formatDate(d.created_at)}{formatDate(d.last_used_at)}
+
+ +
+

Service access keys

+

+ For integrations. Grant only the scopes the integration needs; every key + expires. +

+ + {newSecret && ( +
+ Copy this now — it is shown once and cannot be retrieved. + {newSecret} +
+ )} + +
+ + +
+ {ALL_SCOPES.map((scope) => ( + + ))} +
+ +
+ + + + + + + + + + + + {keys?.keys.map((k) => ( + + + + + + + + ))} + +
NameScopesExpiresLast used +
{k.name}{k.scopes.join(', ')}{formatDate(k.expires_at)}{formatDate(k.last_used_at)} + +
+
+
+ ); +} diff --git a/ui/src/routes/SetupPage.tsx b/ui/src/routes/SetupPage.tsx new file mode 100644 index 00000000..ef6620bc --- /dev/null +++ b/ui/src/routes/SetupPage.tsx @@ -0,0 +1,140 @@ +// First-run admin setup. Reachable only while the server has no usable admin +// account; once bootstrap completes this redirects to login, so it cannot be +// used to re-claim the account. + +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useHost } from '../host'; +import { OperatorApi, ApiError } from '../api-client'; +import styles from './AuthPage.module.css'; + +/** Mirrors the server's minimum; the server is still the authority. */ +const MIN_PASSWORD_LENGTH = 12; + +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); + + useEffect(() => { + const api = new OperatorApi(host); + api + .bootstrapStatus() + .then((status) => { + if (status.state === 'complete') { + navigate('/login', { replace: true }); + return; + } + setNeedsTemporary(status.requires_temporary_password); + }) + .catch(() => setError('Cannot reach the Operator server.')); + }, [host, navigate]); + + const tooShort = password.length > 0 && password.length < MIN_PASSWORD_LENGTH; + const mismatch = confirm.length > 0 && password !== confirm; + const ready = + password.length >= MIN_PASSWORD_LENGTH && + password === confirm && + (!needsTemporary || temporary.length > 0); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setBusy(true); + try { + const api = new OperatorApi(host); + await api.bootstrap({ + temporary_password: needsTemporary ? temporary : null, + new_password: password, + }); + // Bootstrap creates the account but does not sign you in. + await api.login(password); + navigate('/', { replace: true }); + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + setError('This server already has an admin account. Sign in instead.'); + } else if (e instanceof ApiError && e.status === 401) { + setError('The temporary password is incorrect.'); + } else if (e instanceof ApiError && e.status === 429) { + setError('Too many attempts. Wait a moment and try again.'); + } else { + setError(e instanceof ApiError ? e.message : 'Setup failed.'); + } + } finally { + setBusy(false); + } + } + + return ( +
+
+

Set up Operator

+

+ Choose the admin password for this workspace. Operator has a single + human account. +

+ + {error &&

{error}

} + + {needsTemporary && ( + <> +

+ This server was started with a bootstrap secret. Enter it to claim + the admin account. +

+ + + )} + + + + + + +
+
+ ); +} diff --git a/vscode-extension/README.md b/vscode-extension/README.md index ea6bcc81..2a43b327 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -48,6 +48,8 @@ This extension also runs in [Cursor](https://www.cursor.com). Install it from th - **Operator: Start Webhook Server** - Start the webhook server - **Operator: Stop Webhook Server** - Stop the webhook server - **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 ## API Endpoints diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 5085bb80..0e8a8f73 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -128,6 +128,14 @@ "command": "operator.showStatus", "title": "Operator: Show Server Status" }, + { + "command": "operator.signIn", + "title": "Operator: Sign In" + }, + { + "command": "operator.signOut", + "title": "Operator: Sign Out" + }, { "command": "operator.refreshTickets", "title": "Refresh Tickets", diff --git a/vscode-extension/src/api-client.ts b/vscode-extension/src/api-client.ts index 22399624..6d64b8c9 100644 --- a/vscode-extension/src/api-client.ts +++ b/vscode-extension/src/api-client.ts @@ -1,16 +1,21 @@ /** * Operator REST API client * - * Provides methods to communicate with the Operator REST API - * for launching tickets and checking health status. + * Every daemon request goes through one authenticated path (`send`), which + * attaches the credential from `auth/credentials`, retries once after a refresh on 401, and normalizes errors into `ApiError`. */ import * as vscode from 'vscode'; import * as fs from 'fs/promises'; import * as path from 'path'; +import { credentialProvider } from './auth/credentials'; +import { ApiError, AuthRequiredError } from './auth/errors'; + // Import generated types from Rust bindings (source of truth) import type { + ActiveAgentsResponse, + CurrentSessionResponse, KanbanBoardResponse, LaunchTicketRequest, LaunchTicketResponse, @@ -33,13 +38,20 @@ import type { SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, WorkflowExportResponse, + WorkflowFormatDto, ModelServerKindEntry, ModelServerModelsResponse, ModelServerResponse, + ModelServersResponse, CreateModelServerRequest, DelegatorsResponse, DelegatorResponse, CreateDelegatorRequest, + DefaultLlmResponse, + SetDefaultLlmRequest, + LlmToolsResponse, + ExecutionTargetsResponse, + McpDescriptorResponse, } from './generated'; // Re-export generated types for consumers @@ -71,6 +83,7 @@ export type { DelegatorResponse, CreateDelegatorRequest, }; +export { ApiError, AuthRequiredError }; /** * Summary of a project from the Operator REST API @@ -104,7 +117,8 @@ export interface AssessTicketResponse { project_name: string; } -export interface ApiError { +/** Error body the daemon returns on non-2xx responses. */ +export interface ApiErrorBody { error: string; message: string; } @@ -151,6 +165,22 @@ export interface ApiSessionInfo { pid: number; started_at: string; version: string; + /** State directory holding `local-token`; absent from files written by older daemons. */ + state_dir?: string; +} + +export const DEFAULT_API_URL = 'http://localhost:7008'; +/** Public liveness probe: answers without a credential, unlike `/api/v1/health`. */ +export const LIVEZ_PATH = '/livez'; + +/** + * ts-rs maps Rust `u64` to `bigint`, and `JSON.stringify` throws on a bigint, + * so a body containing one would fail before the request is ever sent. + */ +export function toJson(value: unknown): string { + return JSON.stringify(value, (_key, v: unknown) => + typeof v === 'bigint' ? Number(v) : v + ); } /** @@ -186,7 +216,25 @@ export async function discoverApiUrl( } } - return 'http://localhost:7008'; + return DEFAULT_API_URL; +} + +function withBearer(init: RequestInit, token: string | undefined): RequestInit { + if (!token) { + return init; + } + return { + ...init, + headers: { ...(init.headers as Record | undefined), Authorization: `Bearer ${token}` }, + }; +} + +function jsonInit(method: string, body: unknown): RequestInit { + return { + method, + headers: { 'Content-Type': 'application/json' }, + body: toJson(body), + }; } /** @@ -197,18 +245,82 @@ export class OperatorApiClient { constructor(baseUrl?: string) { const config = vscode.workspace.getConfiguration('operator'); - this.baseUrl = baseUrl || config.get('apiUrl', 'http://localhost:7008'); + this.baseUrl = baseUrl || config.get('apiUrl', DEFAULT_API_URL); + } + + /** + * Perform an authenticated request. + * + * A 401 triggers exactly one refresh-and-retry. A refresh that yields the + * same credential (or none) is not retried: the server has already rejected + * it, and repeating the request would only repeat the rejection. + */ + private async send(apiPath: string, init: RequestInit = {}): Promise { + const provider = credentialProvider(); + const url = `${this.baseUrl}${apiPath}`; + + const token = await provider.bearer(this.baseUrl); + let response = await fetch(url, withBearer(init, token)); + + if (response.status === 401) { + const refreshed = await provider.refresh(this.baseUrl); + if (refreshed && refreshed !== token) { + response = await fetch(url, withBearer(init, refreshed)); + } + } + + if (response.status === 401) { + throw new AuthRequiredError(this.baseUrl); + } + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as Partial; + throw new ApiError( + response.status, + body.message ?? body.error ?? `HTTP ${response.status}: ${response.statusText}` + ); + } + return response; + } + + private async request(apiPath: string, init?: RequestInit): Promise { + const response = await this.send(apiPath, init); + return (await response.json()) as T; + } + + private async requestVoid(apiPath: string, init?: RequestInit): Promise { + await this.send(apiPath, init); + } + + /** + * Whether anything is listening at the base URL. Unauthenticated: this is + * the "is the daemon up" question, not "is it ours". + */ + async isReachable(): Promise { + try { + const response = await fetch(`${this.baseUrl}${LIVEZ_PATH}`); + return response.ok; + } catch { + return false; + } } /** * Check if the Operator API is available */ async health(): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/health`); - if (!response.ok) { - throw new Error('Operator API not available'); + try { + return await this.request('/api/v1/health'); + } catch (err) { + if (err instanceof ApiError && !(err instanceof AuthRequiredError)) { + throw new ApiError(err.status, 'Operator API not available'); + } + throw err; } - return (await response.json()) as HealthResponse; + } + + /** The identity the daemon sees for the extension's current credential. */ + async currentSession(): Promise { + return this.request('/api/v1/auth/session'); } /** @@ -221,30 +333,14 @@ export class OperatorApiClient { ticketId: string, options: LaunchTicketRequest ): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/tickets/${encodeURIComponent(ticketId)}/launch`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - delegator: options.delegator ?? null, - provider: options.provider, - model: options.model, - yolo_mode: options.yolo_mode ?? false, - wrapper: options.wrapper, - }), - } + return this.request( + `/api/v1/tickets/${encodeURIComponent(ticketId)}/launch`, + jsonInit('POST', { + ...options, + delegator: options.delegator ?? null, + yolo_mode: options.yolo_mode ?? false, + }) ); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as LaunchTicketResponse; } /** @@ -252,20 +348,15 @@ export class OperatorApiClient { * workflow (.js). Goes through the same shared code path as the CLI and TUI. */ async exportWorkflow(ticketId: string): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/tickets/${encodeURIComponent(ticketId)}/workflow-export`, + return this.request( + `/api/v1/tickets/${encodeURIComponent(ticketId)}/workflow-export`, { method: 'POST' } ); + } - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as WorkflowExportResponse; + /** Agents currently running, for the review pickers. */ + async listActiveAgents(): Promise { + return this.request('/api/v1/agents/active'); } /** @@ -274,19 +365,7 @@ export class OperatorApiClient { * Stops automatic ticket assignment and agent launches. */ async pauseQueue(): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/queue/pause`, { - method: 'POST', - }); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as QueueControlResponse; + return this.request('/api/v1/queue/pause', { method: 'POST' }); } /** @@ -295,19 +374,7 @@ export class OperatorApiClient { * Resumes automatic ticket assignment and agent launches. */ async resumeQueue(): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/queue/resume`, { - method: 'POST', - }); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as QueueControlResponse; + return this.request('/api/v1/queue/resume', { method: 'POST' }); } /** @@ -317,19 +384,7 @@ export class OperatorApiClient { * local tickets in the queue. */ async syncKanban(): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/queue/sync`, { - method: 'POST', - }); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as KanbanSyncResponse; + return this.request('/api/v1/queue/sync', { method: 'POST' }); } /** @@ -342,20 +397,10 @@ export class OperatorApiClient { provider: string, projectKey: string ): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/queue/sync/${encodeURIComponent(provider)}/${encodeURIComponent(projectKey)}`, + return this.request( + `/api/v1/queue/sync/${encodeURIComponent(provider)}/${encodeURIComponent(projectKey)}`, { method: 'POST' } ); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as KanbanSyncResponse; } /** @@ -364,22 +409,10 @@ export class OperatorApiClient { * Clears the review state and signals the agent to continue. */ async approveReview(agentId: string): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/agents/${encodeURIComponent(agentId)}/approve`, - { - method: 'POST', - } + return this.request( + `/api/v1/agents/${encodeURIComponent(agentId)}/approve`, + { method: 'POST' } ); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as ReviewResponse; } /** @@ -388,195 +421,85 @@ export class OperatorApiClient { * Signals the agent that the review was rejected with feedback. */ async rejectReview(agentId: string, reason: string): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/agents/${encodeURIComponent(agentId)}/reject`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ reason }), - } + return this.request( + `/api/v1/agents/${encodeURIComponent(agentId)}/reject`, + jsonInit('POST', { reason }) ); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as ReviewResponse; } /** * List all configured projects with analysis data */ async getProjects(): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/projects`); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as ProjectSummary[]; + return this.request('/api/v1/projects'); } /** * Create an ASSESS ticket for a project */ async assessProject(name: string): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/projects/${encodeURIComponent(name)}/assess`, + return this.request( + `/api/v1/projects/${encodeURIComponent(name)}/assess`, { method: 'POST' } ); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as AssessTicketResponse; } /** * List all issue types from the registry */ async listIssueTypes(): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/issuetypes`); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as IssueTypeSummary[]; + return this.request('/api/v1/issuetypes'); } /** * Get a single issue type by key */ async getIssueType(key: string): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/issuetypes/${encodeURIComponent(key)}` - ); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as IssueTypeResponse; + return this.request(`/api/v1/issuetypes/${encodeURIComponent(key)}`); } /** * Create a new issue type */ async createIssueType(request: CreateIssueTypeRequest): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/issuetypes`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - }); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as IssueTypeResponse; + return this.request('/api/v1/issuetypes', jsonInit('POST', request)); } /** * Update an existing issue type */ async updateIssueType(key: string, request: UpdateIssueTypeRequest): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/issuetypes/${encodeURIComponent(key)}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - } + return this.request( + `/api/v1/issuetypes/${encodeURIComponent(key)}`, + jsonInit('PUT', request) ); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as IssueTypeResponse; } /** * Delete an issue type by key */ async deleteIssueType(key: string): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/issuetypes/${encodeURIComponent(key)}`, + await this.requestVoid( + `/api/v1/issuetypes/${encodeURIComponent(key)}`, { method: 'DELETE' } ); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } } /** * List all collections */ async listCollections(): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/collections`); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as CollectionResponse[]; + return this.request('/api/v1/collections'); } /** * Activate a collection by name */ async activateCollection(name: string): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/collections/${encodeURIComponent(name)}/activate`, + await this.requestVoid( + `/api/v1/collections/${encodeURIComponent(name)}/activate`, { method: 'PUT' } ); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } } /** @@ -585,17 +508,7 @@ export class OperatorApiClient { * truth shared with the TUI / web `/#/kanban` list view. */ async listKanbanProviderCatalog(): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/kanban/providers`); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as KanbanProviderCatalogEntry[]; + return this.request('/api/v1/kanban/providers'); } /** @@ -605,19 +518,9 @@ export class OperatorApiClient { provider: string, projectKey: string ): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/kanban/${encodeURIComponent(provider)}/${encodeURIComponent(projectKey)}/issuetypes` + return this.request( + `/api/v1/kanban/${encodeURIComponent(provider)}/${encodeURIComponent(projectKey)}/issuetypes` ); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as ExternalIssueTypeSummary[]; } /** @@ -625,19 +528,9 @@ export class OperatorApiClient { * provider/project — populates the todo/doing/done mapping dropdowns. */ async getKanbanStatuses(provider: string, projectKey: string): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/kanban/${encodeURIComponent(provider)}/${encodeURIComponent(projectKey)}/statuses` + const body = await this.request<{ statuses: string[] }>( + `/api/v1/kanban/${encodeURIComponent(provider)}/${encodeURIComponent(projectKey)}/statuses` ); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - const body = (await response.json()) as { statuses: string[] }; return body.statuses; } @@ -649,20 +542,10 @@ export class OperatorApiClient { provider: string, projectKey: string ): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/kanban/${encodeURIComponent(provider)}/${encodeURIComponent(projectKey)}/issuetypes/sync`, + return this.request( + `/api/v1/kanban/${encodeURIComponent(provider)}/${encodeURIComponent(projectKey)}/issuetypes/sync`, { method: 'POST' } ); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as SyncKanbanIssueTypesResponse; } // ─── Kanban Onboarding ──────────────────────────────────────────────── @@ -677,21 +560,7 @@ export class OperatorApiClient { async validateKanbanCredentials( req: ValidateKanbanCredentialsRequest ): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/kanban/validate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(req), - }); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as ValidateKanbanCredentialsResponse; + return this.request('/api/v1/kanban/validate', jsonInit('POST', req)); } /** @@ -701,21 +570,10 @@ export class OperatorApiClient { async listKanbanProjects( req: ListKanbanProjectsRequest ): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/kanban/projects`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(req), - }); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - const body = (await response.json()) as ListKanbanProjectsResponse; + const body = await this.request( + '/api/v1/kanban/projects', + jsonInit('POST', req) + ); return body.projects; } @@ -728,21 +586,7 @@ export class OperatorApiClient { async writeKanbanConfig( req: WriteKanbanConfigRequest ): Promise { - const response = await fetch(`${this.baseUrl}/api/v1/kanban/config`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(req), - }); - - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - - return (await response.json()) as WriteKanbanConfigResponse; + return this.request('/api/v1/kanban/config', jsonInit('PUT', req)); } /** @@ -755,81 +599,75 @@ export class OperatorApiClient { async setKanbanSessionEnv( req: SetKanbanSessionEnvRequest ): Promise { - const response = await fetch( - `${this.baseUrl}/api/v1/kanban/session-env`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(req), - } - ); + return this.request('/api/v1/kanban/session-env', jsonInit('POST', req)); + } - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } + // --- LLM tools --- - return (await response.json()) as SetKanbanSessionEnvResponse; + async listLlmTools(): Promise { + return this.request('/api/v1/llm-tools'); } - // --- Model providers --- - - private async getJson(path: string): Promise { - const response = await fetch(`${this.baseUrl}${path}`); - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - return (await response.json()) as T; + async getDefaultLlm(): Promise { + return this.request('/api/v1/llm-tools/default'); } - private async postJson(path: string, body: unknown): Promise { - const response = await fetch(`${this.baseUrl}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!response.ok) { - const error = (await response.json().catch(() => ({ - error: 'unknown', - message: `HTTP ${response.status}: ${response.statusText}`, - }))) as ApiError; - throw new Error(error.message); - } - return (await response.json()) as T; + async setDefaultLlm(req: SetDefaultLlmRequest): Promise { + await this.requestVoid('/api/v1/llm-tools/default', jsonInit('PUT', req)); } + // --- Model providers --- + /** The catalog of supported model providers (kinds). */ async listProviderKinds(): Promise { - return this.getJson('/api/v1/model-servers/kinds'); + return this.request('/api/v1/model-servers/kinds'); } /** Live models for a provider kind (declared instance or kind defaults). */ async providerModels(slug: string): Promise { - return this.getJson(`/api/v1/model-servers/kinds/${encodeURIComponent(slug)}/models`); + return this.request(`/api/v1/model-servers/kinds/${encodeURIComponent(slug)}/models`); + } + + /** Declared model server instances plus builtins. */ + async listModelServers(): Promise { + return this.request('/api/v1/model-servers'); + } + + /** Live models for one declared server. */ + async modelServerModels(name: string): Promise { + return this.request(`/api/v1/model-servers/${encodeURIComponent(name)}/models`); } /** Connect a gateway provider by declaring an instance. */ async createModelServer(req: CreateModelServerRequest): Promise { - return this.postJson('/api/v1/model-servers', req); + return this.request('/api/v1/model-servers', jsonInit('POST', req)); } /** Kanban board columns — the API-backed source for the ticket trees. */ async getKanban(): Promise { - return this.getJson('/api/v1/queue/kanban'); + return this.request('/api/v1/queue/kanban'); } async listDelegators(): Promise { - return this.getJson('/api/v1/delegators'); + return this.request('/api/v1/delegators'); } async createDelegator(req: CreateDelegatorRequest): Promise { - return this.postJson('/api/v1/delegators', req); + return this.request('/api/v1/delegators', jsonInit('POST', req)); + } + + // --- Workflows, targets, MCP --- + + async listWorkflowFormats(): Promise { + return this.request('/api/v1/workflow-formats'); + } + + /** Named execution targets: local, docker, `[[targets]]`, and `[[hosts]]`. */ + async listExecutionTargets(): Promise { + return this.request('/api/v1/execution-targets'); + } + + async mcpDescriptor(): Promise { + return this.request('/api/v1/mcp/descriptor'); } } diff --git a/vscode-extension/src/auth/credentials.ts b/vscode-extension/src/auth/credentials.ts new file mode 100644 index 00000000..5102bfc3 --- /dev/null +++ b/vscode-extension/src/auth/credentials.ts @@ -0,0 +1,180 @@ +/** + * Where the extension's daemon credential comes from. + * + * Order of preference, per request: + * + * 1. The daemon's `local-token` file. A loopback daemon writes it owner-only to + * its state directory; the extension host runs as the same OS user, so being + * able to read it is the same proof the CLI relies on. It is read fresh + * every time because the daemon rotates it on each start. + * 2. A device-flow access token held in SecretStorage, while it is still valid. + * 3. A refresh of that token. + * + * The provider is module-level state set once in `activate()`. The extension + * host activates once, so this is a single wiring point rather than a + * constructor parameter threaded through every client, panel, and section. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import type { TokenRequest, TokenResponse, OAuthErrorResponse } from '../generated'; +import { TokenStore } from './token-store'; + +/** The `client_id` device codes and refresh families are bound to server-side. */ +export const CLIENT_ID = 'vscode'; +export const LOCAL_TOKEN_FILENAME = 'local-token'; +export const SESSION_FILENAME = 'api-session.json'; +/** Treat an access token as expired this long before it actually is. */ +export const EXPIRY_SKEW_MS = 30_000; +export const TOKEN_PATH = '/api/v1/auth/token'; + +/** ts-rs types Rust `u64` as `bigint`, but JSON.parse delivers a number; normalize before arithmetic. */ +export function secondsToMs(seconds: bigint | number): number { + return Number(seconds) * 1000; +} + +export interface CredentialProvider { + /** The best credential currently available, or `undefined` if none. */ + bearer(apiUrl: string): Promise; + /** Exchange the stored refresh token after a 401. Returns the new access token. */ + refresh(apiUrl: string): Promise; + /** The active `.tickets` directory, used to locate the local token. */ + setTicketsDir(dir: string | undefined): void; +} + +let active: CredentialProvider | undefined; + +export function setCredentialProvider(provider: CredentialProvider): void { + active = provider; +} + +export function clearCredentialProvider(): void { + active = undefined; +} + +/** Throws rather than silently sending unauthenticated requests when activation forgot to wire one. */ +export function credentialProvider(): CredentialProvider { + if (!active) { + throw new Error( + 'Operator credential provider is not configured; activate() must call setCredentialProvider()' + ); + } + return active; +} + +/** Only a loopback daemon issues a local token, and it must never be sent anywhere else. */ +export function isLoopbackUrl(apiUrl: string): boolean { + try { + const host = new URL(apiUrl).hostname.replace(/^\[|\]$/g, ''); + return host === 'localhost' || host === '::1' || /^127\.\d+\.\d+\.\d+$/.test(host); + } catch { + return false; + } +} + +/** The state directory advertised by the running daemon, else the default next to the session file. */ +export async function resolveStateDir(ticketsDir: string): Promise { + const operatorDir = path.join(ticketsDir, 'operator'); + try { + const raw = await fs.readFile(path.join(operatorDir, SESSION_FILENAME), 'utf-8'); + const session = JSON.parse(raw) as { state_dir?: string }; + if (session.state_dir) { + return session.state_dir; + } + } catch { + // No running daemon has written a session file; fall through. + } + return operatorDir; +} + +export async function readLocalToken(ticketsDir: string): Promise { + try { + const stateDir = await resolveStateDir(ticketsDir); + const token = (await fs.readFile(path.join(stateDir, LOCAL_TOKEN_FILENAME), 'utf-8')).trim(); + return token || undefined; + } catch { + return undefined; + } +} + +export class OperatorCredentials implements CredentialProvider { + private ticketsDir: string | undefined; + /** One refresh in flight per daemon: a second concurrent redemption of the same rotating token would revoke the whole family. */ + private readonly inflight = new Map>(); + + constructor(private readonly store: TokenStore) {} + + setTicketsDir(dir: string | undefined): void { + this.ticketsDir = dir; + } + + async bearer(apiUrl: string): Promise { + if (this.ticketsDir && isLoopbackUrl(apiUrl)) { + const local = await readLocalToken(this.ticketsDir); + if (local) { + return local; + } + } + + const stored = await this.store.load(apiUrl); + if (!stored) { + return undefined; + } + if (stored.expires_at - Date.now() > EXPIRY_SKEW_MS) { + return stored.access_token; + } + return this.refresh(apiUrl); + } + + refresh(apiUrl: string): Promise { + const pending = this.inflight.get(apiUrl); + if (pending) { + return pending; + } + const run = this.redeem(apiUrl).finally(() => this.inflight.delete(apiUrl)); + this.inflight.set(apiUrl, run); + return run; + } + + private async redeem(apiUrl: string): Promise { + const stored = await this.store.load(apiUrl); + if (!stored) { + return undefined; + } + + const body: TokenRequest = { + grant_type: 'refresh_token', + refresh_token: stored.refresh_token, + client_id: CLIENT_ID, + }; + let response: Response; + try { + response = await fetch(`${apiUrl}${TOKEN_PATH}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch { + // Transient network failure: keep the stored credential for next time. + return undefined; + } + + if (response.ok) { + const token = (await response.json()) as TokenResponse; + await this.store.save(apiUrl, { + access_token: token.access_token, + refresh_token: token.refresh_token ?? stored.refresh_token, + expires_at: Date.now() + secondsToMs(token.expires_in), + scopes: token.scopes, + }); + return token.access_token; + } + + const error = (await response.json().catch(() => ({}))) as Partial; + if (error.error === 'invalid_grant' || error.error === 'invalid_client') { + // The family is dead (expired, revoked, or reuse-detected); nothing to retry with. + await this.store.clear(apiUrl); + } + return undefined; + } +} diff --git a/vscode-extension/src/auth/device-flow.ts b/vscode-extension/src/auth/device-flow.ts new file mode 100644 index 00000000..e077738c --- /dev/null +++ b/vscode-extension/src/auth/device-flow.ts @@ -0,0 +1,153 @@ +/** + * OAuth device authorization, client half. + * + * The server side (`/api/v1/auth/device/code`, `/token`) is complete; the + * extension requests a code, sends the human to the daemon's approval page, + * and polls until the code is approved, denied, expired, or cancelled. + */ + +import type { + DeviceAuthorizationRequest, + DeviceAuthorizationResponse, + OAuthErrorResponse, + Scope, + TokenRequest, + TokenResponse, +} from '../generated'; +import { CLIENT_ID, TOKEN_PATH, secondsToMs } from './credentials'; +import { TokenStore } from './token-store'; + +export const DEVICE_CODE_PATH = '/api/v1/auth/device/code'; +/** RFC 8628 §3.5: on `slow_down` the client adds 5 seconds to its interval. */ +export const SLOW_DOWN_INCREMENT_SECS = 5; +/** IDE clients act as the human admin, so they request every scope. */ +export const IDE_SCOPES: Scope[] = ['read', 'write', 'execute', 'admin']; + +export type DeviceFlowOutcome = + | { status: 'approved'; scopes: Scope[] } + | { status: 'denied' } + | { status: 'expired' } + | { status: 'cancelled' } + | { status: 'error'; message: string }; + +export interface DeviceFlowHooks { + /** Called once with the issued code so the UI can show it and open the browser. */ + onCode(authorization: DeviceAuthorizationResponse): Promise; + isCancelled(): boolean; + /** Injected so tests can run the poll loop without real time passing. */ + sleep(ms: number): Promise; + now(): number; +} + +async function readError(response: Response): Promise> { + return (await response.json().catch(() => ({}))) as Partial; +} + +export async function requestDeviceCode(apiUrl: string): Promise { + const body: DeviceAuthorizationRequest = { client_id: CLIENT_ID, scopes: IDE_SCOPES }; + const response = await fetch(`${apiUrl}${DEVICE_CODE_PATH}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const error = await readError(response); + throw new Error( + error.error_description ?? error.error ?? `HTTP ${response.status}: ${response.statusText}` + ); + } + return (await response.json()) as DeviceAuthorizationResponse; +} + +/** + * Poll the token endpoint until the device code resolves. + * + * The advertised `interval` is a floor the server enforces, so the first poll + * also waits for it rather than firing immediately. + */ +export async function pollForToken( + apiUrl: string, + authorization: DeviceAuthorizationResponse, + store: TokenStore, + hooks: Pick +): Promise { + let intervalSecs = Number(authorization.interval); + const deadline = hooks.now() + secondsToMs(authorization.expires_in); + const body: TokenRequest = { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: authorization.device_code, + client_id: CLIENT_ID, + }; + + for (;;) { + if (hooks.isCancelled()) { + return { status: 'cancelled' }; + } + if (hooks.now() >= deadline) { + return { status: 'expired' }; + } + await hooks.sleep(secondsToMs(intervalSecs)); + if (hooks.isCancelled()) { + return { status: 'cancelled' }; + } + + let response: Response; + try { + response = await fetch(`${apiUrl}${TOKEN_PATH}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch (err) { + return { status: 'error', message: err instanceof Error ? err.message : String(err) }; + } + + if (response.ok) { + const token = (await response.json()) as TokenResponse; + if (!token.refresh_token) { + return { status: 'error', message: 'token response did not include a refresh token' }; + } + await store.save(apiUrl, { + access_token: token.access_token, + refresh_token: token.refresh_token, + expires_at: hooks.now() + secondsToMs(token.expires_in), + scopes: token.scopes, + }); + return { status: 'approved', scopes: token.scopes }; + } + + const error = await readError(response); + switch (error.error) { + case 'authorization_pending': + continue; + case 'slow_down': + intervalSecs += SLOW_DOWN_INCREMENT_SECS; + continue; + case 'access_denied': + return { status: 'denied' }; + case 'expired_token': + return { status: 'expired' }; + default: + return { + status: 'error', + message: error.error_description ?? error.error ?? `HTTP ${response.status}`, + }; + } + } +} + +/** Request a code, hand it to the UI, and poll to completion. */ +export async function runDeviceFlow( + apiUrl: string, + store: TokenStore, + hooks: DeviceFlowHooks +): Promise { + let authorization: DeviceAuthorizationResponse; + try { + authorization = await requestDeviceCode(apiUrl); + } catch (err) { + return { status: 'error', message: err instanceof Error ? err.message : String(err) }; + } + await hooks.onCode(authorization); + return pollForToken(apiUrl, authorization, store, hooks); +} diff --git a/vscode-extension/src/auth/errors.ts b/vscode-extension/src/auth/errors.ts new file mode 100644 index 00000000..38bf5f42 --- /dev/null +++ b/vscode-extension/src/auth/errors.ts @@ -0,0 +1,28 @@ +/** + * Errors raised by the authenticated request path. + */ + +export const SIGN_IN_COMMAND_TITLE = 'Operator: Sign In'; + +/** A non-2xx response from the Operator daemon, carrying the HTTP status. */ +export class ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +/** + * The daemon rejected every credential the extension could present. + * Sign-in is deliberately not started automatically; the message names the + * command so the user chooses when to go through the browser. + */ +export class AuthRequiredError extends ApiError { + constructor(apiUrl: string) { + super(401, `Not signed in to Operator at ${apiUrl}. Run "${SIGN_IN_COMMAND_TITLE}".`); + this.name = 'AuthRequiredError'; + } +} diff --git a/vscode-extension/src/auth/sign-in.ts b/vscode-extension/src/auth/sign-in.ts new file mode 100644 index 00000000..47da0a4b --- /dev/null +++ b/vscode-extension/src/auth/sign-in.ts @@ -0,0 +1,109 @@ +/** + * `Operator: Sign In` / `Operator: Sign Out`. + * + * Sign-in is deliberately a command rather than something a 401 triggers: the + * browser hand-off is a deliberate act the user starts, not a surprise. + */ + +import * as vscode from 'vscode'; +import { OperatorApiClient } from '../api-client'; +import type { CredentialProvider } from './credentials'; +import { DeviceFlowOutcome, runDeviceFlow } from './device-flow'; +import { TokenStore } from './token-store'; + +const COPY_CODE = 'Copy code'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** A credential that already works means the browser round-trip is unnecessary. */ +export async function alreadyAuthenticated( + apiUrl: string, + credentials: CredentialProvider +): Promise { + if (!(await credentials.bearer(apiUrl))) { + return false; + } + try { + await new OperatorApiClient(apiUrl).currentSession(); + return true; + } catch { + return false; + } +} + +export function describeOutcome(apiUrl: string, outcome: DeviceFlowOutcome): string { + switch (outcome.status) { + case 'approved': + return `Signed in to Operator at ${apiUrl}.`; + case 'denied': + return 'Sign-in was declined in the browser.'; + case 'expired': + return 'The sign-in code expired before it was approved. Run Operator: Sign In again.'; + case 'cancelled': + return 'Sign-in cancelled.'; + case 'error': + return `Sign-in failed: ${outcome.message}`; + } +} + +export async function signIn( + apiUrl: string, + credentials: CredentialProvider, + store: TokenStore +): Promise { + if (await alreadyAuthenticated(apiUrl, credentials)) { + void vscode.window.showInformationMessage( + `Already authenticated with Operator at ${apiUrl}; no sign-in needed.` + ); + return undefined; + } + + const outcome = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Operator sign-in', + cancellable: true, + }, + (progress, token) => + runDeviceFlow(apiUrl, store, { + onCode: async (authorization) => { + progress.report({ + message: `Approve code ${authorization.user_code} in the browser`, + }); + const external = await vscode.env.asExternalUri( + vscode.Uri.parse(authorization.verification_uri_complete) + ); + await vscode.env.openExternal(external); + void vscode.window + .showInformationMessage( + `Operator sign-in code: ${authorization.user_code}`, + COPY_CODE + ) + .then((choice) => { + if (choice === COPY_CODE) { + return vscode.env.clipboard.writeText(authorization.user_code); + } + return undefined; + }); + }, + isCancelled: () => token.isCancellationRequested, + sleep, + now: Date.now, + }) + ); + + const message = describeOutcome(apiUrl, outcome); + if (outcome.status === 'approved') { + void vscode.window.showInformationMessage(message); + } else if (outcome.status !== 'cancelled') { + void vscode.window.showErrorMessage(message); + } + return outcome; +} + +export async function signOut(apiUrl: string, store: TokenStore): Promise { + await store.clear(apiUrl); + void vscode.window.showInformationMessage(`Signed out of Operator at ${apiUrl}.`); +} diff --git a/vscode-extension/src/auth/token-store.ts b/vscode-extension/src/auth/token-store.ts new file mode 100644 index 00000000..86d50ad3 --- /dev/null +++ b/vscode-extension/src/auth/token-store.ts @@ -0,0 +1,51 @@ +/** + * Device-flow credentials, kept only in VS Code's SecretStorage. + * + * Settings sync across machines and workspace files land in Git; neither is an + * acceptable home for a refresh token, so nothing here touches `globalState` + * or configuration. + */ + +import type * as vscode from 'vscode'; +import type { Scope } from '../generated/Scope'; + +const KEY_PREFIX = 'operator.auth.'; + +export interface StoredCredential { + access_token: string; + refresh_token: string; + /** Epoch milliseconds at which `access_token` stops being usable. */ + expires_at: number; + scopes: Scope[]; +} + +/** Storage key for one daemon, so several daemons can be signed in at once. */ +export function credentialKey(apiUrl: string): string { + return `${KEY_PREFIX}${apiUrl.replace(/\/+$/, '')}`; +} + +export class TokenStore { + constructor(private readonly secrets: vscode.SecretStorage) {} + + async load(apiUrl: string): Promise { + const raw = await this.secrets.get(credentialKey(apiUrl)); + if (!raw) { + return undefined; + } + try { + return JSON.parse(raw) as StoredCredential; + } catch { + // Unparseable state is treated as absent rather than trusted. + await this.secrets.delete(credentialKey(apiUrl)); + return undefined; + } + } + + async save(apiUrl: string, credential: StoredCredential): Promise { + await this.secrets.store(credentialKey(apiUrl), JSON.stringify(credential)); + } + + async clear(apiUrl: string): Promise { + await this.secrets.delete(credentialKey(apiUrl)); + } +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 3c503f18..ed52435a 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -20,6 +20,13 @@ import { LaunchManager } from './launch-manager'; import { IssueTypeService } from './issuetype-service'; import { TicketInfo } from './types'; import { OperatorApiClient, discoverApiUrl } from './api-client'; +import { + OperatorCredentials, + clearCredentialProvider, + setCredentialProvider, +} from './auth/credentials'; +import { TokenStore } from './auth/token-store'; +import { signIn, signOut } from './auth/sign-in'; import { showLaunchOptionsDialog, showTicketPicker } from './launch-dialog'; import { parseTicketMetadata, getCurrentSessionId } from './ticket-parser'; import { @@ -478,24 +485,10 @@ async function resumeQueueCommand(ctx: CommandContext): Promise { // --------------------------------------------------------------------------- async function showAwaitingAgentPicker( - _apiClient: OperatorApiClient + apiClient: OperatorApiClient ): Promise { try { - const response = await fetch( - `${vscode.workspace.getConfiguration('operator').get('apiUrl', 'http://localhost:7008')}/api/v1/agents/active` - ); - if (!response.ok) { - void vscode.window.showErrorMessage('Failed to fetch active agents'); - return undefined; - } - const data = (await response.json()) as { - agents: Array<{ - id: string; - ticket_id: string; - project: string; - status: string; - }>; - }; + const data = await apiClient.listActiveAgents(); const awaitingAgents = data.agents.filter( (a) => a.status === 'awaiting_input' @@ -960,6 +953,12 @@ export async function activate( context.subscriptions.push(outputChannel); outputChannel.appendLine('[Operator] Activation started'); + // Every daemon request resolves its credential through this one provider. + const tokenStore = new TokenStore(context.secrets); + const credentials = new OperatorCredentials(tokenStore); + setCredentialProvider(credentials); + context.subscriptions.push({ dispose: clearCredentialProvider }); + // Initialize issue type service (constructor is safe — no network calls) const issueTypeService = new IssueTypeService(outputChannel); @@ -1061,6 +1060,7 @@ export async function activate( await completedProvider.refresh(); }, setTicketsDir: async (dir) => { + credentials.setTicketsDir(dir); await statusProvider.setTicketsDir(dir); await inProgressProvider.setTicketsDir(dir); await queueProvider.setTicketsDir(dir); @@ -1133,21 +1133,26 @@ export async function activate( if (!tool || !model) { return; } try { const apiUrl = await discoverApiUrl(ctx.getCurrentTicketsDir()); - const resp = await fetch(`${apiUrl}/api/v1/llm-tools/default`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tool, model }), - }); - if (resp.ok) { - void vscode.window.showInformationMessage(`Default LLM set to ${tool}:${model}`); - void ctx.refreshAllProviders(); - } else { - void vscode.window.showErrorMessage('Failed to set default LLM'); - } - } catch { - void vscode.window.showErrorMessage('Operator API not available'); + await new OperatorApiClient(apiUrl).setDefaultLlm({ tool, model }); + void vscode.window.showInformationMessage(`Default LLM set to ${tool}:${model}`); + void ctx.refreshAllProviders(); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Operator API not available'; + void vscode.window.showErrorMessage(`Failed to set default LLM: ${msg}`); } }), + vscode.commands.registerCommand('operator.signIn', async () => { + const apiUrl = await discoverApiUrl(ctx.getCurrentTicketsDir()); + const outcome = await signIn(apiUrl, credentials, tokenStore); + if (outcome?.status === 'approved') { + await ctx.refreshAllProviders(); + } + }), + vscode.commands.registerCommand('operator.signOut', async () => { + const apiUrl = await discoverApiUrl(ctx.getCurrentTicketsDir()); + await signOut(apiUrl, tokenStore); + await ctx.refreshAllProviders(); + }), vscode.commands.registerCommand('operator.openWalkthrough', openWalkthrough), vscode.commands.registerCommand('operator.openSettings', () => ConfigPanel.createOrShow(ctx.extensionContext.extensionUri)), diff --git a/vscode-extension/src/issuetype-service.ts b/vscode-extension/src/issuetype-service.ts index 4b559378..213b5f0f 100644 --- a/vscode-extension/src/issuetype-service.ts +++ b/vscode-extension/src/issuetype-service.ts @@ -7,6 +7,7 @@ */ import * as vscode from 'vscode'; +import { ApiError, OperatorApiClient } from './api-client'; import { IssueTypeSummary } from './generated'; /** @@ -139,16 +140,7 @@ export class IssueTypeService { */ async refresh(): Promise { try { - const response = await fetch(`${this.baseUrl}/api/v1/issuetypes`); - - if (!response.ok) { - this.outputChannel.appendLine( - `[IssueTypeService] Failed to fetch issue types: ${response.status}` - ); - return; - } - - const data = (await response.json()) as IssueTypeSummary[]; + const data = await new OperatorApiClient(this.baseUrl).listIssueTypes(); // Clear and reload this.types.clear(); @@ -159,8 +151,14 @@ export class IssueTypeService { this.outputChannel.appendLine( `[IssueTypeService] Loaded ${data.length} issue types from API` ); - } catch { - // API not available - keep using defaults + } catch (err) { + // Keep using defaults either way; the log line says which failure it was. + if (err instanceof ApiError) { + this.outputChannel.appendLine( + `[IssueTypeService] Failed to fetch issue types: ${err.status}` + ); + return; + } this.outputChannel.appendLine( `[IssueTypeService] API unavailable, using ${this.types.size} default types` ); diff --git a/vscode-extension/src/launch-dialog.ts b/vscode-extension/src/launch-dialog.ts index 2207015b..44839547 100644 --- a/vscode-extension/src/launch-dialog.ts +++ b/vscode-extension/src/launch-dialog.ts @@ -11,7 +11,7 @@ import { LaunchOptions, TicketInfo, ModelOption } from './types'; import type { DelegatorResponse } from './generated/DelegatorResponse'; import type { DelegatorsResponse } from './generated/DelegatorsResponse'; import type { ModelServerModelsResponse } from './generated/ModelServerModelsResponse'; -import { discoverApiUrl } from './api-client'; +import { discoverApiUrl, OperatorApiClient } from './api-client'; /** * Provider kind probed for the model fallback list when no delegators exist. @@ -36,12 +36,9 @@ async function fetchDelegators( ticketsDir: string | undefined ): Promise { try { - const apiUrl = await discoverApiUrl(ticketsDir); - const response = await fetch(`${apiUrl}/api/v1/delegators`); - if (response.ok) { - const data = (await response.json()) as DelegatorsResponse; - return data.delegators; - } + const client = new OperatorApiClient(await discoverApiUrl(ticketsDir)); + const data: DelegatorsResponse = await client.listDelegators(); + return data.delegators; } catch { // API not available } @@ -59,14 +56,8 @@ async function fetchFallbackModels( ticketsDir: string | undefined ): Promise { try { - const apiUrl = await discoverApiUrl(ticketsDir); - const response = await fetch( - `${apiUrl}/api/v1/model-servers/kinds/${FALLBACK_MODEL_KIND}/models` - ); - if (!response.ok) { - return null; - } - const data = (await response.json()) as ModelServerModelsResponse; + const client = new OperatorApiClient(await discoverApiUrl(ticketsDir)); + const data: ModelServerModelsResponse = await client.providerModels(FALLBACK_MODEL_KIND); if (!data.reachable || data.models.length === 0) { return null; } @@ -243,28 +234,21 @@ async function pickTarget( return choice.label.includes('Auto') ? undefined : choice.label; } -/** Named targets from the server config: [[targets]] entries + [[hosts]] synths. */ +/** The implicit default; offering it as an override would be a no-op. */ +const LOCAL_TARGET = 'local'; + +/** + * Named execution targets: [[targets]] entries, [[hosts]] synths, and docker + * when configured. Served by the read-scoped targets route rather than the + * admin-only configuration tree. + */ async function fetchTargetNames(ticketsDir?: string): Promise { try { - const apiUrl = await discoverApiUrl(ticketsDir); - const response = await fetch(`${apiUrl}/api/v1/configuration`); - if (!response.ok) { - return []; - } - const config = (await response.json()) as { - targets?: { name: string }[]; - hosts?: { name: string }[]; - launch?: { docker?: { enabled?: boolean; image?: string } }; - }; - const names = [ - ...(config.targets ?? []).map((t) => t.name), - ...(config.hosts ?? []).map((h) => h.name), - ]; - // The synthesized docker target is only worth offering when configured. - if (config.launch?.docker?.image) { - names.push('docker'); - } - return names; + const client = new OperatorApiClient(await discoverApiUrl(ticketsDir)); + const { targets } = await client.listExecutionTargets(); + return targets + .filter((t) => t.name !== LOCAL_TARGET && t.available) + .map((t) => t.name); } catch { return []; } diff --git a/vscode-extension/src/mcp-connect.ts b/vscode-extension/src/mcp-connect.ts index f4f5b8e2..63088fcf 100644 --- a/vscode-extension/src/mcp-connect.ts +++ b/vscode-extension/src/mcp-connect.ts @@ -22,7 +22,7 @@ import * as vscode from 'vscode'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; -import { discoverApiUrl } from './api-client'; +import { ApiError, discoverApiUrl, OperatorApiClient } from './api-client'; /** * Stdio entrypoint advertised by the Operator MCP descriptor when @@ -105,26 +105,23 @@ export function cursorMcpConfigPath(): string { export async function fetchMcpDescriptor( apiUrl: string ): Promise { - const url = `${apiUrl}/api/v1/mcp/descriptor`; - - let response: Response; + const client = new OperatorApiClient(apiUrl); try { - response = await fetch(url); + const descriptor = await client.mcpDescriptor(); + return { ...descriptor, stdio: descriptor.stdio ?? undefined }; } catch (err) { + if (err instanceof ApiError) { + throw new Error( + `MCP descriptor unavailable (HTTP ${err.status}). ` + + 'Ensure Operator is updated to a version that supports MCP.', + { cause: err }, + ); + } throw new Error( `Operator API is not running at ${apiUrl}. Start the server first.`, { cause: err }, ); } - - if (!response.ok) { - throw new Error( - `MCP descriptor unavailable (HTTP ${response.status}). ` + - 'Ensure Operator is updated to a version that supports MCP.' - ); - } - - return (await response.json()) as McpDescriptorResponse; } /** diff --git a/vscode-extension/src/open-operator-ui.ts b/vscode-extension/src/open-operator-ui.ts index b39cce84..447bcf67 100644 --- a/vscode-extension/src/open-operator-ui.ts +++ b/vscode-extension/src/open-operator-ui.ts @@ -12,7 +12,7 @@ */ import * as vscode from 'vscode'; -import { discoverApiUrl } from './api-client'; +import { discoverApiUrl, OperatorApiClient } from './api-client'; /** Sections of the hosted UI we can deep-link to (hash routes from ui/src/main.tsx). */ export type OperatorUiRoute = @@ -50,14 +50,7 @@ export async function openOperatorUi( // The hosted UI is served by the daemon; if it's down there is nothing to // show. Probe health before opening so the user gets an actionable message // rather than a blank Simple Browser tab. - let reachable: boolean; - try { - const res = await fetch(`${apiUrl}/api/v1/health`); - reachable = res.ok; - } catch { - reachable = false; - } - if (!reachable) { + if (!(await new OperatorApiClient(apiUrl).isReachable())) { const choice = await vscode.window.showErrorMessage( 'The Operator daemon is not running, so the Operator UI is unavailable. ' + 'Start the daemon, then try again.', diff --git a/vscode-extension/src/schemas/issuetype_schema.json b/vscode-extension/src/schemas/issuetype_schema.json index 4ff65d6f..dade2ac3 100644 --- a/vscode-extension/src/schemas/issuetype_schema.json +++ b/vscode-extension/src/schemas/issuetype_schema.json @@ -282,7 +282,7 @@ "type": "string" }, "review_type": { - "description": "Type of review required for this step (none, plan, visual, pr)", + "description": "Type of review required for this step (none, plan, visual, pr, proof)", "$ref": "#/$defs/ReviewType", "default": "none" }, @@ -298,6 +298,18 @@ ], "default": null }, + "proof_config": { + "description": "Configuration for proof review (required when `review_type` is \"proof\")", + "anyOf": [ + { + "$ref": "#/$defs/ProofReviewConfig" + }, + { + "type": "null" + } + ], + "default": null + }, "on_reject": { "description": "What to do if step output is rejected", "anyOf": [ @@ -603,6 +615,11 @@ "description": "Git interface PR review workflow", "type": "string", "const": "pr" + }, + { + "description": "Assertion command gate, then human confirmation", + "type": "string", + "const": "proof" } ] }, @@ -637,6 +654,45 @@ "url" ] }, + "ProofReviewConfig": { + "description": "Configuration for proof review steps", + "type": "object", + "properties": { + "assertion_command": { + "description": "Assertion command run via `sh -c` in the worktree root; exit code 0 = pass.\nSupports handlebars: `{{ticket_id}}`, `{{step}}`, `{{proof_dir}}`", + "type": "string" + }, + "artifact_command": { + "description": "Artifact-producing command (e.g. screenshot capture), run after the assertion regardless of its result", + "type": [ + "string", + "null" + ], + "default": null + }, + "artifact_patterns": { + "description": "Glob patterns (relative to worktree root) copied into `.proof/{ticket_id}/{step}/`", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "timeout_secs": { + "description": "Per-command timeout in seconds (default 120)", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0, + "default": null + } + }, + "required": [ + "assertion_command" + ] + }, "OnReject": { "description": "Action to take when a step is rejected", "type": "object", diff --git a/vscode-extension/src/sections/connections-section.ts b/vscode-extension/src/sections/connections-section.ts index 06d591ca..748e5c55 100644 --- a/vscode-extension/src/sections/connections-section.ts +++ b/vscode-extension/src/sections/connections-section.ts @@ -5,7 +5,8 @@ import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection, WebhookStatus, ApiStatus } from './types'; import type { SectionId, SectionHealth } from '../generated'; import { SessionInfo } from '../types'; -import { discoverApiUrl, ApiSessionInfo } from '../api-client'; +import { discoverApiUrl, ApiSessionInfo, AuthRequiredError, OperatorApiClient } from '../api-client'; +import { SIGN_IN_COMMAND_TITLE } from '../auth/errors'; import { getOperatorPath, getOperatorVersion } from '../operator-binary'; import { isMcpServerRegistered } from '../mcp-connect'; @@ -137,15 +138,12 @@ export class ConnectionsSection implements StatusSection { } private async tryHealthCheck(apiUrl: string, sessionVersion?: string): Promise { + const client = new OperatorApiClient(apiUrl); + const portStr = new URL(apiUrl).port; + const port = portStr ? parseInt(portStr, 10) : 7008; try { - const response = await fetch(`${apiUrl}/api/v1/health`); - if (response.ok) { - const health = await response.json() as { - version?: string; - directory_name?: string; - }; - const portStr = new URL(apiUrl).port; - const port = portStr ? parseInt(portStr, 10) : 7008; + if (await client.isReachable()) { + const health = await client.health(); const found = health.version; const expected = this.operatorVersion; @@ -195,8 +193,18 @@ export class ConnectionsSection implements StatusSection { await this.checkWebUi(apiUrl); return true; } - } catch { - // Health check failed + } catch (err) { + if (err instanceof AuthRequiredError) { + // Something is listening and it is Operator, but it will not talk to us yet. + this.apiStatus = { + connected: false, + port, + url: apiUrl, + mismatch: { kind: 'auth', detail: `at ${apiUrl} requires sign-in` }, + }; + this.webUiAvailable = false; + return false; + } } this.apiStatus = { connected: false }; this.webUiAvailable = false; @@ -287,6 +295,17 @@ export class ConnectionsSection implements StatusSection { : `Operator REST API at ${this.apiStatus.url}`, sectionId: this.sectionId, }); + } else if (this.apiStatus.mismatch?.kind === 'auth') { + apiItem = new StatusItem({ + label: 'API', + description: 'Sign-in required', + icon: 'key', + tooltip: + `The Operator API ${this.apiStatus.mismatch.detail}. ` + + `Run "${SIGN_IN_COMMAND_TITLE}" to authorize this editor.`, + command: { command: 'operator.signIn', title: SIGN_IN_COMMAND_TITLE }, + sectionId: this.sectionId, + }); } else if (this.apiStatus.mismatch) { // An API answered on the port but is not adoptable as ours. const mismatch = this.apiStatus.mismatch; diff --git a/vscode-extension/src/sections/delegator-section.ts b/vscode-extension/src/sections/delegator-section.ts index dea5177b..cc5dc052 100644 --- a/vscode-extension/src/sections/delegator-section.ts +++ b/vscode-extension/src/sections/delegator-section.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection } from './types'; import type { SectionId, SectionHealth } from '../generated'; -import { discoverApiUrl } from '../api-client'; +import { discoverApiUrl, OperatorApiClient } from '../api-client'; import type { DelegatorResponse } from '../generated/DelegatorResponse'; import type { DelegatorsResponse } from '../generated/DelegatorsResponse'; @@ -24,13 +24,10 @@ export class DelegatorSection implements StatusSection { async check(ctx: SectionContext): Promise { try { - const apiUrl = await discoverApiUrl(ctx.ticketsDir); - const response = await fetch(`${apiUrl}/api/v1/delegators`); - if (response.ok) { - const data = await response.json() as DelegatorsResponse; - this.state = { apiAvailable: true, delegators: data.delegators }; - return; - } + const client = new OperatorApiClient(await discoverApiUrl(ctx.ticketsDir)); + const data: DelegatorsResponse = await client.listDelegators(); + this.state = { apiAvailable: true, delegators: data.delegators }; + return; } catch { // API not available } diff --git a/vscode-extension/src/sections/issuetype-section.ts b/vscode-extension/src/sections/issuetype-section.ts index 523f39c8..46acc58e 100644 --- a/vscode-extension/src/sections/issuetype-section.ts +++ b/vscode-extension/src/sections/issuetype-section.ts @@ -4,7 +4,7 @@ import type { SectionContext, StatusSection } from './types'; import type { SectionId, SectionHealth } from '../generated'; import type { IssueTypeSummary } from '../generated/IssueTypeSummary'; import { DEFAULT_ISSUE_TYPES, GLYPH_TO_ICON, COLOR_TO_THEME } from '../issuetype-service'; -import { discoverApiUrl } from '../api-client'; +import { discoverApiUrl, OperatorApiClient } from '../api-client'; interface IssueTypeState { apiAvailable: boolean; @@ -25,13 +25,10 @@ export class IssueTypeSection implements StatusSection { async check(ctx: SectionContext): Promise { // Try fetching from API try { - const apiUrl = await discoverApiUrl(ctx.ticketsDir); - const response = await fetch(`${apiUrl}/api/v1/issuetypes`); - if (response.ok) { - const types = await response.json() as IssueTypeSummary[]; - this.state = { apiAvailable: true, types }; - return; - } + const client = new OperatorApiClient(await discoverApiUrl(ctx.ticketsDir)); + const types: IssueTypeSummary[] = await client.listIssueTypes(); + this.state = { apiAvailable: true, types }; + return; } catch { // API not available } diff --git a/vscode-extension/src/sections/llm-section.ts b/vscode-extension/src/sections/llm-section.ts index 9c3f6ea5..c034a621 100644 --- a/vscode-extension/src/sections/llm-section.ts +++ b/vscode-extension/src/sections/llm-section.ts @@ -3,8 +3,7 @@ import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection, LlmState, LlmToolInfo } from './types'; import type { SectionId, SectionHealth } from '../generated'; import { detectInstalledLlmTools } from '../walkthrough'; -import { discoverApiUrl } from '../api-client'; -import type { DetectedTool } from '../generated/DetectedTool'; +import { discoverApiUrl, OperatorApiClient } from '../api-client'; export class LlmSection implements StatusSection { readonly sectionId: SectionId = 'llm'; @@ -26,18 +25,15 @@ export class LlmSection implements StatusSection { // Priority 1: Try API (has model_aliases from embedded tool configs) try { - const apiUrl = await discoverApiUrl(ctx.ticketsDir); - const response = await fetch(`${apiUrl}/api/v1/llm-tools`); - if (response.ok) { - const data = await response.json() as { tools: DetectedTool[] }; - for (const tool of data.tools) { - seen.add(tool.name); - toolDetails.push({ - name: tool.name, - version: tool.version, - models: tool.model_aliases, - }); - } + const client = new OperatorApiClient(await discoverApiUrl(ctx.ticketsDir)); + const data = await client.listLlmTools(); + for (const tool of data.tools) { + seen.add(tool.name); + toolDetails.push({ + name: tool.name, + version: tool.version, + models: tool.model_aliases, + }); } } catch { // API not available @@ -91,12 +87,9 @@ export class LlmSection implements StatusSection { let defaultTool: string | undefined; let defaultModel: string | undefined; try { - const apiUrl = await discoverApiUrl(ctx.ticketsDir); - const defaultResp = await fetch(`${apiUrl}/api/v1/llm-tools/default`); - if (defaultResp.ok) { - const data = await defaultResp.json() as { tool: string; model: string }; - if (data.tool) { defaultTool = data.tool; defaultModel = data.model; } - } + const client = new OperatorApiClient(await discoverApiUrl(ctx.ticketsDir)); + const data = await client.getDefaultLlm(); + if (data.tool) { defaultTool = data.tool; defaultModel = data.model; } } catch { // API not available — fall back to config TOML const cfgForDefault = await ctx.readConfigToml(); diff --git a/vscode-extension/src/sections/managed-projects-section.ts b/vscode-extension/src/sections/managed-projects-section.ts index 6738749d..6f9c247e 100644 --- a/vscode-extension/src/sections/managed-projects-section.ts +++ b/vscode-extension/src/sections/managed-projects-section.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection } from './types'; import type { SectionId, SectionHealth } from '../generated'; -import { discoverApiUrl } from '../api-client'; +import { discoverApiUrl, OperatorApiClient } from '../api-client'; import type { ProjectSummary } from '../generated/ProjectSummary'; interface ManagedProjectsState { @@ -23,13 +23,10 @@ export class ManagedProjectsSection implements StatusSection { async check(ctx: SectionContext): Promise { try { - const apiUrl = await discoverApiUrl(ctx.ticketsDir); - const response = await fetch(`${apiUrl}/api/v1/projects`); - if (response.ok) { - const projects = await response.json() as ProjectSummary[]; - this.state = { configured: true, projects }; - return; - } + const client = new OperatorApiClient(await discoverApiUrl(ctx.ticketsDir)); + const projects: ProjectSummary[] = await client.getProjects(); + this.state = { configured: true, projects }; + return; } catch { // API not available } diff --git a/vscode-extension/src/sections/modelserver-section.ts b/vscode-extension/src/sections/modelserver-section.ts index 4bc425e3..00135591 100644 --- a/vscode-extension/src/sections/modelserver-section.ts +++ b/vscode-extension/src/sections/modelserver-section.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection } from './types'; import type { SectionId, SectionHealth } from '../generated'; -import { discoverApiUrl } from '../api-client'; +import { discoverApiUrl, OperatorApiClient } from '../api-client'; import type { ModelServerResponse } from '../generated/ModelServerResponse'; import type { ModelServersResponse } from '../generated/ModelServersResponse'; import type { ModelServerKindEntry } from '../generated/ModelServerKindEntry'; @@ -43,16 +43,13 @@ export class ModelServerSection implements StatusSection { async check(ctx: SectionContext): Promise { try { - const apiUrl = await discoverApiUrl(ctx.ticketsDir); - const response = await fetch(`${apiUrl}/api/v1/model-servers`); - if (!response.ok) { throw new Error('servers fetch failed'); } - const data = await response.json() as ModelServersResponse; + const client = new OperatorApiClient(await discoverApiUrl(ctx.ticketsDir)); + const data: ModelServersResponse = await client.listModelServers(); // Catalog of supported kinds (single source of truth, served by REST). let kinds: ModelServerKindEntry[] = []; try { - const kindsResp = await fetch(`${apiUrl}/api/v1/model-servers/kinds`); - if (kindsResp.ok) { kinds = await kindsResp.json() as ModelServerKindEntry[]; } + kinds = await client.listProviderKinds(); } catch { /* kinds are optional decoration */ } // Probe each server with a base_url for its model list (and reachability). @@ -63,10 +60,7 @@ export class ModelServerSection implements StatusSection { .filter((s) => !!s.base_url) .map(async (s) => { try { - const r = await fetch( - `${apiUrl}/api/v1/model-servers/${encodeURIComponent(s.name)}/models`, - ); - if (r.ok) { models[s.name] = await r.json() as ModelServerModelsResponse; } + models[s.name] = await client.modelServerModels(s.name); } catch { /* leave unprobed */ } }), ); diff --git a/vscode-extension/src/sections/types.ts b/vscode-extension/src/sections/types.ts index b7f9ee21..3be7a592 100644 --- a/vscode-extension/src/sections/types.ts +++ b/vscode-extension/src/sections/types.ts @@ -59,7 +59,8 @@ export interface ApiStatus { port?: number; url?: string; directoryName?: string; - mismatch?: { kind: 'version' | 'project'; detail: string }; + /** `auth`: the daemon answered but rejected every credential the extension holds. */ + mismatch?: { kind: 'version' | 'project' | 'auth'; detail: string }; } /** Internal state for the Configuration section */ diff --git a/vscode-extension/src/sections/workflows-section.ts b/vscode-extension/src/sections/workflows-section.ts index ae14d849..126b5eba 100644 --- a/vscode-extension/src/sections/workflows-section.ts +++ b/vscode-extension/src/sections/workflows-section.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { StatusItem } from '../status-item'; import type { SectionContext, StatusSection } from './types'; import type { SectionId, SectionHealth } from '../generated'; -import { discoverApiUrl } from '../api-client'; +import { discoverApiUrl, OperatorApiClient } from '../api-client'; import type { WorkflowFormatDto } from '../generated/WorkflowFormatDto'; /** @@ -29,13 +29,10 @@ export class WorkflowsSection implements StatusSection { async check(ctx: SectionContext): Promise { try { - const apiUrl = await discoverApiUrl(ctx.ticketsDir); - const response = await fetch(`${apiUrl}/api/v1/workflow-formats`); - if (response.ok) { - const formats = await response.json() as WorkflowFormatDto[]; - this.state = { apiAvailable: true, formats }; - return; - } + const client = new OperatorApiClient(await discoverApiUrl(ctx.ticketsDir)); + const formats: WorkflowFormatDto[] = await client.listWorkflowFormats(); + this.state = { apiAvailable: true, formats }; + return; } catch { // API not available — fall through to the unavailable state. } diff --git a/vscode-extension/test/suite/api-client.test.ts b/vscode-extension/test/suite/api-client.test.ts index d8f0bdf4..f3de0e37 100644 --- a/vscode-extension/test/suite/api-client.test.ts +++ b/vscode-extension/test/suite/api-client.test.ts @@ -11,12 +11,20 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { + AuthRequiredError, + LIVEZ_PATH, OperatorApiClient, discoverApiUrl, + toJson, QueueControlResponse, KanbanSyncResponse, ReviewResponse, } from '../../src/api-client'; +import { + clearCredentialProvider, + setCredentialProvider, +} from '../../src/auth/credentials'; +import { FakeCredentials, fakeCredentials } from './helpers/credentials'; import { HealthResponse, LaunchTicketRequest, @@ -57,14 +65,120 @@ interface RejectRequestBody { suite('API Client Test Suite', () => { let fetchStub: sinon.SinonStub; + let credentials: FakeCredentials; setup(() => { // Stub global fetch fetchStub = sinon.stub(global, 'fetch'); + credentials = fakeCredentials('test-token'); + setCredentialProvider(credentials); }); teardown(() => { sinon.restore(); + clearCredentialProvider(); + }); + + suite('authentication', () => { + test('sends the provider credential as a bearer token', async () => { + const client = new OperatorApiClient('http://localhost:7008'); + fetchStub.resolves(new Response(JSON.stringify([]), { status: 200 })); + + await client.listIssueTypes(); + + const [, init] = fetchStub.firstCall.args as [string, FetchInit]; + assert.strictEqual(init.headers.Authorization, 'Bearer test-token'); + }); + + test('keeps Content-Type alongside the bearer token on bodied requests', async () => { + const client = new OperatorApiClient('http://localhost:7008'); + fetchStub.resolves(new Response(JSON.stringify({}), { status: 200 })); + + await client.rejectReview('agent-1', 'reason'); + + const [, init] = fetchStub.firstCall.args as [string, FetchInit]; + assert.strictEqual(init.headers.Authorization, 'Bearer test-token'); + assert.strictEqual(init.headers['Content-Type'], 'application/json'); + }); + + test('sends no Authorization header when the provider has nothing', async () => { + credentials.token = undefined; + const client = new OperatorApiClient('http://localhost:7008'); + fetchStub.resolves(new Response(JSON.stringify([]), { status: 200 })); + + await client.listIssueTypes(); + + const [, init] = fetchStub.firstCall.args as [string, Partial | undefined]; + assert.strictEqual(init?.headers?.Authorization, undefined); + }); + + test('refreshes once and retries after a 401', async () => { + credentials.refreshed = 'fresh-token'; + const client = new OperatorApiClient('http://localhost:7008'); + fetchStub.onFirstCall().resolves(new Response('{}', { status: 401 })); + fetchStub.onSecondCall().resolves(new Response(JSON.stringify([]), { status: 200 })); + + await client.listIssueTypes(); + + assert.strictEqual(fetchStub.callCount, 2); + assert.strictEqual(credentials.refreshCalls, 1); + const [, init] = fetchStub.secondCall.args as [string, FetchInit]; + assert.strictEqual(init.headers.Authorization, 'Bearer fresh-token'); + }); + + test('throws AuthRequiredError when the retry is also rejected', async () => { + credentials.refreshed = 'fresh-token'; + const client = new OperatorApiClient('http://localhost:7008'); + fetchStub.resolves(new Response('{}', { status: 401 })); + + await assert.rejects( + () => client.listIssueTypes(), + (err: unknown) => + err instanceof AuthRequiredError && + err.status === 401 && + /Operator: Sign In/.test(err.message) + ); + assert.strictEqual(fetchStub.callCount, 2); + }); + + test('does not retry when refresh yields nothing new', async () => { + credentials.refreshed = undefined; + const client = new OperatorApiClient('http://localhost:7008'); + fetchStub.resolves(new Response('{}', { status: 401 })); + + await assert.rejects(() => client.listIssueTypes(), AuthRequiredError); + assert.strictEqual(fetchStub.callCount, 1); + }); + + test('fails loudly when no provider is configured', async () => { + clearCredentialProvider(); + const client = new OperatorApiClient('http://localhost:7008'); + await assert.rejects(() => client.listIssueTypes(), /setCredentialProvider/); + assert.ok(fetchStub.notCalled); + }); + }); + + suite('isReachable()', () => { + test('probes the public liveness route without a credential', async () => { + const client = new OperatorApiClient('http://localhost:7008'); + fetchStub.resolves(new Response('ok', { status: 200 })); + + assert.strictEqual(await client.isReachable(), true); + assert.strictEqual(fetchStub.firstCall.args[0], `http://localhost:7008${LIVEZ_PATH}`); + assert.strictEqual(fetchStub.firstCall.args[1], undefined); + }); + + test('is false when nothing is listening', async () => { + const client = new OperatorApiClient('http://localhost:7008'); + fetchStub.rejects(new TypeError('fetch failed')); + assert.strictEqual(await client.isReachable(), false); + }); + }); + + suite('toJson()', () => { + test('serializes bigint fields as numbers', () => { + assert.strictEqual(toJson({ expires_in_days: 30n, name: 'k' }), '{"expires_in_days":30,"name":"k"}'); + }); }); suite('discoverApiUrl()', () => { @@ -126,7 +240,7 @@ suite('API Client Test Suite', () => { }); suite('OperatorApiClient constructor', () => { - test('uses provided baseUrl', () => { + test('uses provided baseUrl', async () => { const client = new OperatorApiClient('http://custom:9000'); // Verify by making a request @@ -136,7 +250,7 @@ suite('API Client Test Suite', () => { }) ); - void client.health(); + await client.health(); assert.ok( fetchStub.calledWith('http://custom:9000/api/v1/health'), @@ -144,7 +258,7 @@ suite('API Client Test Suite', () => { ); }); - test('uses default URL when none provided', () => { + test('uses default URL when none provided', async () => { const client = new OperatorApiClient(); fetchStub.resolves( @@ -153,7 +267,7 @@ suite('API Client Test Suite', () => { }) ); - void client.health(); + await client.health(); // Default is http://localhost:7008 from vscode config assert.ok( @@ -692,10 +806,8 @@ suite('API Client Test Suite', () => { ) ); - await assert.rejects( - () => client.pauseQueue(), - /Authentication required/ - ); + // The server's wording is replaced by the actionable sign-in hint. + await assert.rejects(() => client.pauseQueue(), AuthRequiredError); }); test('handles HTTP 403 Forbidden', async () => { diff --git a/vscode-extension/test/suite/auth/credentials.test.ts b/vscode-extension/test/suite/auth/credentials.test.ts new file mode 100644 index 00000000..bde8f7ef --- /dev/null +++ b/vscode-extension/test/suite/auth/credentials.test.ts @@ -0,0 +1,260 @@ +/** + * Tests for src/auth/credentials.ts and src/auth/token-store.ts + * + * Group 2: Service Logic - fetch and the filesystem are faked. + */ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { + CLIENT_ID, + EXPIRY_SKEW_MS, + LOCAL_TOKEN_FILENAME, + OperatorCredentials, + SESSION_FILENAME, + clearCredentialProvider, + credentialProvider, + isLoopbackUrl, + readLocalToken, + setCredentialProvider, +} from '../../../src/auth/credentials'; +import { TokenStore, credentialKey } from '../../../src/auth/token-store'; +import { MemorySecrets, fakeCredentials } from '../helpers/credentials'; + +const API_URL = 'http://localhost:7008'; +const REMOTE_URL = 'http://build-box.internal:7008'; + +function tokenResponse(accessToken: string, refreshToken: string, expiresIn = 900): Response { + return new Response( + JSON.stringify({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: expiresIn, + refresh_token: refreshToken, + scopes: ['read', 'write', 'execute', 'admin'], + }), + { status: 200 } + ); +} + +function oauthError(error: string): Response { + return new Response(JSON.stringify({ error }), { status: 400 }); +} + +suite('Credentials Test Suite', () => { + let fetchStub: sinon.SinonStub; + let secrets: MemorySecrets; + let store: TokenStore; + let credentials: OperatorCredentials; + let ticketsDir: string; + + setup(async () => { + fetchStub = sinon.stub(global, 'fetch'); + secrets = new MemorySecrets(); + store = new TokenStore(secrets); + credentials = new OperatorCredentials(store); + ticketsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'operator-credentials-')); + credentials.setTicketsDir(ticketsDir); + }); + + teardown(async () => { + sinon.restore(); + clearCredentialProvider(); + await fs.rm(ticketsDir, { recursive: true, force: true }); + }); + + async function writeLocalToken(token: string, stateDir = path.join(ticketsDir, 'operator')) { + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile(path.join(stateDir, LOCAL_TOKEN_FILENAME), `${token}\n`); + } + + async function storeCredential(expiresInMs: number, accessToken = 'stored-access') { + await store.save(API_URL, { + access_token: accessToken, + refresh_token: 'stored-refresh', + expires_at: Date.now() + expiresInMs, + scopes: ['read'], + }); + } + + suite('module-level provider', () => { + test('throws when no provider has been configured', () => { + assert.throws(() => credentialProvider(), /activate\(\) must call setCredentialProvider/); + }); + + test('returns the configured provider', () => { + const fake = fakeCredentials(); + setCredentialProvider(fake); + assert.strictEqual(credentialProvider(), fake); + }); + + test('clear removes the provider again', () => { + setCredentialProvider(fakeCredentials()); + clearCredentialProvider(); + assert.throws(() => credentialProvider()); + }); + }); + + suite('isLoopbackUrl()', () => { + test('accepts localhost and loopback literals', () => { + assert.ok(isLoopbackUrl('http://localhost:7008')); + assert.ok(isLoopbackUrl('http://127.0.0.1:7008')); + assert.ok(isLoopbackUrl('http://127.0.0.5')); + assert.ok(isLoopbackUrl('http://[::1]:7008')); + }); + + test('rejects other hosts and garbage', () => { + assert.ok(!isLoopbackUrl(REMOTE_URL)); + assert.ok(!isLoopbackUrl('http://0.0.0.0:7008')); + assert.ok(!isLoopbackUrl('not a url')); + }); + }); + + suite('local token discovery', () => { + test('reads the token from the default state dir next to the session file', async () => { + await writeLocalToken('local-secret'); + assert.strictEqual(await readLocalToken(ticketsDir), 'local-secret'); + }); + + test('follows state_dir advertised in api-session.json', async () => { + const customState = path.join(ticketsDir, 'elsewhere'); + await writeLocalToken('custom-secret', customState); + await fs.mkdir(path.join(ticketsDir, 'operator'), { recursive: true }); + await fs.writeFile( + path.join(ticketsDir, 'operator', SESSION_FILENAME), + JSON.stringify({ port: 7008, pid: 1, started_at: '', version: '0', state_dir: customState }) + ); + assert.strictEqual(await readLocalToken(ticketsDir), 'custom-secret'); + }); + + test('is absent when the file is missing or blank', async () => { + assert.strictEqual(await readLocalToken(ticketsDir), undefined); + await writeLocalToken(' '); + assert.strictEqual(await readLocalToken(ticketsDir), undefined); + }); + }); + + suite('bearer()', () => { + test('prefers the local token over a stored device credential', async () => { + await writeLocalToken('local-secret'); + await storeCredential(60_000); + assert.strictEqual(await credentials.bearer(API_URL), 'local-secret'); + assert.ok(fetchStub.notCalled); + }); + + test('never sends the local token to a non-loopback daemon', async () => { + await writeLocalToken('local-secret'); + assert.strictEqual(await credentials.bearer(REMOTE_URL), undefined); + }); + + test('falls back to the stored access token when there is no local token', async () => { + await storeCredential(60_000); + assert.strictEqual(await credentials.bearer(API_URL), 'stored-access'); + assert.ok(fetchStub.notCalled); + }); + + test('refreshes when the stored access token is within the expiry skew', async () => { + await storeCredential(EXPIRY_SKEW_MS - 1000); + fetchStub.resolves(tokenResponse('fresh-access', 'fresh-refresh')); + + assert.strictEqual(await credentials.bearer(API_URL), 'fresh-access'); + const init = fetchStub.firstCall.args[1] as { body: string }; + const body = JSON.parse(init.body) as Record; + assert.strictEqual(body.grant_type, 'refresh_token'); + assert.strictEqual(body.refresh_token, 'stored-refresh'); + assert.strictEqual(body.client_id, CLIENT_ID); + }); + + test('is undefined with nothing stored and no local token', async () => { + assert.strictEqual(await credentials.bearer(API_URL), undefined); + }); + + test('ignores the local token when no tickets dir is known', async () => { + await writeLocalToken('local-secret'); + credentials.setTicketsDir(undefined); + assert.strictEqual(await credentials.bearer(API_URL), undefined); + }); + }); + + suite('refresh()', () => { + test('stores the rotated pair', async () => { + await storeCredential(0); + fetchStub.resolves(tokenResponse('fresh-access', 'fresh-refresh', 900)); + + await credentials.refresh(API_URL); + + const stored = await store.load(API_URL); + assert.ok(stored); + assert.strictEqual(stored.access_token, 'fresh-access'); + assert.strictEqual(stored.refresh_token, 'fresh-refresh'); + assert.ok(stored.expires_at > Date.now() + 800_000); + }); + + test('shares one in-flight request between concurrent callers', async () => { + await storeCredential(0); + let release: (r: Response) => void = () => {}; + fetchStub.returns(new Promise((resolve) => { release = resolve; })); + + const first = credentials.refresh(API_URL); + const second = credentials.refresh(API_URL); + release(tokenResponse('fresh-access', 'fresh-refresh')); + + assert.deepStrictEqual(await Promise.all([first, second]), ['fresh-access', 'fresh-access']); + assert.strictEqual(fetchStub.callCount, 1, 'a second redemption would revoke the family'); + }); + + test('allows a new refresh once the previous one settled', async () => { + await storeCredential(0); + fetchStub.onFirstCall().resolves(tokenResponse('a1', 'r1')); + fetchStub.onSecondCall().resolves(tokenResponse('a2', 'r2')); + + assert.strictEqual(await credentials.refresh(API_URL), 'a1'); + assert.strictEqual(await credentials.refresh(API_URL), 'a2'); + assert.strictEqual(fetchStub.callCount, 2); + }); + + test('clears storage on invalid_grant', async () => { + await storeCredential(0); + fetchStub.resolves(oauthError('invalid_grant')); + + assert.strictEqual(await credentials.refresh(API_URL), undefined); + assert.strictEqual(await store.load(API_URL), undefined); + }); + + test('keeps storage on a network failure', async () => { + await storeCredential(0); + fetchStub.rejects(new TypeError('fetch failed')); + + assert.strictEqual(await credentials.refresh(API_URL), undefined); + assert.ok(await store.load(API_URL), 'a transient failure must not sign the user out'); + }); + + test('does nothing when nothing is stored', async () => { + assert.strictEqual(await credentials.refresh(API_URL), undefined); + assert.ok(fetchStub.notCalled); + }); + }); + + suite('TokenStore', () => { + test('keys credentials per daemon url', () => { + assert.notStrictEqual(credentialKey(API_URL), credentialKey(REMOTE_URL)); + assert.strictEqual(credentialKey('http://localhost:7008/'), credentialKey(API_URL)); + }); + + test('round-trips and clears', async () => { + await storeCredential(1000, 'abc'); + assert.strictEqual((await store.load(API_URL))?.access_token, 'abc'); + await store.clear(API_URL); + assert.strictEqual(await store.load(API_URL), undefined); + }); + + test('treats corrupt storage as absent and removes it', async () => { + secrets.values.set(credentialKey(API_URL), '{not json'); + assert.strictEqual(await store.load(API_URL), undefined); + assert.ok(!secrets.values.has(credentialKey(API_URL))); + }); + }); +}); diff --git a/vscode-extension/test/suite/auth/device-flow.test.ts b/vscode-extension/test/suite/auth/device-flow.test.ts new file mode 100644 index 00000000..ab60b783 --- /dev/null +++ b/vscode-extension/test/suite/auth/device-flow.test.ts @@ -0,0 +1,210 @@ +/** + * Tests for src/auth/device-flow.ts + * + * Group 2: Service Logic - fetch is faked; time is driven by injected hooks. + */ + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { + DEVICE_CODE_PATH, + DeviceFlowHooks, + IDE_SCOPES, + SLOW_DOWN_INCREMENT_SECS, + pollForToken, + requestDeviceCode, + runDeviceFlow, +} from '../../../src/auth/device-flow'; +import { CLIENT_ID, TOKEN_PATH } from '../../../src/auth/credentials'; +import { TokenStore } from '../../../src/auth/token-store'; +import type { DeviceAuthorizationResponse } from '../../../src/generated'; +import { MemorySecrets } from '../helpers/credentials'; + +const API_URL = 'http://build-box.internal:7008'; + +// ts-rs types the u64 seconds as bigint, but a parsed JSON body carries +// numbers, which is what the code under test sees at runtime. +const AUTHORIZATION = { + device_code: 'device-secret', + user_code: 'ABCD-EFGH', + verification_uri: `${API_URL}/#/device`, + verification_uri_complete: `${API_URL}/#/device?user_code=ABCD-EFGH`, + expires_in: 900, + interval: 5, +} as unknown as DeviceAuthorizationResponse; + +function json(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { status }); +} + +const pending = () => json({ error: 'authorization_pending' }, 400); +const approved = () => + json( + { + access_token: 'access', + token_type: 'Bearer', + expires_in: 900, + refresh_token: 'refresh', + scopes: IDE_SCOPES, + }, + 200 + ); + +/** Hooks whose clock only advances when `sleep` is awaited. */ +class FakeClock { + time = 0; + sleeps: number[] = []; + cancelled = false; + + hooks(onCode: DeviceFlowHooks['onCode'] = () => Promise.resolve()): DeviceFlowHooks { + return { + onCode, + isCancelled: () => this.cancelled, + sleep: (ms) => { + this.sleeps.push(ms); + this.time += ms; + return Promise.resolve(); + }, + now: () => this.time, + }; + } +} + +suite('Device Flow Test Suite', () => { + let fetchStub: sinon.SinonStub; + let store: TokenStore; + let clock: FakeClock; + + setup(() => { + fetchStub = sinon.stub(global, 'fetch'); + store = new TokenStore(new MemorySecrets()); + clock = new FakeClock(); + }); + + teardown(() => { + sinon.restore(); + }); + + suite('requestDeviceCode()', () => { + test('asks for every IDE scope as the vscode client', async () => { + fetchStub.resolves(json(AUTHORIZATION, 200)); + + const result = await requestDeviceCode(API_URL); + + assert.strictEqual(fetchStub.firstCall.args[0], `${API_URL}${DEVICE_CODE_PATH}`); + const init = fetchStub.firstCall.args[1] as { method: string; body: string }; + assert.strictEqual(init.method, 'POST'); + assert.deepStrictEqual(JSON.parse(init.body), { client_id: CLIENT_ID, scopes: IDE_SCOPES }); + assert.strictEqual(result.user_code, 'ABCD-EFGH'); + }); + + test('surfaces the server error description', async () => { + fetchStub.resolves(json({ error: 'invalid_client', error_description: 'nope' }, 400)); + await assert.rejects(requestDeviceCode(API_URL), /nope/); + }); + }); + + suite('pollForToken()', () => { + test('waits the advertised interval, then stores tokens on approval', async () => { + fetchStub.onCall(0).resolves(pending()); + fetchStub.onCall(1).resolves(approved()); + + const outcome = await pollForToken(API_URL, AUTHORIZATION, store, clock.hooks()); + + assert.deepStrictEqual(outcome, { status: 'approved', scopes: IDE_SCOPES }); + assert.deepStrictEqual(clock.sleeps, [5000, 5000]); + assert.strictEqual(fetchStub.firstCall.args[0], `${API_URL}${TOKEN_PATH}`); + const init = fetchStub.firstCall.args[1] as { body: string }; + assert.deepStrictEqual(JSON.parse(init.body), { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: 'device-secret', + client_id: CLIENT_ID, + }); + const stored = await store.load(API_URL); + assert.strictEqual(stored?.access_token, 'access'); + assert.strictEqual(stored?.refresh_token, 'refresh'); + assert.strictEqual(stored?.expires_at, clock.time + 900_000); + }); + + test('lengthens the interval on slow_down', async () => { + fetchStub.onCall(0).resolves(json({ error: 'slow_down' }, 400)); + fetchStub.onCall(1).resolves(approved()); + + await pollForToken(API_URL, AUTHORIZATION, store, clock.hooks()); + + assert.deepStrictEqual(clock.sleeps, [5000, (5 + SLOW_DOWN_INCREMENT_SECS) * 1000]); + }); + + test('reports expiry from the server', async () => { + fetchStub.resolves(json({ error: 'expired_token' }, 400)); + assert.deepStrictEqual( + await pollForToken(API_URL, AUTHORIZATION, store, clock.hooks()), + { status: 'expired' } + ); + }); + + test('gives up locally once expires_in has elapsed', async () => { + // A Response body reads once, so each poll needs its own. + fetchStub.callsFake(() => Promise.resolve(pending())); + const outcome = await pollForToken(API_URL, AUTHORIZATION, store, clock.hooks()); + assert.deepStrictEqual(outcome, { status: 'expired' }); + assert.strictEqual(fetchStub.callCount, 900 / 5); + }); + + test('reports denial', async () => { + fetchStub.resolves(json({ error: 'access_denied' }, 400)); + assert.deepStrictEqual( + await pollForToken(API_URL, AUTHORIZATION, store, clock.hooks()), + { status: 'denied' } + ); + }); + + test('stops polling when cancelled', async () => { + fetchStub.callsFake(() => { + clock.cancelled = true; + return Promise.resolve(pending()); + }); + + const outcome = await pollForToken(API_URL, AUTHORIZATION, store, clock.hooks()); + + assert.deepStrictEqual(outcome, { status: 'cancelled' }); + assert.strictEqual(fetchStub.callCount, 1); + assert.strictEqual(await store.load(API_URL), undefined); + }); + + test('treats an unexpected error code as a failure', async () => { + fetchStub.resolves(json({ error: 'invalid_grant', error_description: 'bad code' }, 400)); + assert.deepStrictEqual( + await pollForToken(API_URL, AUTHORIZATION, store, clock.hooks()), + { status: 'error', message: 'bad code' } + ); + }); + }); + + suite('runDeviceFlow()', () => { + test('hands the code to the UI before polling', async () => { + fetchStub.onCall(0).resolves(json(AUTHORIZATION, 200)); + fetchStub.onCall(1).resolves(approved()); + const shown: string[] = []; + + const outcome = await runDeviceFlow( + API_URL, + store, + clock.hooks((auth) => { + shown.push(auth.user_code); + return Promise.resolve(); + }) + ); + + assert.deepStrictEqual(shown, ['ABCD-EFGH']); + assert.strictEqual(outcome.status, 'approved'); + }); + + test('reports a failed code request without polling', async () => { + fetchStub.rejects(new TypeError('fetch failed')); + const outcome = await runDeviceFlow(API_URL, store, clock.hooks()); + assert.deepStrictEqual(outcome, { status: 'error', message: 'fetch failed' }); + assert.strictEqual(fetchStub.callCount, 1); + }); + }); +}); diff --git a/vscode-extension/test/suite/helpers/credentials.ts b/vscode-extension/test/suite/helpers/credentials.ts new file mode 100644 index 00000000..5e77bb6d --- /dev/null +++ b/vscode-extension/test/suite/helpers/credentials.ts @@ -0,0 +1,57 @@ +/** + * Test doubles for the credential layer. + */ + +import * as vscode from 'vscode'; +import { CredentialProvider } from '../../../src/auth/credentials'; + +/** In-memory SecretStorage: enough of the interface for the token store. */ +export class MemorySecrets implements vscode.SecretStorage { + readonly values = new Map(); + private readonly emitter = new vscode.EventEmitter(); + readonly onDidChange = this.emitter.event; + + get(key: string): Thenable { + return Promise.resolve(this.values.get(key)); + } + + store(key: string, value: string): Thenable { + this.values.set(key, value); + this.emitter.fire({ key }); + return Promise.resolve(); + } + + delete(key: string): Thenable { + this.values.delete(key); + this.emitter.fire({ key }); + return Promise.resolve(); + } + + keys(): Thenable { + return Promise.resolve([...this.values.keys()]); + } +} + +export interface FakeCredentials extends CredentialProvider { + /** What `bearer()` returns; set to `undefined` to simulate no credential. */ + token: string | undefined; + /** What `refresh()` returns. */ + refreshed: string | undefined; + refreshCalls: number; +} + +/** A provider whose answers tests control directly. */ +export function fakeCredentials(token: string | undefined = 'test-token'): FakeCredentials { + const fake: FakeCredentials = { + token, + refreshed: undefined, + refreshCalls: 0, + bearer: () => Promise.resolve(fake.token), + refresh: () => { + fake.refreshCalls += 1; + return Promise.resolve(fake.refreshed); + }, + setTicketsDir: () => {}, + }; + return fake; +} diff --git a/vscode-extension/test/suite/issuetype-service.test.ts b/vscode-extension/test/suite/issuetype-service.test.ts index c0b3f786..e8546528 100644 --- a/vscode-extension/test/suite/issuetype-service.test.ts +++ b/vscode-extension/test/suite/issuetype-service.test.ts @@ -25,6 +25,9 @@ const fixturesDir = path.join( 'api' ); +import { clearCredentialProvider, setCredentialProvider } from '../../src/auth/credentials'; +import { fakeCredentials } from './helpers/credentials'; + suite('IssueType Service Test Suite', () => { let outputChannel: vscode.OutputChannel; let service: IssueTypeService; @@ -45,10 +48,12 @@ suite('IssueType Service Test Suite', () => { // Stub global fetch fetchStub = sinon.stub(global, 'fetch'); + setCredentialProvider(fakeCredentials()); }); teardown(() => { sinon.restore(); + clearCredentialProvider(); }); suite('constructor and defaults', () => { @@ -63,14 +68,14 @@ suite('IssueType Service Test Suite', () => { assert.ok(service.isKnownType('INV')); }); - test('uses provided baseUrl', () => { + test('uses provided baseUrl', async () => { service = new IssueTypeService(outputChannel, 'http://custom:9000'); // We can verify by checking that refresh would use the custom URL const customUrl = 'http://custom:9000'; fetchStub.resolves(new Response(JSON.stringify([]), { status: 200 })); - void service.refresh(); + await service.refresh(); assert.ok( fetchStub.calledWith(`${customUrl}/api/v1/issuetypes`), @@ -381,14 +386,14 @@ suite('IssueType Service Test Suite', () => { }); suite('setBaseUrl()', () => { - test('updates the base URL', () => { + test('updates the base URL', async () => { service = new IssueTypeService(outputChannel, 'http://localhost:7008'); service.setBaseUrl('http://newurl:9000'); // Verify by checking fetch calls fetchStub.resolves(new Response(JSON.stringify([]), { status: 200 })); - void service.refresh(); + await service.refresh(); assert.ok( fetchStub.calledWith('http://newurl:9000/api/v1/issuetypes'), diff --git a/vscode-extension/test/suite/mcp-connect.test.ts b/vscode-extension/test/suite/mcp-connect.test.ts index ec27021c..12d68fe0 100644 --- a/vscode-extension/test/suite/mcp-connect.test.ts +++ b/vscode-extension/test/suite/mcp-connect.test.ts @@ -43,15 +43,20 @@ async function loadFixture(name: string): Promise { ) as McpDescriptorResponse; } +import { clearCredentialProvider, setCredentialProvider } from '../../src/auth/credentials'; +import { fakeCredentials } from './helpers/credentials'; + suite('MCP Connect Test Suite', () => { let fetchStub: sinon.SinonStub; setup(() => { fetchStub = sinon.stub(global, 'fetch'); + setCredentialProvider(fakeCredentials()); }); teardown(() => { sinon.restore(); + clearCredentialProvider(); }); suite('fetchMcpDescriptor()', () => { diff --git a/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts index 3abe8eae..bc0055e1 100644 --- a/vscode-extension/webview-ui/types/defaults.ts +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -107,12 +107,15 @@ const DEFAULT_CONFIG: Config = { host: '127.0.0.1', port: 7008, cors_origins: [], + public_url: null, }, git: { provider: null, github: { enabled: true, token_env: 'GITHUB_TOKEN' }, gitlab: { enabled: false, token_env: 'GITLAB_TOKEN', host: null }, - branch_format: '{type}/{ticket_id}-{slug}', + gitea: { enabled: false, token_env: 'GITEA_TOKEN', host: null, wip_prefix: 'WIP: ' }, + forgejo: { enabled: false, token_env: 'FORGEJO_TOKEN', host: null, wip_prefix: 'WIP: ' }, + branch_format: '{type}/{ticket_id}', use_worktrees: false, }, kanban: { diff --git a/webcomponents/README.md b/webcomponents/README.md index 75b6a8f5..b6040779 100644 --- a/webcomponents/README.md +++ b/webcomponents/README.md @@ -11,16 +11,6 @@ The point is that there is one implementation. An Operator workflow drawn in the app and the same workflow drawn on the docs site come from the same source over the same bytes, so they cannot disagree. -## What belongs here - -Anything the docs site and the SPA both need to render. Today that is the -workflow graph and the collection-catalog search; new shared JS should land -here rather than being written twice. - -What does *not* belong here: page chrome and layout owned by one surface. The -collection card grid and table are rendered by the Rust docs generator and -styled by `docs/assets/css/main.css`; this package only enhances them. - ## Layout ``` diff --git a/webcomponents/src/workflow/issuetype-to-ir.test.ts b/webcomponents/src/workflow/issuetype-to-ir.test.ts index 17fe930e..efa89745 100644 --- a/webcomponents/src/workflow/issuetype-to-ir.test.ts +++ b/webcomponents/src/workflow/issuetype-to-ir.test.ts @@ -95,6 +95,11 @@ describe('issueTypeToGraph', () => { expect(nodes.find((n) => n.id === 'build')?.badge).toBeUndefined(); }); + test('surfaces a proof review gate as a node badge', () => { + const { nodes } = issueTypeToGraph(doc([step({ name: 'verify', review_type: 'proof' })])); + expect(nodes.find((n) => n.id === 'verify')?.badge).toBe('proof review'); + }); + test('fans multi_model steps out to a voting aggregate', () => { const { nodes, edges } = issueTypeToGraph( doc([ diff --git a/zed-extension/TODO.md b/zed-extension/TODO.md index 618f5dfc..adce4937 100644 --- a/zed-extension/TODO.md +++ b/zed-extension/TODO.md @@ -33,18 +33,18 @@ Feature comparison and implementation status vs VS Code extension. Features Zed has that VS Code doesn't: -1. **Native MCP integration** — tools appear directly in Agent Panel without manual config -2. **ACP agent sessions** — prompts flow through Operator to Claude Code delegator -3. **AI-accessible slash commands** — both humans and AI can use them in the assistant +1. **Native MCP integration** - tools appear directly in Agent Panel without manual config +2. **ACP agent sessions** - prompts flow through Operator to Claude Code delegator +3. **AI-accessible slash commands** - both humans and AI can use them in the assistant ## Not Possible in Zed (API Limitations) -1. **Sidebar views** — no TreeDataProvider equivalent -2. **Status bar items** — no extension API -3. **Terminal management** — no programmatic terminal API -4. **File watching** — no extension file watcher -5. **Agent server from WASM** — must use settings.json config (extension.toml requires binary downloads) -6. **Webhook server** — WASM sandbox prevents port listening +1. **Sidebar views** - no TreeDataProvider equivalent +2. **Status bar items** - no extension API +3. **Terminal management** - no programmatic terminal API +4. **File watching** - no extension file watcher +5. **Agent server from WASM** - must use settings.json config (extension.toml requires binary downloads) +6. **Webhook server** - WASM sandbox prevents port listening ## Future Improvements