diff --git a/.agents/skills/wrdn-effect-atom-optimistic/SKILL.md b/.agents/skills/wrdn-effect-atom-optimistic/SKILL.md index 44b6ef488..5f121ccc2 100644 --- a/.agents/skills/wrdn-effect-atom-optimistic/SKILL.md +++ b/.agents/skills/wrdn-effect-atom-optimistic/SKILL.md @@ -41,7 +41,7 @@ When the trace cannot resolve with the files at hand, drop the finding. - `useState` for a "busy" / "submitting" boolean used to disable a button while the mutation runs. That is not optimistic state. - `setTimeout` / `setInterval` based debouncing or rate-limiting around a mutation. Different concern. - Toast / error-message state. UI feedback, not optimistic data. -- Server-only code (`apps/cloud`, `apps/local`, `packages/core/**`). This skill is React-specific; do not flag backend handlers, plugin storage, or test helpers. +- Server-only code (`legacy/cloud`, `legacy/local`, `packages/core/**`). This skill is React-specific; do not flag backend handlers, plugin storage, or test helpers. - Storybook files, test files, and example-only code. The pattern matters in shipped UI; not in fixtures. ## Severity ladder diff --git a/.changeset/README.md b/.changeset/README.md deleted file mode 100644 index 7c9773cf4..000000000 --- a/.changeset/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Changesets - -This repo uses Changesets to drive releases for the published `executor` CLI. - -## What to put in a changeset - -Only `executor` is managed directly by Changesets. - -Release PRs should only mention the published CLI package directly. Changesets -will still version the fixed release group and dependent public packages as -needed, and will update each affected package's `CHANGELOG.md`. - -Write the changeset body as the package changelog entry you want to appear in -the Version Packages PR and in the affected package changelogs. Keep broader -user-facing launch notes in `apps/cli/release-notes/next.md`; those are used for -the GitHub Release body. - -## Beta releases - -Use prerelease mode for beta trains: - -- `bun run release:beta:start` -- merge release PRs while prerelease mode is active -- `bun run release:beta:stop` when you want to return to stable releases diff --git a/.changeset/config.json b/.changeset/config.json deleted file mode 100644 index 56e35abe8..000000000 --- a/.changeset/config.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json", - "changelog": ["@changesets/changelog-github", { "repo": "RhysSullivan/executor" }], - "commit": false, - "fixed": [ - [ - "@executor-js/sdk", - "@executor-js/codemode-core", - "@executor-js/runtime-quickjs", - "@executor-js/execution", - "@executor-js/config", - "executor", - "@executor-js/plugin-file-secrets", - "@executor-js/plugin-graphql", - "@executor-js/plugin-keychain", - "@executor-js/plugin-mcp", - "@executor-js/plugin-onepassword", - "@executor-js/plugin-openapi", - "@executor-js/plugin-example", - "@executor-js/plugin-desktop-settings", - "@executor-js/desktop" - ] - ], - "linked": [], - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { - "onlyUpdatePeerDependentsWhenOutOfRange": true - }, - "ignore": [ - "@executor-js/local", - "@executor-js/host-mcp", - "@executor-js/ir", - "@executor-js/runtime-deno-subprocess", - "@executor-js/marketing", - "@executor-js/runtime-dynamic-worker", - "@executor-js/plugin-workos-vault", - "@executor-js/example-promise-sdk", - "@executor-js/app" - ] -} diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index a068f80ef..85231bc81 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -67,9 +67,9 @@ icon = "run" command = "bun i" [[actions]] -name = "Run Local Server" +name = "Run Legacy Local Server" icon = "tool" command = ''' -cd apps/local +cd legacy/local bun dev ''' diff --git a/.dockerignore b/.dockerignore index 792e93cda..fb1787aca 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,6 @@ **/.next **/*.log .claude +secrets +**/secret.key +**/master.key diff --git a/.github/ISSUE_TEMPLATE/desktop-crash.yml b/.github/ISSUE_TEMPLATE/desktop-crash.yml deleted file mode 100644 index 401f9d7e0..000000000 --- a/.github/ISSUE_TEMPLATE/desktop-crash.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Desktop app crash -description: The Executor desktop app crashes, won't start, or shows a blank window. -title: "[desktop] " -labels: ["desktop", "crash"] -body: - - type: markdown - attributes: - value: | - Before filing, please try the quick fixes in the pinned - **[Desktop app crashing? Start here](https://github.com/RhysSullivan/executor/issues?q=is%3Aissue+label%3Acrash-triage)** - guide — most crashes are fixed by updating to the latest version. - If you're still stuck, the details below help us pin it down fast. - - type: input - id: version - attributes: - label: Executor version - description: Shown in the About menu (or Settings page). Please update to the latest first if you can. - placeholder: "1.5.4" - validations: - required: true - - type: dropdown - id: os - attributes: - label: Operating system - options: - - macOS (Apple Silicon) - - macOS (Intel) - - Windows - - Linux - validations: - required: true - - type: dropdown - id: when - attributes: - label: When does it crash? - options: - - On launch — the app never opens - - Shortly after launch - - While doing something specific (describe below) - - Randomly during use - validations: - required: true - - type: textarea - id: what-happened - attributes: - label: What happened - description: What were you doing when it crashed? Does it happen every time? - validations: - required: true - - type: textarea - id: logs - attributes: - label: Diagnostics / logs - description: | - If the app opens far enough to show a menu, open the **Executor** menu → **Export Diagnostics…** - (or **Report a Problem…**) and attach the zip — it has everything we need and no secrets. - - Otherwise, attach the log file directly: - - macOS: `~/Library/Logs/Executor/main.log` - - Windows: `%APPDATA%\Executor\logs\main.log` - - Linux: `~/.config/Executor/logs/main.log` - - (Please don't attach anything from `~/.executor` — that's where your data and secrets live.) - placeholder: Drag the diagnostics zip or main.log here. - - type: input - id: run-id - attributes: - label: Run ID (optional) - description: If a crash report was sent, the Run ID from the diagnostics manifest helps us find it. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 937936e3b..5707a2397 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,18 +6,107 @@ on: branches: - main +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: + rust: + name: Rust + runs-on: ubuntu-22.04 + timeout-minutes: 30 + env: + EXECUTOR_WEB_ASSETS_DIR: tests/fixtures/web-assets + steps: + - name: Checkout source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Install Rust toolchain + shell: bash + run: | + set -euo pipefail + rustup toolchain install 1.96.0 \ + --profile minimal \ + --component clippy,rustfmt + rustup override set 1.96.0 + + - name: Cache Cargo + uses: Swatinem/rust-cache@401aff9a7a08acb9d27b64936a90db81024cff97 # v2.8.2 + with: + cache-on-failure: false + shared-key: rust-1.96.0 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Check all targets and features + run: cargo check --locked --all-targets --all-features + + - name: Lint all targets and features + run: cargo clippy --locked --all-targets --all-features -- -D warnings + + - name: Set up Bun for emulator-backed Rust tests + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: 1.3.11 + + - name: Install locked emulator dependency + run: bun install --frozen-lockfile --ignore-scripts + + - name: Verify OAuth test emulator + run: test -x e2e/node_modules/.bin/emulate + + - name: Test all targets and features + run: cargo test --locked --all-targets --all-features + + web: + name: Svelte web + runs-on: ubuntu-22.04 + timeout-minutes: 20 + steps: + - name: Checkout source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check formatting + run: bun run format:check + working-directory: web + + - name: Lint + run: bun run lint + working-directory: web + + - name: Typecheck + run: bun run typecheck + working-directory: web + + - name: Test + run: bun run test + working-directory: web + format: name: Format runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.3.11 @@ -29,9 +118,11 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.3.11 @@ -43,9 +134,11 @@ jobs: name: Typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.3.11 @@ -57,61 +150,135 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - bun-version: 1.3.11 + persist-credentials: false - # apps/cloud's test script invokes `node` directly; undici 8.x (pulled - # in by @cloudflare/vitest-pool-workers) calls webidl.markAsUncloneable - # which only exists in Node 22.10+. Pin a known-good runtime. - - uses: actions/setup-node@v4 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: - node-version: 22 + bun-version: 1.3.11 - run: bun install --frozen-lockfile - run: bun run test - desktop-smoke: - name: Desktop smoke build - runs-on: ubuntu-latest + - name: Test release workflow and Docker contracts + run: bun run test:release:static + + - name: Test release archive installer + run: bun run test:release:installer + + - name: Test systemd master-key lifecycle + run: bun run test:release:systemd + + - name: Check release shell scripts + run: bun run test:release:shell + + local-selfhost-e2e: + name: Local self-host e2e + runs-on: ubuntu-22.04 + timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - name: Checkout source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - name: Set up Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.3.11 - - run: bun install --frozen-lockfile + - name: Install Rust toolchain + shell: bash + run: | + set -euo pipefail + rustup toolchain install 1.96.0 --profile minimal + rustup override set 1.96.0 + + - name: Cache Cargo + uses: Swatinem/rust-cache@401aff9a7a08acb9d27b64936a90db81024cff97 # v2.8.2 + with: + cache-on-failure: false + shared-key: rust-1.96.0-local-selfhost-e2e + + - name: Install dependencies + run: bun install --frozen-lockfile - - name: Build web app - run: bun run --filter @executor-js/local build + - name: Install Chromium + run: bun run --cwd e2e playwright install --with-deps chromium - - name: Build bundled executor - env: - BUN_TARGET: bun-linux-x64 - run: bun ./scripts/build-sidecar.ts - working-directory: apps/desktop + - name: Run local self-host e2e + run: bun run test:e2e - - name: Build Electron main/preload/renderer - run: bunx --bun electron-vite build - working-directory: apps/desktop + - name: Upload e2e artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: local-selfhost-e2e + path: e2e/runs/ + if-no-files-found: error + retention-days: 14 selfhost-docker-smoke: - name: Self-host Docker image - runs-on: ubuntu-latest + name: Rust self-host Docker image + runs-on: ubuntu-22.04 + timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Build self-host image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . - file: apps/host-selfhost/Dockerfile + file: Dockerfile + load: true push: false tags: executor-selfhost:ci + + - name: Smoke test container health and shutdown + shell: bash + run: | + set -euo pipefail + docker volume create executor-ci-data >/dev/null + docker run --detach \ + --name executor-ci \ + --init \ + --read-only \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + --memory 2g \ + --pids-limit 512 \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \ + --mount source=executor-ci-data,target=/var/lib/executor \ + executor-selfhost:ci >/dev/null + + status="starting" + for _ in {1..60}; do + status="$(docker inspect --format '{{.State.Health.Status}}' executor-ci)" + if [[ "$status" == "healthy" ]]; then + break + fi + if [[ "$(docker inspect --format '{{.State.Status}}' executor-ci)" == "exited" ]]; then + break + fi + sleep 1 + done + if [[ "$status" != "healthy" ]]; then + docker logs executor-ci + exit 1 + fi + + docker stop --time 120 executor-ci >/dev/null + test "$(docker inspect --format '{{.State.ExitCode}}' executor-ci)" = "0" + + - name: Clean up container smoke test + if: always() + run: | + docker rm --force executor-ci >/dev/null 2>&1 || true + docker volume rm --force executor-ci-data >/dev/null 2>&1 || true diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml deleted file mode 100644 index cb9cf0544..000000000 --- a/.github/workflows/pkg-pr-new.yml +++ /dev/null @@ -1,117 +0,0 @@ -name: Publish (pkg.pr.new) - -on: - pull_request: - -permissions: - contents: read - -jobs: - # Per-platform matrix: build the executor binary, tar it, upload to R2. - # The wrapper npm package is built later by the `publish` job which just - # needs to know which platforms made it this far. - build-preview-binary: - name: Build preview binary (${{ matrix.target }}) - # Fork PRs don't receive repo secrets, so the R2 upload step can't - # authenticate. Skip the whole preview pipeline (publish `needs:` this - # job and cascades to skipped) rather than failing the check. - if: github.event.pull_request.head.repo.full_name == github.repository - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-latest - target: executor-linux-x64 - - runner: macos-14 - target: executor-darwin-arm64 - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.11 - - - run: bun install --frozen-lockfile - - - name: Build executor preview tarball - env: - EXECUTOR_PREVIEW_CDN_URL: ${{ vars.EXECUTOR_PREVIEW_CDN_URL }} - EXECUTOR_PREVIEW_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - run: bun run --filter=executor build:preview:tarball - - - name: Upload preview binary to R2 - # curl --aws-sigv4 handles large uploads directly against R2's S3 - # endpoint. Simpler than aws-cli (endpoint validation quirks) or - # Bun.s3 (ConnectionRefused on ubuntu-latest for reasons unclear). - env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com - SHA: ${{ github.event.pull_request.head.sha || github.sha }} - TARGET: ${{ matrix.target }} - run: | - tarball="apps/cli/dist/previews/${TARGET}.tar.gz" - echo "Uploading ${TARGET}.tar.gz → r2://executor-previews/$SHA/" - curl --fail-with-body --silent --show-error \ - -X PUT "$R2_ENDPOINT/executor-previews/$SHA/${TARGET}.tar.gz" \ - --upload-file "$tarball" \ - --aws-sigv4 "aws:amz:auto:s3" \ - --user "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" - - publish: - name: Publish - needs: build-preview-binary - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.11 - - - run: bun install --frozen-lockfile - - - name: Build executor preview wrapper - env: - EXECUTOR_PREVIEW_CDN_URL: ${{ vars.EXECUTOR_PREVIEW_CDN_URL }} - EXECUTOR_PREVIEW_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - EXECUTOR_PREVIEW_TARGETS: executor-linux-x64,executor-darwin-arm64 - run: bun run --filter=executor build:preview:wrapper - - # Apply the same `publishConfig.exports` promotion and `workspace:*` - # resolution the real npm release does. Runs `build:packages` - # internally. Without this, pkg-pr-new ships unresolved - # `workspace:*` references and the dev-time `src/index.ts` exports. - - run: bun run release:publish:packages:prepare - - # Explicit list (not `plugins/*`) — must match PUBLIC_PACKAGE_DIRS - # in scripts/publish-packages.ts. Globbing would pick up unprepared - # private packages that would publish with broken refs. - # - # No --compact: it requires every package already exist on npm to - # generate short URLs. New packages aren't published yet, and - # --compact aborts the whole run when it can't resolve them. - - run: > - npx pkg-pr-new publish --bun - './apps/cli/dist/executor' - './packages/core/storage-core' - './packages/core/sdk' - './packages/core/config' - './packages/core/execution' - './packages/core/cli' - './packages/kernel/core' - './packages/kernel/runtime-quickjs' - './packages/plugins/file-secrets' - './packages/plugins/graphql' - './packages/plugins/keychain' - './packages/plugins/mcp' - './packages/plugins/onepassword' - './packages/plugins/openapi' diff --git a/.github/workflows/preview-sweep.yml b/.github/workflows/preview-sweep.yml index 977ba31c9..b194e238c 100644 --- a/.github/workflows/preview-sweep.yml +++ b/.github/workflows/preview-sweep.yml @@ -6,6 +6,7 @@ name: Preview sweep # open. A second pass (sweep-e2e) cleans up after the e2e harness on the same # account: crashed suite runs leak executor-e2e-* stacks, and synthetic OTP # logins permanently occupy Zero Trust seats. +# The repository variable ENABLE_CLOUDFLARE_PREVIEWS must be exactly "true". on: schedule: @@ -19,11 +20,14 @@ permissions: jobs: sweep: name: Destroy previews for closed PRs + if: vars.ENABLE_CLOUDFLARE_PREVIEWS == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.3.11 @@ -34,13 +38,13 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - for pr in $(bun apps/host-cloudflare/scripts/preview.ts list | jq -r '.[].pr'); do + for pr in $(bun legacy/host-cloudflare/scripts/preview.ts list | jq -r '.[].pr'); do state="$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" --json state -q .state 2>/dev/null || echo UNKNOWN)" if [ "$state" != "OPEN" ]; then - echo "PR #$pr is $state — destroying its preview" - bun apps/host-cloudflare/scripts/preview.ts destroy --pr "$pr" + echo "PR #$pr is $state, destroying its preview" + bun legacy/host-cloudflare/scripts/preview.ts destroy --pr "$pr" else - echo "PR #$pr is open — keeping its preview" + echo "PR #$pr is open, keeping its preview" fi done @@ -48,4 +52,4 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_PREVIEW_ACCOUNT_ID }} - run: bun apps/host-cloudflare/scripts/preview.ts sweep-e2e + run: bun legacy/host-cloudflare/scripts/preview.ts sweep-e2e diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 3d3bef13f..87e76df4a 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -3,13 +3,14 @@ name: Preview # Per-PR preview deploys of the Cloudflare host to a dedicated preview account. # Every same-repo PR gets its own isolated stack (Worker + D1 + Access app) # behind Cloudflare Access; it is torn down when the PR closes. Fork PRs are -# skipped — they must not run with the deploy token. +# skipped because they must not run with the deploy token. The repository +# variable ENABLE_CLOUDFLARE_PREVIEWS must also be exactly "true". # # Required repo configuration: -# secret CLOUDFLARE_PREVIEW_API_TOKEN — scoped token (Workers/D1/R2/Access edit) -# var CLOUDFLARE_PREVIEW_ACCOUNT_ID — the preview Cloudflare account -# var PREVIEW_ACCESS_TEAM_DOMAIN — Zero Trust team domain -# var PREVIEW_ACCESS_EMAILS — comma-separated emails allowed through Access +# secret CLOUDFLARE_PREVIEW_API_TOKEN: scoped token (Workers/D1/R2/Access edit) +# var CLOUDFLARE_PREVIEW_ACCOUNT_ID: the preview Cloudflare account +# var PREVIEW_ACCESS_TEAM_DOMAIN: Zero Trust team domain +# var PREVIEW_ACCESS_EMAILS: comma-separated emails allowed through Access on: pull_request: @@ -17,7 +18,6 @@ on: permissions: contents: read - pull-requests: write concurrency: group: preview-${{ github.event.pull_request.number }} @@ -26,12 +26,24 @@ concurrency: jobs: deploy: name: Deploy preview - if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository + if: vars.ENABLE_CLOUDFLARE_PREVIEWS == 'true' && github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - - uses: actions/checkout@v4 + - id: checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Verify preview commit + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.3.11 @@ -39,7 +51,7 @@ jobs: - name: Deploy id: deploy - working-directory: apps/host-cloudflare + working-directory: legacy/host-cloudflare env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_PREVIEW_ACCOUNT_ID }} @@ -48,9 +60,10 @@ jobs: run: bun scripts/preview.ts deploy --pr ${{ github.event.pull_request.number }} - name: Comment preview URL - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 env: PREVIEW_URL: ${{ steps.deploy.outputs.url }} + PREVIEW_SHA: ${{ steps.checkout.outputs.commit }} with: script: | const marker = ""; @@ -62,10 +75,10 @@ jobs: `|---|---|`, `| Console | ${process.env.PREVIEW_URL} |`, `| MCP | \`${process.env.PREVIEW_URL}/mcp\` |`, - `| Deployed commit | ${context.payload.pull_request.head.sha} |`, + `| Deployed commit | ${process.env.PREVIEW_SHA} |`, "", "Sign-in is Cloudflare Access (one-time PIN to an allowed email). " + - "The preview has its own database and encryption key; it is destroyed when this PR closes.", + "The preview has its own database and encryption key. It is destroyed when this PR closes.", ].join("\n"); const { data: comments } = await github.rest.issues.listComments({ ...context.repo, @@ -81,24 +94,35 @@ jobs: teardown: name: Tear down preview - if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository + if: vars.ENABLE_CLOUDFLARE_PREVIEWS == 'true' && github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Verify preview commit + env: + EXPECTED_SHA: ${{ github.event.pull_request.head.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 with: bun-version: 1.3.11 - # destroy talks straight to the Cloudflare API — no install needed. + # Destroy talks straight to the Cloudflare API, so no install is needed. - name: Destroy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_PREVIEW_ACCOUNT_ID }} - run: bun apps/host-cloudflare/scripts/preview.ts destroy --pr ${{ github.event.pull_request.number }} + run: bun legacy/host-cloudflare/scripts/preview.ts destroy --pr ${{ github.event.pull_request.number }} - name: Mark comment as torn down - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const marker = ""; @@ -112,6 +136,6 @@ jobs: await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, - body: `${marker}\n### Cloudflare preview\n\nTorn down — the PR is closed.`, + body: `${marker}\n### Cloudflare preview\n\nTorn down because the PR is closed.`, }); } diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml deleted file mode 100644 index cad7d4a30..000000000 --- a/.github/workflows/publish-desktop.yml +++ /dev/null @@ -1,233 +0,0 @@ -name: Publish Desktop App -run-name: "${{ format('publish desktop {0}', github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name) }}" - -# Triggered manually or by publish-executor-package.yml after a CLI release -# lands. Builds Electron distributables for mac/win/linux with the compiled -# executor CLI bundled in `resources/executor/`, then attaches them to the GitHub -# release matching the tag so electron-updater can pick them up. - -on: - workflow_dispatch: - inputs: - tag: - description: Git tag to publish (e.g. v1.4.1) - required: true - type: string - -permissions: - contents: read - -concurrency: - group: publish-desktop-${{ github.ref }} - cancel-in-progress: false - -jobs: - build: - permissions: - contents: read - strategy: - fail-fast: false - matrix: - include: - # smoke: run the compiled-sidecar smoke test on legs whose target - # matches the runner (the mac x64 leg cross-compiles on an arm64 - # runner, so its binary can't be executed natively there). - - os: macos-latest - arch: arm64 - platform: mac - bun-target: bun-darwin-arm64 - smoke: true - - os: macos-latest - arch: x64 - platform: mac - bun-target: bun-darwin-x64 - smoke: false - - os: ubuntu-latest - arch: x64 - platform: linux - bun-target: bun-linux-x64 - smoke: true - - os: windows-latest - arch: x64 - platform: win - bun-target: bun-windows-x64 - smoke: true - - runs-on: ${{ matrix.os }} - - # A healthy leg finishes in ~10 minutes. Without a deadline, a hung step - # wedges the whole publish queue: the concurrency group below queues later - # runs behind this one without cancelling it (v1.5.7's mac leg sat 4 hours - # on a bun install that errored without exiting, holding v1.5.8 pending). - timeout-minutes: 45 - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.11 - - - name: Validate release tag - env: - RAW_RELEASE_TAG: ${{ inputs.tag }} - run: bun run scripts/validate-release-ref.ts --tag-env RAW_RELEASE_TAG --write-env RELEASE_TAG - - - name: Checkout release tag - env: - GH_TOKEN: ${{ secrets.RELEASE_PAT || github.token }} - shell: bash - run: | - auth_remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git fetch --force --tags "$auth_remote" "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" - git checkout --detach "$RELEASE_TAG" - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 24 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build web app - run: bun run --filter @executor-js/local build - - - name: Build bundled executor - env: - BUN_TARGET: ${{ matrix.bun-target }} - run: bun ./scripts/build-sidecar.ts - working-directory: apps/desktop - - # Gate the release on the compiled binary actually booting. v1.5.0/.1 - # shipped local-server binaries that died on launch (missing libsql native - # binding) — a regression dev mode can't catch because `bun run` resolves - # node_modules that `bun build --compile` does not bundle. - - name: Smoke test bundled executor - if: matrix.smoke - run: bun run test:smoke - working-directory: apps/desktop - - - name: Build Electron main/preload/renderer - env: - # Crash-report DSN baked into the main bundle (see the define in - # electron.vite.config.ts). Unset (forks, local) → crash reporting - # is compiled out and dumps stay local. - DESKTOP_SENTRY_DSN: ${{ vars.DESKTOP_SENTRY_DSN }} - run: bunx --bun electron-vite build - working-directory: apps/desktop - - - name: Stage Apple API key (mac signing + notarization) - if: matrix.platform == 'mac' && env.APPLE_API_KEY != '' - env: - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - run: | - mkdir -p "${RUNNER_TEMP}/private_keys" - printf '%s' "$APPLE_API_KEY" > "${RUNNER_TEMP}/private_keys/AuthKey.p8" - echo "APPLE_API_KEY_PATH=${RUNNER_TEMP}/private_keys/AuthKey.p8" >> "$GITHUB_ENV" - - - name: Build desktop distributables - env: - # electron-builder reads GH_TOKEN for the publish step. We use - # --publish never here and attach assets explicitly in the release - # job so we don't fight electron-updater's metadata expectations. - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Apple signing + notarization. electron-builder picks these up: - # CSC_LINK / CSC_KEY_PASSWORD → import the Developer ID Application - # cert (.p12, base64) into a temp keychain for codesign - # APPLE_API_KEY (file path) / APPLE_API_KEY_ID / APPLE_API_ISSUER - # → notarytool authentication for the notarization upload - # When the secrets aren't set (forks, local), electron-builder - # silently produces an unsigned build. - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_API_KEY: ${{ env.APPLE_API_KEY_PATH }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - run: bunx --bun electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish never --config electron-builder.config.ts - working-directory: apps/desktop - - # The two mac legs each emit a latest-mac.yml listing only their own - # arch. Rename per-arch here; the release job merges them back into the - # single latest-mac.yml electron-updater clients fetch. Without this, - # merge-multiple in the release job lets one arch clobber the other and - # every Mac gets pointed at whichever leg uploaded last. - - name: Rename mac update manifest per arch - if: matrix.platform == 'mac' - shell: bash - run: mv apps/desktop/dist/latest-mac.yml "apps/desktop/dist/latest-mac-${{ matrix.arch }}.yml" - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: desktop-${{ matrix.platform }}-${{ matrix.arch }} - path: | - apps/desktop/dist/*.dmg - apps/desktop/dist/*.zip - apps/desktop/dist/*.exe - apps/desktop/dist/*.AppImage - apps/desktop/dist/*.deb - apps/desktop/dist/*.rpm - apps/desktop/dist/latest*.yml - if-no-files-found: warn - - release: - needs: build - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout validation script - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.11 - - - name: Validate release tag - env: - RAW_RELEASE_TAG: ${{ inputs.tag }} - run: bun run scripts/validate-release-ref.ts --tag-env RAW_RELEASE_TAG --write-env RELEASE_TAG - - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - merge-multiple: true - - - name: Merge mac update manifests - run: | - set -euo pipefail - bun scripts/merge-latest-mac-yml.ts \ - artifacts/latest-mac-x64.yml \ - artifacts/latest-mac-arm64.yml \ - artifacts/latest-mac.yml - rm artifacts/latest-mac-x64.yml artifacts/latest-mac-arm64.yml - - - name: Upload to GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - while IFS= read -r file; do - echo "Uploading: $file" - gh release upload "$RELEASE_TAG" "$file" --repo "$GITHUB_REPOSITORY" --clobber - done < <(find artifacts -type f \ - \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" \ - -o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" \ - -o -name "latest*.yml" \)) - - # Flip draft → published only after every desktop asset is uploaded — - # this is the atomic point where the new tag becomes "latest". - - name: Promote release (draft → published) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release edit "$RELEASE_TAG" --draft=false --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/publish-executor-package.yml b/.github/workflows/publish-executor-package.yml deleted file mode 100644 index 113ed9411..000000000 --- a/.github/workflows/publish-executor-package.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Publish Executor -run-name: "${{ format('publish executor {0}', github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name) }}" - -on: - push: - tags: - - "v*" - workflow_dispatch: - inputs: - tag: - description: Git tag to publish - required: true - type: string - -permissions: - contents: read - -concurrency: - group: publish-executor-package-${{ github.ref }} - cancel-in-progress: false - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - actions: write - contents: write - id-token: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.11 - - - name: Validate release tag - env: - RAW_RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} - run: bun run scripts/validate-release-ref.ts --tag-env RAW_RELEASE_TAG --write-env RELEASE_TAG - - - name: Checkout release tag - env: - GH_TOKEN: ${{ secrets.RELEASE_PAT || github.token }} - run: | - auth_remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git fetch --force --tags "$auth_remote" "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" - git checkout --detach "$RELEASE_TAG" - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - - name: Update npm for trusted publishing - run: | - npm install -g npm@latest - npm --version - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Run release checks - run: bun run release:check - - - name: Publish package and create release - env: - GH_TOKEN: ${{ github.token }} - run: | - export GITHUB_REF_TYPE=tag - export GITHUB_REF_NAME="$RELEASE_TAG" - export GITHUB_REF="refs/tags/$RELEASE_TAG" - bun run release:publish - - - name: Trigger desktop build - env: - GH_TOKEN: ${{ github.token }} - run: gh workflow run publish-desktop.yml -f tag="$RELEASE_TAG" - - - name: Trigger self-host Docker publish - env: - GH_TOKEN: ${{ github.token }} - run: gh workflow run publish-selfhost-docker.yml -f tag="$RELEASE_TAG" diff --git a/.github/workflows/publish-selfhost-docker.yml b/.github/workflows/publish-selfhost-docker.yml deleted file mode 100644 index ac54fb799..000000000 --- a/.github/workflows/publish-selfhost-docker.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Publish Self-host Docker Image -run-name: "${{ format('publish self-host docker {0}', inputs.tag) }}" - -on: - workflow_dispatch: - inputs: - tag: - description: Git tag to publish (e.g. v1.5.0) - required: true - type: string - -permissions: - contents: read - -concurrency: - group: publish-selfhost-docker-${{ inputs.tag }} - cancel-in-progress: false - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.11 - - - name: Validate release tag - env: - RAW_RELEASE_TAG: ${{ inputs.tag }} - run: bun run scripts/validate-release-ref.ts --tag-env RAW_RELEASE_TAG --write-env RELEASE_TAG - - - name: Checkout release tag - env: - GH_TOKEN: ${{ secrets.RELEASE_PAT || github.token }} - run: | - auth_remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git fetch --force --tags "$auth_remote" "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" - git checkout --detach "$RELEASE_TAG" - - - name: Resolve image metadata - id: image - shell: bash - run: | - set -euo pipefail - - version="${RELEASE_TAG#v}" - owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" - image="ghcr.io/${owner}/executor-selfhost" - revision="$(git rev-parse HEAD)" - - if [[ "$version" == *-* ]]; then - channel="beta" - else - channel="latest" - fi - - { - echo "image=$image" - echo "version=$version" - echo "channel=$channel" - echo "tags<> "$GITHUB_OUTPUT" - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push self-host image - uses: docker/build-push-action@v6 - with: - context: . - file: apps/host-selfhost/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.image.outputs.tags }} - labels: ${{ steps.image.outputs.labels }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c51c05ea2..404e5fc19 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,137 +1,2084 @@ -name: Release -run-name: prepare release +name: Release native Executor +run-name: "${{ format('{0} {1} at {2}', inputs.mode, inputs.tag, inputs.commit_sha) }}" on: - push: - branches: - - main + workflow_dispatch: + inputs: + mode: + description: Build only, or publish and promote the release + required: true + default: dry-run + type: choice + options: + - dry-run + - publish + tag: + description: Exact v-prefixed Cargo version, for example v0.1.0 + required: true + type: string + commit_sha: + description: Exact 40-character commit SHA to build + required: true + type: string permissions: contents: read concurrency: - group: release-${{ github.ref }} + group: native-release cancel-in-progress: false +env: + BUILDKIT_IMAGE: moby/buildkit:v0.30.0@sha256:0168606be2315b7c807a03b3d8aa79beefdb31c98740cebdffdfeebf31190c9f + BUILDX_VERSION: v0.34.1 + BUN_VERSION: 1.3.11 + EXECUTOR_REPOSITORY: ${{ github.repository }} + MACOS_BUILD_CLANG_VERSION: Apple clang version 17.0.0 (clang-1700.0.13.5) + MACOS_BUILD_DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer + MACOS_BUILD_LD_VERSION: "1167.5" + MACOS_BUILD_SDK_VERSION: "15.5" + MACOS_BUILD_XCODE_BUILD: 16F6 + MACOS_BUILD_XCODE_VERSION: "16.4" + MACOS_FLOOR_CLANG_VERSION: Apple clang version 15.0.0 (clang-1500.3.9.4) + MACOS_FLOOR_DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer + MACOS_FLOOR_LD_VERSION: "1053.12" + MACOS_FLOOR_SDK_VERSION: "14.5" + MACOS_FLOOR_XCODE_BUILD: 15F31d + MACOS_FLOOR_XCODE_VERSION: "15.4" + MACOSX_DEPLOYMENT_TARGET: "14.0" + RUST_VERSION: 1.96.0 + jobs: - release: - runs-on: ubuntu-latest + validate: + name: Validate immutable release inputs + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + channel: ${{ steps.release.outputs.channel }} + commit_sha: ${{ steps.release.outputs.commit_sha }} + image: ${{ steps.release.outputs.image }} + prerelease: ${{ steps.release.outputs.prerelease }} + source_date_epoch: ${{ steps.release.outputs.source_date_epoch }} + version: ${{ steps.release.outputs.version }} + steps: + - name: Verify exact native release environment policy + if: inputs.mode == 'publish' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + environment_file="$RUNNER_TEMP/native-release-environment.json" + policies_file="$RUNNER_TEMP/native-release-branch-policies.json" + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "repos/$EXECUTOR_REPOSITORY/environments/native-release" \ + > "$environment_file" + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "repos/$EXECUTOR_REPOSITORY/environments/native-release/deployment-branch-policies?per_page=100" \ + > "$policies_file" + + python3 - "$environment_file" "$policies_file" <<'PY' + import json + import pathlib + import sys + + environment = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) + policies = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")) + + if environment.get("name") != "native-release": + raise SystemExit("native-release environment name does not match") + if environment.get("can_admins_bypass") is not False: + raise SystemExit("native-release must disable administrator bypass") + + protection_rules = environment.get("protection_rules") + if not isinstance(protection_rules, list): + raise SystemExit("native-release protection rules are missing") + rule_types = sorted(rule.get("type") for rule in protection_rules) + if rule_types != ["branch_policy", "required_reviewers"]: + raise SystemExit( + "native-release must have only branch_policy and required_reviewers rules" + ) + + reviewer_rule = next( + rule for rule in protection_rules if rule.get("type") == "required_reviewers" + ) + if reviewer_rule.get("prevent_self_review") is not False: + raise SystemExit("native-release must set prevent_self_review=false") + reviewers = reviewer_rule.get("reviewers") + if not isinstance(reviewers, list) or len(reviewers) != 1: + raise SystemExit("native-release must have exactly one required reviewer") + reviewer = reviewers[0] + identity = reviewer.get("reviewer") + if ( + reviewer.get("type") != "User" + or not isinstance(identity, dict) + or identity.get("login") != "bmdavis419" + or identity.get("id") != 45952064 + ): + raise SystemExit("native-release required reviewer must be bmdavis419 (45952064)") + + deployment_policy = environment.get("deployment_branch_policy") + if deployment_policy != { + "protected_branches": False, + "custom_branch_policies": True, + }: + raise SystemExit("native-release must use custom deployment branch policies only") + + branch_policies = policies.get("branch_policies") + if ( + policies.get("total_count") != 1 + or not isinstance(branch_policies, list) + or len(branch_policies) != 1 + or branch_policies[0].get("name") != "main" + or branch_policies[0].get("type") != "branch" + ): + raise SystemExit("native-release must allow only the main branch") + PY + + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Bind version, commit, tag, and release state + id: release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INPUT_COMMIT_SHA: ${{ inputs.commit_sha }} + RELEASE_MODE: ${{ inputs.mode }} + RELEASE_TAG: ${{ inputs.tag }} + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_ID: ${{ github.run_id }} + WORKFLOW_REF: ${{ github.ref }} + WORKFLOW_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + if [[ ! "$INPUT_COMMIT_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "commit_sha must be an exact lowercase 40-character Git commit SHA" >&2 + exit 1 + fi + if [ "$WORKFLOW_SHA" != "$INPUT_COMMIT_SHA" ]; then + echo "Workflow revision $WORKFLOW_SHA does not match requested release commit $INPUT_COMMIT_SHA" >&2 + exit 1 + fi + if [[ ! "$RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then + echo "Invalid workflow run attempt: $RUN_ATTEMPT" >&2 + exit 1 + fi + + actual_sha="$(git rev-parse HEAD)" + if [ "$actual_sha" != "$INPUT_COMMIT_SHA" ]; then + echo "Checked out $actual_sha, expected $INPUT_COMMIT_SHA" >&2 + exit 1 + fi + + git fetch --no-tags origin "+refs/heads/main:refs/remotes/origin/main" + if ! git merge-base --is-ancestor "$actual_sha" refs/remotes/origin/main; then + echo "Release commit $actual_sha is not on origin/main" >&2 + exit 1 + fi + if [ "$RUN_ATTEMPT" = 1 ] \ + && [ "$(git rev-parse refs/remotes/origin/main)" != "$actual_sha" ]; then + echo "The first release attempt must use the current origin/main commit" >&2 + exit 1 + fi + + version="$(python3 - <<'PY' + import pathlib + import tomllib + + manifest = tomllib.loads(pathlib.Path("Cargo.toml").read_text(encoding="utf-8")) + print(manifest["package"]["version"]) + PY + )" + python3 - "$version" <<'PY' + import re + import sys + + pattern = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?" + r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" + ) + if pattern.fullmatch(sys.argv[1]) is None: + raise SystemExit("Cargo.toml package version is not valid SemVer") + PY + if [ "$RELEASE_TAG" != "v$version" ]; then + echo "Release tag $RELEASE_TAG does not match Cargo.toml version $version" >&2 + exit 1 + fi + if [[ "$version" == *+* ]]; then + echo "Cargo release versions cannot contain build metadata because OCI tags do not support +" >&2 + exit 1 + fi + + if git fetch --no-tags origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"; then + tag_sha="$(git rev-parse "$RELEASE_TAG^{commit}")" + if [ "$tag_sha" != "$actual_sha" ]; then + echo "Release tag $RELEASE_TAG points to $tag_sha, expected $actual_sha" >&2 + exit 1 + fi + elif [ "$RELEASE_MODE" = "publish" ]; then + echo "Publish mode requires existing remote tag $RELEASE_TAG" >&2 + exit 1 + else + echo "Dry run continues without remote tag $RELEASE_TAG" + fi + if [ "$RELEASE_MODE" = "publish" ] \ + && [ "$WORKFLOW_REF" != refs/heads/main ]; then + echo "Publish mode must be dispatched from refs/heads/main, got $WORKFLOW_REF" >&2 + exit 1 + fi + + if [ "$RELEASE_MODE" = "publish" ] \ + && gh release view "$RELEASE_TAG" --repo "$EXECUTOR_REPOSITORY" >/dev/null 2>&1; then + release_title="$RELEASE_TAG [executor-run:$RUN_ID]" + release_state="$RUNNER_TEMP/existing-release.json" + gh api "repos/$EXECUTOR_REPOSITORY/releases/tags/$RELEASE_TAG" > "$release_state" + actual_title="$(jq -r '.name // ""' "$release_state")" + if [ "$actual_title" != "$release_title" ]; then + echo "Release $RELEASE_TAG belongs to a different workflow run" >&2 + exit 1 + fi + python3 - "$release_state" "$RELEASE_TAG" "$actual_sha" "$version" <<'PY' + import json + import pathlib + import sys + + release = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) + expected_prerelease = "-" in sys.argv[4] + if ( + release.get("tag_name") != sys.argv[2] + or release.get("target_commitish") != sys.argv[3] + or release.get("prerelease") is not expected_prerelease + or release.get("draft") not in (True, False) + ): + raise SystemExit("existing release does not match the validated release state") + if release.get("draft") is False and release.get("immutable") is not True: + raise SystemExit("published release does not match the immutable rerun state") + PY + + run_binding="$RUNNER_TEMP/RELEASE-RUN.json" + python3 - "$run_binding" "$EXECUTOR_REPOSITORY" "$RELEASE_TAG" "$actual_sha" "$RUN_ID" <<'PY' + import json + import pathlib + import sys + + binding = { + "commit": sys.argv[4], + "repository": sys.argv[2], + "run_id": sys.argv[5], + "tag": sys.argv[3], + } + pathlib.Path(sys.argv[1]).write_text( + json.dumps(binding, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + PY + is_draft="$(jq -r .draft "$release_state")" + binding_count="$( + jq '[.assets[]? | select(.name == "RELEASE-RUN.json")] | length' \ + "$release_state" + )" + if [ "$binding_count" -eq 1 ]; then + binding_download="$RUNNER_TEMP/existing-release-binding" + rm -rf "$binding_download" + mkdir -p "$binding_download" + gh release download "$RELEASE_TAG" \ + --repo "$EXECUTOR_REPOSITORY" \ + --pattern RELEASE-RUN.json \ + --dir "$binding_download" + if ! cmp "$run_binding" "$binding_download/RELEASE-RUN.json"; then + echo "Release $RELEASE_TAG belongs to a different workflow run or source" >&2 + exit 1 + fi + elif [ "$binding_count" -eq 0 ] && [ "$is_draft" = true ]; then + echo "Same-run draft is missing RELEASE-RUN.json; staging will repair it" + else + echo "Release $RELEASE_TAG must contain exactly one RELEASE-RUN.json asset" >&2 + exit 1 + fi + + if [ "$is_draft" != true ]; then + echo "Same-run retry will reuse immutable published release $RELEASE_TAG" + else + echo "Same-run retry will repair and verify draft release $RELEASE_TAG" + fi + fi + + source_date_epoch="$(git show -s --format=%ct HEAD)" + if [[ ! "$source_date_epoch" =~ ^[0-9]+$ ]]; then + echo "Could not resolve the release commit timestamp" >&2 + exit 1 + fi + + owner="$(printf '%s' "${EXECUTOR_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')" + if [[ "$version" == *-* ]]; then + channel=beta + prerelease=true + else + channel=latest + prerelease=false + fi + + { + echo "channel=$channel" + echo "commit_sha=$actual_sha" + echo "image=ghcr.io/${owner}/executor" + echo "prerelease=$prerelease" + echo "source_date_epoch=$source_date_epoch" + echo "version=$version" + } >> "$GITHUB_OUTPUT" + + authorize-publish: + name: Authorize protected native publication + if: inputs.mode == 'publish' + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: native-release permissions: - actions: write - contents: write - id-token: write - pull-requests: write + contents: read + outputs: + registry_username: ${{ steps.token.outputs.registry_username }} + steps: + - name: Require the environment-scoped release token and capabilities + id: token + shell: bash + env: + GH_TOKEN: ${{ secrets.NATIVE_RELEASE_TOKEN }} + run: | + set -euo pipefail + if [ -z "$GH_TOKEN" ]; then + echo "native-release must provide the NATIVE_RELEASE_TOKEN secret" >&2 + exit 1 + fi + + token_owner="$(gh api user --jq .login)" + if [ "$token_owner" != bmdavis419 ]; then + echo "NATIVE_RELEASE_TOKEN must belong to bmdavis419, got $token_owner" >&2 + exit 1 + fi + oauth_scopes="$( + gh api --include user \ + | awk -F ': ' 'tolower($1) == "x-oauth-scopes" { print $2; exit }' \ + | tr -d '\r' + )" + python3 - "$oauth_scopes" <<'PY' + import sys + + actual_scopes = {scope.strip() for scope in sys.argv[1].split(",") if scope.strip()} + expected_scopes = {"public_repo", "write:packages"} + if actual_scopes != expected_scopes: + raise SystemExit( + "NATIVE_RELEASE_TOKEN must have exactly public_repo and write:packages scopes" + ) + PY + if [ "$(gh api "repos/$EXECUTOR_REPOSITORY" --jq .permissions.push)" != true ]; then + echo "NATIVE_RELEASE_TOKEN cannot write to $EXECUTOR_REPOSITORY" >&2 + exit 1 + fi + if [ "$( + gh api "repos/$EXECUTOR_REPOSITORY/immutable-releases" --jq .enabled + )" != true ]; then + echo "Immutable releases must be enabled for $EXECUTOR_REPOSITORY" >&2 + exit 1 + fi + + printf '%s' "$GH_TOKEN" \ + | docker login ghcr.io --username "$token_owner" --password-stdin + docker logout ghcr.io + echo "registry_username=$token_owner" >> "$GITHUB_OUTPUT" + - name: Verify exact immutable release tag ruleset + shell: bash + env: + GH_TOKEN: ${{ secrets.NATIVE_RELEASE_TOKEN }} + run: | + set -euo pipefail + summaries="$RUNNER_TEMP/release-tag-rulesets.json" + details="$RUNNER_TEMP/release-tag-rulesets.ndjson" + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "repos/$EXECUTOR_REPOSITORY/rulesets?includes_parents=true&per_page=100" \ + > "$summaries" + + : > "$details" + while IFS= read -r ruleset_id; do + gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "repos/$EXECUTOR_REPOSITORY/rulesets/$ruleset_id" \ + >> "$details" + printf '\n' >> "$details" + done < <( + jq -r '.[] | select(.target == "tag" and .enforcement == "active") | .id' \ + "$summaries" + ) + + python3 - "$details" <<'PY' + import json + import pathlib + import sys + + rulesets = [ + json.loads(line) + for line in pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").splitlines() + if line + ] + for ruleset in rulesets: + ref_name = ruleset.get("conditions", {}).get("ref_name", {}) + rules = ruleset.get("rules", []) + rule_types = sorted(rule.get("type") for rule in rules) + update_rules = [rule for rule in rules if rule.get("type") == "update"] + if ( + ruleset.get("target") == "tag" + and ruleset.get("enforcement") == "active" + and ruleset.get("bypass_actors") == [] + and ref_name.get("include") == ["refs/tags/v*"] + and ref_name.get("exclude") == [] + and rule_types == ["deletion", "update"] + and len(update_rules) == 1 + and update_rules[0].get("parameters") + == {"update_allows_fetch_and_merge": False} + ): + break + else: + raise SystemExit( + "an exact active no-bypass v* tag ruleset must restrict updates and deletions" + ) + PY + + web: + name: Build embedded web application once + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - fetch-depth: 0 + ref: ${{ needs.validate.outputs.commit_sha }} persist-credentials: false - - name: Setup Bun - uses: oven-sh/setup-bun@v2 + - name: Set up Bun + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install web dependencies + run: bun install --frozen-lockfile + + - name: Build embedded web application + env: + SOURCE_DATE_EPOCH: ${{ needs.validate.outputs.source_date_epoch }} + run: bun run --cwd web build + + - name: Verify canonical web payload + shell: bash + run: | + set -euo pipefail + test -f web/build/index.html + test -f web/build/THIRD_PARTY_JAVASCRIPT_LICENSES.json + + - name: Upload canonical web payload + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - bun-version: 1.3.11 + name: executor-web-build + path: web/build/ + if-no-files-found: error + compression-level: 9 + overwrite: true + retention-days: 14 - - name: Setup Node - uses: actions/setup-node@v4 + notices: + name: Generate third-party notices + needs: validate + runs-on: ubuntu-22.04 + timeout-minutes: 20 + steps: + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - node-version: 24 - registry-url: https://registry.npmjs.org + ref: ${{ needs.validate.outputs.commit_sha }} + persist-credentials: false - - name: Update npm for trusted publishing + - name: Install Rust toolchain + shell: bash run: | - npm install -g npm@latest - npm --version + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal + rustup override set "$RUST_VERSION" - - name: Install dependencies - run: bun install --frozen-lockfile + - name: Generate dependency license inventory + shell: bash + env: + SOURCE_DATE_EPOCH: ${{ needs.validate.outputs.source_date_epoch }} + run: | + set -euo pipefail + cargo install cargo-about --version 0.9.0 --locked --features cli + cargo about generate about.hbs > THIRD_PARTY_LICENSES.html - - name: Create or update release pull request - id: changesets - uses: changesets/action@v1 + - name: Upload notices + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - version: bun run changeset:version - commit: Version Packages - title: Version Packages - createGithubReleases: false + name: executor-license-notices + path: THIRD_PARTY_LICENSES.html + if-no-files-found: error + compression-level: 9 + overwrite: true + retention-days: 14 + + macos-service-tests: + name: Test macOS service lifecycle on compatibility floor + needs: validate + runs-on: macos-14 + timeout-minutes: 45 + steps: + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate.outputs.commit_sha }} + persist-credentials: false + + - name: Verify pinned compatibility-floor Apple toolchain + shell: bash + run: | + bash scripts/verify-apple-toolchain.sh \ + "$MACOS_FLOOR_DEVELOPER_DIR" \ + "$MACOS_FLOOR_XCODE_VERSION" \ + "$MACOS_FLOOR_XCODE_BUILD" \ + "$MACOS_FLOOR_SDK_VERSION" \ + "$MACOS_FLOOR_CLANG_VERSION" \ + "$MACOS_FLOOR_LD_VERSION" + + - name: Install Rust toolchain + shell: bash + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal + rustup override set "$RUST_VERSION" + + - name: Test Rust service lifecycle env: - # PAT (RELEASE_PAT) so the auto-opened Version Packages PR - # triggers downstream workflows (pkg-pr-new, CI). PRs authored - # by the default GITHUB_TOKEN do not trigger other workflows by - # GitHub design. Falls back to GITHUB_TOKEN when the secret is - # not set, so the workflow keeps working in forks / before the - # secret is configured. - GITHUB_TOKEN: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }} + DEVELOPER_DIR: ${{ env.MACOS_FLOOR_DEVELOPER_DIR }} + run: cargo test --locked --lib service::tests - - name: Smoke test packed @executor-js library packages - if: steps.changesets.outputs.hasChangesets == 'false' - run: bun run release:smoke:packages + - name: Test LaunchAgent safety with isolated fixtures + run: bash scripts/test-install-launchd-safety.sh - - name: Publish @executor-js library packages - if: steps.changesets.outputs.hasChangesets == 'false' - run: bun run release:publish:packages + - name: Test bounded LaunchAgent logging + run: bash scripts/test-bounded-log.sh - - name: Detect release version change - if: steps.changesets.outputs.hasChangesets == 'false' - id: detect_release + - name: Test archive install and uninstall lifecycle + run: bash scripts/test-install-release-archive.sh + + build-native: + name: Build ${{ matrix.target }} + needs: + - validate + - web + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + platform: linux/amd64 + target: x86_64-unknown-linux-gnu + - runner: ubuntu-22.04-arm + platform: linux/arm64 + target: aarch64-unknown-linux-gnu + - runner: macos-15-intel + target: x86_64-apple-darwin + - runner: macos-15 + target: aarch64-apple-darwin + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + steps: + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate.outputs.commit_sha }} + persist-credentials: false + + - name: Download canonical web payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-web-build + path: web/build + + - name: Verify canonical web payload + shell: bash run: | - before="${{ github.event.before }}" - if [ "$before" = "0000000000000000000000000000000000000000" ]; then - before="$(git rev-list --max-count=1 HEAD^ 2>/dev/null || true)" + set -euo pipefail + test -f web/build/index.html + test -f web/build/THIRD_PARTY_JAVASCRIPT_LICENSES.json + + - name: Verify pinned release Apple toolchain + if: runner.os == 'macOS' + shell: bash + run: | + bash scripts/verify-apple-toolchain.sh \ + "$MACOS_BUILD_DEVELOPER_DIR" \ + "$MACOS_BUILD_XCODE_VERSION" \ + "$MACOS_BUILD_XCODE_BUILD" \ + "$MACOS_BUILD_SDK_VERSION" \ + "$MACOS_BUILD_CLANG_VERSION" \ + "$MACOS_BUILD_LD_VERSION" + + - name: Install Rust toolchain + if: runner.os == 'macOS' + shell: bash + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal --target "${{ matrix.target }}" + rustup override set "$RUST_VERSION" + + - name: Set up pinned Docker Buildx for Linux native build + if: runner.os == 'Linux' + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + with: + driver-opts: image=${{ env.BUILDKIT_IMAGE }} + version: ${{ env.BUILDX_VERSION }} + + - name: Build Linux release binary in pinned Ubuntu 22.04 toolchain + if: runner.os == 'Linux' + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + env: + SOURCE_DATE_EPOCH: ${{ needs.validate.outputs.source_date_epoch }} + with: + context: . + file: Dockerfile.release-native + target: artifact + platforms: ${{ matrix.platform }} + build-args: | + RUST_TARGET=${{ matrix.target }} + SOURCE_DATE_EPOCH=${{ needs.validate.outputs.source_date_epoch }} + build-contexts: | + release-web=web/build + outputs: type=local,dest=${{ runner.temp }}/pinned-native + provenance: false + sbom: false + + - name: Stage pinned Linux release binary + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + mkdir -p "target/${{ matrix.target }}/release" + install -m 0755 \ + "$RUNNER_TEMP/pinned-native/executor" \ + "target/${{ matrix.target }}/release/executor" + + - name: Build macOS release binary + if: runner.os == 'macOS' + env: + DEVELOPER_DIR: ${{ env.MACOS_BUILD_DEVELOPER_DIR }} + SOURCE_DATE_EPOCH: ${{ needs.validate.outputs.source_date_epoch }} + run: cargo build --locked --release --target "${{ matrix.target }}" + + - name: Verify macOS 14 deployment target + if: runner.os == 'macOS' + shell: bash + env: + DEVELOPER_DIR: ${{ env.MACOS_BUILD_DEVELOPER_DIR }} + run: | + set -euo pipefail + binary="target/${{ matrix.target }}/release/executor" + build_info="$(xcrun vtool -show-build "$binary")" + printf '%s\n' "$build_info" + minos="$(printf '%s\n' "$build_info" | awk '$1 == "minos" { print $2; exit }')" + if [ "$minos" != "$MACOSX_DEPLOYMENT_TARGET" ]; then + echo "macOS release binary declares minos $minos, expected $MACOSX_DEPLOYMENT_TARGET" >&2 + exit 1 + fi + + - name: Verify native binary version + shell: bash + env: + EXPECTED_VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + actual="$(target/${{ matrix.target }}/release/executor --version)" + test "$actual" = "executor $EXPECTED_VERSION" + + - name: Verify Linux glibc 2.35 compatibility ceiling + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + binary="target/${{ matrix.target }}/release/executor" + max_glibc="$(readelf --version-info "$binary" \ + | grep -oE 'GLIBC_[0-9]+(\.[0-9]+)+' \ + | sed 's/^GLIBC_//' \ + | sort -Vu \ + | tail -n 1)" + test -n "$max_glibc" + if [ "$(printf '%s\n' "$max_glibc" 2.35 | sort -V | tail -n 1)" != 2.35 ]; then + echo "Linux release binary requires GLIBC_$max_glibc, above the 2.35 baseline" >&2 + exit 1 fi - version="$(node -e "console.log(JSON.parse(require('fs').readFileSync('apps/cli/package.json', 'utf8')).version)")" - if [ -n "$before" ] && git cat-file -e "$before:apps/cli/package.json" 2>/dev/null; then - previous_version="$(git show "$before:apps/cli/package.json" | node -e "let data = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { data += chunk; }); process.stdin.on('end', () => { console.log(JSON.parse(data).version ?? ''); });")" + - name: Stage native binary artifact + shell: bash + run: | + set -euo pipefail + output="$RUNNER_TEMP/native-binary" + mkdir -p "$output" + install -m 0755 "target/${{ matrix.target }}/release/executor" "$output/executor" + + - name: Upload raw native binary + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: executor-native-${{ matrix.target }} + path: ${{ runner.temp }}/native-binary/executor + if-no-files-found: error + compression-level: 0 + overwrite: true + retention-days: 14 + + package-native: + name: Package native archives in pinned compressor environment + needs: + - build-native + - notices + - validate + - web + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate.outputs.commit_sha }} + persist-credentials: false + + - name: Download raw native binaries + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: ${{ runner.temp }}/native-binaries + pattern: executor-native-* + + - name: Download third-party notices + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-license-notices + path: ${{ runner.temp }}/release-metadata + + - name: Download canonical web payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-web-build + path: ${{ runner.temp }}/canonical-web + + - name: Stage complete packaging input + shell: bash + run: | + set -euo pipefail + input="$RUNNER_TEMP/release-inputs" + mkdir -p "$input/native" + for target in \ + aarch64-apple-darwin \ + aarch64-unknown-linux-gnu \ + x86_64-apple-darwin \ + x86_64-unknown-linux-gnu; do + source="$RUNNER_TEMP/native-binaries/executor-native-$target/executor" + test -s "$source" + mkdir "$input/native/$target" + install -m 0755 "$source" "$input/native/$target/executor" + done + install -m 0644 LICENSE "$input/LICENSE" + install -m 0644 \ + "$RUNNER_TEMP/release-metadata/THIRD_PARTY_LICENSES.html" \ + "$input/THIRD_PARTY_LICENSES.html" + install -m 0644 \ + "$RUNNER_TEMP/canonical-web/THIRD_PARTY_JAVASCRIPT_LICENSES.json" \ + "$input/THIRD_PARTY_JAVASCRIPT_LICENSES.json" + + - name: Set up pinned Docker Buildx for archive packaging + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + with: + driver-opts: image=${{ env.BUILDKIT_IMAGE }} + version: ${{ env.BUILDX_VERSION }} + + - name: Build archives in pinned Python and zlib environment + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: Dockerfile.release-package + target: artifacts + build-args: | + BUILDKIT_IMAGE=${{ env.BUILDKIT_IMAGE }} + BUILDX_VERSION=${{ env.BUILDX_VERSION }} + MACOS_BUILD_CLANG_VERSION=${{ env.MACOS_BUILD_CLANG_VERSION }} + MACOS_BUILD_LD_VERSION=${{ env.MACOS_BUILD_LD_VERSION }} + MACOS_BUILD_SDK_VERSION=${{ env.MACOS_BUILD_SDK_VERSION }} + MACOS_BUILD_XCODE_BUILD=${{ env.MACOS_BUILD_XCODE_BUILD }} + MACOS_BUILD_XCODE_VERSION=${{ env.MACOS_BUILD_XCODE_VERSION }} + MACOS_FLOOR_CLANG_VERSION=${{ env.MACOS_FLOOR_CLANG_VERSION }} + MACOS_FLOOR_LD_VERSION=${{ env.MACOS_FLOOR_LD_VERSION }} + MACOS_FLOOR_SDK_VERSION=${{ env.MACOS_FLOOR_SDK_VERSION }} + MACOS_FLOOR_XCODE_BUILD=${{ env.MACOS_FLOOR_XCODE_BUILD }} + MACOS_FLOOR_XCODE_VERSION=${{ env.MACOS_FLOOR_XCODE_VERSION }} + REPRODUCIBILITY_RUN=first + SOURCE_DATE_EPOCH=${{ needs.validate.outputs.source_date_epoch }} + build-contexts: | + release-inputs=${{ runner.temp }}/release-inputs + outputs: type=local,dest=${{ runner.temp }}/release-artifacts + provenance: false + sbom: false + + - name: Repeat pinned archive build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: Dockerfile.release-package + target: artifacts + build-args: | + BUILDKIT_IMAGE=${{ env.BUILDKIT_IMAGE }} + BUILDX_VERSION=${{ env.BUILDX_VERSION }} + MACOS_BUILD_CLANG_VERSION=${{ env.MACOS_BUILD_CLANG_VERSION }} + MACOS_BUILD_LD_VERSION=${{ env.MACOS_BUILD_LD_VERSION }} + MACOS_BUILD_SDK_VERSION=${{ env.MACOS_BUILD_SDK_VERSION }} + MACOS_BUILD_XCODE_BUILD=${{ env.MACOS_BUILD_XCODE_BUILD }} + MACOS_BUILD_XCODE_VERSION=${{ env.MACOS_BUILD_XCODE_VERSION }} + MACOS_FLOOR_CLANG_VERSION=${{ env.MACOS_FLOOR_CLANG_VERSION }} + MACOS_FLOOR_LD_VERSION=${{ env.MACOS_FLOOR_LD_VERSION }} + MACOS_FLOOR_SDK_VERSION=${{ env.MACOS_FLOOR_SDK_VERSION }} + MACOS_FLOOR_XCODE_BUILD=${{ env.MACOS_FLOOR_XCODE_BUILD }} + MACOS_FLOOR_XCODE_VERSION=${{ env.MACOS_FLOOR_XCODE_VERSION }} + REPRODUCIBILITY_RUN=second + SOURCE_DATE_EPOCH=${{ needs.validate.outputs.source_date_epoch }} + build-contexts: | + release-inputs=${{ runner.temp }}/release-inputs + outputs: type=local,dest=${{ runner.temp }}/release-artifacts-repeat + provenance: false + sbom: false + + - name: Verify byte-identical archives and checksums + shell: bash + run: | + set -euo pipefail + first="$RUNNER_TEMP/release-artifacts" + second="$RUNNER_TEMP/release-artifacts-repeat" + cmp "$first/BUILD-TOOLCHAINS.txt" "$second/BUILD-TOOLCHAINS.txt" + for target in \ + aarch64-apple-darwin \ + aarch64-unknown-linux-gnu \ + x86_64-apple-darwin \ + x86_64-unknown-linux-gnu; do + archive="executor-${target}.tar.gz" + cmp "$first/$archive" "$second/$archive" + cmp "$first/$archive.sha256" "$second/$archive.sha256" + (cd "$first" && sha256sum --check "$archive.sha256") + done + + - name: Upload canonical packaged archives + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: executor-release-archives + path: ${{ runner.temp }}/release-artifacts/ + if-no-files-found: error + compression-level: 0 + overwrite: true + retention-days: 14 + + smoke-native: + name: Smoke packaged ${{ matrix.target }} + needs: + - package-native + - validate + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + - runner: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + - runner: macos-15-intel + target: x86_64-apple-darwin + - runner: macos-14 + target: aarch64-apple-darwin + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate.outputs.commit_sha }} + persist-credentials: false + + - name: Download packaged native archive + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-release-archives + path: ${{ runner.temp }}/release-archive + + - name: Download canonical web payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-web-build + path: ${{ runner.temp }}/canonical-web + + - name: Verify and extract packaged native archive + shell: bash + run: | + set -euo pipefail + archive="executor-${{ matrix.target }}.tar.gz" + input="$RUNNER_TEMP/release-archive" + output="$RUNNER_TEMP/packaged-executor" + if command -v sha256sum >/dev/null 2>&1; then + (cd "$input" && sha256sum --check "$archive.sha256") else - previous_version="" + (cd "$input" && shasum -a 256 --check "$archive.sha256") + fi + mkdir -p "$output" + tar -xzf "$input/$archive" -C "$output" + test -x "$output/executor" + + - name: Boot archive and verify first boot, health, and embedded web identity + shell: bash + run: | + set -euo pipefail + binary="$RUNNER_TEMP/packaged-executor/executor" + data_dir="$RUNNER_TEMP/executor-smoke-data" + log_file="$RUNNER_TEMP/executor-smoke.log" + port="$(python3 -c 'import socket; sock = socket.socket(); sock.bind(("127.0.0.1", 0)); print(sock.getsockname()[1]); sock.close()')" + mkdir -p "$data_dir" + chmod 0700 "$data_dir" + + "$binary" server \ + --bind "127.0.0.1:$port" \ + --data-dir "$data_dir" \ + > "$log_file" 2>&1 & + server_pid=$! + cleanup() { + if kill -0 "$server_pid" >/dev/null 2>&1; then + kill -TERM "$server_pid" >/dev/null 2>&1 || true + wait "$server_pid" || true + fi + rm -rf "$data_dir" + } + trap cleanup EXIT + + ready=false + for _ in {1..120}; do + if curl --fail --silent --show-error "http://127.0.0.1:$port/healthz" >/dev/null 2>&1; then + ready=true + break + fi + if ! kill -0 "$server_pid" >/dev/null 2>&1; then + break + fi + sleep 1 + done + if [ "$ready" != true ]; then + cat "$log_file" >&2 + exit 1 fi - release_tag="v$version" - if [ -n "$previous_version" ] && [ "$previous_version" != "$version" ]; then - echo "changed=true" >> "$GITHUB_OUTPUT" - elif ! git ls-remote --exit-code --tags origin "refs/tags/$release_tag" >/dev/null 2>&1; then - echo "changed=true" >> "$GITHUB_OUTPUT" + grep -F 'Complete first-boot setup at:' "$log_file" >/dev/null + grep -E "http://127\\.0\\.0\\.1:${port}/setup#token=.+" "$log_file" >/dev/null + scripts/smoke-release-server.sh \ + "http://127.0.0.1:$port" \ + "$RUNNER_TEMP/canonical-web" + + kill -TERM "$server_pid" + wait "$server_pid" + trap - EXIT + rm -rf "$data_dir" + + checksums: + name: Assemble checksum manifest + needs: + - macos-service-tests + - package-native + - smoke-native + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Download target artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-release-archives + path: release-artifacts + + - name: Verify archives and assemble manifest + shell: bash + working-directory: release-artifacts + run: | + set -euo pipefail + + expected_targets=( + aarch64-apple-darwin + aarch64-unknown-linux-gnu + x86_64-apple-darwin + x86_64-unknown-linux-gnu + ) + + : > SHA256SUMS + for target in "${expected_targets[@]}"; do + archive="executor-${target}.tar.gz" + sidecar="${archive}.sha256" + test -f "$archive" + test -f "$sidecar" + cat "$sidecar" >> SHA256SUMS + done + + test "$(find . -maxdepth 1 -name 'executor-*.tar.gz' -type f | wc -l)" -eq 4 + test -s BUILD-TOOLCHAINS.txt + sha256sum BUILD-TOOLCHAINS.txt >> SHA256SUMS + sha256sum --check SHA256SUMS + + - name: Upload complete release bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: executor-release-artifacts + path: release-artifacts/ + if-no-files-found: error + compression-level: 0 + overwrite: true + retention-days: 14 + + stage-release: + name: Stage draft GitHub release + if: inputs.mode == 'publish' + needs: + - assemble-container + - authorize-publish + - checksums + - validate + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: native-release + permissions: + contents: read + steps: + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate.outputs.commit_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Download release bundle + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-release-artifacts + path: release-artifacts + + - name: Create or reuse draft and upload exact assets + shell: bash + env: + GH_TOKEN: ${{ secrets.NATIVE_RELEASE_TOKEN }} + IMAGE: ${{ needs.validate.outputs.image }} + IMAGE_DIGEST: ${{ needs.assemble-container.outputs.digest }} + PRERELEASE: ${{ needs.validate.outputs.prerelease }} + RELEASE_SHA: ${{ needs.validate.outputs.commit_sha }} + RELEASE_TAG: ${{ inputs.tag }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + + git fetch --force --no-tags origin \ + "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + tag_sha="$(git rev-parse "$RELEASE_TAG^{commit}")" + if [ "$tag_sha" != "$RELEASE_SHA" ]; then + echo "Release tag $RELEASE_TAG moved to $tag_sha, expected $RELEASE_SHA" >&2 + exit 1 + fi + + if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Invalid assembled container digest: $IMAGE_DIGEST" >&2 + exit 1 + fi + + bindings="$RUNNER_TEMP/release-bindings" + mkdir -p "$bindings" + run_binding="$bindings/RELEASE-RUN.json" + image_binding="$bindings/RELEASE-IMAGE.json" + python3 - \ + "$run_binding" \ + "$image_binding" \ + "$EXECUTOR_REPOSITORY" \ + "$RELEASE_TAG" \ + "$RELEASE_SHA" \ + "$RUN_ID" \ + "$IMAGE" \ + "$IMAGE_DIGEST" <<'PY' + import json + import pathlib + import sys + + run_binding = { + "commit": sys.argv[5], + "repository": sys.argv[3], + "run_id": sys.argv[6], + "tag": sys.argv[4], + } + image_binding = { + **run_binding, + "digest": sys.argv[8], + "image": sys.argv[7], + } + for path, binding in ( + (sys.argv[1], run_binding), + (sys.argv[2], image_binding), + ): + pathlib.Path(path).write_text( + json.dumps(binding, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + PY + + assets=( + "$image_binding" + "$run_binding" + release-artifacts/BUILD-TOOLCHAINS.txt + release-artifacts/executor-aarch64-apple-darwin.tar.gz + release-artifacts/executor-aarch64-apple-darwin.tar.gz.sha256 + release-artifacts/executor-aarch64-unknown-linux-gnu.tar.gz + release-artifacts/executor-aarch64-unknown-linux-gnu.tar.gz.sha256 + release-artifacts/executor-x86_64-apple-darwin.tar.gz + release-artifacts/executor-x86_64-apple-darwin.tar.gz.sha256 + release-artifacts/executor-x86_64-unknown-linux-gnu.tar.gz + release-artifacts/executor-x86_64-unknown-linux-gnu.tar.gz.sha256 + release-artifacts/SHA256SUMS + ) + for asset in "${assets[@]}"; do + test -f "$asset" + done + + release_title="$RELEASE_TAG [executor-run:$RUN_ID]" + published_release=false + if gh release view "$RELEASE_TAG" --repo "$EXECUTOR_REPOSITORY" >/dev/null 2>&1; then + release_state="$RUNNER_TEMP/staged-release.json" + gh api "repos/$EXECUTOR_REPOSITORY/releases/tags/$RELEASE_TAG" > "$release_state" + actual_title="$(jq -r '.name // ""' "$release_state")" + if [ "$actual_title" != "$release_title" ]; then + echo "Release $RELEASE_TAG belongs to a different workflow run" >&2 + exit 1 + fi + python3 - "$release_state" "$RELEASE_TAG" "$RELEASE_SHA" "$PRERELEASE" <<'PY' + import json + import pathlib + import sys + + release = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) + expected_prerelease = sys.argv[4] == "true" + if ( + release.get("tag_name") != sys.argv[2] + or release.get("target_commitish") != sys.argv[3] + or release.get("prerelease") is not expected_prerelease + or release.get("draft") not in (True, False) + ): + raise SystemExit("existing release does not match the validated release state") + if release.get("draft") is False and release.get("immutable") is not True: + raise SystemExit("published release is not immutable") + PY + + is_draft="$(jq -r .draft "$release_state")" + binding_count="$( + jq '[.assets[]? | select(.name == "RELEASE-RUN.json")] | length' \ + "$release_state" + )" + if [ "$binding_count" -eq 1 ]; then + ownership="$RUNNER_TEMP/existing-release-ownership" + rm -rf "$ownership" + mkdir -p "$ownership" + gh release download "$RELEASE_TAG" \ + --repo "$EXECUTOR_REPOSITORY" \ + --pattern RELEASE-RUN.json \ + --dir "$ownership" + if ! cmp "$run_binding" "$ownership/RELEASE-RUN.json"; then + echo "Release $RELEASE_TAG belongs to a different workflow run or source" >&2 + exit 1 + fi + elif [ "$binding_count" -eq 0 ] && [ "$is_draft" = true ]; then + echo "Recovering same-run draft created before its binding asset uploaded" + else + echo "Release $RELEASE_TAG must contain exactly one RELEASE-RUN.json asset" >&2 + exit 1 + fi + + if [ "$is_draft" != true ]; then + published_release=true + fi else - echo "changed=false" >> "$GITHUB_OUTPUT" + args=( + release create "$RELEASE_TAG" + --repo "$EXECUTOR_REPOSITORY" + --title "$release_title" + --target "$RELEASE_SHA" + --verify-tag + --generate-notes + --draft + ) + if [ "$PRERELEASE" = true ]; then + args+=(--prerelease) + fi + gh "${args[@]}" + fi + + if [ "$published_release" = false ]; then + gh release upload "$RELEASE_TAG" \ + "${assets[@]}" \ + --repo "$EXECUTOR_REPOSITORY" \ + --clobber + fi + + expected_assets="$(for asset in "${assets[@]}"; do basename "$asset"; done | sort)" + remote_assets="$(gh release view "$RELEASE_TAG" --repo "$EXECUTOR_REPOSITORY" --json assets --jq '.assets[].name' | sort)" + if [ "$remote_assets" != "$expected_assets" ]; then + echo "Release assets do not match the exact native release bundle" >&2 + diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$remote_assets") || true + exit 1 fi - echo "version=$version" >> "$GITHUB_OUTPUT" + verified_assets="$RUNNER_TEMP/verified-release-assets" + rm -rf "$verified_assets" + mkdir -p "$verified_assets" + gh release download "$RELEASE_TAG" \ + --repo "$EXECUTOR_REPOSITORY" \ + --dir "$verified_assets" + for asset in "${assets[@]}"; do + cmp "$asset" "$verified_assets/$(basename "$asset")" + done + + build-container-dry-run: + name: Dry-run container ${{ matrix.platform }} + if: inputs.mode == 'dry-run' + needs: + - checksums + - validate + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + artifact: amd64 + platform: linux/amd64 + target: x86_64-unknown-linux-gnu + - runner: ubuntu-22.04-arm + artifact: arm64 + platform: linux/arm64 + target: aarch64-unknown-linux-gnu + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate.outputs.commit_sha }} + persist-credentials: false + + - name: Download matching native release artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-release-artifacts + path: ${{ runner.temp }}/release-archive - - name: Validate release tag - if: steps.changesets.outputs.hasChangesets == 'false' && steps.detect_release.outputs.changed == 'true' - id: validate_release + - name: Download canonical web payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-web-build + path: ${{ runner.temp }}/canonical-web + + - name: Verify and extract native release artifact + shell: bash + run: | + set -euo pipefail + archive="executor-${{ matrix.target }}.tar.gz" + input="$RUNNER_TEMP/release-archive" + output="$RUNNER_TEMP/prebuilt-executor" + (cd "$input" && sha256sum --check "$archive.sha256") + mkdir -p "$output" + tar -xzf "$input/$archive" -C "$output" + test -x "$output/executor" + test -f "$output/LICENSE" + test -f "$output/THIRD_PARTY_LICENSES.html" + test -f "$output/THIRD_PARTY_JAVASCRIPT_LICENSES.json" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + with: + driver-opts: image=${{ env.BUILDKIT_IMAGE }} + version: ${{ env.BUILDX_VERSION }} + + - name: Build prebuilt runtime without publishing + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + env: + SOURCE_DATE_EPOCH: ${{ needs.validate.outputs.source_date_epoch }} + with: + context: . + file: Dockerfile + target: runtime-prebuilt + platforms: ${{ matrix.platform }} + build-contexts: | + prebuilt-executor=${{ runner.temp }}/prebuilt-executor + load: true + push: false + tags: executor-release-smoke:${{ matrix.artifact }} + labels: | + org.opencontainers.image.title=Executor + org.opencontainers.image.description=Native self-hosted Executor + org.opencontainers.image.source=https://github.com/${{ env.EXECUTOR_REPOSITORY }} + org.opencontainers.image.revision=${{ needs.validate.outputs.commit_sha }} + org.opencontainers.image.version=${{ needs.validate.outputs.version }} + provenance: false + sbom: false + + - name: Smoke release-only prebuilt image path + shell: bash + run: | + scripts/smoke-release-container.sh \ + "executor-release-smoke:${{ matrix.artifact }}" \ + "$RUNNER_TEMP/canonical-web" \ + "executor-release-smoke-${GITHUB_RUN_ID}-${{ matrix.artifact }}" \ + "executor-release-smoke-data-${GITHUB_RUN_ID}-${{ matrix.artifact }}" + + publish-container-arch: + name: Publish container ${{ matrix.platform }} by digest + if: inputs.mode == 'publish' + needs: + - authorize-publish + - checksums + - validate + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + platform: linux/amd64 + target: x86_64-unknown-linux-gnu + artifact: amd64 + - runner: ubuntu-22.04-arm + platform: linux/arm64 + target: aarch64-unknown-linux-gnu + artifact: arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + environment: native-release + permissions: + contents: read + steps: + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate.outputs.commit_sha }} + persist-credentials: false + + - name: Download matching native release artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-release-artifacts + path: ${{ runner.temp }}/release-archive + + - name: Verify and extract native release artifact + shell: bash + run: | + set -euo pipefail + archive="executor-${{ matrix.target }}.tar.gz" + input="$RUNNER_TEMP/release-archive" + output="$RUNNER_TEMP/prebuilt-executor" + (cd "$input" && sha256sum --check "$archive.sha256") + mkdir -p "$output" + tar -xzf "$input/$archive" -C "$output" + test -x "$output/executor" + test -f "$output/LICENSE" + test -f "$output/THIRD_PARTY_LICENSES.html" + test -f "$output/THIRD_PARTY_JAVASCRIPT_LICENSES.json" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + with: + driver-opts: image=${{ env.BUILDKIT_IMAGE }} + version: ${{ env.BUILDX_VERSION }} + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ needs.authorize-publish.outputs.registry_username }} + password: ${{ secrets.NATIVE_RELEASE_TOKEN }} + + - name: Build and push architecture image by digest + id: build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 env: - RELEASE_VERSION: ${{ steps.detect_release.outputs.version }} - run: bun run scripts/validate-release-ref.ts --version-env RELEASE_VERSION --output tag + SOURCE_DATE_EPOCH: ${{ needs.validate.outputs.source_date_epoch }} + with: + context: . + file: Dockerfile + target: runtime-prebuilt + platforms: ${{ matrix.platform }} + build-contexts: | + prebuilt-executor=${{ runner.temp }}/prebuilt-executor + outputs: type=image,name=${{ needs.validate.outputs.image }},push-by-digest=true,name-canonical=true,push=true,rewrite-timestamp=true + labels: | + org.opencontainers.image.title=Executor + org.opencontainers.image.description=Native self-hosted Executor + org.opencontainers.image.source=https://github.com/${{ env.EXECUTOR_REPOSITORY }} + org.opencontainers.image.revision=${{ needs.validate.outputs.commit_sha }} + org.opencontainers.image.version=${{ needs.validate.outputs.version }} + provenance: false + sbom: false - - name: Create and push release tag - if: steps.changesets.outputs.hasChangesets == 'false' && steps.detect_release.outputs.changed == 'true' + - name: Export architecture digest + shell: bash env: - GH_TOKEN: ${{ secrets.RELEASE_PAT || github.token }} - RELEASE_TAG: ${{ steps.validate_release.outputs.tag }} + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} run: | - auth_remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - if git ls-remote --exit-code --tags "$auth_remote" "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then - echo "Tag $RELEASE_TAG already exists." - exit 0 + set -euo pipefail + if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Invalid architecture image digest: $IMAGE_DIGEST" >&2 + exit 1 fi + mkdir -p "$RUNNER_TEMP/container-digests" + touch "$RUNNER_TEMP/container-digests/${IMAGE_DIGEST#sha256:}" + + - name: Upload architecture digest + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: executor-container-digest-${{ matrix.artifact }} + path: ${{ runner.temp }}/container-digests/ + if-no-files-found: error + compression-level: 0 + overwrite: true + retention-days: 1 + + assemble-container: + name: Assemble multi-platform container manifest + if: inputs.mode == 'publish' + needs: + - authorize-publish + - publish-container-arch + - validate + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: native-release + permissions: + contents: read + outputs: + digest: ${{ steps.manifest.outputs.digest }} + steps: + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate.outputs.commit_sha }} + persist-credentials: false + + - name: Download architecture digests + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: ${{ runner.temp }}/container-digests + pattern: executor-container-digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + with: + driver-opts: image=${{ env.BUILDKIT_IMAGE }} + version: ${{ env.BUILDX_VERSION }} - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag "$RELEASE_TAG" - git push "$auth_remote" "$RELEASE_TAG" + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ needs.authorize-publish.outputs.registry_username }} + password: ${{ secrets.NATIVE_RELEASE_TOKEN }} - - name: Trigger CLI publish - if: steps.changesets.outputs.hasChangesets == 'false' && steps.detect_release.outputs.changed == 'true' + - name: Assemble and verify staging manifest + id: manifest + shell: bash env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ steps.validate_release.outputs.tag }} + IMAGE: ${{ needs.validate.outputs.image }} + VERSION: ${{ needs.validate.outputs.version }} run: | - gh workflow run publish-executor-package.yml --ref "$RELEASE_TAG" -f tag="$RELEASE_TAG" + set -euo pipefail + mapfile -t digests < <(find "$RUNNER_TEMP/container-digests" -maxdepth 1 -type f -printf '%f\n' | sort) + if [ "${#digests[@]}" -ne 2 ]; then + echo "Expected two architecture digests, found ${#digests[@]}" >&2 + exit 1 + fi + + sources=() + for digest in "${digests[@]}"; do + if [[ ! "$digest" =~ ^[0-9a-f]{64}$ ]]; then + echo "Invalid architecture digest: $digest" >&2 + exit 1 + fi + sources+=("$IMAGE@sha256:$digest") + done - # Desktop build downloads CLI binaries from the release, so it must - # run after CLI publish completes. Trigger it from the CLI workflow - # or manually via: gh workflow run publish-desktop.yml -f tag=vX.Y.Z + staging="$IMAGE:staging-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + docker buildx imagetools create \ + --annotation "index:org.opencontainers.image.version=$VERSION" \ + --tag "$staging" \ + "${sources[@]}" + raw_manifest="$(docker buildx imagetools inspect "$staging" --raw)" + python3 scripts/verify-release-index.py \ + --version "$VERSION" \ + <<< "$raw_manifest" + + digest="$(docker buildx imagetools inspect "$staging" | awk '$1 == "Digest:" { print $2; exit }')" + if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Invalid assembled image digest: $digest" >&2 + exit 1 + fi + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + smoke-container: + name: Smoke published prebuilt container ${{ matrix.platform }} + if: inputs.mode == 'publish' + needs: + - assemble-container + - authorize-publish + - validate + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-22.04 + artifact: amd64 + platform: linux/amd64 + - runner: ubuntu-22.04-arm + artifact: arm64 + platform: linux/arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + environment: native-release + permissions: + contents: read + steps: + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate.outputs.commit_sha }} + persist-credentials: false + + - name: Download canonical web payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-web-build + path: ${{ runner.temp }}/canonical-web + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ needs.authorize-publish.outputs.registry_username }} + password: ${{ secrets.NATIVE_RELEASE_TOKEN }} + + - name: Pull and smoke exact assembled digest + shell: bash + env: + IMAGE: ${{ needs.validate.outputs.image }} + IMAGE_DIGEST: ${{ needs.assemble-container.outputs.digest }} + run: | + set -euo pipefail + reference="$IMAGE@$IMAGE_DIGEST" + docker pull "$reference" + scripts/smoke-release-container.sh \ + "$reference" \ + "$RUNNER_TEMP/canonical-web" \ + "executor-published-smoke-${GITHUB_RUN_ID}-${{ matrix.artifact }}" \ + "executor-published-smoke-data-${GITHUB_RUN_ID}-${{ matrix.artifact }}" + + - name: Verify exact digest is anonymously pullable + shell: bash + env: + IMAGE: ${{ needs.validate.outputs.image }} + IMAGE_DIGEST: ${{ needs.assemble-container.outputs.digest }} + run: | + set -euo pipefail + anonymous_config="$RUNNER_TEMP/anonymous-docker-config" + mkdir -p "$anonymous_config" + DOCKER_CONFIG="$anonymous_config" docker pull "$IMAGE@$IMAGE_DIGEST" + + promote: + name: Promote release + if: inputs.mode == 'publish' + needs: + - assemble-container + - authorize-publish + - smoke-container + - stage-release + - validate + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: native-release + permissions: + contents: read + steps: + - name: Checkout exact commit + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.validate.outputs.commit_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Download release bundle for final verification + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: executor-release-artifacts + path: release-artifacts + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + with: + driver-opts: image=${{ env.BUILDKIT_IMAGE }} + version: ${{ env.BUILDX_VERSION }} + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ needs.authorize-publish.outputs.registry_username }} + password: ${{ secrets.NATIVE_RELEASE_TOKEN }} + + - name: Revalidate remote release tag before promotion + shell: bash + env: + RELEASE_SHA: ${{ needs.validate.outputs.commit_sha }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + git fetch --force --no-tags origin \ + "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + tag_sha="$(git rev-parse "$RELEASE_TAG^{commit}")" + if [ "$tag_sha" != "$RELEASE_SHA" ]; then + echo "Release tag $RELEASE_TAG moved to $tag_sha, expected $RELEASE_SHA" >&2 + exit 1 + fi + + - name: Assign immutable tags and verify channel eligibility + shell: bash + env: + CHANNEL: ${{ needs.validate.outputs.channel }} + GH_TOKEN: ${{ secrets.NATIVE_RELEASE_TOKEN }} + IMAGE: ${{ needs.validate.outputs.image }} + IMAGE_DIGEST: ${{ needs.assemble-container.outputs.digest }} + RELEASE_SHA: ${{ needs.validate.outputs.commit_sha }} + RELEASE_TAG: ${{ inputs.tag }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Invalid container digest: $IMAGE_DIGEST" >&2 + exit 1 + fi + + inspect_digest() { + local reference="$1" + local error_file="$RUNNER_TEMP/imagetools-inspect-error" + local output digest + : > "$error_file" + if output="$(docker buildx imagetools inspect "$reference" 2>"$error_file")"; then + digest="$(awk '$1 == "Digest:" { print $2; exit }' <<< "$output")" + if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Could not resolve a valid digest for $reference" >&2 + return 2 + fi + printf '%s\n' "$digest" + return 0 + fi + if grep -Eqi 'manifest unknown|not found|404' "$error_file"; then + return 1 + fi + cat "$error_file" >&2 + return 2 + } + + release_history="$RUNNER_TEMP/published-releases.json" + gh api --paginate --slurp \ + "repos/$EXECUTOR_REPOSITORY/releases?per_page=100" \ + > "$release_history" + history_relation="$( + python3 scripts/check-release-channel-order.py \ + --channel "$CHANNEL" \ + --history-file "$release_history" \ + --candidate "$VERSION" + )" + echo "Published release history permits $history_relation candidate $VERSION" + + channel_reference="$IMAGE:$CHANNEL" + if current_channel_digest="$(inspect_digest "$channel_reference")"; then + channel_manifest="$(docker buildx imagetools inspect "$channel_reference" --raw)" + current_channel_version="$( + jq -r '.annotations["org.opencontainers.image.version"] // ""' \ + <<< "$channel_manifest" + )" + if [ -z "$current_channel_version" ]; then + echo "Existing channel $channel_reference has no release version annotation" >&2 + exit 1 + fi + relation="$( + python3 scripts/check-release-channel-order.py \ + --channel "$CHANNEL" \ + --current "$current_channel_version" \ + --candidate "$VERSION" + )" + if [ "$relation" = equal ]; then + if [ "$current_channel_digest" != "$IMAGE_DIGEST" ]; then + echo "Channel $channel_reference already has version $VERSION at a different digest" >&2 + exit 1 + fi + echo "Channel $channel_reference already points to the expected digest" + fi + else + rc=$? + if [ "$rc" -ne 1 ]; then + exit "$rc" + fi + echo "Channel $channel_reference does not exist yet" + fi + + immutable_tags=("$RELEASE_TAG" "$VERSION" "sha-$RELEASE_SHA") + missing_tags=() + for tag in "${immutable_tags[@]}"; do + reference="$IMAGE:$tag" + if existing="$(inspect_digest "$reference")"; then + if [ "$existing" != "$IMAGE_DIGEST" ]; then + echo "Immutable tag $reference already points to $existing, expected $IMAGE_DIGEST" >&2 + exit 1 + fi + echo "Immutable tag $reference already points to the expected digest" + else + status=$? + if [ "$status" -ne 1 ]; then + exit "$status" + fi + missing_tags+=("$tag") + fi + done + + for tag in "${missing_tags[@]}"; do + docker buildx imagetools create \ + --tag "$IMAGE:$tag" \ + "$IMAGE@$IMAGE_DIGEST" + done + + for tag in "${immutable_tags[@]}"; do + actual="$(inspect_digest "$IMAGE:$tag")" + if [ "$actual" != "$IMAGE_DIGEST" ]; then + echo "Immutable tag $IMAGE:$tag resolved to $actual after assignment" >&2 + exit 1 + fi + done + + - name: Verify release ownership and image binding before publication + shell: bash + env: + GH_TOKEN: ${{ secrets.NATIVE_RELEASE_TOKEN }} + IMAGE: ${{ needs.validate.outputs.image }} + IMAGE_DIGEST: ${{ needs.assemble-container.outputs.digest }} + RELEASE_SHA: ${{ needs.validate.outputs.commit_sha }} + RELEASE_TAG: ${{ inputs.tag }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Invalid assembled container digest: $IMAGE_DIGEST" >&2 + exit 1 + fi + + release_title="$RELEASE_TAG [executor-run:$RUN_ID]" + release_state="$RUNNER_TEMP/pre-publication-release.json" + gh api "repos/$EXECUTOR_REPOSITORY/releases/tags/$RELEASE_TAG" > "$release_state" + python3 - \ + "$release_state" \ + "$RELEASE_TAG" \ + "$RELEASE_SHA" \ + "$release_title" <<'PY' + import json + import pathlib + import sys + + release = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) + if ( + release.get("tag_name") != sys.argv[2] + or release.get("target_commitish") != sys.argv[3] + or release.get("name") != sys.argv[4] + or release.get("draft") not in (True, False) + ): + raise SystemExit("release ownership does not match this workflow run") + if release.get("draft") is False and release.get("immutable") is not True: + raise SystemExit("published release is not immutable") + PY + + bindings="$RUNNER_TEMP/final-release-bindings" + mkdir -p "$bindings" + run_binding="$bindings/RELEASE-RUN.json" + image_binding="$bindings/RELEASE-IMAGE.json" + python3 - \ + "$run_binding" \ + "$image_binding" \ + "$EXECUTOR_REPOSITORY" \ + "$RELEASE_TAG" \ + "$RELEASE_SHA" \ + "$RUN_ID" \ + "$IMAGE" \ + "$IMAGE_DIGEST" <<'PY' + import json + import pathlib + import sys + + run_binding = { + "commit": sys.argv[5], + "repository": sys.argv[3], + "run_id": sys.argv[6], + "tag": sys.argv[4], + } + image_binding = { + **run_binding, + "digest": sys.argv[8], + "image": sys.argv[7], + } + for path, binding in ( + (sys.argv[1], run_binding), + (sys.argv[2], image_binding), + ): + pathlib.Path(path).write_text( + json.dumps(binding, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + PY + + assets=( + "$image_binding" + "$run_binding" + release-artifacts/BUILD-TOOLCHAINS.txt + release-artifacts/executor-aarch64-apple-darwin.tar.gz + release-artifacts/executor-aarch64-apple-darwin.tar.gz.sha256 + release-artifacts/executor-aarch64-unknown-linux-gnu.tar.gz + release-artifacts/executor-aarch64-unknown-linux-gnu.tar.gz.sha256 + release-artifacts/executor-x86_64-apple-darwin.tar.gz + release-artifacts/executor-x86_64-apple-darwin.tar.gz.sha256 + release-artifacts/executor-x86_64-unknown-linux-gnu.tar.gz + release-artifacts/executor-x86_64-unknown-linux-gnu.tar.gz.sha256 + release-artifacts/SHA256SUMS + ) + for asset in "${assets[@]}"; do + test -f "$asset" + done + + expected_assets="$(for asset in "${assets[@]}"; do basename "$asset"; done | sort)" + remote_assets="$(gh release view "$RELEASE_TAG" --repo "$EXECUTOR_REPOSITORY" --json assets --jq '.assets[].name' | sort)" + if [ "$remote_assets" != "$expected_assets" ]; then + echo "Release assets do not match the exact pre-publication bundle" >&2 + diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$remote_assets") || true + exit 1 + fi + + verified_assets="$RUNNER_TEMP/final-verified-release-assets" + rm -rf "$verified_assets" + mkdir -p "$verified_assets" + gh release download "$RELEASE_TAG" \ + --repo "$EXECUTOR_REPOSITORY" \ + --dir "$verified_assets" + for asset in "${assets[@]}"; do + cmp "$asset" "$verified_assets/$(basename "$asset")" + done + + - name: Publish GitHub release + shell: bash + env: + GH_TOKEN: ${{ secrets.NATIVE_RELEASE_TOKEN }} + PRERELEASE: ${{ needs.validate.outputs.prerelease }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + is_draft="$( + gh release view "$RELEASE_TAG" \ + --repo "$EXECUTOR_REPOSITORY" \ + --json isDraft \ + --jq .isDraft + )" + if [ "$is_draft" = true ]; then + args=(release edit "$RELEASE_TAG" --repo "$EXECUTOR_REPOSITORY" --draft=false) + if [ "$PRERELEASE" = true ]; then + args+=(--prerelease) + else + args+=(--latest) + fi + gh "${args[@]}" + else + actual_prerelease="$( + gh release view "$RELEASE_TAG" \ + --repo "$EXECUTOR_REPOSITORY" \ + --json isPrerelease \ + --jq .isPrerelease + )" + if [ "$actual_prerelease" != "$PRERELEASE" ]; then + echo "Published release $RELEASE_TAG has unexpected prerelease state" >&2 + exit 1 + fi + echo "GitHub release $RELEASE_TAG is already published" + fi + + - name: Verify published GitHub release is immutable + shell: bash + env: + GH_TOKEN: ${{ secrets.NATIVE_RELEASE_TOKEN }} + PRERELEASE: ${{ needs.validate.outputs.prerelease }} + RELEASE_SHA: ${{ needs.validate.outputs.commit_sha }} + RELEASE_TAG: ${{ inputs.tag }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + release_state="$RUNNER_TEMP/published-release.json" + gh api "repos/$EXECUTOR_REPOSITORY/releases/tags/$RELEASE_TAG" > "$release_state" + release_title="$RELEASE_TAG [executor-run:$RUN_ID]" + python3 - \ + "$release_state" \ + "$RELEASE_TAG" \ + "$RELEASE_SHA" \ + "$PRERELEASE" \ + "$release_title" <<'PY' + import json + import pathlib + import sys + + release = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) + if ( + release.get("tag_name") != sys.argv[2] + or release.get("target_commitish") != sys.argv[3] + or release.get("draft") is not False + or release.get("prerelease") is not (sys.argv[4] == "true") + or release.get("name") != sys.argv[5] + or release.get("immutable") is not True + ): + raise SystemExit("published GitHub release is not the expected immutable release") + PY + + verified=false + verification_error="$RUNNER_TEMP/release-attestation-error" + verification_output="$RUNNER_TEMP/release-attestation.json" + for attempt in {1..24}; do + if gh release verify "$RELEASE_TAG" \ + --repo "$EXECUTOR_REPOSITORY" \ + --format json \ + > "$verification_output" \ + 2> "$verification_error"; then + cat "$verification_output" + verified=true + break + fi + if [ "$attempt" -lt 24 ]; then + sleep 5 + fi + done + if [ "$verified" != true ]; then + cat "$verification_error" >&2 + echo "GitHub release attestation was not available after bounded retries" >&2 + exit 1 + fi + + - name: Move mutable channel after GitHub release publication + shell: bash + env: + CHANNEL: ${{ needs.validate.outputs.channel }} + GH_TOKEN: ${{ secrets.NATIVE_RELEASE_TOKEN }} + IMAGE: ${{ needs.validate.outputs.image }} + IMAGE_DIGEST: ${{ needs.assemble-container.outputs.digest }} + RELEASE_TAG: ${{ inputs.tag }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + test "$( + gh release view "$RELEASE_TAG" \ + --repo "$EXECUTOR_REPOSITORY" \ + --json isDraft \ + --jq .isDraft + )" = false + if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Invalid container digest: $IMAGE_DIGEST" >&2 + exit 1 + fi + + inspect_digest() { + local reference="$1" + local error_file="$RUNNER_TEMP/imagetools-inspect-error" + local output digest + : > "$error_file" + if output="$(docker buildx imagetools inspect "$reference" 2>"$error_file")"; then + digest="$(awk '$1 == "Digest:" { print $2; exit }' <<< "$output")" + if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Could not resolve a valid digest for $reference" >&2 + return 2 + fi + printf '%s\n' "$digest" + return 0 + fi + if grep -Eqi 'manifest unknown|not found|404' "$error_file"; then + return 1 + fi + cat "$error_file" >&2 + return 2 + } + + release_history="$RUNNER_TEMP/published-releases-after-promotion.json" + gh api --paginate --slurp \ + "repos/$EXECUTOR_REPOSITORY/releases?per_page=100" \ + > "$release_history" + python3 scripts/check-release-channel-order.py \ + --channel "$CHANNEL" \ + --history-file "$release_history" \ + --candidate "$VERSION" + + channel_reference="$IMAGE:$CHANNEL" + move_channel=true + if current_channel_digest="$(inspect_digest "$channel_reference")"; then + channel_manifest="$(docker buildx imagetools inspect "$channel_reference" --raw)" + current_channel_version="$( + jq -r '.annotations["org.opencontainers.image.version"] // ""' \ + <<< "$channel_manifest" + )" + if [ -z "$current_channel_version" ]; then + echo "Existing channel $channel_reference has no release version annotation" >&2 + exit 1 + fi + relation="$( + python3 scripts/check-release-channel-order.py \ + --channel "$CHANNEL" \ + --current "$current_channel_version" \ + --candidate "$VERSION" + )" + if [ "$relation" = equal ]; then + if [ "$current_channel_digest" != "$IMAGE_DIGEST" ]; then + echo "Channel $channel_reference already has version $VERSION at a different digest" >&2 + exit 1 + fi + move_channel=false + fi + else + status=$? + if [ "$status" -ne 1 ]; then + exit "$status" + fi + fi + + if [ "$move_channel" = true ]; then + docker buildx imagetools create \ + --tag "$channel_reference" \ + "$IMAGE@$IMAGE_DIGEST" + fi + channel_digest="$(inspect_digest "$channel_reference")" + if [ "$channel_digest" != "$IMAGE_DIGEST" ]; then + echo "Channel tag $channel_reference resolved to $channel_digest" >&2 + exit 1 + fi diff --git a/.gitignore b/.gitignore index 4c3f79186..7d6eadfbc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ coverage *.lcov # logs -logs +/logs/ _.log report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json @@ -53,18 +53,18 @@ executor.har secret.key # desktop app build artifacts -apps/desktop/resources/ +legacy/desktop/resources/ # cloud local dev database .pglite -apps/cloud/.dev-db/ -apps/cloud/.e2e-db/ +legacy/cloud/.dev-db/ +legacy/cloud/.e2e-db/ # e2e suite: generated run artifacts + throwaway target state (the * also # covers ad-hoc variants like .e2e-stub-db-manual from debugging boots) e2e/runs/ -apps/cloud/.e2e-stub-db*/ -apps/host-selfhost/.e2e-data*/ +legacy/cloud/.e2e-stub-db*/ +legacy/host-selfhost/.e2e-data*/ # playwright e2e artifacts test-results/ @@ -115,3 +115,8 @@ scratch/ # Throwaway UX prototype (not part of the app) ux-demo/ + + +# Added by cargo + +/target diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d92df54ec..20ace484d 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -5,6 +5,7 @@ ".reference", ".turbo", "dist", + "legacy", "vendor", "e2e/runs", "node_modules", @@ -12,7 +13,6 @@ "bun.lock", "*.tsbuildinfo", "executor-*.tgz", - "**/routeTree.gen.ts", - "apps/cloud/src/services/executor-schema.ts" + "**/routeTree.gen.ts" ] } diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 53839c4bb..9c9841171 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -59,10 +59,11 @@ "overrides": [ { "files": [ - "apps/cli/src/**/*.{ts,tsx}", - "apps/desktop/src/main.ts", + "legacy/cli/src/**/*.{ts,tsx}", + "legacy/desktop/src/main/**/*.{ts,tsx}", "scripts/**/*.{ts,js}", "apps/*/scripts/**/*.{ts,js}", + "legacy/*/scripts/**/*.{ts,js}", "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}", ], "rules": { @@ -153,6 +154,7 @@ "vendor/", "emulators/", "e2e/runs/", + "legacy/", "node_modules/", "packages/core/fumadb/", "packages/core/sdk/src/vendor/json-schema-to-typescript/", diff --git a/.skills/cli-release/SKILL.md b/.skills/cli-release/SKILL.md index 11fb4234c..d5263e88c 100644 --- a/.skills/cli-release/SKILL.md +++ b/.skills/cli-release/SKILL.md @@ -1,133 +1,65 @@ --- name: cli-release -description: Runbook for releasing the `executor` CLI package (stable and beta). Covers scope of what ships with the CLI, user-facing changelog conventions, Changesets + Version Packages PR flow, beta train entry/exit, and owner preferences. Use when the user asks to cut a release, prepare release notes, enter/exit a beta train, or write changesets for the CLI. +description: Runbook for the supported native Executor release. Covers Cargo versioning, immutable release inputs, dry runs, protected authorization, and owner-gated publishing. --- -# Executor CLI release runbook +# Executor release runbook -## Authoritative doc +## Authoritative document -`RELEASING.md` at repo root is the source of truth. This skill encodes the owner's preferences on top of it. +`RELEASING.md` at the repository root is the source of truth. The supported +native Rust product, published by `.github/workflows/release.yml`, is the only +release surface. -## What the `executor` CLI actually ships +Do not infer another release path from archived source or historical tags. -The CLI binary bundles: +## Native product release -- `apps/cli/**` — CLI source + daemon -- `apps/local/**` — the web UI (embedded as a virtual module via `apps/cli/src/build.ts:178`) + drizzle migrations (`build.ts:205`) -- `packages/**` — `core`, `kernel`, `hosts/mcp`, `runtime-quickjs`, and every plugin under `packages/plugins/**` +`Cargo.toml` is the only native product version source. The manual +`.github/workflows/release.yml` workflow is the only entrypoint allowed to +publish native archives, the root container image, or the primary GitHub +release. -Does **not** ship in the CLI: +The workflow requires: -- `apps/cloud/**` (Cloudflare Workers deployment) -- `apps/marketing/**`, `apps/desktop/**` -- `examples/**`, `tests/**` +- `mode`: `dry-run` or `publish` +- `tag`: exact `v` +- `commit_sha`: exact lowercase 40-character commit on `origin/main` -**Implication for changelogs**: when asked "what changed since the last release", scope is `git log v..HEAD -- apps/cli apps/local packages`, not just `apps/cli`. Skipping `apps/local` and `packages` misses the bulk of product changes (Connections UI, OAuth plugins, SDK scope, OTEL, etc.). +Always run dry-run mode first. Publish mode additionally requires the remote tag +to point at the exact supplied commit, the protected `native-release` +environment, and its scoped `NATIVE_RELEASE_TOKEN` secret. -## Versioning preferences - -- Prior convention in this repo uses **`patch`** bumps for feature-heavy releases (see `.changeset/executor-1.4.6-beta.md` for precedent). Don't push back on patch unless there are genuine SemVer-breaking API changes to a library consumer surface. -- Breaking CLI UX changes (removed flags, changed argv shape) have historically still been `patch` bumps. Follow the owner's call — ask, don't assume `minor`. -- Normal release/patch PRs must add a `.changeset/*.md` file with frontmatter like `"executor": patch`. Do **not** directly bump `apps/cli/package.json` or `bun.lock` in a feature/fix PR. -- Only the Changesets-generated `Version Packages` PR should move `apps/cli/package.json`. If a normal PR directly changes that version, merging it to `main` can make `.github/workflows/release.yml` tag the commit and dispatch `publish-executor-package.yml`, causing an immediate CLI publish. -- `@executor-js/*` library packages have their own publish path. - -## Release notes: standard Changesets flow — the changeset body IS the changelog - -As of v1.5.0 this repo uses the canonical Changesets pipeline. The old -`apps/cli/release-notes/next.md` rolling file is gone — do not recreate it. - -### How it's wired - -- Every user-visible PR adds a `.changeset/*.md`; its **body** is the - user-facing changelog entry. -- `changeset version` (run by `changesets/action@v1` when building the - Version Packages PR) compiles changeset bodies into each bumped - package's `CHANGELOG.md` using `@changesets/changelog-github` - (configured in `.changeset/config.json`), which prefixes each entry - with the PR link and credits the author automatically. -- `apps/cli/src/release.ts` (`changelogSectionForVersion`) extracts the - released version's `## ` section from `apps/cli/CHANGELOG.md` - and uses it as the GitHub Release body. Missing section → falls back to - `--generate-notes`. -- Per-package `CHANGELOG.md` seed files are still required for every - workspace package (`bun run lint:changelog-stubs --fix` creates them); - `changesets/action@v1` crashes with `ENOENT` on missing files. -- `@changesets/changelog-github` needs `GITHUB_TOKEN` during - `changeset version`. CI provides it; locally: - `GITHUB_TOKEN=$(gh auth token) bun run changeset:version`. - -### Writing changeset bodies - -- Lead with user-visible behavior, not implementation. One sentence for a - typical fix; a short paragraph for a feature. -- Big releases: a changeset body can be a full markdown section — use - **bold sub-headings** + bullets, never `#`/`##` headings (they end up - nested inside a changelog list item). -- Breaking changes: include the before/after surface in the body. -- Don't duplicate content across changesets — every changeset in the - release lands in the same version section. -- Attribution is automatic via changelog-github; don't hand-write - `Thanks @...` lines. - -### When drafting a release-spanning changeset from `git log` - -- Look at `git diff v..HEAD -- README.md` first — best single view of user-facing changes. -- Read commits in bulk (`git log --oneline v..HEAD -- apps/cli apps/local packages`), bucket by theme, then write prose. -- Merged PRs without changesets still ship in the release — their content - ships regardless; only the changelog text is driven by changesets. If - something important landed without a changeset, fold its story into a - release-summary changeset. - -## Beta release flow +```sh +git fetch origin main +sha="$(git rev-parse origin/main)" +version="$(git show "$sha:Cargo.toml" | python3 -c 'import sys,tomllib; print(tomllib.load(sys.stdin.buffer)["package"]["version"])')" +tag="v$version" +gh workflow run release.yml --ref main \ + -f mode=dry-run \ + -f tag="$tag" \ + -f commit_sha="$sha" ``` -git checkout -b rs/beta-v-start -bun run release:beta:start # creates .changeset/pre.json -# write .changeset/executor--beta.md (frontmatter + user-facing body) -git add ... && git commit # ONLY when owner says commit -git push -u origin rs/beta-v-start -# Open PR -> merge -> release.yml opens "Version Packages (beta)" PR -> merge to publish -``` - -- Published under npm dist-tag `beta`. -- Users install: `npm i -g executor@beta`. -- Exit the train with `bun run release:beta:stop` when going back to stable. - -## Stable release flow - -Identical to beta except skip `release:beta:start`/`stop`. Changesets produce a normal `Version Packages` PR; merging publishes under `latest`. - -## Owner preferences (hard rules) -- **Never commit until the owner explicitly says so.** Set everything up in the working tree, run `git status`, and stop. -- **No AI / Claude / Anthropic / Co-Authored-By trailers** in commits, commit messages, PRs, or any generated file. This is in `CLAUDE.md` — do not violate. -- **Branch naming**: `rs/` for Rhys's branches. Beta-start branch: `rs/beta-v-start`. -- **Remote**: `origin` = `https://github.com/RhysSullivan/executor.git`. If another remote appears (e.g. a fork remote), ask whether to remove it. -- **Dirty working tree**: if there are uncommitted changes when starting a release, ask whether to include them, stash them, or commit separately first. Don't sweep them into the release commit silently. -- **Don't estimate time** — code is cheap to write. Focus on what to do, not how long it takes. -- **Fact-check scope claims** before publishing. If release notes say "does not affect X", verify by reading the diff. - -## Common commands - -``` -bun run changeset # interactive; or write .changeset/*.md directly -bun run lint:changelog-stubs --fix # seed missing per-package CHANGELOG.md files -bun run release:beta:start # enter prerelease -bun run release:beta:stop # exit prerelease -bun run release:publish:dry-run # build full CLI payload without publishing -bun run release:publish:packages:dry-run # pack @executor-js/* without publishing -bun run release:check # invoked by publish workflow -``` +Create and push the tag only after the dry run succeeds, then dispatch the same +workflow with `mode=publish`. Follow the exact tag verification sequence in +`RELEASING.md`. -## What the workflow does after merge to `main` +## Retired package release paths -1. `.github/workflows/release.yml` opens/updates a `Version Packages` PR. -2. Merging that PR: - - Publishes every `@executor-js/*` library that's not yet on npm (via `scripts/publish-packages.ts`). - - If `apps/cli/package.json` bumped, tags the commit and dispatches `publish-executor-package.yml`, which runs `release:check`, does a full dry-run build, publishes the CLI to npm, and creates/updates the GitHub Release with binary assets. +The TypeScript workspaces are private implementation packages used by the +active product and its retained source. This fork does not publish them to npm. +The old npm workflows, publisher scripts, and Changesets release machinery are +retired. Do not recreate a package release path from archived source or +upstream package metadata. -## Fallback behavior +## Owner rules -If something is unclear (bump level, whether to include in-flight work, whether to push), **ask the owner**. A release is a high-blast-radius action; one clarifying question is cheaper than a rogue publish. +- Never commit, push, tag, dispatch publish mode, or publish without explicit + owner approval. +- Never add AI assistant attribution or co-author trailers. +- Preserve unrelated dirty worktree changes. +- Verify version, tag, commit, workflow inputs, and remote state before + describing a release as ready. +- Ask one question when release scope or publish authority is unclear. diff --git a/.skills/warden-security-review/SKILL.md b/.skills/warden-security-review/SKILL.md index 83555b865..f403c9bcb 100644 --- a/.skills/warden-security-review/SKILL.md +++ b/.skills/warden-security-review/SKILL.md @@ -23,6 +23,13 @@ npm exec --yes --package=@sentry/warden -- warden --help The repo has a `warden.toml` that uses remote skills from `getsentry/warden-skills`. +Before scanning, verify that every configured target resolves to at least one +non-ignored file: + +```bash +bun run lint:warden-targets +``` + Reference skills are mirrored under `.reference/warden-skills` when needed. `.reference/` is gitignored. ## Local Outputs @@ -42,22 +49,29 @@ Warden may not treat bare directories as recursive targets. Prefer explicit quot ## Recommended Scans -Authz on cloud/API surfaces: +Authz on the active Rust API and OAuth surfaces plus archived opt-in API +surfaces: ```bash npm exec --yes --package=@sentry/warden -- \ - warden "apps/cloud/src/auth/**/*.ts" "apps/cloud/src/api/**/*.ts" \ - "apps/cloud/src/routes/**/*.tsx" "packages/core/api/src/**/*.ts" \ + warden "src/api.rs" "src/api/**/*.rs" "src/oauth/**/*.rs" \ + "legacy/cloud/src/auth/**/*.ts" "legacy/cloud/src/api/**/*.ts" \ + "legacy/cloud/src/routes/**/*.tsx" "packages/core/api/src/**/*.ts" \ --skill wrdn-authz --fail-on off --report-on low --min-confidence low \ --parallel 2 --log -o .warden-runs/authz.jsonl ``` Code execution on sink-bearing runtime/plugin files: +The archived local runtime remains in scope because explicit legacy development +and test commands can still execute it. No release workflow publishes it. Its +server code lives directly under `legacy/local/src`, not a `src/server` +subdirectory. + ```bash rg -l "\b(exec|spawn|execFile|fork|subprocess|Deno\.Command|new Function|eval\(|vm\.|QuickJS|quickjs|Worker\(|import\(|compile|instantiate|runIn|shell|command|child_process)\b" \ - apps/local/src/server apps/cli/src packages/core/execution/src packages/core/sdk/src packages/kernel packages/plugins \ - -g "*.ts" -g "*.tsx" -g "!*.test.ts" -g "!*.spec.ts" -g "!*.e2e.ts" -g "!**/dist/**" -g "!**/node_modules/**" \ + src legacy/local/src legacy/cli/src packages/core/execution/src packages/core/sdk/src packages/kernel packages/plugins \ + -g "*.rs" -g "*.ts" -g "*.tsx" -g "!*.test.ts" -g "!*.spec.ts" -g "!*.e2e.ts" -g "!**/dist/**" -g "!**/node_modules/**" \ > .warden-runs/code-execution-targets.txt npm exec --yes --package=@sentry/warden -- \ @@ -69,15 +83,12 @@ npm exec --yes --package=@sentry/warden -- \ Data exfiltration on backend/API/storage/plugin SDK surfaces: ```bash -find apps/cloud/src/api apps/cloud/src/auth apps/local/src/server \ - packages/core/api/src packages/core/storage-core/src packages/core/storage-file/src \ - packages/core/storage-postgres/src packages/core/storage-drizzle/src \ - packages/plugins/mcp/src packages/plugins/openapi/src packages/plugins/graphql/src \ - packages/plugins/google-discovery/src packages/plugins/oauth2/src \ - packages/plugins/onepassword/src packages/plugins/workos-vault/src \ - packages/plugins/file-secrets/src packages/plugins/keychain/src \ - -type f \( -name "*.ts" -o -name "*.tsx" \) | - rg -v '(\.test\.|\.spec\.|\.e2e\.|dist/|node_modules/|embedded-migrations\.gen\.ts|/react/)' \ +find src/api src/catalog src/invocation src/mcp src/oauth src/protocols src/runtime \ + src/api.rs src/database.rs src/outbound.rs src/request_logs.rs \ + legacy/cloud/src/api legacy/cloud/src/auth legacy/cloud/src/routes legacy/local/src \ + packages/core/api/src packages/plugins packages/react/src/api \ + -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) | + rg -v '(\.test\.|\.spec\.|\.e2e\.|dist/|node_modules/|embedded-migrations\.gen\.ts)' \ > .warden-runs/exfil-targets-focused.txt npm exec --yes --package=@sentry/warden -- \ @@ -109,12 +120,9 @@ For each candidate: - Determine what data returns to the caller: raw body, parsed fields, typed error message, timing/status oracle, or no observable data. - State confidence and deployment caveats. -## Current Known Findings - -As of the Warden pass on 2026-04-29: - -- Real: authenticated SSRF in plugin/source setup URL fetching for OpenAPI, Google Discovery, GraphQL, and MCP remote endpoints. -- Real: mutable third-party GitHub Actions refs in publish/release workflows, especially `oven-sh/setup-bun@v2` and `changesets/action@v1`. -- Clean in that pass: authz scan on cloud auth/API/core API surfaces; code-execution scan on narrowed CLI/runtime/kernel/plugin sink files. +## Result freshness -Do not claim the whole codebase is secure from those clean runs. They are scoped scanner results. +Do not carry old findings or clean claims forward without rerunning the current +targets. Report the exact commit, skill, target list, confidence threshold, and +output artifact for every result. A clean scoped pass is not evidence that the +whole codebase is secure. diff --git a/AGENTS.md b/AGENTS.md index 1c0dd1661..c9c7a8788 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,10 +115,16 @@ before using it. - `packages/plugins/*`: protocol and provider plugins. Plugin-specific runtime, React, API, and testing helpers should live with the owning plugin. - `packages/react`: shared React UI and atom/client integration. +- `packages/app`: shared React application shell and Vite composition. - `packages/hosts/mcp`: MCP host surface for exposing Executor through MCP. - `packages/kernel/*`: execution runtimes and code execution substrate. -- `apps/local`, `apps/cloud`, `apps/cli`, and `apps/desktop`: product entry - points that compose the packages. +- `packages/core/execution` and `packages/core/cli`: shared execution and + configuration tooling retained for package compatibility. +- `src` and `web`: the active local/self-hosted Rust and Svelte product. +- `legacy/cli` and `legacy/local`: archived TypeScript compatibility entry + points. +- `legacy/cloud`, `legacy/desktop`, `legacy/host-cloudflare`, and + `legacy/host-selfhost`: archived deployment entry points. ## Other diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..9b0d766c4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4028 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dragonbox_ecma" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "executor" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "async-trait", + "axum", + "base64", + "chacha20poly1305", + "clap", + "directories", + "hkdf", + "hmac", + "http-body-util", + "ipnet", + "jsonschema", + "libc", + "oxc", + "percent-encoding", + "rand 0.8.6", + "reqwest", + "rmcp", + "rquickjs", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "sqlx", + "subtle", + "tempfile", + "thiserror", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "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]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "hmac-sha1-compact" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.8", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-escape-simd" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e770254dd7802184595b1d30da2a15cb72569e2aca2b177aef8d22eac8a693" + +[[package]] +name = "jsonschema" +version = "0.46.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8374249b1bdce1c1773a09fa294347e19e4bfeb86d845c2d8ebaf8a8dbf11233" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "oxc" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c837e177cfe9e45dc8b474b16565046b673e5e2e8c2a2aece798ff9a1e104602" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_codegen", + "oxc_diagnostics", + "oxc_parser", + "oxc_semantic", + "oxc_span", + "oxc_syntax", + "oxc_transformer", + "oxc_transformer_plugins", +] + +[[package]] +name = "oxc-browserslist" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b87743c2f6963036971ca587539cd9235693d953dc018a734014241afb20d5b" +dependencies = [ + "flate2", + "postcard", + "rustc-hash", + "serde", + "thiserror", +] + +[[package]] +name = "oxc-miette" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b776084bf11ad750806cb63f9ed2606a12ab374cf458f36a7731eba1f5d753fc" +dependencies = [ + "cfg-if", + "owo-colors", + "oxc-miette-derive", + "textwrap", + "thiserror", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "oxc-miette-derive" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e0bafc397eee2f94c5bf89bb5a82ba6d522d1b79e2924c8169f053febb433f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oxc_allocator" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34ec71e68e73a0ed7d929e58ab2bb4f58752dea944c08f14ccc3b8272c77946" +dependencies = [ + "allocator-api2", + "hashbrown 0.17.1", + "oxc_data_structures", + "rustc-hash", +] + +[[package]] +name = "oxc_ast" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa406c62bb247767a6a051be6ac3be7db6c4f818129ccae570da6ee9eb96ead0" +dependencies = [ + "bitflags", + "oxc_allocator", + "oxc_ast_macros", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_estree", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", +] + +[[package]] +name = "oxc_ast_macros" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3706e193b659865e03155a44e443f0447abf6789f9a3eb436ae10e557afab899" +dependencies = [ + "phf", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oxc_ast_visit" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5353724c6c835bfd37ed59db65ccd506b7721598bcbe4750eda165754d105463" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_codegen" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d57d63987a27e8e67bc40518a4133b96ad2c0475f4735b8110e577a25680a09e" +dependencies = [ + "bitflags", + "cow-utils", + "dragonbox_ecma", + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_index", + "oxc_semantic", + "oxc_sourcemap", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", +] + +[[package]] +name = "oxc_compat" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a25f516a7f4c05f66da375d07b29a87fe0e6037c79374f409153cb9496f3e0" +dependencies = [ + "cow-utils", + "oxc-browserslist", + "oxc_syntax", + "rustc-hash", + "serde", +] + +[[package]] +name = "oxc_data_structures" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde9ca13ef89018fb4c18502cefb9668baac6066905d27a914c29fb5fae1c8ac" +dependencies = [ + "ropey", +] + +[[package]] +name = "oxc_diagnostics" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cbafa6cc3c38d72e35cd9337a38a4558e9fee4521fa5cabe321b90519e80aa" +dependencies = [ + "cow-utils", + "oxc-miette", + "percent-encoding", +] + +[[package]] +name = "oxc_ecmascript" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55408e4f458ec4b9cd842f67364e9395b28bdf2201c7755ed04d6707c1084ea" +dependencies = [ + "cow-utils", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_regular_expression", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_estree" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6c062d52b9ff15d118b87679bdd0a05c43599c1d55fd1cab280da1cc822858" + +[[package]] +name = "oxc_index" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191884bee6c3744909a51acc7d78d4ae370d817b25875b10642f632327b6296e" +dependencies = [ + "nonmax", + "serde", +] + +[[package]] +name = "oxc_parser" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6efc796fc0c65a2a6bd40984b65b1052b5550b017af303f02b08794e304c14c8" +dependencies = [ + "bitflags", + "cow-utils", + "memchr", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "seq-macro", +] + +[[package]] +name = "oxc_regular_expression" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e533f1345534dceaee6c2c7102e5f3d2e7466269199eca9abeb871e02a7496a4" +dependencies = [ + "bitflags", + "oxc_allocator", + "oxc_ast_macros", + "oxc_diagnostics", + "oxc_span", + "oxc_str", + "phf", + "rustc-hash", + "unicode-id-start", +] + +[[package]] +name = "oxc_semantic" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de31d2015099e33ecdc23ad59730b768a141a0b2941d43970a26ba50de003e10" +dependencies = [ + "itertools", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_index", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "self_cell", + "smallvec", +] + +[[package]] +name = "oxc_sourcemap" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac6db7d8c33f46a0b9ebb702c077364abcd6b64c3a3a97bb86b9f5b3554833c" +dependencies = [ + "base64-simd", + "json-escape-simd", + "rustc-hash", + "serde", + "serde_json", +] + +[[package]] +name = "oxc_span" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5245fa99a7c9405d57e2d9666afc9fd01ac644e37aff1cb3e1cedd50c17915" +dependencies = [ + "compact_str", + "oxc-miette", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_str", +] + +[[package]] +name = "oxc_str" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c61444d5681ce805fbbb5dc0498d980685a4c6a82dd57c2e17ff948041c1fb" +dependencies = [ + "compact_str", + "hashbrown 0.17.1", + "oxc_allocator", + "oxc_estree", +] + +[[package]] +name = "oxc_syntax" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296b94ecc1235a2b5ea2a47412d4bc573b7a25a93132a1a2f49c617d85b58995" +dependencies = [ + "bitflags", + "cow-utils", + "dragonbox_ecma", + "nonmax", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_index", + "oxc_span", + "oxc_str", + "phf", + "unicode-id-start", +] + +[[package]] +name = "oxc_transformer" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494dcdb3e8c8dcb50e2ce3ba238030c9116a8b47b54f55c0f0211d68ae66b183" +dependencies = [ + "base64", + "compact_str", + "hmac-sha1-compact", + "indexmap", + "itoa", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_compat", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "oxc_traverse", + "rustc-hash", + "serde", + "serde_json", +] + +[[package]] +name = "oxc_transformer_plugins" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ce80c16ee776e23a0bcb472153db9ec66c59b709fcdb63f43f7623cdfadce7" +dependencies = [ + "cow-utils", + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_parser", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "oxc_transformer", + "oxc_traverse", + "rustc-hash", +] + +[[package]] +name = "oxc_traverse" +version = "0.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da52a3734ef3ad4c10ebb6f4e768f63ea1597677d53785e3ceb26d9735f5c1f8" +dependencies = [ + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_data_structures", + "oxc_ecmascript", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[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 = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "referencing" +version = "0.46.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65a910f9d63351f9c9d7dc3053d02b214ea3a005917140c70087d7b59cbc5bb1" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.8", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +dependencies = [ + "async-trait", + "chrono", + "futures", + "pin-project-lite", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + +[[package]] +name = "rquickjs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0688f8b0192998cca685adefdfad3483da295fa40a0ec406b4c14ecd729e858" +dependencies = [ + "rquickjs-core", +] + +[[package]] +name = "rquickjs-core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fee8c5383f0cfda3b980a80ca4520e726e09b593c59562f579daa51b6c20411" +dependencies = [ + "async-lock", + "hashbrown 0.17.1", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-sys" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "698077537c286a169de8693b216672bcef148bf2e2e112ebf50758c68e9afa09" +dependencies = [ + "cc", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..c033263c7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "executor" +version = "0.1.0" +edition = "2024" +rust-version = "1.96" +license = "MIT" +build = "build.rs" + +[dependencies] +anyhow = "1" +argon2 = "0.5" +async-trait = "0.1" +axum = "0.8" +base64 = "0.22" +chacha20poly1305 = "0.10" +clap = { version = "4", features = ["derive", "env"] } +directories = "6" +hkdf = "0.12" +hmac = "0.12" +ipnet = "2.12.0" +jsonschema = { version = "0.46.6", default-features = false } +libc = "0.2" +oxc = { version = "=0.137.0", default-features = false, features = [ + "semantic", + "transformer", + "codegen", + "ast_visit", +] } +percent-encoding = "2" +rand = "0.8.5" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +rmcp = { version = "=1.8.0", default-features = false } +rquickjs = { version = "=0.12.0", default-features = false, features = ["std", "futures"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" +sqlx = { version = "0.8", default-features = false, features = [ + "runtime-tokio-rustls", + "sqlite", + "migrate", + "macros", +] } +subtle = "2" +thiserror = "2" +tokio = { version = "1", features = [ + "macros", + "rt-multi-thread", + "net", + "signal", + "time", + "fs", + "sync", + "process", + "io-util", + "io-std", +] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" +uuid = { version = "1", features = ["v4", "serde"] } + +[dev-dependencies] +http-body-util = "0.1" +tempfile = "3" +tower = { version = "0.5", features = ["util"] } + +[build-dependencies] +base64 = "0.22" +sha2 = "0.10" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..6b1d975b8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,125 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e + +ARG SOURCE_DATE_EPOCH + +FROM oven/bun:1.3.11@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS web-builder + +ARG TARGETARCH + +WORKDIR /build + +COPY package.json bun.lock ./ +COPY patches ./patches +COPY apps ./apps +COPY e2e ./e2e +COPY examples ./examples +COPY legacy ./legacy +COPY packages ./packages +COPY web/package.json ./web/package.json + +RUN --mount=type=cache,id=executor-bun-${TARGETARCH},target=/root/.bun/install/cache,sharing=locked \ + bun install --frozen-lockfile --ignore-scripts --filter @executor-js/web + +COPY web ./web +RUN bun run --cwd web build + + +FROM rust:1.96-bookworm@sha256:6d19f49541d185805745b8baa781b1fd482118c81a3154510ee18dcce985d005 AS rust-builder + +ARG TARGETARCH + +WORKDIR /build + +COPY Cargo.toml Cargo.lock build.rs LICENSE about.toml about.hbs ./ +COPY migrations ./migrations +COPY packaging/launchd/bounded-log.sh ./packaging/launchd/bounded-log.sh +COPY packaging/launchd/dev.executor.gateway.plist ./packaging/launchd/dev.executor.gateway.plist +COPY packaging/systemd/executor.env.example ./packaging/systemd/executor.env.example +COPY packaging/systemd/executor.service ./packaging/systemd/executor.service +COPY scripts/install-launchd.sh ./scripts/install-launchd.sh +COPY scripts/install-systemd.sh ./scripts/install-systemd.sh +COPY scripts/lib/install-systemd-master-key.sh ./scripts/lib/install-systemd-master-key.sh +COPY src ./src +COPY --from=web-builder /build/web/build ./web/build + +RUN --mount=type=cache,id=executor-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,id=executor-cargo-target-${TARGETARCH},target=/build/target,sharing=locked \ + cargo install cargo-about --version 0.9.0 --locked --features cli && \ + cargo about generate about.hbs > THIRD_PARTY_LICENSES.html && \ + cargo build --locked --release && \ + cp target/release/executor /usr/local/bin/executor + + +FROM debian:bookworm-20260623-slim@sha256:60eac759739651111db372c07be67863818726f754804b8707c90979bda511df AS runtime-base + +ARG SOURCE_DATE_EPOCH +ARG DEBIAN_SNAPSHOT=20260623T000000Z +ARG EXECUTOR_UID=10001 +ARG EXECUTOR_GID=10001 + +RUN printf '%s\n' \ + 'Types: deb' \ + "URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ + 'Suites: bookworm bookworm-updates' \ + 'Components: main' \ + 'Check-Valid-Until: no' \ + '' \ + 'Types: deb' \ + "URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ + 'Suites: bookworm-security' \ + 'Components: main' \ + 'Check-Valid-Until: no' \ + > /etc/apt/sources.list.d/debian.sources && \ + apt-get update && \ + apt-get install --yes --no-install-recommends ca-certificates curl && \ + rm -rf \ + /var/cache/ldconfig/aux-cache \ + /var/lib/apt/lists/* \ + /var/log/apt/* \ + /var/log/dpkg.log && \ + groupadd --gid "${EXECUTOR_GID}" executor && \ + useradd --uid "${EXECUTOR_UID}" --gid executor --no-create-home \ + --home-dir /var/lib/executor --shell /usr/sbin/nologin executor && \ + chage --lastday \ + "$(date --utc --date="@${SOURCE_DATE_EPOCH:-0}" +%Y-%m-%d)" \ + executor && \ + install --directory --owner executor --group executor --mode 0700 \ + /var/lib/executor && \ + install --directory --owner root --group executor --mode 0750 \ + /etc/executor + +ENV EXECUTOR_DATA_DIR=/var/lib/executor \ + EXECUTOR_PUBLIC_ORIGIN=http://127.0.0.1:4788 \ + RUST_LOG=executor=info + +USER executor:executor +WORKDIR /var/lib/executor + +EXPOSE 4788 +VOLUME ["/var/lib/executor"] +STOPSIGNAL SIGTERM + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:4788/healthz"] + +ENTRYPOINT ["/usr/local/bin/executor"] +CMD ["server", "--bind", "0.0.0.0:4788", "--allow-unsafe-http-non-loopback"] + + +# Release automation supplies the extracted Linux archive as the named +# prebuilt-executor context. This target never compiles web or Rust code. +FROM runtime-base AS runtime-prebuilt + +COPY --from=prebuilt-executor --chmod=0755 /executor /usr/local/bin/executor +COPY --from=prebuilt-executor --chmod=0644 /LICENSE /usr/share/licenses/executor/LICENSE +COPY --from=prebuilt-executor --chmod=0644 /THIRD_PARTY_LICENSES.html /usr/share/licenses/executor/THIRD_PARTY_LICENSES.html +COPY --from=prebuilt-executor --chmod=0644 /THIRD_PARTY_JAVASCRIPT_LICENSES.json /usr/share/licenses/executor/THIRD_PARTY_JAVASCRIPT_LICENSES.json + + +# Keep the default target self-contained for local and CI builds. +FROM runtime-base AS runtime + +COPY --from=rust-builder /usr/local/bin/executor /usr/local/bin/executor +COPY --from=rust-builder /build/LICENSE /usr/share/licenses/executor/LICENSE +COPY --from=rust-builder /build/THIRD_PARTY_LICENSES.html /usr/share/licenses/executor/THIRD_PARTY_LICENSES.html +COPY --from=rust-builder /build/web/build/THIRD_PARTY_JAVASCRIPT_LICENSES.json /usr/share/licenses/executor/THIRD_PARTY_JAVASCRIPT_LICENSES.json diff --git a/Dockerfile.dockerignore b/Dockerfile.dockerignore new file mode 100644 index 000000000..d314f70d4 --- /dev/null +++ b/Dockerfile.dockerignore @@ -0,0 +1,69 @@ +** + +!Dockerfile +!Cargo.toml +!Cargo.lock +!build.rs +!package.json +!bun.lock +!LICENSE +!about.toml +!about.hbs + +# Bun validates the complete workspace graph during a frozen filtered install. +# Keep these roots manifest-only so application source changes do not invalidate +# the dependency layer. +!apps +apps/** +!apps/*/package.json + +!e2e +e2e/** +!e2e/package.json + +!examples +examples/** +!examples/*/package.json + +!legacy +legacy/** +!legacy/*/package.json + +!packages +packages/** +!packages/*/*/package.json +!packages/app/package.json +!packages/react/package.json + +!migrations +!migrations/** +!packaging +packaging/** +!packaging/launchd +!packaging/launchd/bounded-log.sh +!packaging/launchd/dev.executor.gateway.plist +!packaging/systemd +!packaging/systemd/executor.env.example +!packaging/systemd/executor.service +!patches +!patches/** +!scripts +scripts/** +!scripts/install-launchd.sh +!scripts/install-systemd.sh +!scripts/package-release-archive.py +!scripts/lib +!scripts/lib/install-systemd-master-key.sh +!src +!src/** +!web +!web/** + +web/build +web/.svelte-kit +web/node_modules +web/**/.env* +web/**/*.key +web/**/*.pem +web/**/*.db* +web/**/*secret* diff --git a/Dockerfile.release-native b/Dockerfile.release-native new file mode 100644 index 000000000..f1a81320f --- /dev/null +++ b/Dockerfile.release-native @@ -0,0 +1,79 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e + +ARG SOURCE_DATE_EPOCH + +FROM ubuntu:22.04@sha256:4f838adc7181d9039ac795a7d0aba05a9bd9ecd480d294483169c5def983b64d AS builder + +ARG RUST_TARGET +ARG RUST_VERSION=1.96.0 +ARG SOURCE_DATE_EPOCH +ARG TARGETARCH +ARG UBUNTU_SNAPSHOT=20260623T000000Z + +ENV DEBIAN_FRONTEND=noninteractive + +RUN sed -i -E \ + "s|^deb |deb [snapshot=${UBUNTU_SNAPSHOT}] |" \ + /etc/apt/sources.list && \ + apt-get -o Acquire::Check-Valid-Until=false update && \ + apt-get -o APT::Get::Always-Include-Phased-Updates=true install \ + --yes --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + pkg-config \ + xz-utils && \ + rm -rf \ + /var/cache/ldconfig/aux-cache \ + /var/lib/apt/lists/* \ + /var/log/apt/* \ + /var/log/dpkg.log + +RUN case "$TARGETARCH" in \ + amd64) \ + expected_target=x86_64-unknown-linux-gnu; \ + checksum=c295047583a56238ea06b43f849f4b877fa12bfd4c7103f8d9a74c94c9c4e108 \ + ;; \ + arm64) \ + expected_target=aarch64-unknown-linux-gnu; \ + checksum=371eadcca97062219cbd8593628eb5d2802bc370515d085fedce1b56b2baed57 \ + ;; \ + *) \ + echo "Unsupported release architecture: $TARGETARCH" >&2; \ + exit 1 \ + ;; \ + esac && \ + test "$RUST_TARGET" = "$expected_target" && \ + archive="rust-${RUST_VERSION}-${RUST_TARGET}.tar.xz" && \ + curl --fail --location --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --max-filesize 268435456 \ + --output "/tmp/$archive" \ + "https://static.rust-lang.org/dist/$archive" && \ + printf '%s %s\n' "$checksum" "/tmp/$archive" | sha256sum --check - && \ + tar -xJf "/tmp/$archive" -C /tmp && \ + "/tmp/rust-${RUST_VERSION}-${RUST_TARGET}/install.sh" \ + --prefix=/usr/local \ + --disable-ldconfig && \ + rm -rf "/tmp/$archive" "/tmp/rust-${RUST_VERSION}-${RUST_TARGET}" + +WORKDIR /build + +COPY Cargo.toml Cargo.lock build.rs ./ +COPY migrations ./migrations +COPY packaging/launchd/bounded-log.sh ./packaging/launchd/bounded-log.sh +COPY packaging/launchd/dev.executor.gateway.plist ./packaging/launchd/dev.executor.gateway.plist +COPY packaging/systemd/executor.env.example ./packaging/systemd/executor.env.example +COPY packaging/systemd/executor.service ./packaging/systemd/executor.service +COPY scripts/install-launchd.sh ./scripts/install-launchd.sh +COPY scripts/install-systemd.sh ./scripts/install-systemd.sh +COPY scripts/lib/install-systemd-master-key.sh ./scripts/lib/install-systemd-master-key.sh +COPY src ./src +COPY --from=release-web / ./web/build + +RUN cargo build --locked --release --target "$RUST_TARGET" + +FROM scratch AS artifact + +ARG RUST_TARGET + +COPY --from=builder "/build/target/${RUST_TARGET}/release/executor" /executor diff --git a/Dockerfile.release-package b/Dockerfile.release-package new file mode 100644 index 000000000..3a897fa55 --- /dev/null +++ b/Dockerfile.release-package @@ -0,0 +1,83 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e + +FROM ubuntu:22.04@sha256:4f838adc7181d9039ac795a7d0aba05a9bd9ecd480d294483169c5def983b64d AS packager + +ARG SOURCE_DATE_EPOCH +ARG BUILDKIT_IMAGE +ARG BUILDX_VERSION +ARG MACOS_BUILD_CLANG_VERSION +ARG MACOS_BUILD_LD_VERSION +ARG MACOS_BUILD_SDK_VERSION +ARG MACOS_BUILD_XCODE_BUILD +ARG MACOS_BUILD_XCODE_VERSION +ARG MACOS_FLOOR_CLANG_VERSION +ARG MACOS_FLOOR_LD_VERSION +ARG MACOS_FLOOR_SDK_VERSION +ARG MACOS_FLOOR_XCODE_BUILD +ARG MACOS_FLOOR_XCODE_VERSION +ARG REPRODUCIBILITY_RUN +ARG UBUNTU_SNAPSHOT=20260623T000000Z + +ENV DEBIAN_FRONTEND=noninteractive + +RUN sed -i -E \ + "s|^deb |deb [snapshot=${UBUNTU_SNAPSHOT}] |" \ + /etc/apt/sources.list && \ + apt-get -o Acquire::Check-Valid-Until=false update && \ + apt-get -o APT::Get::Always-Include-Phased-Updates=true install \ + --yes --no-install-recommends \ + python3-minimal && \ + rm -rf \ + /var/cache/ldconfig/aux-cache \ + /var/lib/apt/lists/* \ + /var/log/apt/* \ + /var/log/dpkg.log + +COPY scripts/package-release-archive.py /usr/local/bin/package-release-archive.py +COPY --from=release-inputs / /release-inputs + +RUN set -eux; \ + test -n "$SOURCE_DATE_EPOCH"; \ + test -n "$REPRODUCIBILITY_RUN"; \ + mkdir -p /output /staging; \ + { \ + printf 'buildx=%s\n' "$BUILDX_VERSION"; \ + printf 'buildkit=%s\n' "$BUILDKIT_IMAGE"; \ + printf 'macos-release-xcode=%s (%s)\n' "$MACOS_BUILD_XCODE_VERSION" "$MACOS_BUILD_XCODE_BUILD"; \ + printf 'macos-release-sdk=%s\n' "$MACOS_BUILD_SDK_VERSION"; \ + printf 'macos-release-clang=%s\n' "$MACOS_BUILD_CLANG_VERSION"; \ + printf 'macos-release-ld=%s\n' "$MACOS_BUILD_LD_VERSION"; \ + printf 'macos-floor-xcode=%s (%s)\n' "$MACOS_FLOOR_XCODE_VERSION" "$MACOS_FLOOR_XCODE_BUILD"; \ + printf 'macos-floor-sdk=%s\n' "$MACOS_FLOOR_SDK_VERSION"; \ + printf 'macos-floor-clang=%s\n' "$MACOS_FLOOR_CLANG_VERSION"; \ + printf 'macos-floor-ld=%s\n' "$MACOS_FLOOR_LD_VERSION"; \ + printf 'ubuntu-packager=22.04@sha256:4f838adc7181d9039ac795a7d0aba05a9bd9ecd480d294483169c5def983b64d\n'; \ + printf 'ubuntu-snapshot=%s\n' "$UBUNTU_SNAPSHOT"; \ + python3 -c 'import sys, zlib; print(f"python={sys.version.split()[0]}"); print(f"zlib-build={zlib.ZLIB_VERSION}"); print(f"zlib-runtime={zlib.ZLIB_RUNTIME_VERSION}")'; \ + } > /output/BUILD-TOOLCHAINS.txt; \ + for target in \ + aarch64-apple-darwin \ + aarch64-unknown-linux-gnu \ + x86_64-apple-darwin \ + x86_64-unknown-linux-gnu; do \ + stage="/staging/$target"; \ + archive="executor-${target}.tar.gz"; \ + mkdir "$stage"; \ + install -m 0755 "/release-inputs/native/$target/executor" "$stage/executor"; \ + install -m 0644 /release-inputs/LICENSE "$stage/LICENSE"; \ + install -m 0644 \ + /release-inputs/THIRD_PARTY_LICENSES.html \ + "$stage/THIRD_PARTY_LICENSES.html"; \ + install -m 0644 \ + /release-inputs/THIRD_PARTY_JAVASCRIPT_LICENSES.json \ + "$stage/THIRD_PARTY_JAVASCRIPT_LICENSES.json"; \ + python3 /usr/local/bin/package-release-archive.py \ + --source "$stage" \ + --output "/output/$archive" \ + --epoch "$SOURCE_DATE_EPOCH"; \ + (cd /output && sha256sum "$archive" > "$archive.sha256"); \ + done + +FROM scratch AS artifacts + +COPY --from=packager /output/ / diff --git a/README.md b/README.md index 5eabe62c6..75ef7229b 100644 --- a/README.md +++ b/README.md @@ -1,184 +1,191 @@ -# executor +# Executor -[https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad](https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad) +Executor is a single-user, self-hosted tool gateway for AI agents. One Rust +binary serves the Svelte dashboard, stores state in SQLite, exposes an MCP +endpoint, and runs concurrent TypeScript tool workflows in isolated QuickJS +workers. -The integration layer for AI agents. One catalog for every tool, shared across every agent you use. +The active product supports: -[Ask DeepWiki](https://deepwiki.com/RhysSullivan/executor) +- OpenAPI, GraphQL, and MCP sources +- API key, bearer, basic, manual OAuth token, and managed OAuth credentials +- one global tool catalog with Enabled, Ask, and Disabled modes +- interactive approval for sensitive calls +- a stateful Streamable HTTP MCP endpoint and a local stdio bridge +- native Linux and macOS binaries, plus Docker -## Quick start +Windows is not a release target. The previous TypeScript CLI, local app, +hosted deployments, managed cloud, and Electron products are archived in +[`legacy/`](legacy/README.md). -```bash -npm install -g executor -executor install -executor web -``` - -This installs the local background service and opens the web UI. From there, add your first source and start using tools. +`Cargo.toml` is the native product version source. Published releases provide +four checksum-verified Linux and macOS archives plus the multi-platform +`ghcr.io//executor` image. See [installation](docs/install.md) +and [release operations](RELEASING.md). -### Use as an MCP server +## Try it from this checkout -Point any MCP-compatible agent (Cursor, Claude Code, OpenCode, etc.) at Executor to share your tool catalog, auth, and policies across all of them. +The production binary embeds the dashboard, so the web build must run before +the release Cargo build. From the repository root: -```bash +```sh +bun run bootstrap +bun run --cwd web build +cargo build --locked --release -executor mcp +mkdir -p .executor-local/data +chmod 0700 .executor-local/data +./target/release/executor server --data-dir "$PWD/.executor-local/data" ``` -Example `mcp.json` for Claude Code / Cursor: - -```json -{ - "mcpServers": { - "executor": { - "command": "executor", - "args": ["mcp"] - } - } -} -``` +This checkout expects Bun 1.3.x and Rust 1.96 or newer. The release workflow +currently pins Bun 1.3.11 and Rust 1.96.0. -### Use with Pi +Executor listens at `http://127.0.0.1:4788` by default. Open the one-time +`/setup#token=...` URL printed by the server and create the only administrator +account with a password of at least 12 characters. The dashboard then signs in +with those credentials. Open **API tokens** and create a token for your client. +The token secret is shown once. -[Pi](https://pi.dev) does not include a built-in MCP client. To use Executor from Pi, install the community bridge: +On WSL2, paste the setup URL into the Windows browser. You can also open the +dashboard from the WSL shell after setup: -```bash -pi install git:github.com/gvkhosla/pi-executor-mcp@v0.2.0 +```sh +/mnt/c/windows/explorer.exe http://127.0.0.1:4788 ``` -Reload Pi, then verify the bridge: +State is under `.executor-local/data` in this example. Stop the server before +copying that directory for backup, and keep `executor.db`, its WAL files, and +`master.key` together. -```text -/reload -/executor-status -``` - -After that, ask Pi to search, inspect, and call tools through Executor. - -## Add a source - -If you can represent it with a JSON schema, it can be an integration. Executor has first-party support for OpenAPI, GraphQL, MCP, and Google Discovery — but the plugin system is open to any source type. - -### Via the web UI +## Connect sources -Run `executor web`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth. +Open **Sources**, choose a connector, and follow the preview or connection +flow: -### Via the CLI +- OpenAPI accepts a URL or pasted OpenAPI 3.0/3.1 JSON or YAML. +- GraphQL connects to an introspection-enabled endpoint. +- MCP Streamable HTTP connects to a remote or local HTTP MCP endpoint. +- MCP stdio selects only a machine-admin-approved command template. -```bash -executor call executor openapi addSource '{ - "spec": "https://petstore3.swagger.io/api/v3/openapi.json", - "namespace": "petstore", - "baseUrl": "https://petstore3.swagger.io/api/v3" -}' -``` - -Use `baseUrl` when the OpenAPI document has relative `servers` entries (for example `"/api/v3"`). +After import, review each source under **Tools**. GraphQL queries start Enabled, +mutations start Ask, and deprecated operations start Disabled. MCP tools are +Enabled only when the upstream explicitly marks them read-only and not +destructive. Other MCP tools start Ask. -## Use tools +See [source and OAuth setup](docs/sources.md) and the detailed +[MCP contract](docs/mcp.md). -Agents discover and call tools through a typed TypeScript runtime: +## Use the CLI -```ts -// discover by intent -const matches = await tools.discover({ query: "github issues", limit: 5 }); +Client commands talk to an already-running Executor server. They never open a +second copy of the database. -// inspect the schema -const detail = await tools.describe.tool({ - path: matches.bestPath, - includeSchemas: true, -}); +```sh +./target/release/executor --version +export EXECUTOR_API_TOKEN='token-shown-by-the-dashboard' -// call with type safety -const issues = await tools.github.issues.list({ - owner: "vercel", - repo: "next.js", -}); +./target/release/executor tools sources +./target/release/executor tools search 'create issue' +./target/release/executor tools describe source_slug.tool_name +./target/release/executor call source_slug.tool_name '{"input":"value"}' ``` -Use tools via the CLI: +Use `--base-url` or `EXECUTOR_BASE_URL` for another instance. Remote instances +must use HTTPS unless you deliberately pass `--allow-insecure-http` on a +separately authenticated and encrypted tunnel. A private LAN alone does not +protect the bearer token. See the [CLI guide](docs/cli.md). -```bash -executor tools search "send email" -executor call --help -executor call github --help -executor call github issues --help -executor call cloudflare --help --match dns --limit 20 -executor call github issues create '{"owner":"octocat","repo":"Hello-World","title":"Hi"}' -executor call gmail send '{"to":"alice@example.com","subject":"Hi"}' -``` +## Use Executor as an MCP server -`executor call`, `executor resume`, and `executor tools ...` commands auto-start a local daemon if needed. -If the default port is busy, the CLI will pick an available local port and track it automatically. +For clients that support Streamable HTTP, use the instance origin plus `/mcp` +and send the dashboard API token as a bearer token. Client configuration +schemas differ, so treat this as a schematic shape and adapt it to the selected +client's MCP documentation: -If an execution pauses for auth or approval, resume it: - -```bash -executor resume --execution-id exec_123 -``` - -## CLI reference - -```bash -executor install # install/start the durable background service -executor web # open the running web UI -executor web --foreground # start a temporary foreground runtime + web UI -executor daemon run # start persistent local daemon in background -executor daemon status # show daemon status -executor daemon stop # stop daemon -executor daemon restart # restart daemon -executor mcp # start MCP endpoint -executor call '{"k":"v"}' # invoke a tool by path segments -executor call --help # browse namespaces/resources/methods -executor call --help --match "" --limit # narrow huge namespaces -executor resume --execution-id # resume paused execution -executor tools search "" # search tools by intent -executor tools sources # list configured sources + tool counts -executor tools describe # show tool TypeScript/JSON schema +```json +{ + "mcpServers": { + "executor": { + "type": "http", + "url": "http://127.0.0.1:4788/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} ``` -## Developing locally +For a client that launches only stdio MCP servers, the common shape is: -```bash -bun install -bun dev +```json +{ + "mcpServers": { + "executor": { + "command": "/absolute/path/to/executor", + "args": ["mcp"], + "env": { + "EXECUTOR_API_TOKEN": "" + } + } + } +} ``` -The dev server starts at `http://127.0.0.1:4788`. +The bridge connects to `http://127.0.0.1:4788` by default. Set +`EXECUTOR_BASE_URL` when the server uses another origin. -### Tests +## Install and operate -```bash -bun run test # unit + integration suites -bun run test:e2e # full-stack e2e: boots the cloud and self-host apps and drives them -``` - -The browser e2e scenarios need Playwright's Chromium once per machine: -`bunx playwright install chromium`. - -## Community +- [Native install and first boot](docs/install.md) +- [Docker Compose](docs/docker.md) +- [Linux systemd](docs/systemd.md) +- [macOS launchd](docs/launchd.md) +- [Runtime and sandbox boundary](docs/runtime.md) +- [Architecture and security contracts](docs/architecture.md) -Join the Discord: [https://discord.gg/eF29HBHwM6](https://discord.gg/eF29HBHwM6) +For a reverse proxy or managed OAuth, set `EXECUTOR_PUBLIC_ORIGIN` to the exact +HTTPS origin used in the browser before starting Executor. Callback URLs are +connection-specific and are displayed in the source's Managed OAuth panel. -## Learn more +## Develop and verify -Visit [executor.sh](https://executor.sh) to learn more. +`bun run bootstrap` installs workspace dependencies, prepares the retained +TypeScript packages, and installs Playwright Chromium. -## Attribution +A debug Rust server does not require production web assets: -- Thank you to [Crystian](https://www.linkedin.com/in/crystian/) for providing the npm package name `executor`. +```sh +mkdir -p .executor-debug +chmod 0700 .executor-debug +cargo run -- server --data-dir "$PWD/.executor-debug" +``` -## References +Debug builds intentionally embed a small fixture page. That path is suitable +for API, CLI, and MCP work, not dashboard acceptance. Use the release build +sequence above when you need the real embedded Svelte application. + +The broad merge gates are: + +```sh +bun run format:check +bun run lint +bun run typecheck +bun run test +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets --all-features +bun run test:e2e +``` -As part of my coding process, I give my agent access to references to other codebases to understand patterns and how other people have implemented systems. +The e2e command builds the Svelte assets and a local debug Rust binary before +driving the real first-boot, source, tool-mode, approval, log, token, and OAuth +journeys in Chromium. -A non exhaustive list of references are: +See [`RUNNING.md`](RUNNING.md) for the current repository workflow and e2e +status. Default commands exclude all archived application packages. -- [Better Auth](https://github.com/better-auth/better-auth) - Storage adapter reference -- [Effect](https://github.com/Effect-TS/effect) - General code patterns -- [OpenCode](https://github.com/anomalyco/opencode) - Plugin system reference -- [OpenClaw](https://github.com/openclaw/openclaw) - Plugin system reference -- [Emdash](https://github.com/emdash-cms/emdash) - Plugin system reference -- [Pi](https://github.com/badlogic/pi-mono) - Plugin system reference +## License -It's encouraged also that you can use this codebase as a reference to understand how it's implemented +MIT diff --git a/RELEASING.md b/RELEASING.md index 7285fa719..8f16ab133 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,108 +1,243 @@ # Releasing -This repo uses Changesets for version orchestration and three publish paths: -the CLI (`executor` npm package plus its platform packages), the -`@executor-js/*` library packages (`core`, `sdk`, and the public plugins), and -the self-host Docker image. - -## Normal release flow - -1. Add a changeset in the PR that should ship: - - `bun run changeset` -2. Merge that PR to `main`. -3. `.github/workflows/release.yml` opens or updates a `Version Packages` PR. -4. Merge the `Version Packages` PR. -5. The release workflow then does two things in parallel: - - Publishes every `@executor-js/*` library package whose current version - is not already on npm, via `bun run release:publish:packages` - (see `scripts/publish-packages.ts`). - - If `apps/cli/package.json` bumped, tags the commit and dispatches - `.github/workflows/publish-executor-package.yml`, which: - - runs `bun run release:check` - - performs a full dry-run release build before publish - - publishes the CLI npm package under the correct dist-tag - - creates or updates the GitHub release with build artifacts - - dispatches `.github/workflows/publish-desktop.yml` - - dispatches `.github/workflows/publish-selfhost-docker.yml` -6. The self-host Docker workflow publishes `ghcr.io/rhyssullivan/executor-selfhost` - for `linux/amd64` and `linux/arm64`: - - stable releases get `vX.Y.Z`, `X.Y.Z`, and `latest` - - prereleases get `vX.Y.Z-...`, `X.Y.Z-...`, and `beta` - -## Beta releases - -Enter prerelease mode before starting a beta train: - -- `bun run release:beta:start` - -That commits `.changeset/pre.json` into the repo and causes future release PRs to produce versions like `1.5.0-beta.0`, `1.5.0-beta.1`, and so on. - -When the beta train is done: - -- `bun run release:beta:stop` - -Stable versions publish to npm under `latest`. -Beta versions publish to npm under `beta`. - -## Local dry run - -To build the full CLI release payload without publishing to npm or GitHub: - -- `bun run release:publish:dry-run` - -That produces: - -- platform archives in `apps/cli/dist` -- the packed wrapper tarball in `apps/cli/dist/release` - -To pack the `@executor-js/*` library packages without publishing: - -- `bun run release:publish:packages:dry-run` - -To validate the self-host Dockerfile locally without publishing: - -- `docker build -f apps/host-selfhost/Dockerfile -t executor-selfhost:local .` - -## Release notes - -Release notes follow the standard Changesets flow: **the changeset body -IS the changelog entry.** Write the user-facing summary in the -`.changeset/*.md` you add with your PR; `changeset version` compiles -every changeset into the bumped packages' `CHANGELOG.md` files (via -`@changesets/changelog-github`, which links the PR and credits the -author), and `apps/cli/src/release.ts` uses the released version's -section of `apps/cli/CHANGELOG.md` as the GitHub Release body. If the -section is missing it falls back to `gh release create ---generate-notes`. - -There is no separate release-notes file to remember to update — if your -change deserves a mention, its changeset body is the mention. - -### Authoring rules - -Write changeset bodies for users, not for the diff: - -- Lead with the user-visible behavior, not the implementation. -- A typical fix is one sentence. A feature can be a short paragraph. -- For a large release, a changeset body can be a full markdown section - (bold sub-headings + bullets). Avoid `#`/`##` headings inside bodies — - they end up nested inside a changelog list item. -- For breaking changes, include the before/after surface in the body. - -Contributor attribution is automatic: `@changesets/changelog-github` -prefixes each entry with the PR link and the author's handle. - -## Notes - -- Changesets owns the published CLI version via `apps/cli/package.json`. -- Only the Version Packages PR should change `apps/cli/package.json`; the rest of the workspace is not version-synced for release PRs. -- Per-package `CHANGELOG.md` files are seeded for every workspace package - (`bun run lint:changelog-stubs --fix`). `changeset version` inserts - generated sections after the H1, and the `changesets/action@v1` GitHub - Action reads each bumped package's `CHANGELOG.md` to build the Version - Packages PR description (it crashes with `ENOENT` if any are missing). -- `@changesets/changelog-github` needs a `GITHUB_TOKEN` when running - `changeset version` (it resolves PR numbers and authors). CI provides - one; locally use `GITHUB_TOKEN=$(gh auth token) bun run changeset:version`. -- The publish workflow supports either npm trusted publishing or an `NPM_TOKEN` secret. -- Re-running the publish workflow for the same tag is safe for packages that are already on npm; existing versions are skipped. +`Cargo.toml` is the only version source for the native Executor product. The +manual `Release native Executor` workflow is the only entrypoint that may +publish native archives, the root Docker image, or the primary GitHub release. +It derives `EXECUTOR_REPOSITORY` from the repository running the workflow. The +supported installer currently defaults to `davis7dotsh/executor-fork-testing` +and accepts the same variable as an explicit fork override. + +## Prepare the release commit + +1. Change `[package].version` in `Cargo.toml`. +2. Run `cargo check --locked`. If Cargo reports that the lockfile needs an + update, run `cargo check` once, review the `Cargo.lock` change, then rerun + `cargo check --locked`. +3. Verify `cargo run -- --version` prints `executor `. +4. Merge the release commit to `main` and record its full SHA. + +Release tags must be exactly `v`. The commit must be on +`origin/main`, and publish mode requires the existing remote tag to resolve to +the exact 40-character SHA supplied to the workflow. Release versions cannot +contain SemVer build metadata (`+...`) because OCI image tags do not support +that character. + +The `ghcr.io//executor` container package must be public +before promotion. On the first publish attempt, GHCR may create it as private. +If the anonymous-pull gate fails, open the package settings, change visibility +to public, then rerun the workflow with the same tag and commit. The GitHub +release remains a draft until that gate passes. + +Before publishing, configure these repository controls. The workflow reads +them through GitHub's REST API and fails closed if they drift: + +- Create a `native-release` environment with only a required-reviewers rule + and a branch-policy rule. Set `prevent_self_review=false`, make the sole + reviewer the GitHub user `bmdavis419` (user ID `45952064`), and configure a + custom deployment branch policy whose sole entry is the `main` branch. + Disable administrator bypass so every publish requires approval. +- Create an active tag ruleset for exactly `refs/tags/v*`, with no exclusions + or bypass actors. Enable both restrict updates and restrict deletions. Do not + allow fetch-and-merge updates, and do not add any other rule. Do not restrict + creations, because the release tag must be created before dispatch. +- Enable immutable releases in the repository release settings. +- Add a `NATIVE_RELEASE_TOKEN` secret only to the `native-release` environment. + It must be a classic personal access token owned by `bmdavis419`, with the + exact scopes `public_repo` and `write:packages`, and no others. The same + account login is used as the GHCR username. Create it from the + [prefilled exact-scope form](https://github.com/settings/tokens/new?scopes=public_repo,write:packages) + because selecting `write:packages` manually can auto-select the broader + `repo` scope. Before saving, verify that only `public_repo` and + `write:packages` are selected. Do not add this token as a repository or + organization secret. + +Every native job that publishes or reads a protected release credential uses +the `native-release` environment. A dedicated preflight checks +that the secret is present, belongs to the expected account, has both classic +PAT scopes and no extras, can push to the repository, can authenticate to GHCR, and sees +immutable releases enabled. All GitHub release and GHCR writes use that token. +The exact no-bypass ruleset check runs only after protected-environment approval +because the public read token does not expose bypass actors. The job-scoped +`GITHUB_TOKEN` remains read-only. Require a fresh protected-environment approval +on reruns. + +## Dry run + +Use dry-run mode before creating the tag. It performs the four native builds, +archive packaging, checksum assembly, version smoke tests, and the root +multi-platform Docker build. It does not publish an image, create a GitHub +release, or move a release channel. + +```sh +git fetch origin main +sha="$(git rev-parse origin/main)" +version="$(git show "$sha:Cargo.toml" | python3 -c 'import sys,tomllib; print(tomllib.load(sys.stdin.buffer)["package"]["version"])')" +tag="v$version" + +gh workflow run release.yml --ref main \ + -f mode=dry-run \ + -f tag="$tag" \ + -f commit_sha="$sha" +``` + +The workflow run revision and `commit_sha` must be identical. If `main` moves +between resolving `sha` and dispatching the workflow, the run fails closed. +Resolve the new `origin/main` SHA and dispatch again. + +Inspect the run and download the `executor-release-artifacts` workflow artifact +if desired. Dry runs are safe for forks because all publishing steps are +skipped. + +## Publish + +After a successful dry run, create the tag at the same commit and push only +that tag: + +```sh +git tag -a "$tag" "$sha" -m "Release $tag" +git push origin "refs/tags/$tag" + +test "$(git rev-parse "$tag^{commit}")" = "$sha" +test "$(git ls-remote origin "refs/tags/$tag^{}" | cut -f1)" = "$sha" || \ + test "$(git ls-remote origin "refs/tags/$tag" | cut -f1)" = "$sha" + +gh workflow run release.yml --ref main \ + -f mode=publish \ + -f tag="$tag" \ + -f commit_sha="$sha" +``` + +Publish mode must be dispatched from `main`. On the first attempt, `main`, the +workflow definition, workflow source revision, checked-out source, input +commit, and release tag must all resolve to the same commit. A later attempt of +that same run may continue after `main` advances, but the original commit must +remain an ancestor of `main` and the remote tag must still resolve to it. Do +not dispatch a new run with the older SHA. Rerun the complete original run so +GitHub preserves its workflow run ID, source SHA, protected authorization, and +every release check: + +```sh +run_id= +gh run rerun "$run_id" +gh run watch "$run_id" --exit-status +``` + +Publish mode performs these gated stages: + +1. Verify the exact protected-environment policy, immutable tag ruleset, + workflow revision, Cargo version, full commit SHA, `main` ancestry, and + remote tag are bound to one commit. +2. Build the Svelte payload once, then build and smoke-test four native + archives against that identical payload with their preserved installer + names. Linux binaries use a digest-pinned Ubuntu 22.04 build image and + checksummed Rust 1.96 toolchain archives. Both macOS binaries declare a + macOS 14.0 minimum, which the workflow verifies from their Mach-O metadata. + Release compilation selects Xcode 16.4 build 16F6, macOS SDK 15.5, Apple + clang 17.0.0 (clang-1700.0.13.5), and ld 1167.5 by exact path and version. + The macOS 14 compatibility-floor service tests select Xcode 15.4 build + 15F31d, macOS SDK 14.5, Apple clang 15.0.0 (clang-1500.3.9.4), and ld + 1053.12. The ARM64 archive is booted on macOS 14, while the x86-64 archive + is booted on the available standard Intel macOS 15 runner. +3. Package all four raw binaries in one digest-pinned Ubuntu snapshot with one + fixed Python and zlib implementation. Build the archives twice and require + byte-identical archives and sidecars before assembling `SHA256SUMS`. +4. Extract the two Linux release archives into the root `Dockerfile`'s + `runtime-prebuilt` target, push each architecture by digest, and assemble a + run-scoped multi-platform staging manifest. The runtime base and apt + snapshot are pinned, and image timestamps use the release commit time so a + retry produces the same digest. Build orchestration and manifest creation + use the exact Buildx v0.34.1 client and digest-pinned BuildKit v0.30.0 daemon. +5. Create the draft with an atomic `[executor-run:]` title marker, then + upload the complete exact asset set. `RELEASE-RUN.json` binds the repository, + tag, commit, and workflow run ID. `RELEASE-IMAGE.json` additionally binds the + exact assembled OCI image name and digest. +6. Boot both published architectures by exact assembled digest, then verify + first boot, health, every byte of the canonical embedded web payload, and + anonymous pull access. +7. Re-fetch the remote release tag and assign the immutable release, version, + and commit image tags. +8. Recompute and byte-compare the run binding, image binding, and every release + asset, then publish the draft GitHub release. Publication creates GitHub's + automatic immutable release attestation. Require the release API to report + the exact release as immutable, and retry bounded attestation verification + until it succeeds. +9. Only after the GitHub release is public, move the mutable `latest` or + `beta` channel to the verified image digest. + +Stable versions move the Docker `latest` channel. Prerelease versions move the +`beta` channel. The image is `ghcr.io//executor` and receives +immutable `vX.Y.Z`, `X.Y.Z`, and `sha-` tags plus the channel tag. + +The workflow may reuse only a release whose title contains the exact current +workflow run ID. If release creation succeeded before its binding asset upload, +that atomic title marker lets the same run recover the partial draft. Every +same-run draft retry uploads the complete asset list with clobber semantics, +then downloads and byte-compares the final exact set. An already-published +immutable release is compare-only and must contain a byte-identical +`RELEASE-RUN.json`, `RELEASE-IMAGE.json`, and complete rebuilt bundle. A new run +refuses to reuse either release state. Before the workflow +assigns any immutable release, version, or commit image tag, it verifies that +an existing tag already has the expected digest or fails closed. Only `latest` +or `beta` may move to another digest, and neither moves until the GitHub release +is public. The remote `v*` tag is force-refetched and compared with the validated +commit immediately before draft-release mutation and again immediately before +promotion. All native release runs share one +serialized concurrency group. Promotion reads the current channel manifest's +version annotation and refuses to move a channel to an older SemVer. A retry at +the same version succeeds only when the channel already has the exact assembled +digest. Published GitHub release history is checked too, so removing a channel +tag cannot bypass the version floor. A channel without a valid version +annotation fails closed. + +## Release archives + +The primary GitHub release contains: + +- `executor-x86_64-unknown-linux-gnu.tar.gz` +- `executor-aarch64-unknown-linux-gnu.tar.gz` +- `executor-x86_64-apple-darwin.tar.gz` +- `executor-aarch64-apple-darwin.tar.gz` +- one `.sha256` sidecar for each archive +- `RELEASE-RUN.json`, binding the release to its repository, tag, commit, and + workflow run ID +- `RELEASE-IMAGE.json`, binding that same source to the exact OCI image and + digest +- `BUILD-TOOLCHAINS.txt`, recording the asserted build and packaging toolchains +- `SHA256SUMS` + +Each archive contains `executor`, `LICENSE`, `THIRD_PARTY_LICENSES.html`, and +`THIRD_PARTY_JAVASCRIPT_LICENSES.json`. + +After downloading an archive, verify both GitHub's release attestation and the +specific local asset before checking the checksum manifest: + +```sh +gh release verify "$tag" --repo davis7dotsh/executor-fork-testing +gh release verify-asset "$tag" "executor-${target}.tar.gz" \ + --repo davis7dotsh/executor-fork-testing +sha256sum --check "executor-${target}.tar.gz.sha256" +``` + +The release installer fixture exercises archive verification, serialized +concurrent install and uninstall, stale-lock recovery with PID-reuse defense, +live-owner identity lookup failure, hostile and swapped install-path ancestors, +durability ordering, interrupted rollback, resumable partial uninstall, +Python-free installation, and locked cross-root PATH updates. Its injected +durability log must show owned-file changes before manifest publication or +deletion, and manifest changes before recovery retirement. + +The macOS command-line archives are not Developer ID signed or notarized. The +supported path is the checksum-verifying curl installer, or a `gh` download +followed by the attestation and checksum verification above. GitHub records the +attestation automatically when the immutable release is published. +Browser-downloaded +quarantined binaries and app-style Gatekeeper distribution are not release +claims until a signing identity and notarization workflow are configured. Both +macOS architectures require macOS 14 Sonoma or newer. + +## Legacy desktop compatibility + +The desktop publishing workflow is retired. Historical tags predate the +`legacy/desktop` move and cannot be built honestly with current-tree paths. The +archived source remains available for local archaeology, but no release +workflow claims to produce supported desktop artifacts. diff --git a/RUNNING.md b/RUNNING.md index 55c1c6fba..c8ae4459f 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -1,125 +1,219 @@ -# RUNNING.md — how things run today - -> **This document may be out of date.** It describes how things run today, -> not how they must run. Trust it as a starting point; if you hit weirdness, -> the implementation has probably moved and this file is why. Verify against -> the code, then update this file when you notice drift. The principles in -> [AGENTS.md](AGENTS.md) are the stable contract; everything below is -> implementation detail that churns. - -## Fresh checkout / worktree setup - -`bun run bootstrap` from the repo root — idempotent: `bun install` (whose -prepare hook builds `@executor-js/vite-plugin` and `packages/react`, the -artifacts dev servers fail without) plus Playwright chromium. A fresh -worktree that skips it dies with "Failed to resolve entry for package -'@executor-js/vite-plugin'". - -Our two upstream forks — `@executor-js/emulate` (service emulators) and -`@executor-js/mcporter` (headless MCP client) — are consumed purely as -published npm packages; nothing in this repo references them by path. There -are no `vendor/` submodules. Each fork is its own standalone repo -(`github.com/UsefulSoftwareCo/emulate`, `github.com/UsefulSoftwareCo/mcporter`): -develop on its `main`, publish a bump, then bump the dependency here. The -`emulate` skill covers the emulator publish/deploy loop. - -## Dev servers - -- Everything except desktop/cloud: `bun run dev` (turbo, from root) -- One app: `bun run dev` from its `apps/` directory -- Self-host boots standalone with just env vars — see - `e2e/setup/selfhost.globalsetup.ts` for the canonical recipe (data dir, - bootstrap admin email/password, base URL, `EXECUTOR_ALLOW_LOCAL_NETWORK`) -- Cloud needs WorkOS + Autumn; for a no-.env boot, point it at emulators — - see `e2e/setup/cloud.globalsetup.ts` for the canonical recipe (the real - SDKs against emulated services, PGlite dev DB) - -The e2e globalsetup files are the source of truth for "how do I boot a -working instance of X" — read them before inventing a boot path. - -## E2E: running, viewing, sharing - -`e2e/AGENTS.md` covers writing scenarios. Operationally: - -- `cd e2e && bun run test` boots dev servers and runs everything; - `--project cloud|selfhost` narrows. `E2E_CLOUD_URL`/`E2E_SELFHOST_URL` - attach to an already-running server instead of booting. -- Runs land in `e2e/runs///` — `result.json`, step - screenshots, `session.mp4` + `trace.zip` for browser scenarios, and the - scenario source as `test.ts`. -- `cd e2e && bun run serve` builds the viewer and serves the scenario × - target matrix over HTTP, bound to all interfaces (reachable over the - tailnet). It prefers port 8901 but walks forward to the next free port if - that's taken (so concurrent worktrees, or a leaked previous viewer, never - wedge each other) — read the printed `e2e viewer → …` URL for the actual - port. `PORT=…` pins a port explicitly and fails loudly if it's busy. The - built SPA is port- and mount-agnostic (relative assets + hash routing), so - whatever port it lands on just works. Individual runs are at - `#//` hash routes — when handing results to the user, link - those directly, not the bare matrix. -- `bun e2e/scripts/pr-media.ts e2e/runs//` converts a run's - recording to a gif, uploads it to the `e2e-media` branch, and prints - PR-ready markdown. - -E2E dev-server ports are derived and CLAIMED per checkout (`cd e2e && bun -run ports` prints this checkout's block; see `e2e/src/ports.ts`) — each -checkout hashes its repo root to a preferred block, atomically locks it, -and walks to the next free block if squatted, so concurrent worktrees never -collide or attach to each other's servers. `E2E_*_PORT` env vars pin ports -explicitly. If a boot reports a squatted port, an old dev server leaked — -`bun run reap` (repo root) lists and kills orphaned stacks. - -## The dev CLI: live instances, interactively - -`cd e2e && bun run cli` — the same primitives scenarios use, as commands. -Boot a target, mint identities, make typed API calls, drive MCP, read the -emulator ledger — develop interactively, then crystallize the journey into -a scenario. +# Running the Rust and Svelte rewrite + +This file describes the active local and self-hosted product. The archived +TypeScript application entry points are under `legacy/` and are not part of +the default workflow. + +## Fresh checkout + +From the repository root: + +```sh +bun run bootstrap +``` + +Bootstrap runs the workspace install and prepare hooks, then installs +Playwright Chromium. It is safe to rerun. The workspace currently declares Bun +1.3.11 and the native release workflow uses Rust 1.96.0. + +`Cargo.toml` is the only native product version source. Confirm the active +checkout before packaging with: + +```sh +cargo run -- --version +``` + +## Production-like local run + +The real Svelte dashboard is compiled first and embedded in the Rust release +binary: + +```sh +bun run --cwd web build +cargo build --locked --release + +mkdir -p .executor-local/data +chmod 0700 .executor-local/data +./target/release/executor server --data-dir "$PWD/.executor-local/data" +``` + +Open the setup URL printed by the process. The dashboard creates the +administrator and immediately signs in with those credentials. Add a source, +review its tool modes, and create an API token under `/tokens`. + +The server binds to `127.0.0.1:4788` unless `--bind` changes it. Keep plaintext +HTTP on loopback. For another browser-facing hostname, terminate TLS at a +reverse proxy and set the exact external origin with `--public-origin` or +`EXECUTOR_PUBLIC_ORIGIN`. + +On this WSL2 machine, the Windows browser can reach the loopback server. Open +it with: + +```sh +/mnt/c/windows/explorer.exe http://127.0.0.1:4788 +``` + +Use a worktree-specific data directory, as shown above, so concurrent checkouts +never share SQLite or the instance master key. One process lock protects each +data directory. + +## Debug server without a web build + +Normal debug compilation does not require `web/build`: + +```sh +mkdir -p .executor-debug +chmod 0700 .executor-debug +cargo run -- server --data-dir "$PWD/.executor-debug" +``` + +Debug builds embed the deterministic fixture under `tests/fixtures/web-assets`. +This is useful for Rust API, CLI, MCP, and lifecycle work. It is not a visual +dashboard development server. Use the production-like sequence when the real +Svelte application must be present. + +`EXECUTOR_WEB_ASSETS_DIR` is a compile-time packaging override for focused +asset tests. It is not a runtime static-directory setting. + +## Client smoke test + +Create a dashboard API token, then use the same binary as a client: + +```sh +export EXECUTOR_API_TOKEN='token-shown-once-by-the-dashboard' + +./target/release/executor tools sources +./target/release/executor tools search 'health check' +./target/release/executor tools describe source_slug.tool_name +./target/release/executor call source_slug.tool_name '{}' +``` + +The server never auto-starts for client commands. `call`, `tools`, and `mcp` +require a running server and an API token. `open` only opens the clean dashboard +URL and uses the administrator login cookie in the browser. + +## Managed OAuth callbacks + +Set the final browser-facing origin before creating an OAuth connection: ```sh -bun run cli up selfhost --share # boot, reachable over the tailnet, stays up -bun run cli up cloud --share # emulated WorkOS+Autumn, tailscale-HTTPS fronted -bun run cli status # what's running, URLs, creds -bun run cli identity selfhost # fresh identity (headers / cookies / creds) -bun run cli api selfhost tools.list -bun run cli mcp selfhost call execute '{"code":"return 1+1;"}' -bun run cli ledger cloud workos # what hit the emulator -bun run cli down selfhost # tear down (also removes tailscale serves) +./target/release/executor server \ + --data-dir "$PWD/.executor-local/data" \ + --public-origin https://executor.example.com +``` + +After importing an eligible OpenAPI, GraphQL, or HTTP MCP source, open its +**Managed OAuth** panel. Save the provider discovery and client configuration, +copy the exact displayed callback URL into the provider, then select **Connect +OAuth**. Every connection has its own callback path: + +```text +/api/v1/oauth/callback/ ``` -Instances persist until `down` — `up --share` IS the "touch it" handoff -artifact, and the seeding direction too: boot, drive the product into a -state (API/MCP/UI), hand across the URL. State files in `e2e/.dev/` mark -deliberate long-lived instances (vs leaks); attach scenarios to a running -instance with `E2E__URL`. - -Why cloud `--share` is more involved (encoded in the CLI, kept here for -when you hit it manually): the cloud app sets `secure: true` auth cookies, -so login breaks over plain http from any non-localhost origin ("Invalid -login state"). Both the app AND the WorkOS emulator get fronted with -`tailscale serve` HTTPS, the emulator advertises its public URL on both -sides (its `baseUrl` and the app's `WORKOS_API_URL` — the browser-facing -authorize URL derives from the latter), and Vite must allow the public -hostname (`__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS`). - -## Environment gotchas (learned the hard way) - -- The shell is fish, and the working directory resets between Bash calls. - Use absolute paths rooted at THIS worktree; don't rely on a prior `cd`. -- Don't write probe scripts to `/tmp` — they can't resolve workspace - packages (`effect`, `playwright`, …). Put scratch scripts under the repo - root (`scratch/` is gitignored) so bun resolves the workspace. -- A fresh worktree's Vite dep-optimizer cache can serve PRE-REBASE code - (symptom: behavior matching old code only in dev servers, while unit - tests pass). Kill the server, clear `node_modules/.vite` / - `.tanstack`-adjacent caches, reboot. -- The real Tailscale CLI on this machine is - `/opt/homebrew/opt/tailscale/bin/tailscale`; `/usr/local/bin/tailscale` - is a broken shim pointing at a deleted app. The tailnet IP is on the - `utun` interface (100.x.y.z) if the CLI fails. -- `bun.lock` conflicts on rebase: take either side, re-run `bun install`, - never hand-merge. -- Long-lived demo servers you left up for the user look like leaks to - cleanup tooling — `e2e/.dev/.json` marks deliberate instances; - check it before reaping, and `bun run cli down ` is the clean - teardown. +The supported managed flow is OAuth 2 authorization code with PKCE. OpenID +Connect and the `openid` scope are not supported. See +[`docs/sources.md`](docs/sources.md). + +## Verification + +Use the narrowest relevant command while iterating. For a merge-ready change, +run the complete active-product gates: + +```sh +bun run format:check +bun run lint +bun run typecheck +bun run test + +bun run --cwd web format:check +bun run --cwd web lint +bun run --cwd web check +bun run --cwd web test + +cargo fmt --check +cargo check --all-targets --all-features +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets --all-features +``` + +Use `vitest run ...` or a package script that delegates to Vitest for focused +TypeScript tests. Never use `bun test`. + +## Browser e2e + +`bun run test:e2e` builds the Svelte distribution, embeds it in the debug Rust +binary, and runs only the active `local-selfhost` browser project. The harness +boots two fresh loopback instances: one prepared single-admin instance for the +main journeys and one untouched instance for the first-boot setup journey. +Both use temporary private data directories that are removed during teardown. + +The throwaway main-instance credentials are: + +```text +username: admin +password: executor-e2e-admin-password +``` + +The archived cloud browser suite remains an explicit opt-in: + +```sh +bun run legacy:test:e2e:cloud +``` + +Runs land under `e2e/runs/local-selfhost//`. View them with +`cd e2e && bun run serve`, then open the exact URL and port printed by the +viewer. Credential-entry journeys deliberately omit traces and video so setup +secrets, passwords, and one-time API tokens never become artifacts. + +## Service and container runs + +The release binary embeds its hardened systemd and launchd installation assets. +On Linux, system service changes require root: + +```sh +sudo "$(command -v executor)" service install +executor service status +sudo executor service restart +sudo executor service stop +sudo executor service start +sudo executor service remove +``` + +On macOS, run the same commands without `sudo`. The LaunchAgent belongs to the +logged-in user. `service install --no-start` writes the managed files while +leaving the service stopped. `service status` prints exactly `active` or +`inactive`; inactive exits with status 3. + +These commands mutate real operating-system service state. Do not use them for +ordinary development checkouts or tests. Use the maintained operator guides +for paths, permissions, backup requirements, and removal behavior: + +- [`docs/docker.md`](docs/docker.md) +- [`docs/systemd.md`](docs/systemd.md) +- [`docs/launchd.md`](docs/launchd.md) +- [`docs/install.md`](docs/install.md) + +## Legacy opt-ins + +Default dev, test, lint, typecheck, and format paths exclude all six archived +application packages. Work on them only through the explicit scripts: + +```sh +bun run legacy:dev +bun run legacy:dev:cli -- --help +bun run legacy:test +bun run legacy:typecheck +bun run legacy:typecheck:slow +bun run legacy:test:e2e:cloud +bun run legacy:test:e2e:desktop +``` + +See [`legacy/README.md`](legacy/README.md) for the boundary. + +Publishing is also opt-in. `release.yml` is the sole native product release +entrypoint and requires explicit dry-run or publish mode, a semver tag, and an +exact full commit SHA. The separate TypeScript workflow publishes only +explicitly confirmed `@executor-js/*` library versions under immutable +compatibility tags. Legacy CLI and desktop publishing are retired. See +[`RELEASING.md`](RELEASING.md) for the checked release sequence. diff --git a/about.hbs b/about.hbs new file mode 100644 index 000000000..31f8092d2 --- /dev/null +++ b/about.hbs @@ -0,0 +1,27 @@ + + + + + Executor third-party licenses + + +

Executor third-party licenses

+
    + {{#each overview}} +
  • {{name}} ({{count}})
  • + {{/each}} +
+ {{#each licenses}} +
+

{{name}}

+

Used by:

+
    + {{#each used_by}} +
  • {{crate.name}} {{crate.version}}
  • + {{/each}} +
+
{{text}}
+
+ {{/each}} + + \ No newline at end of file diff --git a/about.toml b/about.toml new file mode 100644 index 000000000..a50618dc2 --- /dev/null +++ b/about.toml @@ -0,0 +1,32 @@ +accepted = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0", + "OpenSSL", + "Unicode-3.0", + "Unicode-DFS-2016", + "Unlicense", + "Zlib", +] + +targets = [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", +] + +ignore-build-dependencies = false +ignore-dev-dependencies = true +filter-noassertion = true +no-clearly-defined = true +workarounds = ["ring", "rustls"] diff --git a/apps/cli/src/release.ts b/apps/cli/src/release.ts deleted file mode 100644 index 4471fe56f..000000000 --- a/apps/cli/src/release.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { existsSync } from "node:fs"; -import { mkdir, readdir, readFile, rename, rm } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -type ReleaseChannel = "latest" | "beta"; - -type ReleaseCliOptions = { - readonly dryRun: boolean; -}; - -type CommandInput = { - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd: string; - readonly captureOutput?: boolean; -}; - -type CommandOutput = { - readonly stdout: string; - readonly stderr: string; -}; - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); -const cliRoot = resolve(repoRoot, "apps/cli"); -const distDir = resolve(cliRoot, "dist"); -const releaseDir = resolve(distDir, "release"); -const wrapperDir = resolve(distDir, "executor"); -const versionPackagePath = resolve(cliRoot, "package.json"); -const semverPattern = - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; - -const parseArgs = (argv: ReadonlyArray): ReleaseCliOptions => { - let dryRun = false; - - for (const arg of argv) { - if (arg === "--dry-run") { - dryRun = true; - continue; - } - - throw new Error(`Unknown argument: ${arg}`); - } - - return { dryRun }; -}; - -const runCommand = async (input: CommandInput): Promise => { - const proc = Bun.spawn([input.command, ...input.args], { - cwd: input.cwd, - env: process.env, - stdio: input.captureOutput ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"], - }); - - const exitCode = await proc.exited; - const stdout = input.captureOutput && proc.stdout ? await new Response(proc.stdout).text() : ""; - const stderr = input.captureOutput && proc.stderr ? await new Response(proc.stderr).text() : ""; - - if (exitCode !== 0) { - throw new Error( - [ - `${input.command} ${input.args.join(" ")} exited with code ${exitCode}`, - stdout.trim().length > 0 ? `stdout:\n${stdout.trim()}` : null, - stderr.trim().length > 0 ? `stderr:\n${stderr.trim()}` : null, - ] - .filter((part) => part !== null) - .join("\n\n"), - ); - } - - return { - stdout: stdout.trim(), - stderr: stderr.trim(), - }; -}; - -const readVersion = async (): Promise => { - const pkg = (await Bun.file(versionPackagePath).json()) as { version?: string }; - const version = pkg.version?.trim(); - - if (!version) { - throw new Error(`Missing version in ${versionPackagePath}`); - } - - return version; -}; - -const validateVersion = (version: string): void => { - if (!semverPattern.test(version)) { - throw new Error(`${versionPackagePath} version is not valid semver: ${version}`); - } -}; - -const resolveChannel = (version: string): ReleaseChannel => - version.includes("-") ? "beta" : "latest"; - -const resolveTagFromEnvironment = (): string | undefined => { - const refName = process.env.GITHUB_REF_NAME?.trim(); - if (process.env.GITHUB_REF_TYPE === "tag" && refName) { - return refName; - } - - const ref = process.env.GITHUB_REF?.trim(); - if (ref?.startsWith("refs/tags/")) { - return ref.slice("refs/tags/".length); - } - - return undefined; -}; - -const resolveGitHubRepository = (): string => { - const repository = process.env.GH_REPO?.trim() || process.env.GITHUB_REPOSITORY?.trim(); - if (!repository) { - throw new Error("Set GH_REPO or GITHUB_REPOSITORY before creating a GitHub release."); - } - - return repository; -}; - -const packWrapperPackage = async (): Promise => { - if (!existsSync(wrapperDir)) { - throw new Error(`Wrapper package directory not found: ${wrapperDir}`); - } - - await mkdir(releaseDir, { recursive: true }); - - const before = new Set((await readdir(wrapperDir)).filter((entry) => entry.endsWith(".tgz"))); - await runCommand({ - command: "bun", - args: ["pm", "pack"], - cwd: wrapperDir, - }); - - const after = (await readdir(wrapperDir)).filter((entry) => entry.endsWith(".tgz")); - const archiveName = after.find((entry) => !before.has(entry)) ?? after[0]; - - if (!archiveName) { - throw new Error(`bun pm pack did not create a .tgz archive in ${wrapperDir}`); - } - - const sourcePath = join(wrapperDir, archiveName); - const destinationPath = join(releaseDir, archiveName); - - await rm(destinationPath, { force: true }); - await rename(sourcePath, destinationPath); - - return destinationPath; -}; - -const collectReleaseAssetPaths = async ( - wrapperArchivePath: string, -): Promise> => { - const assetNames = (await readdir(distDir)) - .filter((entry) => /^executor-.*\.(?:tar\.gz|zip)$/.test(entry)) - .sort(); - - return [wrapperArchivePath, ...assetNames.map((entry) => join(distDir, entry))]; -}; - -const githubReleaseExists = async (tag: string, repository: string): Promise => { - const proc = Bun.spawn(["gh", "release", "view", tag, "--repo", repository], { - cwd: repoRoot, - env: process.env, - stdio: ["ignore", "ignore", "ignore"], - }); - - return (await proc.exited) === 0; -}; - -/** The CHANGELOG section Changesets generated for `version` (compiled from - * `.changeset/*.md` bodies at Version Packages time) — the GitHub Release - * body. Returns null when the section is missing so the caller can fall back - * to GitHub's auto-generated notes. */ -const changelogSectionForVersion = async (version: string): Promise => { - const changelogPath = resolve(cliRoot, "CHANGELOG.md"); - if (!existsSync(changelogPath)) return null; - const lines = (await readFile(changelogPath, "utf8")).split("\n"); - const start = lines.findIndex((line) => line.trim() === `## ${version}`); - if (start === -1) return null; - let end = lines.length; - for (let i = start + 1; i < lines.length; i += 1) { - if (/^## /.test(lines[i] ?? "")) { - end = i; - break; - } - } - const body = lines - .slice(start + 1, end) - .join("\n") - .trim(); - return body.length > 0 ? body : null; -}; - -const syncGitHubRelease = async (input: { - readonly tag: string; - readonly channel: ReleaseChannel; - readonly assetPaths: ReadonlyArray; -}): Promise => { - if (!process.env.GH_TOKEN?.trim()) { - throw new Error("GH_TOKEN is required to create or update a GitHub release."); - } - - const repository = resolveGitHubRepository(); - - if (await githubReleaseExists(input.tag, repository)) { - await runCommand({ - command: "gh", - args: [ - "release", - "upload", - input.tag, - ...input.assetPaths, - "--repo", - repository, - "--clobber", - ], - cwd: repoRoot, - }); - return; - } - - const notes = await changelogSectionForVersion(input.tag.replace(/^v/, "")); - - // Draft until publish-desktop.yml finishes uploading installers and flips - // it; otherwise /releases/latest/download/ 404s during the - // ~15-20 min desktop build window. - const args = [ - "release", - "create", - input.tag, - ...input.assetPaths, - "--repo", - repository, - "--title", - input.tag, - ...(notes ? ["--notes", notes] : ["--generate-notes"]), - "--verify-tag", - "--draft", - ]; - - if (input.channel === "beta") { - args.push("--prerelease"); - } - - await runCommand({ - command: "gh", - args, - cwd: repoRoot, - }); -}; - -const main = async () => { - const options = parseArgs(process.argv.slice(2)); - const version = await readVersion(); - const tag = `v${version}`; - const refTag = resolveTagFromEnvironment(); - const channel = resolveChannel(version); - - validateVersion(version); - - if (refTag && refTag !== tag) { - throw new Error(`GitHub tag ${refTag} does not match ${versionPackagePath} version ${version}`); - } - - await rm(releaseDir, { recursive: true, force: true }); - await mkdir(releaseDir, { recursive: true }); - - await runCommand({ - command: "bun", - args: ["run", "src/build.ts", "binary"], - cwd: cliRoot, - }); - - await runCommand({ - command: "bun", - args: ["run", "src/build.ts", "release-assets"], - cwd: cliRoot, - }); - - const wrapperArchivePath = await packWrapperPackage(); - const assetPaths = await collectReleaseAssetPaths(wrapperArchivePath); - - console.log(`Prepared executor@${version} for ${channel}`); - for (const assetPath of assetPaths) { - console.log(`- ${assetPath}`); - } - - if (options.dryRun) { - return; - } - - await syncGitHubRelease({ - tag, - channel, - assetPaths, - }); - - await runCommand({ - command: "bun", - args: ["run", "src/build.ts", "publish", channel], - cwd: cliRoot, - }); -}; - -await main(); diff --git a/apps/docs/README.md b/apps/docs/README.md index 1999936d8..7b30dad46 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -18,14 +18,13 @@ server hot-reloads. ## How it's served -Mintlify hosts the built site at `executor.mintlify.dev`. The Executor Cloud -worker reverse-proxies it onto the first-party origin at `executor.sh/docs` -(see `apps/cloud/src/edge/docs.ts`), so the public docs live at -`executor.sh/docs` instead of a `*.mintlify.dev` subdomain. +Mintlify builds and hosts the site at `executor.mintlify.dev`. The canonical +public URL is `executor.sh/docs`, configured through the Mintlify project and +the production domain's external routing settings. Mintlify is configured to host under the `/docs` subpath (Settings → Domain -setup → **Host at /docs**), so it serves `/docs/*` paths that the proxy -forwards unchanged. A config change like that only takes effect on the next -build, so push a commit to `apps/docs` to redeploy. +setup → **Host at /docs**). The docs deployment is independent of archived code +under `legacy/`; no supported Executor runtime serves or proxies this site. A +Mintlify config change takes effect on the next docs build. To deploy from this directory, point the Mintlify GitHub app at `apps/docs`. diff --git a/apps/docs/cli.mdx b/apps/docs/cli.mdx new file mode 100644 index 000000000..ea8c16805 --- /dev/null +++ b/apps/docs/cli.mdx @@ -0,0 +1,44 @@ +--- +title: Rust CLI +description: "Use the supported executor binary as a client for an already-running self-hosted server." +--- + +The `executor` binary contains both the self-hosted server and its command-line +client. Client commands connect to a running server. They do not open the SQLite +database or start another server. + +## Connect + +The default server is `http://127.0.0.1:4788`. Create an API token in the +dashboard, then provide it through the environment: + +```bash +export EXECUTOR_API_TOKEN='token-shown-by-the-dashboard' +executor tools sources +executor tools search 'create issue' +``` + +Use `--base-url` or `EXECUTOR_BASE_URL` for another instance. Remote instances +must use HTTPS unless a separately authenticated and encrypted tunnel protects +the complete path and you explicitly pass `--allow-insecure-http`. + +## Inspect and call tools + +```bash +executor tools describe github.issues_create +executor call github.issues_create '{"owner":"acme","repo":"app","title":"Bug"}' +``` + +Use `--json` for machine-readable output. An Ask-mode call prints and opens the +dashboard approval URL, then waits for the administrator's decision. + +## MCP stdio bridge + +For an MCP client that launches only stdio servers, configure it to run: + +```bash +executor mcp +``` + +The bridge forwards MCP JSON-RPC to the running authenticated `/mcp` endpoint. +It requires `EXECUTOR_API_TOKEN` and honors `EXECUTOR_BASE_URL`. diff --git a/apps/docs/concepts/connections.mdx b/apps/docs/concepts/connections.mdx index 81757241e..113556a2c 100644 --- a/apps/docs/concepts/connections.mdx +++ b/apps/docs/concepts/connections.mdx @@ -1,11 +1,14 @@ --- -title: Connections -description: "A connection is a configured instance of an integration. One integration can have many connections." +title: "Legacy concept: connections" +description: "The supported product stores authentication directly on each source." --- -A **connection** is a configured instance of an [integration](/concepts/integrations). -A single integration can have many connections: the same API authenticated for two -different accounts, say. + + This page preserves an old term for incoming links. The active self-hosted product does not create + account connections beneath a source. + -A connection doesn't have to be authenticated: public APIs and public MCP servers can be -connected with no credentials. Executor runs tool calls in a sandbox, so the agent can never access the credentials. + + Configure at most one encrypted static credential profile per source, with Managed OAuth available + for eligible sources. + diff --git a/apps/docs/concepts/credentials.mdx b/apps/docs/concepts/credentials.mdx new file mode 100644 index 000000000..f44822d71 --- /dev/null +++ b/apps/docs/concepts/credentials.mdx @@ -0,0 +1,28 @@ +--- +title: Credentials and OAuth +description: "Configure one encrypted static credential profile per source, with Managed OAuth for eligible sources." +--- + +Each [source](/concepts/sources) has at most one active static credential +profile. For a public upstream, leave it unauthenticated. Otherwise configure +the profile with the fields required by that source: + +- API key +- Bearer token +- Basic username and password +- Manual OAuth access token + +Saving a replacement profile replaces the previous static secret +configuration. Clearing it removes the source's static credentials. Executor +encrypts stored secrets with the instance master key, resolves them only when +dispatching a request, and never exposes them to the agent or JavaScript +sandbox. + +Eligible OpenAPI, GraphQL, and HTTP MCP sources can also use **Managed OAuth**. +Configure the provider and client on the source, register the exact callback +URL shown by Executor, then select **Connect OAuth**. Access and refresh tokens +remain encrypted on the instance. When a source supports both forms, its +static profile takes precedence until you clear it. + +The signed-in administrator manages credentials. Dashboard-issued API tokens +can discover and call tools, but cannot read or change source secrets. diff --git a/apps/docs/concepts/integrations.mdx b/apps/docs/concepts/integrations.mdx index 4d15343ee..cb443fd48 100644 --- a/apps/docs/concepts/integrations.mdx +++ b/apps/docs/concepts/integrations.mdx @@ -1,18 +1,13 @@ --- -title: Integrations -description: "An integration is something Executor connects to, defined by an MCP server, an OpenAPI spec, or a GraphQL endpoint." +title: "Legacy concept: integrations" +description: "The supported product now calls each configured upstream a source." --- -An **integration** is something Executor can connect to. Each integration is defined by -a source that describes the available tools: + + This page preserves an old term for incoming links. The active self-hosted product calls each + configured upstream a **source**. + -- an **MCP server** -- an **OpenAPI** specification -- a **GraphQL** endpoint - -The integration describes the catalog of tools; on its own it isn't a live, authenticated -connection. To actually call tools, you create a [connection](/concepts/connections). - - - See the CLI page for adding an integration from the web UI or the CLI. + + Configure an MCP, OpenAPI, or GraphQL source and review its imported tools. diff --git a/apps/docs/concepts/policies.mdx b/apps/docs/concepts/policies.mdx index 85318b611..4e1159c1b 100644 --- a/apps/docs/concepts/policies.mdx +++ b/apps/docs/concepts/policies.mdx @@ -1,14 +1,13 @@ --- -title: Policies -description: "Policies control whether each tool is always allowed, requires approval, or is blocked." +title: "Legacy concept: policies" +description: "The supported product controls tools with explicit tool modes." --- -A **policy** controls what an agent can do with each tool: + + This page preserves an old term for incoming links. The active self-hosted product uses + **Inherit**, **Enabled**, **Ask**, and **Disabled** tool modes. + -- **Allow**: the tool runs without interruption. -- **Require approval**: the call pauses until a human approves it. -- **Block**: the tool can't be called. - -Policies start from a sensible default derived from the integration's spec. For example, -read-only `GET` operations on an OpenAPI spec are allowed by default, while writes can be -set to require approval. You can tune the policy for any tool at any time. + + Review the global tool set and choose when calls run, wait for approval, or remain unavailable. + diff --git a/apps/docs/concepts/sources.mdx b/apps/docs/concepts/sources.mdx new file mode 100644 index 000000000..161b3930e --- /dev/null +++ b/apps/docs/concepts/sources.mdx @@ -0,0 +1,24 @@ +--- +title: Sources +description: "A source connects one MCP server, OpenAPI specification, or GraphQL API to Executor's global tool catalog." +--- + +A **source** is one configured upstream that contributes tools to Executor's +global catalog: + +- an MCP server over Streamable HTTP +- a trusted local MCP stdio template approved by the machine administrator +- an OpenAPI 3.0 or 3.1 specification +- an introspection-enabled GraphQL API + +The source owns its endpoint or process template, imported tool identities, +refresh history, default tool mode, and at most one encrypted static credential +profile. Eligible sources can also use Managed OAuth. Add a second source when +you intentionally need a second upstream. + +After adding or refreshing a source, open **Tools** to review every imported +tool before giving an agent access. + + + Install the supported self-hosted server, then add a source from its dashboard. + diff --git a/apps/docs/concepts/tool-modes.mdx b/apps/docs/concepts/tool-modes.mdx new file mode 100644 index 000000000..5eec4e20a --- /dev/null +++ b/apps/docs/concepts/tool-modes.mdx @@ -0,0 +1,23 @@ +--- +title: Tool modes +description: "Use Inherit, Enabled, Ask, and Disabled to control the one global tool catalog." +--- + +Every imported tool has one effective mode: + +- **Inherit**: follow the source default, then the tool's built-in default. +- **Enabled**: advertise the tool and run calls immediately. +- **Ask**: advertise the tool, but pause each call for one administrator decision. +- **Disabled**: hide the tool from gateway clients and reject calls. + +Source defaults let you change a group of tools together. A tool override takes +priority over the source default, while **Inherit** returns that tool to the +source and built-in behavior. Broad Enabled or Disabled changes require an +explicit confirmation in the dashboard. + +Ask approvals are exact and single-use. Executor shows a structural, redacted +argument preview, and a stale tool, source, credential, or mode revision cannot +reuse an earlier decision. + +All dashboard-issued API tokens use the same global tool set. Per-token tool +catalogs are not part of the current single-user product. diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 3a0e19ade..8e1024e7c 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -25,19 +25,19 @@ "groups": [ { "group": "Get started", - "pages": ["index", "mcp-proxy"] + "pages": ["index"] }, { - "group": "Local", - "pages": ["local/cli", "local/desktop"] + "group": "Run Executor", + "pages": ["self-hosting", "hosted/docker", "cli", "mcp-proxy"] }, { - "group": "Hosted", - "pages": ["hosted/cloud", "hosted/docker", "hosted/cloudflare"] + "group": "Concepts", + "pages": ["concepts/sources", "concepts/credentials", "concepts/tool-modes"] }, { - "group": "Concepts", - "pages": ["concepts/integrations", "concepts/connections", "concepts/policies"] + "group": "Legacy archive", + "pages": ["local/cli", "local/desktop", "hosted/cloud", "hosted/cloudflare"] } ] }, @@ -45,18 +45,18 @@ "links": [ { "label": "GitHub", - "href": "https://github.com/RhysSullivan/executor" + "href": "https://github.com/davis7dotsh/executor-fork-testing" } ], "primary": { "type": "button", - "label": "Open Executor", - "href": "https://executor.sh" + "label": "Install Executor", + "href": "/self-hosting" } }, "footer": { "socials": { - "github": "https://github.com/RhysSullivan/executor" + "github": "https://github.com/davis7dotsh/executor-fork-testing" } } } diff --git a/apps/docs/hosted/cloud.mdx b/apps/docs/hosted/cloud.mdx index 15a6df662..8346f8e67 100644 --- a/apps/docs/hosted/cloud.mdx +++ b/apps/docs/hosted/cloud.mdx @@ -1,14 +1,14 @@ --- -title: Executor Cloud -description: "Use hosted Executor Cloud: no setup, with a free tier." +title: "Legacy: managed cloud" +description: "Archived reference for the previous managed Executor Cloud service." --- -Executor Cloud is the hosted version: the same catalog, auth, and policies, run for -you. There is nothing to install or keep running, and a free tier lets you start -right away. + + Executor Cloud is archived and is not offered as the supported product. Run Executor on your own + Linux or macOS host with the [native binary](/self-hosting) or use the supported [Docker + image](/hosted/docker). + -Sign in at [executor.sh](https://executor.sh), then add your first -[integration](/concepts/integrations), create a [connection](/concepts/connections), -and point your agents at the hosted [MCP endpoint](/mcp-proxy). It is the easiest way -to share one tool catalog across every agent you use, including cloud agents like -ChatGPT. +This page is retained only to identify links and configurations that refer to +the previous managed deployment. Do not use `executor.sh` as a hosted MCP +endpoint. diff --git a/apps/docs/hosted/cloudflare.mdx b/apps/docs/hosted/cloudflare.mdx index 285642d16..30aa79704 100644 --- a/apps/docs/hosted/cloudflare.mdx +++ b/apps/docs/hosted/cloudflare.mdx @@ -1,9 +1,15 @@ --- -title: Self Hosted Cloudflare -description: "Run Executor as a single Cloudflare Worker in your own account, with Cloudflare Access for auth and D1 for storage." +title: "Legacy: Cloudflare Worker" +description: "Archived reference for the previous TypeScript Cloudflare Worker deployment." --- -Executor runs as a single Cloudflare Worker. Authentication is handled entirely by + + This TypeScript Cloudflare Worker deployment is archived. It is not the supported self-hosted + product and does not ship the current Rust and Svelte application. Use [native + self-hosting](/self-hosting) or [Docker](/hosted/docker). + + +The archived deployment runs as a single Cloudflare Worker. Authentication is handled entirely by [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/) (there is no separate app login), storage is [D1](https://developers.cloudflare.com/d1/), tool code runs in QuickJS inside the Worker, and the web console, API, and MCP @@ -15,12 +21,12 @@ and you manage members and credentials in Cloudflare Access rather than in the a ## Prerequisites - A Cloudflare account with Workers and Zero Trust (Access) enabled. -- The [Executor repo](https://github.com/RhysSullivan/executor) checked out, with +- The [Executor repo](https://github.com/davis7dotsh/executor-fork-testing) checked out, with dependencies installed (`bun install`). ## Deploy -From `apps/host-cloudflare`: +From `legacy/host-cloudflare`: ```bash bunx wrangler login diff --git a/apps/docs/hosted/docker.mdx b/apps/docs/hosted/docker.mdx index c286aa7f1..913d7636c 100644 --- a/apps/docs/hosted/docker.mdx +++ b/apps/docs/hosted/docker.mdx @@ -1,95 +1,55 @@ --- -title: Self Hosted Docker -description: "Run the whole Executor server in one Docker container over a SQLite file: API, MCP, auth, code execution, and the web UI." +title: Docker self-hosting +description: "Run the supported Executor Rust server and embedded Svelte dashboard in the published Linux container." --- -The self-hosted image is the entire Executor server in a single container: the -typed API, the MCP server, authentication, QuickJS code execution, and the web -console, all in one process over a libSQL (SQLite) file. There is no external -database, worker, or proxy to run. +The supported Docker image contains the same Rust binary and embedded Svelte +dashboard as the native release. It supports Linux `amd64` and `arm64`. -## Run it +## Run the published image -Using the published image: +Pin a release version for repeatable deployments. Replace `v0.1.0` with the +version you intend to run: ```bash -docker run -d \ - --name executor-selfhost \ - -p 17888:17888 \ - -v executor-data:/data \ - ghcr.io/rhyssullivan/executor-selfhost:latest +docker run --detach \ + --name executor \ + --publish 127.0.0.1:4788:4788 \ + --volume executor-data:/var/lib/executor \ + ghcr.io/davis7dotsh/executor:v0.1.0 ``` -Or build from a clone of the [repo](https://github.com/RhysSullivan/executor), -from `apps/host-selfhost`: +Open `http://127.0.0.1:4788`. On first boot, `docker logs executor` contains the +one-time setup URL. Create the administrator, then create an API token under +**API tokens**. -```bash -docker compose up -d --build -``` +The `executor-data` volume contains SQLite state, WAL files when present, and +the generated `master.key`. Stop the container before backing up the complete +volume. The database and key are one recovery unit. -No configuration is required. Open [http://localhost:17888](http://localhost:17888); -the first person to create an account becomes the owner. After that, people join -through single-use invite links you mint from the **Admin** page, and open signup -is closed. +## Build from a checkout -## Storage - -Everything that must persist (the SQLite database and the generated encryption -keys) lives in the data directory, mounted at **`/data`** in the container. Always -mount a volume there so it survives restarts and upgrades: +The root Compose file builds the supported root `Dockerfile`: ```bash --v executor-data:/data # named volume (what compose uses) -# or a host path: --v /srv/executor:/data +docker compose up --detach --build +docker compose logs executor ``` -Back it up by snapshotting that volume (or copying `/data`, primarily `data.db`). -`EXECUTOR_DATA_DIR` (default `/data`) sets the directory, and `EXECUTOR_DB_PATH` -(default `/data.db`) sets the database file. - -## Environment variables +Compose publishes the service only on host loopback and mounts the +`executor-data` volume at `/var/lib/executor`. -Everything is optional: a bare run boots a working instance. The defaults below are -the container defaults. +## HTTPS reverse proxy -| Variable | Default | Purpose | -| ----------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- | -| `PORT` | `17888` | HTTP port the server listens on. | -| `EXECUTOR_HOST` | `0.0.0.0` | Bind address. The image binds all interfaces. | -| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. | -| `EXECUTOR_DB_PATH` | `/data.db` | SQLite database file. | -| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:17888`) | Public URL browsers use. Required behind a domain or TLS (see below). | -| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. | -| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. | -| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. | -| `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Password for the bootstrap admin. | -| `EXECUTOR_BOOTSTRAP_ADMIN_NAME` | `Admin` | Display name for the bootstrap admin. | -| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. | -| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. | -| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. | - -## Behind a domain or TLS - - - Set `EXECUTOR_WEB_BASE_URL` to the exact public URL you load in the browser (scheme, host, and - port). If it does not match, browser logins are rejected with an invalid-origin error. - +Leave the container port bound to host loopback. Set the exact browser-facing +origin and terminate TLS in a reverse proxy on the same host: ```bash -docker run -d \ - -p 17888:17888 \ - -v executor-data:/data \ - -e EXECUTOR_WEB_BASE_URL=https://executor.example.com \ - ghcr.io/rhyssullivan/executor-selfhost:latest +EXECUTOR_PUBLIC_ORIGIN=https://executor.example.com docker compose up --detach ``` -For a headless deploy (CI or infra-as-code), set `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` -and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser -setup screen. - -## Connect an agent +Do not expose the raw plaintext port to an untrusted network. A private LAN +alone does not protect dashboard sessions or MCP Bearer tokens. -The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at -`http://localhost:17888/mcp` (or your public URL). See [MCP Proxy](/mcp-proxy) for -how that endpoint exposes your integrations. +The MCP endpoint is `/mcp`. See [MCP endpoint](/mcp-proxy) for client +configuration. diff --git a/apps/docs/index.mdx b/apps/docs/index.mdx index 0b019f4fa..231ae5233 100644 --- a/apps/docs/index.mdx +++ b/apps/docs/index.mdx @@ -1,64 +1,65 @@ --- title: Introduction -description: "Executor is the open-source integration layer for AI agents: one catalog for every tool, shared across every agent you use." +description: "Executor is a single-user, self-hosted tool gateway for AI agents, delivered as one Rust binary with an embedded Svelte dashboard." --- -Configure every integration once (MCP servers, OpenAPI specs, GraphQL -endpoints) with authentication and per-tool [policies](/concepts/policies), -then use that same catalog from any MCP-compatible agent. +Executor is a single-user, self-hosted tool gateway for AI agents. One Rust +binary serves the Svelte dashboard, stores state in SQLite, exposes an MCP +endpoint, and runs TypeScript tool workflows in isolated QuickJS workers. + +Add OpenAPI, GraphQL, and MCP [sources](/concepts/sources), configure each +source's [credential profile or Managed OAuth](/concepts/credentials), and +review its [tool modes](/concepts/tool-modes). Any MCP-compatible agent can then +use the same global catalog. ## Get started -Choose how to run Executor: +Choose one supported deployment: + +- **[Native Linux or macOS](/self-hosting)**: install the release archive and run + the `executor` binary. +- **[Docker](/hosted/docker)**: run the same Rust server and embedded dashboard in + the published Linux container. -- **[CLI](/local/cli)**: a background service on your machine, from the terminal -- **[Desktop app](/local/desktop)**: the same runtime, as a desktop app -- **[Executor Cloud](/hosted/cloud)**: hosted, with a free tier; nothing to run yourself -- **Self-host**: your own infrastructure, via [Docker](/hosted/docker) or [Cloudflare](/hosted/cloudflare) +Windows is not a native release target. The previous npm CLI, Electron desktop +app, managed cloud service, and Cloudflare Worker are retained only in the +**Legacy archive** section of these docs. ## Set up with your agent Prefer to let your coding agent do the setup? Copy this prompt and paste it into Claude, Cursor, or any MCP-capable agent. It will help you pick the right form -of Executor, install it, connect over MCP, and get your first integration +of Executor, install it, connect over MCP, and get your first source working. ```text Setup prompt -Help me set up Executor and get my first connection working. - -Executor is an open source integration layer for AI agents: one place to configure every integration (MCP servers, OpenAPI specs, GraphQL APIs) and connect to them over MCP. - -Start by helping me pick the right form to run it in. Chat with me about it rather than jumping straight to a yes/no question, and recommend one. If I just want the fastest path, suggest Executor Cloud (free tier, nothing to install). All forms expose the same functionality, just packaged differently: +Help me set up Executor and get my first source working. -Local (everything stays on my machine): -- Desktop app: a native app for Mac, Windows, and Linux. Best for a regular desktop environment. -- CLI (`executor`): best for a headless or server environment. -Both run a local HTTP server as a background service that any MCP client can connect to. +Executor is an open source gateway for sources and tools. It turns MCP servers, OpenAPI specifications, and GraphQL APIs into one global tool catalog exposed through one MCP endpoint. -Hosted (use it from multiple agents, including cloud ones, with nothing running locally): -- Executor Cloud: hosted, generous free tier, sign in and start immediately. -- Self-hosted: a Docker image or a Cloudflare Worker. +Executor is a single-user, self-hosted tool gateway. The supported product is one Rust binary with an embedded Svelte dashboard and SQLite state. It is available as native Linux and macOS archives, or as a Linux Docker image. Windows is not a native release target. Do not install the archived npm CLI, Electron desktop app, managed cloud service, or Cloudflare Worker. -How to think about it: -- Want all your data on your own machine? Go local: the desktop app for a regular environment, the CLI for a headless one. -- Want to use it from multiple agents (including cloud agents like ChatGPT), or not run anything locally? Go hosted: Executor Cloud is the fastest start; the self-hosted Docker or Cloudflare versions give you full control. +Help me choose between the native release and Docker: +- Native: best for a Linux or macOS machine where I want a normal executable and local service management. +- Docker: best when I already operate containers or need a Linux container deployment. Terms you'll come across: -- Integration: anything you add (an MCP server, an OpenAPI spec, a GraphQL API). -- Connection: one configured instance of an integration. An integration can have many connections, and a connection doesn't have to be authenticated. -- Policy: whether each tool is always allowed, requires approval, or is blocked. Policies start from a sensible default derived from the imported spec (for example, GET requests on an OpenAPI spec are allowed by default). +- Source: one configured MCP server, OpenAPI specification, or GraphQL API. A source owns its imported tools and at most one encrypted static credential profile. Eligible sources can also use Managed OAuth. +- Credential profile: the source-owned API key, bearer token, basic credentials, or manual OAuth token. Saving a replacement profile replaces the old one. +- Tool mode: Inherit follows the source and built-in defaults. Enabled runs immediately, Ask waits for the administrator, and Disabled is unavailable to gateway clients. Once you know which form I want: -1. Walk me through installing it. -2. Connect Executor to you over MCP. Most MCP clients only load servers at startup, so after adding it I may need to restart the client or open a new chat before the Executor tools appear. Tell me if that's needed and wait for me to do it before continuing. -3. Once the tools are available, help me add my first integration and get one tool working end to end. +1. Follow https://executor.sh/docs/self-hosting or https://executor.sh/docs/hosted/docker. +2. Start the server, open its one-time setup URL, create the administrator, and create an API token in the dashboard. +3. Connect to the Streamable HTTP MCP endpoint at the instance origin plus `/mcp`, using the API token as a Bearer token. If the client supports only stdio, configure it to launch `executor mcp` with `EXECUTOR_API_TOKEN` set. +4. Help me add my first source, review its tool modes, and get one tool working end to end. Docs: https://executor.sh/docs -Source (and the place to start if something breaks): https://github.com/RhysSullivan/executor +Source (and the place to start if something breaks): https://github.com/davis7dotsh/executor-fork-testing ``` ## Concepts -- **[Integrations](/concepts/integrations)**: what Executor connects to -- **[Connections](/concepts/connections)**: configured instances of an integration -- **[Policies](/concepts/policies)**: what each tool is allowed to do +- **[Sources](/concepts/sources)**: OpenAPI, GraphQL, MCP HTTP, and trusted MCP stdio upstreams +- **[Credentials and OAuth](/concepts/credentials)**: source-owned authentication +- **[Tool modes](/concepts/tool-modes)**: Inherit, Enabled, Ask, and Disabled behavior diff --git a/apps/docs/local/cli.mdx b/apps/docs/local/cli.mdx index 062357432..2c2627272 100644 --- a/apps/docs/local/cli.mdx +++ b/apps/docs/local/cli.mdx @@ -1,111 +1,41 @@ --- -title: CLI -description: "Run Executor as a local background service and drive it from the command line." +title: "Legacy: npm CLI" +description: "Archived reference for the previous TypeScript npm CLI. It is not the supported Executor product." --- -The CLI runs a small HTTP runtime on your machine: a durable background service -that any MCP-compatible agent can connect to. Your integrations, credentials, and -policies stay local. - -## Install - -Requires Node.js 20 or newer. - - - -```bash npm -npm install -g executor -``` - -```bash pnpm -pnpm add -g executor -``` - -```bash bun -bun add -g executor -``` - -```bash yarn -yarn global add executor -``` - - - -## Start the service - -Install the durable background service, then open the web UI: - -```bash -executor install # install/start the durable background service -executor web # open the web UI at http://127.0.0.1:17888 -``` - -`executor install` registers Executor so it keeps running across restarts. For a -throwaway foreground runtime instead, run `executor web --foreground`. - -## Connect an agent - -Add Executor to any MCP client (Claude Code, Cursor, OpenCode) with `npx add-mcp`. -It detects the client and writes its config for you. Connect over HTTP or through -the CLI: - - - - Executor serves a streamable-HTTP MCP endpoint at `http://127.0.0.1:17888/mcp`: - - ```bash - npx add-mcp http://127.0.0.1:17888/mcp --transport http --name executor - ``` - - The **Connect** card in the web UI shows this command with your exact URL - (and port, if it differs) already filled in. - - - - With the `executor` CLI on your PATH, the client launches `executor mcp` - directly (no URL needed): - - ```bash - npx add-mcp "executor mcp" --name executor - ``` - - - - -## Add an integration - -From the web UI, click **Add Source** and paste an OpenAPI, GraphQL, or MCP URL. -Executor detects the type, indexes the tools, and handles auth. Or add one from the -CLI: - -```bash -executor call executor openapi addSource '{ - "spec": "https://petstore3.swagger.io/api/v3/openapi.json", - "namespace": "petstore", - "baseUrl": "https://petstore3.swagger.io/api/v3" -}' -``` - -Use `baseUrl` when the OpenAPI document has relative `servers` entries (e.g. `"/api/v3"`). - -Confirm it's live: - -```bash -executor tools sources # lists configured integrations + tool counts -``` - -## Call tools - -```bash -executor tools search "send email" # find tools by intent -executor call github issues --help # browse a namespace -executor call github issues create '{"owner":"octocat","repo":"Hello-World","title":"Hi"}' -``` - -`executor call`, `executor resume`, and `executor tools …` auto-start the local -daemon if it isn't already running. If an execution pauses for auth or approval, -resume it: - -```bash -executor resume --execution-id exec_123 -``` + + The TypeScript npm CLI is retired and unsupported. Historical registry artifacts may still + resolve, but they are no longer updated. Do not use them for a new installation. Install the + current Rust binary through [native self-hosting](/self-hosting), then use its [Rust CLI](/cli). + + +This page preserves context for repositories and old configuration files that +mention the previous npm-distributed CLI. It is an archive, not an installation +or operations guide. + +## What the archived CLI did + +The TypeScript CLI previously installed a local daemon, opened a React web UI, +configured MCP clients with a helper package, and auto-started the daemon for +some commands. It used the earlier integrations, connections, and policies +model. Those commands, package releases, and background-service workflows are +not part of the supported product. + +Existing references to the old package should be treated as migration clues +only. Do not install a cached package or reuse its daemon and web commands for a +new Executor instance. + +## Use the supported product + +1. Install and start the current single-user Rust server through [native + self-hosting](/self-hosting), or use the supported [Docker image](/hosted/docker). +2. Open the one-time setup URL, create the administrator, and issue an API token + from the dashboard. +3. Use the current [Rust CLI](/cli) to search and call tools, or connect an agent + through the authenticated [MCP endpoint](/mcp-proxy). +4. Recreate old upstream configuration as OpenAPI, GraphQL, MCP HTTP, or trusted + MCP stdio [sources](/concepts/sources). Configure credentials on each source + and review its [tool modes](/concepts/tool-modes). + +The current Rust CLI does not auto-start the server or reuse the archived local +daemon. Follow the linked supported guides for exact commands and ports. diff --git a/apps/docs/local/desktop.mdx b/apps/docs/local/desktop.mdx index b8a8a822c..2334bb3a2 100644 --- a/apps/docs/local/desktop.mdx +++ b/apps/docs/local/desktop.mdx @@ -1,9 +1,14 @@ --- -title: Desktop app -description: "Run Executor locally with a native desktop app: a graphical console that runs alongside the CLI." +title: "Legacy: Electron desktop app" +description: "Archived reference for the previous Electron desktop application." --- -The desktop app is Executor running locally with a graphical console in a native + + The Electron desktop application is archived and is not a supported release target. Use the + current [native Rust binary](/self-hosting), which serves its Svelte dashboard in the browser. + + +The archived desktop app is Executor running locally with a graphical console in a native window. It's a companion to the [CLI](/local/cli), not a replacement: both drive the same local background service, so you can use them at the same time. diff --git a/apps/docs/mcp-proxy.mdx b/apps/docs/mcp-proxy.mdx index c142f31b3..6810f7367 100644 --- a/apps/docs/mcp-proxy.mdx +++ b/apps/docs/mcp-proxy.mdx @@ -1,58 +1,71 @@ --- -title: MCP Proxy -description: "Executor is one MCP endpoint in front of all your integrations: connect once, and every agent gets the same catalog with shared auth and policies." +title: MCP endpoint +description: "Connect an MCP client to the supported self-hosted Executor server over Streamable HTTP or the local stdio bridge." --- -Executor sits between your agents and your tools as a single MCP endpoint. Your -agents connect to Executor; Executor connects out to your integrations and -re-exposes them as one catalog. Every tool call passes through the proxy, which is -where auth and policy live. - -## Why proxy through Executor - -- **One endpoint, every agent.** Point Claude Code, Cursor, ChatGPT, or your own - SDK at the same Executor endpoint instead of configuring each tool in each client. -- **Credentials stay out of the agent.** A [connection](/concepts/connections)'s - credentials are stored by Executor and attached to the upstream call. The agent - runs in a sandbox and never sees them. -- **Per-tool policies.** Every call is governed by a [policy](/concepts/policies): - allow, require approval, or block. -- **Mix [integration types](/concepts/integrations).** Upstream MCP servers, OpenAPI - specs, and GraphQL endpoints all show up in the same catalog. - -## How it works - -```mermaid -flowchart LR - A[Your agents] -->|MCP| E[Executor] - E -->|MCP| M[Upstream MCP servers] - E -->|HTTP| O[OpenAPI APIs] - E -->|HTTP| G[GraphQL endpoints] +Executor sits between your agents and your tools as one authenticated MCP +endpoint. Agents connect to Executor, while Executor connects to OpenAPI, +GraphQL, and upstream MCP sources. Tool modes, approval, credentials, and +invocation logs stay under the self-hosted instance's control. + +## Streamable HTTP + +The endpoint is the instance origin plus `/mcp`, such as +`http://127.0.0.1:4788/mcp`. Create an API token in the dashboard and send it as +a Bearer token on every MCP request: + +```text +Authorization: Bearer ``` -Agents speak MCP to Executor. For each tool call, Executor picks the right -integration, attaches that connection's credentials to the upstream request, -enforces the tool's policy, and returns the result. The agent only ever talks to -Executor, and the credentials never reach it. +MCP client configuration schemas differ, but the common shape is: -## Proxy an upstream MCP server +```json +{ + "mcpServers": { + "executor": { + "type": "http", + "url": "http://127.0.0.1:4788/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` -Add an MCP server as an [integration](/concepts/integrations) and its tools join -your catalog alongside everything else. Create a [connection](/concepts/connections) -to it (with credentials if it needs them), and it becomes reachable through your one -Executor endpoint, governed by the same policies as the rest of your tools. +Use HTTPS for a remote origin. A private LAN alone does not protect a reusable +Bearer token. -This is the core of the proxy: your agents keep talking to a single endpoint while -you add, swap, or remove upstream servers behind it, with no client-side change. +## Local stdio bridge + +If a client launches only stdio MCP servers, configure it to run the same +supported binary in bridge mode: + +```json +{ + "mcpServers": { + "executor": { + "command": "/absolute/path/to/executor", + "args": ["mcp"], + "env": { + "EXECUTOR_API_TOKEN": "" + } + } + } +} +``` -## Connect your agents +The bridge connects to `http://127.0.0.1:4788` by default. Set +`EXECUTOR_BASE_URL` when the server uses another origin. The server must already +be running. -Agents connect to Executor's MCP endpoint. The exact command depends on how you run -Executor: +## What clients see -- **Local:** see [CLI](/local/cli) for `executor mcp` and the `add-mcp` command. -- **Hosted:** see [Executor Cloud](/hosted/cloud), or self-host on - [Docker](/hosted/docker) or [Cloudflare](/hosted/cloudflare). +Enabled and Ask tools from every configured source appear in the instance's +global catalog. Disabled tools are not advertised. Ask calls pause for the +administrator's decision, and credentials are resolved by Executor instead of +being exposed to the MCP client. -Once a client is connected, every integration you add to Executor appears in that -agent automatically. +Start with [native self-hosting](/self-hosting), [Docker](/hosted/docker), or the +[Rust CLI](/cli). diff --git a/apps/docs/self-hosting.mdx b/apps/docs/self-hosting.mdx new file mode 100644 index 000000000..0c69f103f --- /dev/null +++ b/apps/docs/self-hosting.mdx @@ -0,0 +1,68 @@ +--- +title: Native self-hosting +description: "Install and run the supported Executor Rust binary on Linux or macOS." +--- + +Executor ships as one native binary with the Svelte dashboard embedded. Native +releases support Linux and macOS on x86-64 and ARM64. Windows is not a native +release target. + +## Install a release + +The installer downloads the matching archive, verifies its checksum, and places +`executor` under `$HOME/.executor/bin` by default: + +```bash +curl -fsSL https://raw.githubusercontent.com/davis7dotsh/executor-fork-testing/main/scripts/install.sh | bash +``` + +To install a release published by another fork, set the same repository +override used by the installer: + +```bash +export EXECUTOR_REPOSITORY=owner/repository +curl -fsSL "https://raw.githubusercontent.com/$EXECUTOR_REPOSITORY/main/scripts/install.sh" | bash +``` + +Start a new shell, source its configuration file, or invoke +`$HOME/.executor/bin/executor` directly until the updated `PATH` is active. +The installer serializes install, recovery, and uninstall under a private lock, +and uses stock `sync` durability barriers before publishing or retiring its +ownership state. Custom install paths cannot contain symbolic-link or writable +ancestor components. PATH edits use the same portable shell implementation on +Linux and macOS, with no Python runtime dependency. + +## First boot + +Choose a private data directory and start the server: + +```bash +mkdir -p "$HOME/.executor-data" +chmod 0700 "$HOME/.executor-data" +executor server --data-dir "$HOME/.executor-data" +``` + +Executor binds to `127.0.0.1:4788` by default. Open the one-time setup URL +printed by the server, create the administrator, then create an API token under +**API tokens**. Token secrets are shown once. + +The data directory contains the SQLite database, WAL files when present, and +`master.key`. Stop Executor before backing up the complete directory, and keep +the database and key together. + +## Expose another origin + +Keep plain HTTP on loopback. When a reverse proxy exposes Executor through +HTTPS, set the exact browser-facing origin before starting the server: + +```bash +executor server \ + --data-dir "$HOME/.executor-data" \ + --public-origin https://executor.example.com +``` + +The public origin does not secure Executor's own listener. Keep that listener on +loopback or otherwise isolate it so clients reach it only through the TLS proxy. + +Next, use the [Rust CLI](/cli) or connect an agent through the +[MCP endpoint](/mcp-proxy). diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 877af5b81..aa31603ba 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -9,7 +9,7 @@ import react from "@astrojs/react"; import cloudflare from "@astrojs/cloudflare"; // Single source of truth for public build-time vars: wrangler.toml `[vars]`. -// Mirrors apps/cloud, which reads its wrangler.jsonc vars the same way. The +// Mirrors legacy/cloud, which reads its wrangler.jsonc vars the same way. The // PUBLIC_ ones are inlined into the client bundle via Vite `define`, so the // browser PostHog SDK gets the key at build time; they also remain runtime // Worker bindings. diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 6b3592652..74cf3cc8f 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -1,6 +1,7 @@ { "name": "@executor-js/marketing", "version": "0.0.5", + "private": true, "type": "module", "scripts": { "dev": "bun run dev:proxy && bun run dev:vite", diff --git a/apps/marketing/src/components/LegalLayout.astro b/apps/marketing/src/components/LegalLayout.astro index b51755d22..255e3f57f 100644 --- a/apps/marketing/src/components/LegalLayout.astro +++ b/apps/marketing/src/components/LegalLayout.astro @@ -58,7 +58,7 @@ const { title, description, lastUpdated } = Astro.props; Privacy Terms GitHub diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index f7fd865e4..4a9cfd3ec 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -7,8 +7,8 @@ interface Props { } const { - title = 'Executor — The gateway to connect your agent to everything', - description = 'One place every agent plugs into every tool you already use. Wire it up once, bring the whole team.' + title = 'Executor: the self-hosted tool gateway for AI agents', + description = 'One Rust binary, one Svelte dashboard, and one MCP endpoint for the tools you run on your own infrastructure.' } = Astro.props const ogImage = new URL('/og-image.png', Astro.site ?? Astro.url).toString() diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 4e977a998..ff4103be9 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -1,50 +1,40 @@ --- import Layout from "../layouts/Layout.astro"; import { AnimatedBeamDemo } from "../components/animated-beam-demo"; -import { ContextBloatDemo } from "../components/context-bloat-demo"; -import { SelfHostContactModal } from "../components/self-host-contact-modal"; // Imported so Vite emits it as a hashed build asset (served from /_astro/...); // the deploy pipeline does not pick up newly added /public files. import ycBackedBy from "../assets/yc-backed-by.svg?url"; -const GH = "https://github.com/RhysSullivan/executor"; +const GH = "https://github.com/davis7dotsh/executor-fork-testing"; // Copied to the clipboard by the "Set up with your agent" hero CTA. A visitor // pastes it into their coding agent (Claude, Cursor, ...) and the agent picks // the right form of Executor, installs it, connects over MCP, and gets a first -// connection working. Keep in sync with the docs "Set up with your agent" +// source working. Keep in sync with the docs "Set up with your agent" // section (apps/docs/index.mdx). -const setupPrompt = `Help me set up Executor and get my first connection working. +const setupPrompt = `Help me set up Executor and get my first source working. -Executor is an open source integration layer for AI agents: one place to configure every integration (MCP servers, OpenAPI specs, GraphQL APIs) and connect to them over MCP. +Executor is an open source gateway for sources and tools. It turns MCP servers, OpenAPI specifications, and GraphQL APIs into one global tool catalog exposed through one MCP endpoint. -Start by helping me pick the right form to run it in. Chat with me about it rather than jumping straight to a yes/no question, and recommend one. If I just want the fastest path, suggest Executor Cloud (free tier, nothing to install). All forms expose the same functionality, just packaged differently: +Executor is a single-user, self-hosted tool gateway. The supported product is one Rust binary with an embedded Svelte dashboard and SQLite state. It is available as native Linux and macOS archives, or as a Linux Docker image. Windows is not a native release target. Do not install the archived npm CLI, Electron desktop app, managed cloud service, or Cloudflare Worker. -Local (everything stays on my machine): -- Desktop app: a native app for Mac, Windows, and Linux. Best for a regular desktop environment. -- CLI (\`executor\`): best for a headless or server environment. -Both run a local HTTP server as a background service that any MCP client can connect to. - -Hosted (use it from multiple agents, including cloud ones, with nothing running locally): -- Executor Cloud: hosted, generous free tier, sign in and start immediately. -- Self-hosted: a Docker image or a Cloudflare Worker. - -How to think about it: -- Want all your data on your own machine? Go local: the desktop app for a regular environment, the CLI for a headless one. -- Want to use it from multiple agents (including cloud agents like ChatGPT), or not run anything locally? Go hosted: Executor Cloud is the fastest start; the self-hosted Docker or Cloudflare versions give you full control. +Help me choose between the native release and Docker: +- Native: best for a Linux or macOS machine where I want a normal executable and local service management. +- Docker: best when I already operate containers or need a Linux container deployment. Terms you'll come across: -- Integration: anything you add (an MCP server, an OpenAPI spec, a GraphQL API). -- Connection: one configured instance of an integration. An integration can have many connections, and a connection doesn't have to be authenticated. -- Policy: whether each tool is always allowed, requires approval, or is blocked. Policies start from a sensible default derived from the imported spec (for example, GET requests on an OpenAPI spec are allowed by default). +- Source: one configured MCP server, OpenAPI specification, or GraphQL API. A source owns its imported tools and at most one encrypted static credential profile. Eligible sources can also use Managed OAuth. +- Credential profile: the source-owned API key, bearer token, basic credentials, or manual OAuth token. Saving a replacement profile replaces the old one. +- Tool mode: Inherit follows the source and built-in defaults. Enabled runs immediately, Ask waits for the administrator, and Disabled is unavailable to gateway clients. Once you know which form I want: -1. Walk me through installing it. -2. Connect Executor to you over MCP. Most MCP clients only load servers at startup, so after adding it I may need to restart the client or open a new chat before the Executor tools appear. Tell me if that's needed and wait for me to do it before continuing. -3. Once the tools are available, help me add my first integration and get one tool working end to end. +1. Follow https://executor.sh/docs/self-hosting or https://executor.sh/docs/hosted/docker. +2. Start the server, open its one-time setup URL, create the administrator, and create an API token in the dashboard. +3. Connect to the Streamable HTTP MCP endpoint at the instance origin plus \`/mcp\`, using the API token as a Bearer token. If the client supports only stdio, configure it to launch \`executor mcp\` with \`EXECUTOR_API_TOKEN\` set. +4. Help me add my first source, review its tool modes, and get one tool working end to end. Docs: https://executor.sh/docs -Source (and the place to start if something breaks): https://github.com/RhysSullivan/executor`; +Source (and the place to start if something breaks): https://github.com/davis7dotsh/executor-fork-testing`; --- @@ -73,11 +63,6 @@ Source (and the place to start if something breaks): https://github.com/RhysSull > {/* For agents: hand setup to a coding agent, or read the docs */} @@ -194,31 +179,51 @@ Source (and the place to start if something breaks): https://github.com/RhysSull class="text-[clamp(1.5rem,3.4vw,2.2rem)] font-medium tracking-[-0.02em] leading-[1.28] text-ink text-balance" > Wiring tools to agents is fiddly, per-client, and easy to get wrong. - Executor makes every tool, from any protocol, look the - same: one name, one input - schema, one output schema, so any agent can call any of them - the same way. + Executor makes every MCP, OpenAPI, and GraphQL tool look the + same: one name, one input schema, + one output schema, so any agent can call any of them the same way.

- {/* ─── NO CONTEXT BLOAT (interactive) ─── */} + {/* ─── ONE GATEWAY ─── */}
-
Context efficiency
+
One catalog

- Thousands of tools, no bloat. + Connect once, then decide what can run.

- Connect everything you use and Executor still shows the model a - single tool. It searches your catalog and loads a tool's schema - only when the code actually calls it, so the prompt never - balloons. + Every supported client reaches the same catalog through one MCP + endpoint. Add sources in the dashboard, review their tools, and + keep tool-mode decisions with the server you operate.

- +
+
+
Stable endpoint
+

+ One MCP address for every source +

+

+ Agents authenticate to the instance at /mcp. OpenAPI, + GraphQL, and upstream MCP tools all join the same global catalog. +

+
+
+
Human control
+

+ Inherit, Enabled, Ask, or Disabled +

+

+ Inherit follows source and built-in defaults. Enabled tools can + run, Ask tools pause for your decision, and Disabled tools stay + out of the advertised catalog. +

+
+
@@ -246,13 +251,12 @@ Source (and the place to start if something breaks): https://github.com/RhysSull >

One tool shape

- MCP, OpenAPI, GraphQL, or a custom integration. Under the hood - they all become a tool name, an input schema, and an output - schema. + MCP, OpenAPI, and GraphQL sources all become tool names, input + schemas, and output schemas in one catalog.

- {/* Code-mode */} + {/* One supported runtime */}
-

Call it any way

+

One binary, every surface

- Today it is a code-mode MCP. It could just as well be the - Executor CLI, a one-off script, a gen-UI dashboard, or a reusable - workflow. Same tools, every surface. + The Rust binary serves the Svelte dashboard, MCP endpoint, and + command-line client. Every surface uses the same SQLite-backed + catalog and tool-mode decisions.

- {/* Trace every call (coming soon) */} -
- Coming soon + {/* Review every call */} +
-

Trace every call

+

Review every call

- One place to see every run and tool call. Audit any - decision after the fact. + Inspect runs and tool calls from the dashboard. See inputs, + results, failures, and approval decisions after the fact.

@@ -318,7 +321,7 @@ Source (and the place to start if something breaks): https://github.com/RhysSull
- {/* Teams */} + {/* Self-hosted control */}
-

Set up once, whole team has it

+

Your gateway, under your control

- Per-user credentials and shared ones. No onboarding ritual, no - toggling MCPs on and off mid-task. + Run one single-user instance on your own Linux or macOS host, or + in Docker. State and credentials stay with the server you + operate.

@@ -348,8 +352,9 @@ Source (and the place to start if something breaks): https://github.com/RhysSull

Destructive actions pull you back in

Executor keeps the semantics it imported: GET vs DELETE for - OpenAPI, destructiveHint for MCP, mutations for GraphQL. Agents - auto-run the safe stuff and ask before the rest. + OpenAPI, destructiveHint for MCP, and mutations for GraphQL. + Review those defaults as Enabled, Ask, or Disabled before an + agent calls them.

@@ -409,85 +414,58 @@ Source (and the place to start if something breaks): https://github.com/RhysSull - {/* Cloud (recommended: fastest start) */} + {/* Native Linux and macOS */}

- Cloud + Native

- executor.sh/cloud -

- Hosted Executor. Auth, sync, policies, and your whole team online - in five minutes. Free tier to start. + Linux · macOS +

+ Install one checksum-verified Rust binary with the Svelte + dashboard embedded. Releases support x86-64 and ARM64.

- Try Cloud → + Install the binary →

- + Windows is not a native release target.

- {/* Desktop (native app, local) */} + {/* Docker */}

- Desktop + Docker

- Mac · Windows · Linux + linux/amd64 · linux/arm64

- A native app that runs entirely on your machine. Your - integrations, credentials, and sessions never leave the device. - MIT licensed. + Run the same Rust server and embedded dashboard in the published + Linux image, with SQLite state in a persistent volume.

- + Run with Docker →
- {/* CLI (headless / servers) */} + {/* Source */}

- CLI + Build from source

- npm i -g executor + Rust · Svelte · MIT

- Run Executor as a background service and drive it from your - terminal. Best for headless and server environments. MIT - licensed. + Inspect the implementation, build the embedded dashboard and + release binary, or adapt the self-hosted runtime for your own + environment.

- Read the docs → + View on GitHub →

- View source →Use the Rust CLI →

@@ -550,9 +528,9 @@ Source (and the place to start if something breaks): https://github.com/RhysSull >
- Any MCP client (Claude Code, Cursor, Codex, and others), the - Executor CLI, or a native client you drop in. Because tools share - one shape, the calling surface is interchangeable. + Any Streamable HTTP MCP client can connect to the server with a + dashboard-issued API token. The same Rust binary also provides a + command-line client and an MCP stdio bridge.
@@ -569,8 +547,8 @@ Source (and the place to start if something breaks): https://github.com/RhysSull
Executor preserves the semantics of whatever it imported: GET vs DELETE for OpenAPI, destructiveHint for MCP, and mutations for - GraphQL. That tells the agent what it can run on its own and what - should pull you back into the loop. + GraphQL. Those defaults become explicit Inherit, Enabled, Ask, + or Disabled modes that you can review in the dashboard.
@@ -585,146 +563,15 @@ Source (and the place to start if something breaks): https://github.com/RhysSull >
- Yes. Executor is MIT licensed and built on the SDK we publish to - npm. Run the desktop app locally, self-host the server, or use - the hosted cloud. Same code paths, different deployment. + Yes. Executor is MIT licensed and self-hosted. Run the supported + Rust binary on Linux or macOS, or run the same server and + embedded Svelte dashboard through Docker.
- {/* ─── PRICING ─── */} -
-
-
-
Pricing
-

- Start free, pay as you run. -

-
- -
- {/* Free */} -
-
- Get started -
-

- Free -

-

For small teams getting started

-
- $0 - / month -
- Start free → -
    - {[ - "Up to 3 members", - "10,000 included executions per month", - "$0.20 per 1,000 additional executions", - "Unlimited integrations", - ].map((f) => ( -
  • - {f} -
  • - ))} -
-
- - {/* Team */} -
-
- Recommended -
-

- Team -

-

For growing organizations

-
- $150 - / org / month -
- Start Team → -
    - {[ - "Unlimited members", - "250,000 included executions per month", - "5 minute execution timeout", - "Join by team domain", - "$0.20 per 1,000 additional executions", - ].map((f) => ( -
  • - {f} -
  • - ))} -
-
- - {/* Enterprise */} -
-
- Custom needs -
-

- Enterprise -

-

For orgs with custom needs

-
- Custom -
- Contact us → -

Everything in Team, plus

-
    - {[ - "Self-hosted or dedicated cloud deployment support", - "SSO / SAML & SCIM provisioning", - "Audit logs for every tool call", - "Dedicated support & onboarding", - "Security reviews, DPA & SOC 2 on request", - ].map((f) => ( -
  • - {f} -
  • - ))} -
-
-
-
-
- {/* ─── FINAL CTA ─── */}
@@ -741,13 +588,6 @@ Source (and the place to start if something breaks): https://github.com/RhysSull >Star on GitHub →
-

- or try Executor Cloud → -

@@ -860,85 +700,6 @@ Source (and the place to start if something breaks): https://github.com/RhysSull } - - ") + .expect("fixture script body closes"); + let digest = STANDARD.encode(Sha256::digest(&after_open.as_bytes()[..body_end])); + hashes.insert(format!("'sha256-{digest}'")); + remainder = &after_open[body_end + "".len()..]; + } + assert!( + !hashes.is_empty(), + "fixture must exercise CSP script hashes" + ); + hashes + } +} diff --git a/tests/active-product-docs.test.ts b/tests/active-product-docs.test.ts new file mode 100644 index 000000000..6192e85ea --- /dev/null +++ b/tests/active-product-docs.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "@effect/vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const readRepoFile = (path: string) => readFileSync(resolve(repoRoot, path), "utf8"); + +const docsConfig = readRepoFile("apps/docs/docs.json"); +const docsIndex = readRepoFile("apps/docs/index.mdx"); +const installGuide = readRepoFile("docs/install.md"); +const selfHosting = readRepoFile("apps/docs/self-hosting.mdx"); +const docker = readRepoFile("apps/docs/hosted/docker.mdx"); +const cli = readRepoFile("apps/docs/cli.mdx"); +const mcpEndpoint = readRepoFile("apps/docs/mcp-proxy.mdx"); +const sources = readRepoFile("apps/docs/concepts/sources.mdx"); +const credentials = readRepoFile("apps/docs/concepts/credentials.mdx"); +const toolModes = readRepoFile("apps/docs/concepts/tool-modes.mdx"); +const marketing = readRepoFile("apps/marketing/src/pages/index.astro"); +const activeProductSurfaces = [ + docsIndex, + selfHosting, + docker, + cli, + mcpEndpoint, + sources, + credentials, + toolModes, + marketing, +]; +const legacyConcepts = [ + "apps/docs/concepts/integrations.mdx", + "apps/docs/concepts/connections.mdx", + "apps/docs/concepts/policies.mdx", +].map(readRepoFile); + +const extract = (contents: string, pattern: RegExp, label: string) => { + const match = contents.match(pattern); + expect(match, label).not.toBeNull(); + return match?.[1] ?? ""; +}; +const normalizeWhitespace = (contents: string) => contents.replace(/\s+/gu, " ").trim(); + +describe("active product terminology", () => { + it("navigates by sources, credentials, and tool modes", () => { + expect(docsConfig).toContain('"concepts/sources"'); + expect(docsConfig).toContain('"concepts/credentials"'); + expect(docsConfig).toContain('"concepts/tool-modes"'); + expect(docsConfig).not.toContain('"concepts/integrations"'); + expect(docsConfig).not.toContain('"concepts/connections"'); + expect(docsConfig).not.toContain('"concepts/policies"'); + + for (const page of [ + "apps/docs/concepts/sources.mdx", + "apps/docs/concepts/credentials.mdx", + "apps/docs/concepts/tool-modes.mdx", + ]) { + expect(existsSync(resolve(repoRoot, page)), page).toBe(true); + } + + for (const legacyConcept of legacyConcepts) { + expect(legacyConcept).toContain("Legacy concept:"); + expect(legacyConcept).toContain(""); + } + }); + + it("teaches the supported source and credential model", () => { + for (const surface of activeProductSurfaces) { + expect(surface).not.toMatch( + /\b(?:integration|integrations|connection|connections|policy|policies)\b/iu, + ); + expect(surface).not.toMatch(/one integration can have many connections/iu); + expect(surface).not.toMatch(/custom integration/iu); + expect(surface).not.toMatch(/configured instance of an integration/iu); + expect(surface).not.toMatch(/\bAllow\b.*\bBlock\b/isu); + } + + expect(docsIndex).toContain("sources"); + expect(sources).toContain("MCP server over Streamable HTTP"); + expect(sources).toContain("MCP stdio template"); + expect(sources).toContain("OpenAPI"); + expect(sources).toContain("GraphQL"); + expect(credentials).toContain("at most one active static credential"); + expect(credentials).toContain("Managed OAuth"); + expect(marketing).toContain("at most one encrypted static credential profile"); + expect(marketing).not.toContain("from any protocol"); + }); + + it("uses the current global tool-mode vocabulary", () => { + for (const surface of [docsIndex, toolModes, marketing]) { + expect(surface).toContain("Inherit"); + expect(surface).toContain("Enabled"); + expect(surface).toContain("Ask"); + expect(surface).toContain("Disabled"); + expect(surface).not.toMatch(/Require approval/iu); + expect(surface).not.toMatch(/\bAllow\b.*\bBlock\b/isu); + } + + expect(toolModes).toContain("same global tool set"); + }); + + it("keeps the docs and marketing setup prompt synchronized", () => { + const docsPrompt = extract( + docsIndex, + /```text Setup prompt\n([\s\S]*?)\n```/u, + "docs setup prompt", + ); + const marketingPrompt = extract( + marketing, + /const setupPrompt = `([\s\S]*?)`;/u, + "marketing setup prompt", + ).replaceAll("\\`", "`"); + + expect(marketingPrompt).toBe(docsPrompt); + expect(docsPrompt).toContain("one-time setup URL"); + expect(docsPrompt).toContain("create the administrator"); + expect(docsPrompt).toContain("create an API token in the dashboard"); + }); + + it("keeps first boot and dashboard-issued API tokens on every setup surface", () => { + for (const setupPage of [selfHosting, docker]) { + expect(setupPage).toMatch(/one-time setup URL/iu); + expect(setupPage).toMatch(/create the administrator/iu); + expect(setupPage).toMatch(/create an API token/iu); + } + + expect(normalizeWhitespace(mcpEndpoint)).toContain("Create an API token in the dashboard"); + expect(normalizeWhitespace(cli)).toContain("Create an API token in the dashboard"); + expect(marketing).toContain("dashboard-issued API token"); + }); + + it("verifies native archives through checksums and immutable GitHub Releases", () => { + expect(installGuide).toContain("sha256sum --check SHA256SUMS"); + expect(installGuide).toContain("shasum -a 256 --check SHA256SUMS"); + expect(installGuide).toContain('gh release verify "$tag" --repo "$repository"'); + expect(installGuide).toContain( + 'gh release verify-asset "$tag" "$archive" --repo "$repository"', + ); + expect(installGuide).not.toContain("gh attestation verify"); + expect(installGuide).not.toMatch(/build[- ]provenance/iu); + expect(installGuide).not.toContain("archives, attestations"); + }); +}); diff --git a/tests/approval_api_strictness.rs b/tests/approval_api_strictness.rs new file mode 100644 index 000000000..ce9edf375 --- /dev/null +++ b/tests/approval_api_strictness.rs @@ -0,0 +1,184 @@ +use axum::{ + Router, + body::Body, + http::{Method, Request, StatusCode, header}, +}; +use executor::{AppConfig, ExecutorApp}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; + +struct Admin { + cookie: String, + csrf: String, +} + +async fn send( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn json_body(response: axum::response::Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +fn cookies(response: &axum::response::Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie should be text") + .split(';') + .next() + .expect("cookie should contain a value") + }) + .collect::>() + .join("; ") +} + +async fn setup(app: &ExecutorApp) -> Admin { + let setup_token = app + .setup_token() + .expect("fresh app should expose a setup token"); + let response = send( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": setup_token, + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send( + app.router(), + Method::POST, + "/api/v1/session", + json!({ + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = cookies(&response); + let csrf = json_body(response).await["csrfToken"] + .as_str() + .expect("login should return a CSRF token") + .to_owned(); + Admin { cookie, csrf } +} + +fn admin_headers(admin: &Admin) -> [(&str, &str); 3] { + [ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ] +} + +async fn create_gateway_token(app: &ExecutorApp, admin: &Admin) -> String { + let response = send( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "approval strictness" }), + &[ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ("idempotency-key", "approval-strictness-token"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + json_body(response).await["token"] + .as_str() + .expect("token should be returned") + .to_owned() +} + +#[tokio::test] +async fn approval_endpoints_reject_unknown_body_fields_and_query_parameters() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + + let response = send( + app.router(), + Method::GET, + "/api/v1/approvals?limit=10&unexpected=true", + json!(null), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(json_body(response).await["error"]["code"], "invalid_query"); + + let response = send( + app.router(), + Method::POST, + "/api/v1/approvals/missing/decision", + json!({ + "decision": "approve", + "expectedRevision": 0, + "unexpected": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(json_body(response).await["error"]["code"], "invalid_json"); + + let token = create_gateway_token(&app, &admin).await; + let authorization = format!("Bearer {token}"); + let response = send( + app.router(), + Method::DELETE, + "/api/v1/gateway/approvals/missing?expectedRevision=0&unexpected=true", + json!(null), + &[(header::AUTHORIZATION.as_str(), authorization.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(json_body(response).await["error"]["code"], "invalid_query"); + + app.shutdown().await; +} diff --git a/tests/approval_service.rs b/tests/approval_service.rs new file mode 100644 index 000000000..fceeca575 --- /dev/null +++ b/tests/approval_service.rs @@ -0,0 +1,441 @@ +use std::collections::BTreeMap; + +use executor::{ + AppConfig, ExecutorApp, + actor::ToolActor, + approval::{ApprovalDecision, ApprovalStatus}, + catalog::{ + ArtifactKind, AuditContext, CreateSource, CredentialPayload, InitialCatalogSnapshot, + RequestSurface, SourceKind, StagedArtifact, StagedTool, StagedToolBinding, ToolBinding, + ToolMode, + }, + invocation::{ApprovalWaitError, ToolCall, ToolCallSubmission}, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, +}; +use serde_json::{Map, json}; + +async fn app_with_ask_tool() -> (tempfile::TempDir, ExecutorApp) { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + sqlx::query( + "INSERT INTO api_tokens \ + (id, name, token_digest, token_prefix, token_suffix, created_at) \ + VALUES ('owner-token', 'Owner', x'010203', 'exr_test', 'test', 1)", + ) + .execute(app.pool()) + .await + .expect("owner token should insert"); + sqlx::query( + "INSERT INTO admins (id, username, password_hash, created_at) \ + VALUES (1, 'admin', 'unused', 1)", + ) + .execute(app.pool()) + .await + .expect("admin should insert"); + app.catalog() + .create_source_with_catalog( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: "approval".to_owned(), + display_name: "Approval source".to_owned(), + description: None, + configuration: Map::new(), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({ + "locator": { "type": "inline" }, + "credentials": { "schemes": {} } + }), + }, + InitialCatalogSnapshot { + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![StagedTool { + stable_key: "write".to_owned(), + preferred_name: "write".to_owned(), + display_name: "Write".to_owned(), + description: None, + input_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": ["password"], + "properties": { + "password": { "type": "string", "format": "password" } + } + }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: ToolMode::Ask, + }], + }, + vec![StagedToolBinding { + stable_key: "write".to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: "POST".to_owned(), + path_template: "/write".to_owned(), + server_url: "http://127.0.0.1:9".to_owned(), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + }], + AuditContext::system(Some("approval-test")), + ) + .await + .expect("ask tool should import"); + (directory, app) +} + +fn call(arguments: serde_json::Value) -> ToolCall { + call_for("execution-1", "call-1", arguments) +} + +fn call_for(execution_id: &str, call_id: &str, arguments: serde_json::Value) -> ToolCall { + ToolCall { + request_id: uuid::Uuid::new_v4().to_string(), + actor: ToolActor::api_token("owner-token", Some("Owner".to_owned())), + surface: RequestSurface::Gateway, + execution_id: execution_id.to_owned(), + call_id: call_id.to_owned(), + worker_generation: 0, + path: "approval.write".to_owned(), + arguments, + } +} + +async fn wait_for_terminal_log(app: &ExecutorApp, approval_id: &str, error_code: &str) { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let (found, pending) = sqlx::query_as::<_, (i64, i64)>( + "SELECT \ + EXISTS(SELECT 1 FROM request_logs WHERE approval_id = ? AND error_code = ?), \ + EXISTS(SELECT 1 FROM approval_log_outbox WHERE approval_id = ?)", + ) + .bind(approval_id) + .bind(error_code) + .bind(approval_id) + .fetch_one(app.pool()) + .await + .expect("request log should read"); + if found != 0 && pending == 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| panic!("terminal approval log {error_code} should be stored")); +} + +#[tokio::test] +async fn wait_is_correlated_cancelable_and_wakes_for_a_denial_without_plaintext_storage() { + let (directory, app) = app_with_ask_tool().await; + let secret = "unique-approval-secret-92f781"; + let submission = app + .tool_calls() + .submit(call(json!({ "password": secret }))) + .await + .expect("valid Ask call should become pending"); + let approval = match submission { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must not execute immediately"), + }; + + let admin = app + .tool_calls() + .approvals() + .get_admin(&approval.id) + .await + .expect("approval should read") + .expect("approval should exist"); + assert_eq!(admin.redacted_arguments["password"], "[redacted]"); + + let canceled_submission = app + .tool_calls() + .submit(call_for( + "execution-canceled-wait", + "call-canceled-wait", + json!({ "password": "cancel-wait-secret" }), + )) + .await + .expect("cancelable Ask call should become pending"); + let canceled_approval = match canceled_submission { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must not execute immediately"), + }; + let canceled = app + .tool_calls() + .wait_for_approval( + canceled_approval, + &ToolActor::api_token("owner-token", Some("Owner".to_owned())), + "execution-canceled-wait", + "call-canceled-wait", + async {}, + ) + .await; + assert!(matches!(canceled, Err(ApprovalWaitError::Canceled))); + + let service = app.tool_calls().clone(); + let approval_id = approval.id.clone(); + let approval_revision = approval.revision; + let waiter = tokio::spawn(async move { + service + .wait_for_approval( + approval, + &ToolActor::api_token("owner-token", Some("Owner".to_owned())), + "execution-1", + "call-1", + std::future::pending(), + ) + .await + }); + app.tool_calls() + .decide( + &approval_id, + "deny-request", + approval_revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("admin denial should succeed"); + let denied = tokio::time::timeout(std::time::Duration::from_secs(2), waiter) + .await + .expect("waiter should wake") + .expect("wait task should not panic") + .expect("wait should return the terminal approval"); + assert_eq!(denied.record.status, ApprovalStatus::Denied); + assert!(denied.result.is_none()); + + for entry in std::fs::read_dir(directory.path()).expect("data directory should read") { + let path = entry.expect("data entry should read").path(); + if path.is_file() { + let bytes = std::fs::read(&path).expect("data file should read"); + assert!( + !bytes + .windows(secret.len()) + .any(|window| window == secret.as_bytes()), + "plaintext approval argument leaked into {}", + path.display() + ); + } + } + + let audit_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events WHERE action = 'approval.deny' \ + AND json_extract(metadata_json, '$.approvalId') = ?", + ) + .bind(&approval_id) + .fetch_one(app.pool()) + .await + .expect("approval audit should read"); + assert_eq!(audit_count, 1); +} + +#[tokio::test] +async fn invalid_arguments_create_no_approval() { + let (_directory, app) = app_with_ask_tool().await; + let result = app + .tool_calls() + .submit(call(json!({ "unknown": true }))) + .await; + assert!(matches!( + result, + Err(executor::invocation::ToolCallError::InvalidArguments) + )); + let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM approvals") + .fetch_one(app.pool()) + .await + .expect("approval count should read"); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn bulk_cancellation_and_expiry_attempt_correlated_metadata_logs() { + let (_directory, app) = app_with_ask_tool().await; + + let canceled = app + .tool_calls() + .submit(call_for( + "execution-cancel", + "call-cancel", + json!({ "password": "cancel-secret" }), + )) + .await + .expect("cancel approval should persist"); + let canceled = match canceled { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must become pending"), + }; + assert_eq!( + app.tool_calls() + .cancel_execution("execution-cancel") + .await + .expect("execution cancellation should run"), + 1 + ); + wait_for_terminal_log(&app, &canceled.id, "approval_canceled").await; + + let expiring = app + .tool_calls() + .submit(call_for( + "execution-expire", + "call-expire", + json!({ "password": "expire-secret" }), + )) + .await + .expect("expiring approval should persist"); + let expiring = match expiring { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must become pending"), + }; + sqlx::query("UPDATE approval_clock SET effective_now = ? WHERE id = 1") + .bind(expiring.expires_at) + .execute(app.pool()) + .await + .expect("approval clock should advance"); + assert!( + app.tool_calls() + .expire_approvals() + .await + .expect("expiry should run") + >= 1 + ); + wait_for_terminal_log(&app, &expiring.id, "approval_expired").await; +} + +#[tokio::test] +async fn token_revocation_logs_every_canceled_approval() { + let (_directory, app) = app_with_ask_tool().await; + let pending = app + .tool_calls() + .submit(call_for( + "execution-revoke", + "call-revoke", + json!({ "password": "revoke-secret" }), + )) + .await + .expect("revoked approval should persist"); + let pending = match pending { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must become pending"), + }; + assert!( + app.tool_calls() + .revoke_owner_token("owner-token") + .await + .expect("token revocation should run") + ); + wait_for_terminal_log(&app, &pending.id, "approval_canceled").await; +} + +#[tokio::test] +async fn shutdown_releases_background_approval_activity_before_immediate_reopen() { + let (directory, app) = app_with_ask_tool().await; + + let pending = app + .tool_calls() + .submit(call_for( + "execution-shutdown-pending", + "call-shutdown-pending", + json!({ "password": "pending-secret" }), + )) + .await + .expect("pending approval should persist"); + let pending = match pending { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must become pending"), + }; + + let approved = app + .tool_calls() + .submit(call_for( + "execution-shutdown-approved", + "call-shutdown-approved", + json!({ "password": "approved-secret" }), + )) + .await + .expect("approved candidate should persist"); + let approved = match approved { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must become pending"), + }; + app.tool_calls() + .decide( + &approved.id, + "shutdown-approve", + approved.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval should persist before shutdown"); + + let denied = app + .tool_calls() + .submit(call_for( + "execution-shutdown-outbox", + "call-shutdown-outbox", + json!({ "password": "outbox-secret" }), + )) + .await + .expect("denied candidate should persist"); + let denied = match denied { + ToolCallSubmission::ApprovalRequired(approval) => approval, + ToolCallSubmission::Completed(_) => panic!("Ask call must become pending"), + }; + app.tool_calls() + .decide( + &denied.id, + "shutdown-deny", + denied.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("terminal outbox event should persist before shutdown"); + let config = AppConfig::new(directory.path().to_path_buf()); + app.shutdown().await; + + let reopened = ExecutorApp::open(config) + .await + .expect("shutdown should release the instance lock before returning"); + let pending_after_restart = reopened + .tool_calls() + .approvals() + .get_admin(&pending.id) + .await + .expect("pending approval should read after restart") + .expect("pending approval should remain stored after restart"); + assert_eq!(pending_after_restart.record.status, ApprovalStatus::Pending); + wait_for_terminal_log(&reopened, &denied.id, "approval_denied").await; + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let recovered = reopened + .tool_calls() + .approvals() + .get_admin(&approved.id) + .await + .expect("approved recovery state should read") + .expect("approved recovery state should remain stored"); + if recovered.record.status.is_terminal() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("approved work should reach a recovery-safe terminal state"); + reopened.shutdown().await; +} diff --git a/tests/archive-product-boundaries.test.ts b/tests/archive-product-boundaries.test.ts new file mode 100644 index 000000000..5cb132e23 --- /dev/null +++ b/tests/archive-product-boundaries.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "@effect/vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const readRepoFile = (path: string) => readFileSync(resolve(repoRoot, path), "utf8"); +const normalizeWhitespace = (contents: string) => contents.replace(/\s+/gu, " ").trim(); + +const rootPackage = readRepoFile("package.json"); +const cloudflarePackage = readRepoFile("legacy/host-cloudflare/package.json"); +const selfhostPackage = readRepoFile("legacy/host-selfhost/package.json"); +const cloudTsconfig = readRepoFile("legacy/cloud/tsconfig.json"); +const openCode = readRepoFile("opencode.json"); +const releaseRunbook = readRepoFile(".skills/cli-release/SKILL.md"); +const warden = readRepoFile("warden.toml"); +const wardenRunbook = readRepoFile(".skills/warden-security-review/SKILL.md"); +const docsReadme = readRepoFile("apps/docs/README.md"); +const docsConfig = readRepoFile("apps/docs/docs.json"); +const docsIndex = readRepoFile("apps/docs/index.mdx"); +const dockerDocs = readRepoFile("apps/docs/hosted/docker.mdx"); +const legacyCliDocs = readRepoFile("apps/docs/local/cli.mdx"); +const normalizedLegacyCliDocs = normalizeWhitespace(legacyCliDocs); +const marketing = readRepoFile("apps/marketing/src/pages/index.astro"); +const marketingLayout = readRepoFile("apps/marketing/src/layouts/Layout.astro"); + +const legacyDocs = [ + "apps/docs/local/desktop.mdx", + "apps/docs/hosted/cloud.mdx", + "apps/docs/hosted/cloudflare.mdx", +] + .map(readRepoFile) + .concat(legacyCliDocs); + +describe("archived product boundaries", () => { + it("keeps every runnable legacy entrypoint explicitly named", () => { + expect(rootPackage).not.toMatch(/^\s+"dev:cli":/mu); + expect(rootPackage).toContain('"legacy:dev:cli"'); + + const aggregateDev = rootPackage.split("\n").find((line) => line.includes('"legacy:dev":')); + expect(aggregateDev).not.toContain("--filter='executor'"); + + expect(cloudflarePackage).toContain('"typecheck:slow": "tsc --noEmit"'); + expect(selfhostPackage).toContain('"typecheck:slow": "tsc --noEmit"'); + expect(rootPackage).toContain( + '"legacy:typecheck:slow": "bun run --filter=\'./legacy/*\' typecheck:slow"', + ); + expect(cloudTsconfig).not.toContain('"rootDir": "."'); + }); + + it("does not launch the archived local MCP from the default OpenCode config", () => { + expect(openCode).toContain('"mcp": {}'); + expect(openCode).not.toContain("legacy:dev:cli"); + expect(openCode).not.toContain("legacy/local"); + }); + + it("documents only the supported native release path", () => { + expect(releaseRunbook).toContain(".github/workflows/release.yml"); + expect(releaseRunbook).toContain("This fork does not publish them to npm"); + expect(releaseRunbook).toContain("old npm workflows, publisher scripts"); + expect(releaseRunbook).not.toContain("confirm_publish=true"); + expect(releaseRunbook).not.toContain("compat-"); + expect(releaseRunbook).not.toContain("legacy/cli"); + expect(releaseRunbook).not.toContain("pkg.pr.new"); + }); + + it("keeps the supported product in active docs navigation", () => { + const legacyGroup = docsConfig.indexOf('"group": "Legacy archive"'); + expect(legacyGroup).toBeGreaterThan(0); + + const activeNavigation = docsConfig.slice(0, legacyGroup); + expect(activeNavigation).toContain('"self-hosting"'); + expect(activeNavigation).toContain('"hosted/docker"'); + expect(activeNavigation).toContain('"cli"'); + expect(activeNavigation).not.toContain('"local/cli"'); + expect(activeNavigation).not.toContain('"local/desktop"'); + expect(activeNavigation).not.toContain('"hosted/cloud"'); + expect(activeNavigation).not.toContain('"hosted/cloudflare"'); + + expect(legacyDocs.every((legacyDoc) => legacyDoc.includes('title: "Legacy:'))).toBe(true); + expect(legacyDocs.every((legacyDoc) => legacyDoc.includes(""))).toBe(true); + }); + + it("describes only the Rust, Svelte, native, and Docker product as supported", () => { + expect(docsIndex).toContain("binary serves the Svelte dashboard"); + expect(docsIndex).toContain("Native Linux or macOS"); + expect(dockerDocs).toContain("supported Docker image"); + expect(dockerDocs).toContain("ghcr.io/davis7dotsh/executor:"); + expect(dockerDocs).not.toContain("legacy/host-selfhost"); + expect(dockerDocs).not.toContain("executor-selfhost"); + + expect(marketing).toContain("single-user, self-hosted tool gateway"); + expect(marketing).toContain("Linux and macOS archives"); + expect(marketing).not.toContain("Executor Cloud"); + expect(marketing).not.toContain('href="/cloud"'); + expect(marketing).not.toContain("npm i -g executor"); + expect(marketing).not.toContain("executor-desktop-"); + expect(marketing).not.toContain('id="pricing"'); + expect(marketing).not.toContain("ContextBloatDemo"); + expect(marketing).not.toContain("single tool"); + expect(marketingLayout).toContain("self-hosted tool gateway"); + expect(docsReadme).toContain("independent of archived code"); + expect(docsReadme).not.toContain("legacy/cloud"); + }); + + it("retires actionable npm CLI installation guidance", () => { + expect(normalizedLegacyCliDocs).toContain("TypeScript npm CLI is retired and unsupported"); + expect(normalizedLegacyCliDocs).toContain("Historical registry artifacts may still resolve"); + expect(normalizedLegacyCliDocs).toContain("they are no longer updated"); + expect(normalizedLegacyCliDocs).toContain("Do not use them for a new installation"); + expect(normalizedLegacyCliDocs).not.toContain("installation channels are unavailable"); + expect(normalizedLegacyCliDocs).toContain("[native self-hosting](/self-hosting)"); + expect(normalizedLegacyCliDocs).toContain("[Rust CLI](/cli)"); + expect(legacyCliDocs).not.toMatch( + /(?:npm install|npm i|pnpm add|bun add|yarn global add)\s+-g\s+executor/iu, + ); + expect(legacyCliDocs).not.toContain("executor install"); + expect(legacyCliDocs).not.toContain("executor web"); + expect(legacyCliDocs).not.toContain("npx add-mcp"); + }); + + it("scans active Rust and retained compatibility surfaces that exist", () => { + expect(warden).toContain('"src/database.rs"'); + expect(warden).toContain('"src/outbound.rs"'); + expect(warden).toContain('"src/mcp/**/*.rs"'); + expect(warden).toContain('"legacy/local/src/**/*.ts"'); + expect(warden).not.toContain("packages/core/storage-"); + expect(warden).not.toContain('"legacy/local/src/**/*.tsx"'); + expect(warden).not.toContain("legacy/local/src/server"); + expect(wardenRunbook).toContain("src legacy/local/src legacy/cli/src"); + expect(wardenRunbook).toContain("legacy/cloud/src/routes legacy/local/src"); + expect(wardenRunbook).not.toContain("packages/core/storage-"); + expect(wardenRunbook).not.toContain("packages/plugins/google-discovery"); + expect(wardenRunbook).not.toContain("packages/plugins/oauth2"); + expect(wardenRunbook).not.toContain("legacy/local/src/server"); + }); +}); diff --git a/tests/bootstrap-script.test.ts b/tests/bootstrap-script.test.ts new file mode 100644 index 000000000..70797bb5b --- /dev/null +++ b/tests/bootstrap-script.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { runBootstrap, type BootstrapExecutor } from "../scripts/bootstrap"; + +describe("fresh checkout bootstrap", () => { + it("executes only the locked dependency and browser installs, in order", () => { + const commands: Parameters[] = []; + runBootstrap((label, command, args) => commands.push([label, command, args])); + + expect(commands.map(([, command, args]) => [command, ...args])).toEqual([ + ["bun", "install", "--frozen-lockfile"], + ["bun", "run", "--cwd", "e2e", "playwright", "install", "--with-deps", "chromium"], + ]); + }); +}); diff --git a/tests/catalog.rs b/tests/catalog.rs new file mode 100644 index 000000000..427beb4f6 --- /dev/null +++ b/tests/catalog.rs @@ -0,0 +1,3844 @@ +use std::{ + collections::BTreeMap, + fs, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use axum::{ + Router, + body::Body, + http::{Method, Request, Response, StatusCode, header}, +}; +use executor::{ + AppConfig, DatabaseError, ExecutorApp, + catalog::{ + ArtifactKind, AuditContext, CatalogError, CatalogSnapshot, CreateSource, CredentialPayload, + InitialCatalogSnapshot, ListToolsFilter, ModeProvenance, NewRequestLog, RequestOutcome, + RequestSurface, SourceHealth, SourceKind, StagedArtifact, StagedTool, StagedToolBinding, + ToolBinding, ToolMode, UpdateSource, + }, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, +}; +use http_body_util::BodyExt; +use serde_json::{Map, Value, json}; +use tempfile::TempDir; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; +const PASSWORD: &str = "correct-horse-battery-staple"; +static TOKEN_IDEMPOTENCY_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +struct TestExecutor { + directory: TempDir, + app: Arc, +} + +struct AdminSession { + cookie: String, + csrf: String, +} + +impl TestExecutor { + async fn new() -> Self { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("test Executor should open"); + Self { + directory, + app: Arc::new(app), + } + } + + fn router(&self) -> Router { + self.app.router() + } + + async fn source(&self, preferred_slug: &str) -> executor::catalog::SourceRecord { + self.source_with_kind(preferred_slug, SourceKind::Openapi) + .await + } + + async fn source_with_kind( + &self, + preferred_slug: &str, + kind: SourceKind, + ) -> executor::catalog::SourceRecord { + self.app + .catalog() + .create_source( + CreateSource { + kind, + preferred_slug: preferred_slug.to_owned(), + display_name: preferred_slug.to_owned(), + description: None, + configuration: Map::new(), + }, + AuditContext::system(None), + ) + .await + .expect("source should be created") + } + + async fn sync( + &self, + source_id: &str, + tools: Vec, + ) -> executor::catalog::CatalogSyncResult { + let source = self + .app + .catalog() + .source(source_id) + .await + .expect("source should exist"); + let credential_revision = self + .app + .catalog() + .credential(source_id) + .await + .expect("credential read should succeed") + .map(|credential| credential.revision); + let bindings = openapi_bindings(&tools); + self.app + .catalog() + .sync_catalog_with_bindings( + source_id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: credential_revision, + artifacts: Vec::new(), + tools, + }, + bindings, + AuditContext::system(None), + ) + .await + .expect("catalog sync should succeed") + } + + async fn setup_admin(&self) { + let response = send_json( + self.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": self.app.setup_token().expect("setup token should exist"), + "username": "admin", + "password": PASSWORD + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + } + + async fn login(&self) -> AdminSession { + let response = send_json( + self.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = response_cookie_header(&response); + let body = response_json(response).await; + let csrf = body["csrfToken"] + .as_str() + .expect("login should reveal CSRF") + .to_owned(); + AdminSession { cookie, csrf } + } + + async fn create_api_token(&self, admin: &AdminSession, name: &str) -> String { + let idempotency_key = format!( + "catalog-api-token-{}", + TOKEN_IDEMPOTENCY_SEQUENCE.fetch_add(1, Ordering::Relaxed) + ); + let response = send_json( + self.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": name }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ("idempotency-key", idempotency_key.as_str()), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + response_json(response).await["token"] + .as_str() + .expect("token should be revealed") + .to_owned() + } +} + +fn staged(stable_key: &str, preferred_name: &str, mode: ToolMode) -> StagedTool { + StagedTool { + stable_key: stable_key.to_owned(), + preferred_name: preferred_name.to_owned(), + display_name: preferred_name.to_owned(), + description: Some(format!("Operate on {preferred_name}")), + input_schema: json!({ "type": "object" }), + output_schema: Some(json!({ "type": "object" })), + input_typescript: Some("{ id: string }".to_owned()), + output_typescript: Some("{ ok: boolean }".to_owned()), + typescript_definitions: BTreeMap::new(), + intrinsic_mode: mode, + } +} + +fn openapi_binding(stable_key: &str, method: &str) -> StagedToolBinding { + StagedToolBinding { + stable_key: stable_key.to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: method.to_owned(), + path_template: format!("/{stable_key}"), + server_url: "https://catalog.example.test".to_owned(), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + } +} + +fn openapi_bindings(tools: &[StagedTool]) -> Vec { + tools + .iter() + .map(|tool| openapi_binding(&tool.stable_key, "GET")) + .collect() +} + +#[tokio::test] +async fn every_imported_source_kind_requires_bindings_for_active_tools() { + let executor = TestExecutor::new().await; + for kind in [ + SourceKind::Openapi, + SourceKind::Graphql, + SourceKind::McpHttp, + SourceKind::McpStdio, + ] { + let source = executor + .source_with_kind(&format!("binding-required-{}", kind.as_str()), kind) + .await; + let error = executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![staged("tool", "Tool", ToolMode::Enabled)], + }, + AuditContext::system(None), + ) + .await + .expect_err("active imported tools without bindings must fail closed"); + assert!(matches!( + error, + CatalogError::Validation { + code: "incomplete_tool_bindings", + .. + } + )); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM tools WHERE source_id = ?") + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("failed sync leaves no tools"), + 0 + ); + } +} + +#[tokio::test] +async fn catalog_migration_is_strict_and_enforces_foreign_keys_and_constraints() { + let executor = TestExecutor::new().await; + let strict_tables = + sqlx::query_as::<_, (String, String, String, i64, i64, i64)>("PRAGMA table_list") + .fetch_all(executor.app.pool()) + .await + .expect("table list should be readable") + .into_iter() + .filter(|(_, name, _, _, _, _)| { + matches!( + name.as_str(), + "catalog_state" + | "sources" + | "source_credentials" + | "source_artifacts" + | "tools" + | "request_logs" + | "audit_events" + ) + }) + .collect::>(); + assert_eq!(strict_tables.len(), 7); + assert!(strict_tables.iter().all(|row| row.5 == 1)); + + let foreign_key_error = sqlx::query( + "INSERT INTO source_credentials \ + (source_id, schema_version, payload_ciphertext, revision, created_at, updated_at) \ + VALUES ('missing', 1, X'01', 0, 1, 1)", + ) + .execute(executor.app.pool()) + .await + .expect_err("credential without source must fail"); + assert!(foreign_key_error.as_database_error().is_some()); + + let invalid_kind = sqlx::query( + "INSERT INTO sources \ + (id, kind, slug, display_name, configuration_json, created_at, updated_at) \ + VALUES ('bad', 'unknown', 'bad', 'Bad', '{}', 1, 1)", + ) + .execute(executor.app.pool()) + .await + .expect_err("closed source kind must reject unknown values"); + assert!(invalid_kind.as_database_error().is_some()); + + for reserved in ["tools", "search", "describe", "executor"] { + let reserved_slug = sqlx::query( + "INSERT INTO sources \ + (id, kind, slug, display_name, configuration_json, created_at, updated_at) \ + VALUES (?, 'openapi', ?, 'Reserved', '{}', 1, 1)", + ) + .bind(format!("reserved-{reserved}")) + .bind(reserved) + .execute(executor.app.pool()) + .await + .expect_err("reserved source roots must be rejected by SQLite"); + assert!(reserved_slug.as_database_error().is_some()); + } + + let source = executor.source("strict").await; + let strict_error = sqlx::query("UPDATE sources SET display_name = ? WHERE id = ?") + .bind(vec![0_u8, 1_u8]) + .bind(source.id) + .execute(executor.app.pool()) + .await + .expect_err("STRICT text column must reject blobs"); + assert!(strict_error.as_database_error().is_some()); + + let foreign_key_check = + sqlx::query_as::<_, (String, i64, String, i64)>("PRAGMA foreign_key_check") + .fetch_all(executor.app.pool()) + .await + .expect("foreign key check should run"); + assert!(foreign_key_check.is_empty()); +} + +#[tokio::test] +async fn source_and_tool_names_are_collision_safe_and_order_independent() { + let executor = TestExecutor::new().await; + let first = executor.source("Git Hub").await; + let second = executor.source("Git-Hub").await; + assert_eq!(first.slug, "git_hub"); + assert_eq!(second.slug, "git_hub_2"); + for reserved in ["tools", "search", "describe", "sources", "executor"] { + let source = executor.source(reserved).await; + assert_eq!(source.slug, format!("{reserved}_2")); + } + let graphql = executor.source("graphql").await; + assert_eq!(graphql.slug, "graphql"); + + let updated = executor + .app + .catalog() + .update_source( + &first.id, + UpdateSource { + display_name: "GitHub API".to_owned(), + description: Some("Repository operations".to_owned()), + configuration: Map::from_iter([( + "baseUrl".to_owned(), + json!("https://api.example.invalid"), + )]), + expected_revision: first.revision, + }, + AuditContext::system(None), + ) + .await + .expect("source should update optimistically"); + assert_eq!(updated.slug, first.slug); + assert_eq!(updated.display_name, "GitHub API"); + assert!(matches!( + executor + .app + .catalog() + .update_source( + &first.id, + UpdateSource { + display_name: "Stale".to_owned(), + description: None, + configuration: Map::new(), + expected_revision: first.revision, + }, + AuditContext::system(None), + ) + .await, + Err(CatalogError::RevisionConflict { + scope: "source", + .. + }) + )); + + executor + .sync( + &first.id, + vec![ + staged("z-key", "Get Repo", ToolMode::Enabled), + staged("a-key", "Get-Repo", ToolMode::Enabled), + ], + ) + .await; + let first_tools = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(first.id.clone()), + limit: 100, + ..Default::default() + }) + .await + .expect("tools should list") + .items; + let names_by_key = first_tools + .iter() + .map(|tool| (tool.stable_key.as_str(), tool.local_name.as_str())) + .collect::>(); + assert_eq!(names_by_key["a-key"], "get_repo"); + assert_eq!(names_by_key["z-key"], "get_repo_2"); + assert!(first_tools.iter().all(|tool| { + tool.callable_path == format!("tools.{}.{}", first.slug, tool.local_name) + && tool.sandbox_path == format!("{}.{}", first.slug, tool.local_name) + })); +} + +#[tokio::test] +async fn refresh_is_atomic_and_preserves_overrides_through_tombstones() { + let executor = TestExecutor::new().await; + let source = executor.source("catalog").await; + executor + .sync( + &source.id, + vec![ + staged("alpha", "Run", ToolMode::Enabled), + staged("beta", "Run", ToolMode::Ask), + ], + ) + .await; + let tools = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 100, + ..Default::default() + }) + .await + .expect("tools should list") + .items; + let beta = tools + .iter() + .find(|tool| tool.stable_key == "beta") + .expect("beta should exist") + .clone(); + let beta = executor + .app + .catalog() + .set_tool_mode( + &beta.id, + Some(ToolMode::Disabled), + beta.revision, + AuditContext::system(None), + ) + .await + .expect("override should update"); + let before_failure = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let duplicate = executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: before_failure.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![ + staged("same", "One", ToolMode::Enabled), + staged("same", "Two", ToolMode::Enabled), + ], + }, + AuditContext::system(None), + ) + .await + .expect_err("duplicate staging must fail before the transaction"); + assert!(matches!( + duplicate, + CatalogError::Validation { + code: "duplicate_stable_key", + .. + } + )); + let after_failure = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + assert_eq!( + after_failure.catalog_revision, + before_failure.catalog_revision + ); + + executor + .sync(&source.id, vec![staged("alpha", "Run", ToolMode::Enabled)]) + .await; + let tombstoned = executor + .app + .catalog() + .tool(&beta.id) + .await + .expect("tombstoned tool should remain in admin catalog"); + assert!(!tombstoned.present); + assert_eq!(tombstoned.mode_override, Some(ToolMode::Disabled)); + + executor + .sync( + &source.id, + vec![ + staged("beta", "Different Display Name", ToolMode::Ask), + staged("alpha", "Run", ToolMode::Enabled), + ], + ) + .await; + let restored = executor + .app + .catalog() + .tool(&beta.id) + .await + .expect("restored tool should retain identity"); + assert!(restored.present); + assert_eq!(restored.id, beta.id); + assert_eq!(restored.local_name, beta.local_name); + assert_eq!(restored.mode_override, Some(ToolMode::Disabled)); +} + +#[tokio::test] +async fn source_health_failures_preserve_the_last_good_catalog_and_sync_restores_health() { + let executor = TestExecutor::new().await; + let source = executor.source("health-state").await; + let healthy = executor + .app + .catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: None, + artifacts: vec![StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: "last-good".to_owned(), + content: json!({ "version": 1 }), + }], + tools: vec![staged("alpha", "Alpha", ToolMode::Enabled)], + }, + vec![openapi_binding("alpha", "GET")], + AuditContext::system(None), + ) + .await + .expect("initial catalog should sync"); + let before_failure = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let global_before_failure = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let tool_before_failure = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("last-good tool should list") + .items + .remove(0); + + let failed = executor + .app + .catalog() + .mark_source_error( + &source.id, + "mcp_discovery_failed", + before_failure.revision, + AuditContext::system(Some("health-failure")), + ) + .await + .expect("source health failure should persist"); + assert_eq!(failed.health_status, SourceHealth::Error); + assert_eq!( + failed.health_error_code.as_deref(), + Some("mcp_discovery_failed") + ); + assert_eq!(failed.revision, before_failure.revision + 1); + assert_eq!(failed.catalog_revision, before_failure.catalog_revision); + assert_eq!(failed.last_refreshed_at, before_failure.last_refreshed_at); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_before_failure + 1 + ); + let retried_failure = executor + .app + .catalog() + .mark_source_error( + &source.id, + "mcp_discovery_failed", + before_failure.revision, + AuditContext::system(Some("health-failure-retry")), + ) + .await + .expect("an identical stale error retry should be idempotent"); + assert_eq!(retried_failure.revision, failed.revision); + assert_eq!(retried_failure.catalog_revision, failed.catalog_revision); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_before_failure + 1 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events WHERE request_id = 'health-failure-retry'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("retry audit count should read"), + 0 + ); + let tool_after_failure = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("last-good tool should remain") + .items + .remove(0); + assert_eq!(tool_after_failure.id, tool_before_failure.id); + assert_eq!(tool_after_failure.revision, tool_before_failure.revision); + assert!(tool_after_failure.present); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM source_artifacts WHERE source_id = ? AND stable_key = 'last-good'", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("last-good artifact should remain"), + 1 + ); + let audit_metadata = sqlx::query_scalar::<_, String>( + "SELECT metadata_json FROM audit_events WHERE request_id = 'health-failure'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("health audit should exist"); + assert_eq!( + serde_json::from_str::(&audit_metadata).expect("audit metadata should be JSON"), + json!({ + "healthStatus": "error", + "errorCode": "mcp_discovery_failed", + "reasonCode": "mcp_discovery_failed", + "globalRevision": global_before_failure + 1, + }) + ); + + let restored = executor + .app + .catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: failed.revision, + expected_credential_revision: None, + artifacts: vec![StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: "last-good".to_owned(), + content: json!({ "version": 2 }), + }], + tools: vec![staged("alpha", "Alpha", ToolMode::Enabled)], + }, + vec![openapi_binding("alpha", "GET")], + AuditContext::system(None), + ) + .await + .expect("successful sync should restore healthy state"); + assert_eq!(restored.source_id, source.id); + let source_after_restore = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(source_after_restore.health_status, SourceHealth::Healthy); + assert_eq!(source_after_restore.health_error_code, None); + assert_eq!( + source_after_restore.catalog_revision, + healthy.catalog_revision + 1 + ); + let tool_after_restore = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id), + limit: 10, + ..Default::default() + }) + .await + .expect("restored tool should list") + .items + .remove(0); + assert_eq!(tool_after_restore.id, tool_before_failure.id); +} + +#[tokio::test] +async fn deferred_source_creation_atomically_persists_an_unknown_empty_catalog() { + let executor = TestExecutor::new().await; + let (source, catalog) = executor + .app + .catalog() + .create_source_with_catalog_health( + CreateSource { + kind: SourceKind::McpStdio, + preferred_slug: "deferred-stdio".to_owned(), + display_name: "Deferred stdio".to_owned(), + description: None, + configuration: Map::new(), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({}), + }, + InitialCatalogSnapshot { + artifacts: Vec::new(), + tools: Vec::new(), + }, + Vec::new(), + SourceHealth::Unknown, + AuditContext::system(Some("deferred-source-create")), + ) + .await + .expect("deferred source and its empty catalog should commit atomically"); + assert_eq!(source.health_status, SourceHealth::Unknown); + assert_eq!(source.health_error_code, None); + assert_eq!(source.last_refreshed_at, None); + assert_eq!(source.tool_count, 0); + assert_eq!(source.tombstoned_tool_count, 0); + assert_eq!(catalog.active_tool_count, 0); + assert_eq!(catalog.tombstoned_tool_count, 0); + assert_eq!(catalog.source_revision, source.revision); + assert_eq!(catalog.catalog_revision, source.catalog_revision); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sources WHERE id = ?") + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("created source should exist"), + 1 + ); + + let source_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sources") + .fetch_one(executor.app.pool()) + .await + .expect("source count should read"); + let error = executor + .app + .catalog() + .create_source_with_catalog_health( + CreateSource { + kind: SourceKind::McpStdio, + preferred_slug: "invalid-error-source".to_owned(), + display_name: "Invalid error source".to_owned(), + description: None, + configuration: Map::new(), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({}), + }, + InitialCatalogSnapshot { + artifacts: Vec::new(), + tools: Vec::new(), + }, + Vec::new(), + SourceHealth::Error, + AuditContext::system(None), + ) + .await + .expect_err("error health without a stable code must be rejected before insertion"); + assert!(matches!( + error, + CatalogError::Validation { + code: "invalid_initial_source_health", + .. + } + )); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sources") + .fetch_one(executor.app.pool()) + .await + .expect("source count should read"), + source_count + ); +} + +#[tokio::test] +async fn authorization_required_source_creation_is_atomic_and_has_a_fixed_error_code() { + let executor = TestExecutor::new().await; + let (source, catalog) = executor + .app + .catalog() + .create_authorization_required_source_with_catalog( + CreateSource { + kind: SourceKind::McpHttp, + preferred_slug: "protected-mcp".to_owned(), + display_name: "Protected MCP".to_owned(), + description: None, + configuration: Map::new(), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({}), + }, + InitialCatalogSnapshot { + artifacts: Vec::new(), + tools: Vec::new(), + }, + Vec::new(), + AuditContext::system(Some("authorization-required-create")), + ) + .await + .expect("authorization-required source should commit in its final initial state"); + assert_eq!(source.health_status, SourceHealth::Error); + assert_eq!( + source.health_error_code.as_deref(), + Some("authorization_required") + ); + assert_eq!(source.last_refreshed_at, None); + assert_eq!(source.tool_count, 0); + assert_eq!(catalog.active_tool_count, 0); + assert_eq!(catalog.source_revision, source.revision); + assert_eq!(catalog.catalog_revision, source.catalog_revision); + + let source_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sources") + .fetch_one(executor.app.pool()) + .await + .expect("source count should read"); + sqlx::query( + "CREATE TRIGGER reject_source_credentials BEFORE INSERT ON source_credentials \ + BEGIN SELECT RAISE(ABORT, 'injected credential failure'); END", + ) + .execute(executor.app.pool()) + .await + .expect("failure injection trigger should install"); + + executor + .app + .catalog() + .create_authorization_required_source_with_catalog( + CreateSource { + kind: SourceKind::McpHttp, + preferred_slug: "must-not-leak".to_owned(), + display_name: "Must not leak".to_owned(), + description: None, + configuration: Map::new(), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({}), + }, + InitialCatalogSnapshot { + artifacts: Vec::new(), + tools: Vec::new(), + }, + Vec::new(), + AuditContext::system(Some("authorization-required-rollback")), + ) + .await + .expect_err("a later write failure should roll back the source insertion"); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sources") + .fetch_one(executor.app.pool()) + .await + .expect("source count should read after rollback"), + source_count + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sources WHERE slug = 'must_not_leak'") + .fetch_one(executor.app.pool()) + .await + .expect("rolled-back source should not exist"), + 0 + ); +} + +#[tokio::test] +async fn credential_required_health_is_fixed_bounded_and_revision_guarded() { + let executor = TestExecutor::new().await; + let source = executor + .source_with_kind("stdio-needs-credential", SourceKind::McpStdio) + .await; + let global_before = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let already_unknown = executor + .app + .catalog() + .mark_source_credential_required( + &source.id, + source.revision, + AuditContext::system(Some("credential-required-existing")), + ) + .await + .expect("an already unknown incomplete source should be an idempotent success"); + assert_eq!(already_unknown.revision, source.revision); + assert_eq!(already_unknown.health_status, SourceHealth::Unknown); + assert_eq!(already_unknown.health_error_code, None); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_before + ); + + let errored = executor + .app + .catalog() + .mark_source_error( + &source.id, + "mcp_start_failed", + source.revision, + AuditContext::system(None), + ) + .await + .expect("source should enter error state"); + let marked = executor + .app + .catalog() + .mark_source_credential_required( + &source.id, + errored.revision, + AuditContext::system(Some("credential-required")), + ) + .await + .expect("credential requirement should restore unknown state without violating schema"); + assert_eq!(marked.health_status, SourceHealth::Unknown); + assert_eq!(marked.health_error_code, None); + assert_eq!(marked.revision, errored.revision + 1); + assert_eq!(marked.catalog_revision, source.catalog_revision); + let global_after_mark = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + assert_eq!(global_after_mark, global_before + 2); + let transition_metadata = sqlx::query_scalar::<_, String>( + "SELECT metadata_json FROM audit_events WHERE request_id = 'credential-required'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("credential-required audit should exist"); + assert_eq!( + serde_json::from_str::(&transition_metadata) + .expect("credential-required audit should be JSON"), + json!({ + "healthStatus": "unknown", + "errorCode": null, + "reasonCode": "credential_required", + "globalRevision": global_after_mark, + }) + ); + + let retried = executor + .app + .catalog() + .mark_source_credential_required( + &source.id, + errored.revision, + AuditContext::system(Some("credential-required-retry")), + ) + .await + .expect("an identical stale retry should be idempotent"); + assert_eq!(retried.revision, marked.revision); + assert_eq!(retried.catalog_revision, marked.catalog_revision); + assert_eq!(retried.health_status, marked.health_status); + assert_eq!(retried.health_error_code, marked.health_error_code); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_after_mark + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events WHERE request_id = 'credential-required-retry'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("retry audit count should read"), + 0 + ); + + let stale = executor + .app + .catalog() + .mark_source_error( + &source.id, + "mcp_discovery_failed", + source.revision, + AuditContext::system(None), + ) + .await + .expect_err("stale health transition should fail"); + assert!(matches!( + stale, + CatalogError::RevisionConflict { + scope: "source", + expected, + actual, + } if expected == source.revision && actual == marked.revision + )); + let after_stale = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(after_stale.revision, marked.revision); + assert_eq!(after_stale.health_status, SourceHealth::Unknown); + assert_eq!(after_stale.health_error_code, None); +} + +#[tokio::test] +async fn source_health_error_codes_reject_unbounded_or_nonstable_values_without_mutation() { + let executor = TestExecutor::new().await; + let source = executor.source("invalid-health-codes").await; + let global_revision = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + for invalid in [ + "", + "UPSTREAM_FAILED", + "upstream-failed", + "upstream__failed", + "upstream_failed_", + "1_upstream_failed", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ] { + let error = executor + .app + .catalog() + .mark_source_error( + &source.id, + invalid, + source.revision, + AuditContext::system(None), + ) + .await + .expect_err("invalid health code should be rejected"); + assert!(matches!( + error, + CatalogError::Validation { + code: "invalid_source_health_error_code", + .. + } + )); + } + let after = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(after.revision, source.revision); + assert_eq!(after.health_status, SourceHealth::Unknown); + assert_eq!(after.health_error_code, None); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_revision + ); +} + +#[tokio::test] +async fn credential_replacement_and_catalog_sync_commit_as_one_revision() { + let executor = TestExecutor::new().await; + let source = executor + .source_with_kind("atomic-credential-sync", SourceKind::Openapi) + .await; + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "old-secret" }), + }, + None, + AuditContext::system(None), + ) + .await + .expect("initial credential should persist"); + let basis = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let credential = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + executor + .app + .catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: basis.revision, + expected_credential_revision: Some(credential.revision), + artifacts: Vec::new(), + tools: vec![staged("alpha", "Alpha", ToolMode::Enabled)], + }, + vec![openapi_binding("alpha", "GET")], + AuditContext::system(None), + ) + .await + .expect("initial catalog should sync"); + let before = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let credential_before = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + let global_before = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let replacement = CredentialPayload { + schema_version: 1, + payload: json!({ "token": "new-secret" }), + }; + let (stored, sync, current) = executor + .app + .catalog() + .replace_credential_and_sync_catalog( + &source.id, + &replacement, + CatalogSnapshot { + expected_source_revision: before.revision, + expected_credential_revision: Some(credential_before.revision), + artifacts: vec![StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: "discovery".to_owned(), + content: json!({ "version": 2 }), + }], + tools: vec![ + staged("alpha", "Updated Alpha", ToolMode::Ask), + staged("beta", "Beta", ToolMode::Enabled), + ], + }, + vec![ + openapi_binding("alpha", "POST"), + openapi_binding("beta", "GET"), + ], + AuditContext::system(Some("atomic-credential-sync")), + ) + .await + .expect("credential and discovered catalog should commit together"); + assert_eq!(stored.revision, credential_before.revision + 1); + assert_eq!(stored.credential.payload, replacement.payload); + assert_eq!(current.revision, before.revision + 1); + assert_eq!(current.catalog_revision, before.catalog_revision + 1); + assert_eq!(sync.source_revision, current.revision); + assert_eq!(sync.catalog_revision, current.catalog_revision); + assert_eq!(sync.global_revision, global_before + 1); + assert_eq!(current.health_status, SourceHealth::Healthy); + assert_eq!(current.health_error_code, None); + assert_eq!(sync.active_tool_count, 2); + assert_eq!( + executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist") + .credential + .payload, + replacement.payload + ); + let audit = sqlx::query_scalar::<_, String>( + "SELECT group_concat(metadata_json, ' ') FROM audit_events \ + WHERE request_id = 'atomic-credential-sync'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("atomic audit metadata should read"); + assert!(!audit.contains("new-secret")); +} + +#[tokio::test] +async fn atomic_credential_sync_rolls_back_credential_on_late_catalog_failure() { + let executor = TestExecutor::new().await; + let source = executor.source("atomic-rollback").await; + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "retained" }), + }, + None, + AuditContext::system(None), + ) + .await + .expect("initial credential should persist"); + let basis = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + executor + .sync( + &source.id, + vec![staged("current", "Current", ToolMode::Enabled)], + ) + .await; + sqlx::query( + "WITH RECURSIVE sequence(value) AS ( \ + SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value < 25000) \ + INSERT INTO tools \ + (id, source_id, stable_key, local_name, display_name, description, input_schema_json, \ + typescript_definitions_json, intrinsic_mode, present, revision, created_at, updated_at, \ + last_seen_at, tombstoned_at) \ + SELECT printf('atomic-history-%05d', value), ?, printf('atomic-key-%05d', value), \ + printf('atomic_%05d', value), 'Retained', NULL, '{}', '{}', 'enabled', \ + 0, 0, 1, 1, 1, 1 FROM sequence", + ) + .bind(&source.id) + .execute(executor.app.pool()) + .await + .expect("tombstone ceiling fixture should seed"); + let before = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + assert!(before.revision > basis.revision); + let credential_before = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + let global_before = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let error = executor + .app + .catalog() + .replace_credential_and_sync_catalog( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "must-roll-back" }), + }, + CatalogSnapshot { + expected_source_revision: before.revision, + expected_credential_revision: Some(credential_before.revision), + artifacts: vec![StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: "must-not-commit".to_owned(), + content: json!({ "atomic": true }), + }], + tools: vec![staged("replacement", "Replacement", ToolMode::Enabled)], + }, + vec![openapi_binding("replacement", "GET")], + AuditContext::system(Some("atomic-rollback")), + ) + .await + .expect_err("late catalog failure must roll back the credential replacement"); + assert!(matches!( + error, + CatalogError::Validation { + code: "catalog_too_large", + .. + } + )); + let credential_after = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + assert_eq!(credential_after.revision, credential_before.revision); + assert_eq!( + credential_after.credential.payload, + json!({ "token": "retained" }) + ); + let after = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(after.revision, before.revision); + assert_eq!(after.catalog_revision, before.catalog_revision); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_before + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM source_artifacts WHERE source_id = ? AND stable_key = 'must-not-commit'", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("artifact count should read"), + 0 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events WHERE request_id = 'atomic-rollback'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("audit count should read"), + 0 + ); +} + +#[tokio::test] +async fn atomic_credential_mutations_are_cas_guarded_and_set_unknown_together() { + let executor = TestExecutor::new().await; + let source = executor.source("atomic-unknown").await; + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "secrets": ["old"] }), + }, + None, + AuditContext::system(None), + ) + .await + .expect("initial credential should persist"); + let healthy = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let credential = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + let cleared = CredentialPayload { + schema_version: 1, + payload: json!({ "secrets": [] }), + }; + let first_store = executor.app.catalog().clone(); + let second_store = executor.app.catalog().clone(); + let first = first_store.replace_credential_and_mark_unknown( + &source.id, + &cleared, + healthy.revision, + credential.revision, + AuditContext::system(Some("atomic-unknown-1")), + ); + let second = second_store.replace_credential_and_mark_unknown( + &source.id, + &cleared, + healthy.revision, + credential.revision, + AuditContext::system(Some("atomic-unknown-2")), + ); + let (first, second) = tokio::join!(first, second); + let outcomes = [first, second]; + assert_eq!(outcomes.iter().filter(|outcome| outcome.is_ok()).count(), 1); + assert_eq!( + outcomes.iter().filter(|outcome| outcome.is_err()).count(), + 1 + ); + let (stored, unknown) = outcomes + .into_iter() + .find_map(Result::ok) + .expect("one atomic mutation should win"); + assert_eq!(stored.revision, credential.revision + 1); + assert_eq!(stored.credential.payload, cleared.payload); + assert_eq!(unknown.revision, healthy.revision + 1); + assert_eq!(unknown.health_status, SourceHealth::Unknown); + assert_eq!(unknown.health_error_code, None); + let persisted = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + assert_eq!(persisted.revision, stored.revision); + assert_eq!(persisted.credential.payload, cleared.payload); + + let deleted = executor + .app + .catalog() + .delete_credential_and_mark_unknown( + &source.id, + unknown.revision, + persisted.revision, + AuditContext::system(None), + ) + .await + .expect("credential deletion and unknown health should commit together"); + assert_eq!(deleted.revision, unknown.revision + 1); + assert_eq!(deleted.health_status, SourceHealth::Unknown); + assert_eq!(deleted.health_error_code, None); + assert!( + executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential read should succeed") + .is_none() + ); +} + +#[tokio::test] +async fn generic_sync_rejects_unbound_openapi_tools_without_changing_catalog_state() { + let executor = TestExecutor::new().await; + let source = executor + .source_with_kind("bound-openapi", SourceKind::Openapi) + .await; + executor + .app + .catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![staged("alpha", "Alpha", ToolMode::Enabled)], + }, + vec![openapi_binding("alpha", "GET")], + AuditContext::system(None), + ) + .await + .expect("bound OpenAPI catalog should sync"); + + let source_before = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let global_revision_before = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let tool_before = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("tool should list") + .items + .remove(0); + let binding_before = executor + .app + .catalog() + .tool_binding(&tool_before.id) + .await + .expect("binding should exist"); + + let error = executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: source_before.revision, + expected_credential_revision: None, + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "replacement".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![ + staged("alpha", "Changed Alpha", ToolMode::Ask), + staged("beta", "Beta", ToolMode::Enabled), + ], + }, + AuditContext::system(None), + ) + .await + .expect_err("generic sync must not leave active OpenAPI tools unbound"); + assert!(matches!( + error, + CatalogError::Validation { + code: "incomplete_tool_bindings", + .. + } + )); + + let source_after = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(source_after.revision, source_before.revision); + assert_eq!( + source_after.catalog_revision, + source_before.catalog_revision + ); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_revision_before + ); + let tools_after = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("original tool should list") + .items; + assert_eq!(tools_after.len(), 1); + assert_eq!(tools_after[0].id, tool_before.id); + assert_eq!(tools_after[0].revision, tool_before.revision); + assert_eq!(tools_after[0].display_name, tool_before.display_name); + let binding_after = executor + .app + .catalog() + .tool_binding(&tool_before.id) + .await + .expect("original binding should remain"); + assert_eq!(binding_after.binding, binding_before.binding); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM source_artifacts WHERE source_id = ?",) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("artifact count should read"), + 0 + ); +} + +#[tokio::test] +async fn non_openapi_sources_reject_openapi_bindings() { + let executor = TestExecutor::new().await; + let source = executor + .source_with_kind("graphql-bindings", SourceKind::Graphql) + .await; + let current = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let sync_error = executor + .app + .catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: current.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![staged("query", "Query", ToolMode::Enabled)], + }, + vec![openapi_binding("query", "POST")], + AuditContext::system(None), + ) + .await + .expect_err("GraphQL refresh must reject OpenAPI bindings"); + assert!(matches!( + sync_error, + CatalogError::Validation { + code: "invalid_source_kind", + .. + } + )); + + let source_after = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(source_after.revision, current.revision); + assert_eq!(source_after.catalog_revision, current.catalog_revision); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tool_bindings JOIN tools ON tools.id = tool_bindings.tool_id \ + WHERE tools.source_id = ?", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("binding count should read"), + 0 + ); +} + +#[tokio::test] +async fn refresh_rejects_oversized_serialized_schema_payloads_before_writing() { + let executor = TestExecutor::new().await; + let source = executor.source("payload-limit").await; + let mut oversized = staged("large", "Large", ToolMode::Enabled); + oversized.input_schema = json!({ "value": "x".repeat(2 * 1024 * 1024) }); + let error = executor + .app + .catalog() + .sync_catalog( + &source.id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![oversized], + }, + AuditContext::system(None), + ) + .await + .expect_err("oversized schema must be rejected before refresh"); + assert!(matches!( + error, + CatalogError::Validation { + code: "schema_too_large", + .. + } + )); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM tools WHERE source_id = ?") + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("tool count should read"), + 0 + ); +} + +#[tokio::test] +async fn refresh_cas_and_writer_lock_prevent_stale_or_partial_catalogs() { + let executor = TestExecutor::new().await; + let source = executor.source("refresh-basis").await; + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "first" }), + }, + None, + AuditContext::system(None), + ) + .await + .expect("credential should store"); + let first_credential = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + let basis = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "second" }), + }, + Some(first_credential.revision), + AuditContext::system(None), + ) + .await + .expect("credential should rotate"); + let current = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let credential = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + let stale_credential = executor + .app + .catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: current.revision, + expected_credential_revision: Some(credential.revision - 1), + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "root".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![staged("new", "New", ToolMode::Enabled)], + }, + vec![openapi_binding("new", "GET")], + AuditContext::system(None), + ) + .await; + assert!(matches!( + stale_credential, + Err(CatalogError::RevisionConflict { + scope: "credential", + .. + }) + )); + assert_eq!( + executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist") + .catalog_revision, + basis.catalog_revision + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM source_artifacts") + .fetch_one(executor.app.pool()) + .await + .expect("artifact count should read"), + 0 + ); + + let current = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let snapshot = CatalogSnapshot { + expected_source_revision: current.revision, + expected_credential_revision: Some(credential.revision), + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "root".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![staged("new", "New", ToolMode::Enabled)], + }; + let first_store = executor.app.catalog().clone(); + let second_store = executor.app.catalog().clone(); + let first = first_store.sync_catalog_with_bindings( + &source.id, + snapshot.clone(), + vec![openapi_binding("new", "GET")], + AuditContext::system(Some("refresh-race-1")), + ); + let second = second_store.sync_catalog_with_bindings( + &source.id, + snapshot, + vec![openapi_binding("new", "GET")], + AuditContext::system(Some("refresh-race-2")), + ); + let (first, second) = tokio::join!(first, second); + let outcomes = [first, second]; + assert_eq!(outcomes.iter().filter(|outcome| outcome.is_ok()).count(), 1); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!( + outcome, + Err(CatalogError::RevisionConflict { + scope: "source", + .. + }) + )) + .count(), + 1 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM source_artifacts") + .fetch_one(executor.app.pool()) + .await + .expect("artifact count should read"), + 1 + ); +} + +#[tokio::test] +async fn credential_writes_require_absence_or_the_current_revision() { + let executor = TestExecutor::new().await; + let source = executor.source("credential-cas").await; + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "first" }), + }, + None, + AuditContext::system(Some("credential-create")), + ) + .await + .expect("an absent credential should be created"); + let first = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + assert_eq!(first.revision, 0); + + let source_after_create = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let global_after_create = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let duplicate_create = executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "duplicate" }), + }, + None, + AuditContext::system(Some("credential-duplicate")), + ) + .await; + assert!(matches!( + duplicate_create, + Err(CatalogError::RevisionConflict { + scope: "credential", + expected: -1, + actual: 0, + }) + )); + assert_eq!( + executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist") + .revision, + source_after_create.revision + ); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_after_create + ); + + executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "newer" }), + }, + Some(first.revision), + AuditContext::system(Some("credential-rotate")), + ) + .await + .expect("the current credential revision should rotate"); + let source_after_rotate = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let global_after_rotate = executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let audit_after_rotate = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events \ + WHERE action = 'source.credential_changed' AND source_id = ?", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("credential audit count should read"); + assert_eq!(audit_after_rotate, 2); + + let stale_rotate = executor + .app + .catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": "stale" }), + }, + Some(first.revision), + AuditContext::system(Some("credential-stale")), + ) + .await; + assert!(matches!( + stale_rotate, + Err(CatalogError::RevisionConflict { + scope: "credential", + expected: 0, + actual: 1, + }) + )); + let stored = executor + .app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + assert_eq!(stored.revision, 1); + assert_eq!(stored.credential.payload["token"], "newer"); + assert_eq!( + executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist") + .revision, + source_after_rotate.revision + ); + assert_eq!( + executor + .app + .catalog() + .global_revision() + .await + .expect("global revision should read"), + global_after_rotate + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events \ + WHERE action = 'source.credential_changed' AND source_id = ?", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("credential audit count should read"), + audit_after_rotate + ); +} + +#[tokio::test] +async fn refresh_audits_preserve_correlation_and_explicit_actor() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let source = executor.source("refresh-audit").await; + executor + .app + .catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![staged("run", "Run", ToolMode::Enabled)], + }, + vec![openapi_binding("run", "GET")], + AuditContext::system(Some("refresh-job-1")), + ) + .await + .expect("background refresh should commit"); + let system_audit = sqlx::query_as::<_, (Option, Option)>( + "SELECT request_id, actor_admin_id FROM audit_events \ + WHERE action = 'source.catalog_refreshed' AND request_id = 'refresh-job-1'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("system refresh audit should exist"); + assert_eq!(system_audit, (Some("refresh-job-1".to_owned()), None)); + + let current = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + executor + .app + .catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: current.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools: vec![staged("run", "Run", ToolMode::Enabled)], + }, + vec![openapi_binding("run", "GET")], + AuditContext::admin("admin-request-1", 1), + ) + .await + .expect("administrator refresh should commit"); + let admin_audit = sqlx::query_as::<_, (Option, Option)>( + "SELECT request_id, actor_admin_id FROM audit_events \ + WHERE action = 'source.catalog_refreshed' AND request_id = 'admin-request-1'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("administrator refresh audit should exist"); + assert_eq!(admin_audit, (Some("admin-request-1".to_owned()), Some(1))); +} + +#[tokio::test] +async fn effective_modes_follow_precedence_and_gateway_visibility_rules() { + let executor = TestExecutor::new().await; + let source = executor.source("modes").await; + executor + .sync(&source.id, vec![staged("review", "Review", ToolMode::Ask)]) + .await; + let tool = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("tool should list") + .items + .remove(0); + assert_eq!(tool.effective_mode.mode, ToolMode::Ask); + assert_eq!(tool.effective_mode.provenance, ModeProvenance::Intrinsic); + let discovery = executor + .app + .catalog() + .search_tools("review", None, 10, 0) + .await + .expect("search should work"); + assert_eq!(discovery.total, 1); + assert!(discovery.items[0].requires_approval); + assert_eq!(discovery.items[0].effective_mode, ToolMode::Ask); + let ask_lookup = executor + .app + .catalog() + .guard_invocation(&tool.callable_path) + .await + .expect("Ask tool should remain callable through approval"); + assert!(ask_lookup.requires_approval); + + let source = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + executor + .app + .catalog() + .set_source_mode( + &source.id, + Some(ToolMode::Disabled), + source.revision, + AuditContext::system(None), + ) + .await + .expect("source override should update"); + let disabled = executor + .app + .catalog() + .tool(&tool.id) + .await + .expect("admin catalog should retain disabled tool"); + assert_eq!(disabled.effective_mode.mode, ToolMode::Disabled); + assert_eq!( + disabled.effective_mode.provenance, + ModeProvenance::SourceOverride + ); + assert_eq!( + executor + .app + .catalog() + .search_tools("review", None, 10, 0) + .await + .expect("search should work") + .total, + 0 + ); + assert!(matches!( + executor + .app + .catalog() + .describe_tool(&tool.sandbox_path) + .await, + Err(CatalogError::ToolNotFound { .. }) + )); + assert!(matches!( + executor + .app + .catalog() + .guard_invocation(&tool.sandbox_path) + .await, + Err(CatalogError::ToolDisabled { .. }) + )); + + let override_enabled = executor + .app + .catalog() + .set_tool_mode( + &tool.id, + Some(ToolMode::Enabled), + disabled.revision, + AuditContext::system(None), + ) + .await + .expect("tool override should update"); + assert_eq!(override_enabled.effective_mode.mode, ToolMode::Enabled); + assert_eq!( + override_enabled.effective_mode.provenance, + ModeProvenance::ToolOverride + ); + let reset = executor + .app + .catalog() + .set_tool_mode( + &tool.id, + None, + override_enabled.revision, + AuditContext::system(None), + ) + .await + .expect("tool override should reset"); + assert_eq!(reset.effective_mode.mode, ToolMode::Disabled); +} + +#[tokio::test] +async fn lexical_search_ranks_filters_and_paginates_like_the_typescript_runtime() { + let executor = TestExecutor::new().await; + let github = executor.source("github").await; + let stripe = executor.source("stripe").await; + let go = executor.source("go").await; + executor + .sync( + &github.id, + vec![ + staged("create-issue", "createIssue", ToolMode::Ask), + staged("list-issues", "listIssues", ToolMode::Enabled), + ], + ) + .await; + executor + .sync(&go.id, vec![staged("ping", "ping", ToolMode::Enabled)]) + .await; + let mut camel_description = staged("customer-ledger", "archive", ToolMode::Enabled); + camel_description.description = Some("CustomerLedger workflow".to_owned()); + executor + .sync( + &stripe.id, + vec![ + staged("create-invoice", "createInvoice", ToolMode::Enabled), + camel_description, + ], + ) + .await; + + let exact = executor + .app + .catalog() + .search_tools("create issue", None, 10, 0) + .await + .expect("search should work"); + assert_eq!(exact.total, 1); + assert_eq!(exact.items[0].path, "github.create_issue"); + + let first_page = executor + .app + .catalog() + .search_tools("create", None, 1, 0) + .await + .expect("search should work"); + assert_eq!(first_page.total, 2); + assert!(first_page.has_more); + assert_eq!(first_page.next_offset, Some(1)); + let second_page = executor + .app + .catalog() + .search_tools("create", None, 1, 1) + .await + .expect("search should work"); + assert_eq!(second_page.items.len(), 1); + assert!(!second_page.has_more); + assert_ne!(first_page.items[0].path, second_page.items[0].path); + + let github_only = executor + .app + .catalog() + .search_tools("create", Some("github"), 10, 0) + .await + .expect("namespace search should work"); + assert_eq!(github_only.total, 1); + assert_eq!(github_only.items[0].integration, "github"); + let camel_description = executor + .app + .catalog() + .search_tools("ledger", None, 10, 0) + .await + .expect("normalized description search should work"); + assert_eq!(camel_description.total, 1); + assert_eq!(camel_description.items[0].path, "stripe.archive"); + for query in ["hub", "githubs", "it"] { + let legacy_match = executor + .app + .catalog() + .search_tools(query, None, 10, 0) + .await + .expect("legacy substring and reverse-prefix search should work"); + assert!( + legacy_match + .items + .iter() + .any(|item| item.integration == "github"), + "{query} should reach the indexed github candidate" + ); + } + let short_reverse_prefix = executor + .app + .catalog() + .search_tools("google", None, 10, 0) + .await + .expect("short reverse-prefix search should work"); + assert!( + short_reverse_prefix + .items + .iter() + .any(|item| item.integration == "go") + ); + let empty = executor + .app + .catalog() + .search_tools("---", None, 10, 0) + .await + .expect("punctuation search should be empty"); + assert_eq!(empty.total, 0); +} + +#[tokio::test] +async fn gateway_search_rejects_unbounded_direct_store_queries_before_index_work() { + let executor = TestExecutor::new().await; + let excessive_bytes = "token ".repeat(100_000); + for (query, namespace) in [ + (excessive_bytes.as_str(), None), + (&"x".repeat(257), None), + (&"x ".repeat(65), None), + ("safe", Some(excessive_bytes.as_str())), + ] { + assert!(matches!( + executor + .app + .catalog() + .search_tools(query, namespace, 10, 0) + .await, + Err(CatalogError::Validation { + code: "invalid_search_query", + .. + }) + )); + } +} + +#[tokio::test] +async fn gateway_search_bounds_candidates_and_filters_historical_rows_before_ranking() { + let executor = TestExecutor::new().await; + let source = executor.source("search-scale").await; + executor + .sync( + &source.id, + vec![staged("active", "Needle", ToolMode::Enabled)], + ) + .await; + + sqlx::query( + "WITH RECURSIVE sequence(value) AS ( \ + SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value < 10000) \ + INSERT INTO tools \ + (id, source_id, stable_key, local_name, display_name, description, input_schema_json, \ + typescript_definitions_json, intrinsic_mode, present, revision, created_at, updated_at, \ + last_seen_at, tombstoned_at) \ + SELECT printf('historical-%05d', value), ?, printf('historical-key-%05d', value), \ + printf('needle_historical_%05d', value), 'Historical Needle', \ + 'needle historical entry', '{}', '{}', \ + CASE WHEN value <= 5000 THEN 'enabled' ELSE 'disabled' END, \ + CASE WHEN value <= 5000 THEN 0 ELSE 1 END, 0, 1, 1, 1, \ + CASE WHEN value <= 5000 THEN 1 ELSE NULL END \ + FROM sequence", + ) + .bind(&source.id) + .execute(executor.app.pool()) + .await + .expect("large tombstone history should seed"); + sqlx::query( + "INSERT INTO tool_search \ + (source_id, tool_id, source_slug, local_name, description, sandbox_path) \ + SELECT tools.source_id, tools.id, replace(sources.slug, '_', ' '), \ + replace(tools.local_name, '_', ' '), tools.description, \ + replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE tools.source_id = ? AND tools.id LIKE 'historical-%'", + ) + .bind(&source.id) + .execute(executor.app.pool()) + .await + .expect("historical search entries should seed"); + + let historical = executor + .app + .catalog() + .search_tools("needle", None, 10, 0) + .await + .expect("search should ignore historical candidates in SQL"); + assert_eq!(historical.total, 1); + assert_eq!(historical.items[0].path, "search_scale.needle"); + + sqlx::query( + "WITH RECURSIVE sequence(value) AS ( \ + SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value < 5000) \ + INSERT INTO tools \ + (id, source_id, stable_key, local_name, display_name, description, input_schema_json, \ + typescript_definitions_json, intrinsic_mode, present, revision, created_at, updated_at, \ + last_seen_at, tombstoned_at) \ + SELECT printf('bounded-%05d', value), ?, printf('bounded-key-%05d', value), \ + printf('common_%05d', value), 'Common', 'common candidate', '{}', '{}', \ + 'enabled', 1, 0, 1, 1, 1, NULL FROM sequence", + ) + .bind(&source.id) + .execute(executor.app.pool()) + .await + .expect("large active catalog should seed"); + sqlx::query( + "INSERT INTO tool_search \ + (source_id, tool_id, source_slug, local_name, description, sandbox_path) \ + SELECT tools.source_id, tools.id, replace(sources.slug, '_', ' '), \ + replace(tools.local_name, '_', ' '), tools.description, \ + replace(sources.slug, '_', ' ') || ' ' || replace(tools.local_name, '_', ' ') \ + FROM tools JOIN sources ON sources.id = tools.source_id \ + WHERE tools.source_id = ? AND tools.id LIKE 'bounded-%'", + ) + .bind(&source.id) + .execute(executor.app.pool()) + .await + .expect("active search entries should seed"); + let bounded = executor + .app + .catalog() + .search_tools("common", None, 10, 0) + .await + .expect("large search should remain bounded"); + assert_eq!(bounded.items.len(), 10); + assert_eq!(bounded.total, 4096); + assert!(bounded.has_more); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tools WHERE source_id = ? AND present = 1 \ + AND description = 'common candidate'", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("active candidate count should read"), + 5000 + ); +} + +#[tokio::test] +async fn admin_tool_listing_filters_counts_and_pages_in_sql() { + let executor = TestExecutor::new().await; + let alpha = executor.source("alpha-list").await; + let beta = executor.source("beta-list").await; + executor + .sync( + &alpha.id, + vec![ + staged("keep", "Alpha Search", ToolMode::Enabled), + staged("gone", "Archived Match", ToolMode::Enabled), + staged("ask", "Review Match", ToolMode::Ask), + ], + ) + .await; + executor + .sync( + &beta.id, + vec![staged("other", "Alpha Other", ToolMode::Enabled)], + ) + .await; + executor + .sync( + &alpha.id, + vec![ + staged("keep", "Alpha Search", ToolMode::Enabled), + staged("ask", "Review Match", ToolMode::Ask), + ], + ) + .await; + + let active = executor + .app + .catalog() + .list_tools(ListToolsFilter { + limit: 100, + ..Default::default() + }) + .await + .expect("active tools should list"); + assert_eq!(active.total, 3); + assert!(active.items.iter().all(|tool| tool.present)); + + let source_query = executor + .app + .catalog() + .list_tools(ListToolsFilter { + query: Some("match".to_owned()), + source_id: Some(alpha.id.clone()), + limit: 100, + ..Default::default() + }) + .await + .expect("source and query filters should compose"); + assert_eq!(source_query.total, 1); + assert_eq!(source_query.items[0].stable_key, "ask"); + + let with_tombstones = executor + .app + .catalog() + .list_tools(ListToolsFilter { + query: Some("match".to_owned()), + source_id: Some(alpha.id.clone()), + include_tombstoned: true, + limit: 100, + ..Default::default() + }) + .await + .expect("tombstones should be included on request"); + assert_eq!(with_tombstones.total, 2); + assert_eq!( + with_tombstones + .items + .iter() + .filter(|tool| !tool.present) + .count(), + 1 + ); + + let ask_only = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(alpha.id), + effective_mode: Some(ToolMode::Ask), + include_tombstoned: true, + limit: 100, + ..Default::default() + }) + .await + .expect("effective mode should filter in SQL"); + assert_eq!(ask_only.total, 1); + assert_eq!(ask_only.items[0].stable_key, "ask"); + + let first_page = executor + .app + .catalog() + .list_tools(ListToolsFilter { + include_tombstoned: true, + limit: 2, + offset: 0, + ..Default::default() + }) + .await + .expect("first page should list"); + let second_page = executor + .app + .catalog() + .list_tools(ListToolsFilter { + include_tombstoned: true, + limit: 2, + offset: 2, + ..Default::default() + }) + .await + .expect("second page should list"); + assert_eq!(first_page.total, 4); + assert_eq!(second_page.total, 4); + assert!(first_page.has_more); + assert_eq!(first_page.next_offset, Some(2)); + assert!(!second_page.has_more); + let mut combined_paths = first_page + .items + .iter() + .chain(&second_page.items) + .map(|tool| tool.callable_path.clone()) + .collect::>(); + let original_paths = combined_paths.clone(); + combined_paths.sort(); + assert_eq!(original_paths, combined_paths); + combined_paths.dedup(); + assert_eq!(combined_paths.len(), 4); +} + +#[tokio::test] +async fn catalog_churn_rejects_tombstone_history_overflow_atomically() { + let executor = TestExecutor::new().await; + let source = executor.source("history-ceiling").await; + executor + .sync( + &source.id, + vec![staged("current", "Current", ToolMode::Enabled)], + ) + .await; + sqlx::query( + "WITH RECURSIVE sequence(value) AS ( \ + SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value < 25000) \ + INSERT INTO tools \ + (id, source_id, stable_key, local_name, display_name, description, input_schema_json, \ + typescript_definitions_json, intrinsic_mode, present, revision, created_at, updated_at, \ + last_seen_at, tombstoned_at) \ + SELECT printf('retained-history-%05d', value), ?, printf('retained-key-%05d', value), \ + printf('retained_%05d', value), 'Retained', NULL, '{}', '{}', 'enabled', \ + 0, 0, 1, 1, 1, 1 FROM sequence", + ) + .bind(&source.id) + .execute(executor.app.pool()) + .await + .expect("tombstone ceiling fixture should seed"); + let before = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + let error = executor + .app + .catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: before.revision, + expected_credential_revision: None, + artifacts: vec![StagedArtifact { + kind: ArtifactKind::Metadata, + stable_key: "must-not-write".to_owned(), + content: json!({ "atomic": true }), + }], + tools: vec![staged("new-key", "New Tool", ToolMode::Enabled)], + }, + vec![openapi_binding("new-key", "GET")], + AuditContext::system(None), + ) + .await + .expect_err("churn beyond retained tombstone ceiling must fail"); + assert!(matches!( + error, + CatalogError::Validation { + code: "catalog_too_large", + .. + } + )); + let after = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should remain"); + assert_eq!(after.revision, before.revision); + assert_eq!(after.catalog_revision, before.catalog_revision); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tools WHERE source_id = ? AND stable_key = 'current' \ + AND present = 1", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("current tool state should read"), + 1 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tools WHERE source_id = ? AND stable_key = 'new-key'", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("new tool state should read"), + 0 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM source_artifacts WHERE source_id = ? \ + AND stable_key = 'must-not-write'", + ) + .bind(&source.id) + .fetch_one(executor.app.pool()) + .await + .expect("artifact state should read"), + 0 + ); +} + +#[tokio::test] +async fn optimistic_and_bulk_mode_changes_are_atomic() { + let executor = TestExecutor::new().await; + let source = executor.source("bulk").await; + executor + .sync( + &source.id, + vec![ + staged("one", "One", ToolMode::Enabled), + staged("two", "Two", ToolMode::Enabled), + ], + ) + .await; + let tools = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("tools should list") + .items; + let first = &tools[0]; + executor + .app + .catalog() + .set_tool_mode( + &first.id, + Some(ToolMode::Ask), + first.revision, + AuditContext::system(None), + ) + .await + .expect("first optimistic update should work"); + assert!(matches!( + executor + .app + .catalog() + .set_tool_mode( + &first.id, + Some(ToolMode::Disabled), + first.revision, + AuditContext::system(None), + ) + .await, + Err(CatalogError::RevisionConflict { scope: "tool", .. }) + )); + + let global_revision = executor + .app + .catalog() + .global_revision() + .await + .expect("revision should read"); + let invalid_bulk = executor + .app + .catalog() + .bulk_set_tool_modes( + &[tools[1].id.clone(), "missing".to_owned()], + Some(ToolMode::Disabled), + global_revision, + AuditContext::system(None), + ) + .await; + assert!(matches!( + invalid_bulk, + Err(CatalogError::NotFound { entity: "tool" }) + )); + assert_eq!( + executor + .app + .catalog() + .tool(&tools[1].id) + .await + .expect("tool should remain") + .mode_override, + None + ); + + let result = executor + .app + .catalog() + .bulk_set_tool_modes( + &tools.iter().map(|tool| tool.id.clone()).collect::>(), + Some(ToolMode::Disabled), + global_revision, + AuditContext::system(Some("explicit-bulk-audit")), + ) + .await + .expect("valid bulk update should commit"); + assert_eq!(result.updated_count, 2); + let audit_metadata = sqlx::query_scalar::<_, String>( + "SELECT metadata_json FROM audit_events \ + WHERE request_id = 'explicit-bulk-audit' AND action = 'tool.mode_bulk_changed'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("explicit bulk audit metadata should exist"); + let audit_metadata: Value = + serde_json::from_str(&audit_metadata).expect("audit metadata should be valid JSON"); + let mut expected_selection = tools + .iter() + .map(|tool| { + json!({ + "toolId": tool.id, + "path": tool.callable_path + }) + }) + .collect::>(); + expected_selection + .sort_by(|left, right| left["toolId"].as_str().cmp(&right["toolId"].as_str())); + assert_eq!( + audit_metadata["selectedTools"], + Value::Array(expected_selection) + ); + for tool in &tools { + assert_eq!( + executor + .app + .catalog() + .tool(&tool.id) + .await + .expect("tool should exist") + .mode_override, + Some(ToolMode::Disabled) + ); + } + + let stale_source = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist"); + assert!(matches!( + executor + .app + .catalog() + .bulk_set_source_tool_modes( + &source.id, + Some(ToolMode::Enabled), + stale_source.revision - 1, + AuditContext::system(None), + ) + .await, + Err(CatalogError::RevisionConflict { + scope: "source", + .. + }) + )); +} + +#[tokio::test] +async fn catalog_writes_wait_for_non_catalog_writers_without_busy_snapshots() { + let executor = TestExecutor::new().await; + let source = executor.source("writer-race").await; + let mut outside_write = executor + .app + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .expect("outside writer should reserve SQLite"); + sqlx::query( + "INSERT INTO request_logs \ + (request_id, surface, path_snapshot, outcome, duration_ms, created_at) \ + VALUES ('blocking-write', 'gateway', 'tools.search', 'succeeded', 1, 1)", + ) + .execute(&mut *outside_write) + .await + .expect("outside writer should hold an uncommitted write"); + + let catalog = executor.app.catalog().clone(); + let source_id = source.id.clone(); + let update = tokio::spawn(async move { + catalog + .set_source_mode( + &source_id, + Some(ToolMode::Ask), + source.revision, + AuditContext::system(None), + ) + .await + }); + tokio::task::yield_now().await; + assert!(!update.is_finished()); + outside_write + .commit() + .await + .expect("outside writer should commit"); + let updated = tokio::time::timeout(std::time::Duration::from_secs(2), update) + .await + .expect("catalog writer should resume before the busy timeout") + .expect("catalog writer task should not panic") + .expect("catalog writer should succeed"); + assert_eq!(updated.mode_override, Some(ToolMode::Ask)); +} + +#[tokio::test] +async fn credentials_are_encrypted_bound_to_the_source_and_never_logged() { + let executor = TestExecutor::new().await; + let first = executor.source("credential-one").await; + let second = executor.source("credential-two").await; + let secret = "catalog-super-secret-value"; + executor + .app + .catalog() + .put_credential( + &first.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "token": secret, "header": "Authorization" }), + }, + None, + AuditContext::system(None), + ) + .await + .expect("credential should be stored"); + let stored = executor + .app + .catalog() + .credential(&first.id) + .await + .expect("credential should decrypt") + .expect("credential should exist"); + assert_eq!(stored.credential.payload["token"], secret); + + let ciphertext = sqlx::query_scalar::<_, Vec>( + "SELECT payload_ciphertext FROM source_credentials WHERE source_id = ?", + ) + .bind(&first.id) + .fetch_one(executor.app.pool()) + .await + .expect("ciphertext should exist"); + assert!(!String::from_utf8_lossy(&ciphertext).contains(secret)); + sqlx::query( + "INSERT INTO source_credentials \ + (source_id, schema_version, payload_ciphertext, revision, created_at, updated_at) \ + VALUES (?, 1, ?, 0, 1, 1)", + ) + .bind(&second.id) + .bind(ciphertext) + .execute(executor.app.pool()) + .await + .expect("test should copy ciphertext to another record"); + assert!(matches!( + executor.app.catalog().credential(&second.id).await, + Err(CatalogError::Crypto(_)) + )); + + let database_bytes = fs::read(executor.directory.path().join("executor.db")) + .expect("database should be readable"); + assert!(!String::from_utf8_lossy(&database_bytes).contains(secret)); + let leaked_logs = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM request_logs WHERE path_snapshot LIKE '%' || ? || '%' \ + OR error_code LIKE '%' || ? || '%'", + ) + .bind(secret) + .bind(secret) + .fetch_one(executor.app.pool()) + .await + .expect("logs should be queryable"); + assert_eq!(leaked_logs, 0); +} + +#[tokio::test] +async fn request_logs_are_redacted_paginated_and_survive_catalog_deletion() { + let executor = TestExecutor::new().await; + let source = executor.source("history").await; + executor + .sync(&source.id, vec![staged("run", "Run", ToolMode::Enabled)]) + .await; + let tool = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("tools should list") + .items + .remove(0); + executor + .app + .catalog() + .record_request(NewRequestLog { + request_id: "request-2".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: Some(source.id.clone()), + tool_id: Some(tool.id.clone()), + path_snapshot: Some(tool.callable_path.clone()), + outcome: RequestOutcome::Failed, + error_code: Some("upstream_unavailable".to_owned()), + duration_ms: 12, + approval_id: None, + created_at: 2, + }) + .await + .expect("log should store"); + executor + .app + .catalog() + .record_request(NewRequestLog { + request_id: "request-1".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: None, + tool_id: None, + path_snapshot: Some("tools.search".to_owned()), + outcome: RequestOutcome::Succeeded, + error_code: None, + duration_ms: 1, + approval_id: None, + created_at: 1, + }) + .await + .expect("second log should store"); + let first_page = executor + .app + .catalog() + .list_request_logs(None, 1) + .await + .expect("logs should list"); + assert_eq!(first_page.items[0].request_id, "request-2"); + let second_page = executor + .app + .catalog() + .list_request_logs(first_page.next_cursor.as_deref(), 1) + .await + .expect("cursor should continue"); + assert_eq!(second_page.items[0].request_id, "request-1"); + + let columns = sqlx::query_as::<_, (i64, String, String, i64, Option, i64)>( + "PRAGMA table_info(request_logs)", + ) + .fetch_all(executor.app.pool()) + .await + .expect("log columns should be readable") + .into_iter() + .map(|row| row.1) + .collect::>(); + for forbidden in ["headers", "credentials", "arguments", "results", "body"] { + assert!(!columns.iter().any(|column| column.contains(forbidden))); + } + + executor + .app + .catalog() + .delete_source(&source.id, AuditContext::system(None)) + .await + .expect("source should delete"); + let retained = executor + .app + .catalog() + .request_log("request-2") + .await + .expect("history should survive"); + assert_eq!(retained.source_id, None); + assert_eq!(retained.tool_id, None); + assert_eq!( + retained.path_snapshot.as_deref(), + Some(tool.callable_path.as_str()) + ); + let audit_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM audit_events") + .fetch_one(executor.app.pool()) + .await + .expect("audit count should read"); + assert!(audit_count > 0); + let audit_without_snapshot = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audit_events \ + WHERE action LIKE 'source.%' AND target_path_snapshot IS NULL", + ) + .fetch_one(executor.app.pool()) + .await + .expect("source audit snapshots should be queryable"); + assert_eq!(audit_without_snapshot, 0); + + executor + .app + .catalog() + .record_request(NewRequestLog { + request_id: "late-request".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: Some(source.id), + tool_id: Some(tool.id), + path_snapshot: Some(tool.callable_path), + outcome: RequestOutcome::Succeeded, + error_code: None, + duration_ms: 2, + approval_id: None, + created_at: 3, + }) + .await + .expect("late logs should null stale foreign keys"); + let late = executor + .app + .catalog() + .request_log("late-request") + .await + .expect("late log should exist"); + assert_eq!(late.source_id, None); + assert_eq!(late.tool_id, None); +} + +#[tokio::test] +async fn request_logs_prune_atomically_and_apply_bounded_writer_backpressure() { + let executor = TestExecutor::new().await; + sqlx::query( + "WITH RECURSIVE sequence(value) AS ( \ + SELECT 1 UNION ALL SELECT value + 1 FROM sequence WHERE value < 10000) \ + INSERT INTO request_logs \ + (request_id, surface, path_snapshot, outcome, duration_ms, created_at) \ + SELECT printf('retained-%05d', value), 'gateway', 'tools.search', \ + 'succeeded', 1, value FROM sequence", + ) + .execute(executor.app.pool()) + .await + .expect("retention boundary should seed"); + executor + .app + .catalog() + .record_request(NewRequestLog { + request_id: "newest-retained".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: None, + tool_id: None, + path_snapshot: Some("tools.search".to_owned()), + outcome: RequestOutcome::Succeeded, + error_code: None, + duration_ms: 1, + approval_id: None, + created_at: 10001, + }) + .await + .expect("insert and retention prune should commit together"); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM request_logs") + .fetch_one(executor.app.pool()) + .await + .expect("retained count should read"), + 10000 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM request_logs WHERE request_id = 'retained-00001'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("oldest retention state should read"), + 0 + ); + executor + .app + .catalog() + .request_log("newest-retained") + .await + .expect("new log should survive pruning"); + + let outside_writer = executor + .app + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .expect("outside writer should reserve SQLite"); + let blocked_catalog = executor.app.catalog().clone(); + let blocked = tokio::spawn(async move { + blocked_catalog + .record_request(NewRequestLog { + request_id: "blocked-log".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: None, + tool_id: None, + path_snapshot: Some("tools.search".to_owned()), + outcome: RequestOutcome::Succeeded, + error_code: None, + duration_ms: 1, + approval_id: None, + created_at: 10002, + }) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let overloaded = executor + .app + .catalog() + .record_request(NewRequestLog { + request_id: "overloaded-log".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: None, + tool_id: None, + path_snapshot: Some("tools.search".to_owned()), + outcome: RequestOutcome::Succeeded, + error_code: None, + duration_ms: 1, + approval_id: None, + created_at: 10003, + }) + .await; + assert!(matches!( + overloaded, + Err(CatalogError::Validation { + code: "request_log_backpressure", + .. + }) + )); + assert!(!blocked.is_finished()); + outside_writer + .commit() + .await + .expect("outside writer should commit"); + tokio::time::timeout(std::time::Duration::from_secs(2), blocked) + .await + .expect("the single logger should resume after contention clears") + .expect("blocked logger task should not panic") + .expect("blocked log should persist after contention clears"); + executor + .app + .catalog() + .record_request(NewRequestLog { + request_id: "after-backpressure".to_owned(), + actor_api_token_id: None, + surface: RequestSurface::Gateway, + source_id: None, + tool_id: None, + path_snapshot: Some("tools.search".to_owned()), + outcome: RequestOutcome::Succeeded, + error_code: None, + duration_ms: 1, + approval_id: None, + created_at: 10004, + }) + .await + .expect("logger should recover after database contention"); +} + +#[tokio::test] +async fn catalog_state_keeps_missing_boot_sentinels_fail_closed() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let config = AppConfig::new(directory.path().to_path_buf()); + let app = ExecutorApp::open(config.clone()) + .await + .expect("initial open should succeed"); + app.catalog() + .create_source( + CreateSource { + kind: SourceKind::Graphql, + preferred_slug: "state".to_owned(), + display_name: "State".to_owned(), + description: None, + configuration: Map::new(), + }, + AuditContext::system(None), + ) + .await + .expect("source should create"); + sqlx::query("DELETE FROM setup_state") + .execute(app.pool()) + .await + .expect("setup state should delete"); + sqlx::query("DELETE FROM instance_metadata WHERE key = 'boot_sentinel'") + .execute(app.pool()) + .await + .expect("sentinel should delete"); + app.pool().close().await; + drop(app); + let Err(error) = ExecutorApp::open(config).await else { + panic!("catalog state without sentinel must fail closed"); + }; + assert!(matches!(error, DatabaseError::MissingBootSentinel)); +} + +#[tokio::test] +async fn catalog_api_preserves_auth_planes_csrf_and_global_token_equivalence() { + let executor = TestExecutor::new().await; + let source = executor.source("tools").await; + assert_eq!(source.slug, "tools_2"); + executor + .sync(&source.id, vec![staged("run", "Run", ToolMode::Enabled)]) + .await; + let tool = executor + .app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("tool should list") + .items + .remove(0); + executor.setup_admin().await; + let admin = executor.login().await; + let first_token = executor.create_api_token(&admin, "First").await; + let second_token = executor.create_api_token(&admin, "Second").await; + + let admin_list = send_empty( + executor.router(), + Method::GET, + "/api/v1/tools", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(admin_list.status(), StatusCode::OK); + let admin_body = response_json(admin_list).await; + assert_eq!(admin_body["total"], 1); + assert!(admin_body["items"][0].get("inputSchema").is_none()); + assert!(admin_body["items"][0].get("inputTypescript").is_none()); + + let admin_detail = send_empty( + executor.router(), + Method::GET, + &format!("/api/v1/tools/{}", tool.id), + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(admin_detail.status(), StatusCode::OK); + assert_eq!( + response_json(admin_detail).await["inputSchema"]["type"], + "object" + ); + + let bearer_control = send_empty( + executor.router(), + Method::GET, + "/api/v1/tools", + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {first_token}"), + )], + ) + .await; + assert_error(bearer_control, StatusCode::UNAUTHORIZED, "unauthorized").await; + + let session_gateway = send_json( + executor.router(), + Method::POST, + "/api/v1/gateway/tools/search", + json!({ "query": "run" }), + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_error(session_gateway, StatusCode::UNAUTHORIZED, "unauthorized").await; + + let first_search = gateway_search_request(&executor, &first_token, "run").await; + let second_search = gateway_search_request(&executor, &second_token, "run").await; + assert_eq!(first_search, second_search); + assert_eq!(first_search["total"], 1); + let discovered_path = first_search["items"][0]["path"] + .as_str() + .expect("search should return a sandbox path") + .to_owned(); + assert_eq!(discovered_path, "tools_2.run"); + let described = send_json( + executor.router(), + Method::POST, + "/api/v1/gateway/tools/describe", + json!({ "path": discovered_path }), + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {first_token}"), + )], + ) + .await; + assert_eq!(described.status(), StatusCode::OK); + assert!( + response_json(described).await["outputTypescript"] + .as_str() + .is_some_and(|output| output.contains("ToolError")) + ); + + let missing_csrf = send_json( + executor.router(), + Method::PATCH, + &format!("/api/v1/tools/{}/mode", tool.id), + json!({ "mode": "disabled", "expectedRevision": tool.revision }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ], + ) + .await; + assert_error(missing_csrf, StatusCode::FORBIDDEN, "invalid_csrf").await; + + let disabled = send_json( + executor.router(), + Method::PATCH, + &format!("/api/v1/tools/{}/mode", tool.id), + json!({ "mode": "disabled", "expectedRevision": tool.revision }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(disabled.status(), StatusCode::OK); + let disabled_request_id = disabled + .headers() + .get("x-request-id") + .expect("mutation should have a request ID") + .to_str() + .expect("request ID should be text") + .to_owned(); + let disabled_body = response_json(disabled).await; + assert_eq!(disabled_body["effectiveMode"]["mode"], "disabled"); + assert_eq!( + sqlx::query_scalar::<_, Option>( + "SELECT actor_admin_id FROM audit_events \ + WHERE request_id = ? AND action = 'tool.mode_changed'", + ) + .bind(disabled_request_id) + .fetch_one(executor.app.pool()) + .await + .expect("administrator audit actor should read"), + Some(1) + ); + let disabled_revision = disabled_body["revision"] + .as_i64() + .expect("updated revision should be an integer"); + + let missing_mode = send_json( + executor.router(), + Method::PATCH, + &format!("/api/v1/tools/{}/mode", tool.id), + json!({ "expectedRevision": disabled_revision }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_error(missing_mode, StatusCode::BAD_REQUEST, "invalid_json").await; + assert_eq!( + executor + .app + .catalog() + .tool(&tool.id) + .await + .expect("tool should remain disabled") + .mode_override, + Some(ToolMode::Disabled) + ); + + assert_eq!( + gateway_search_request(&executor, &first_token, "run").await["total"], + 0 + ); + assert_eq!( + gateway_search_request(&executor, &second_token, "run").await["total"], + 0 + ); + let guarded = send_json( + executor.router(), + Method::POST, + "/api/v1/gateway/tools/lookup", + json!({ "path": tool.callable_path }), + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {first_token}"), + )], + ) + .await; + assert_error(guarded, StatusCode::FORBIDDEN, "tool_disabled").await; + + let logs = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let logs = executor + .app + .catalog() + .list_request_logs(None, 20) + .await + .expect("gateway calls should be readable"); + let disabled_call_recorded = logs.items.iter().any(|log| { + log.path_snapshot.as_deref() == Some(tool.callable_path.as_str()) + && log.error_code.as_deref() == Some("tool_disabled") + }); + let actor_count = logs + .items + .iter() + .filter_map(|log| log.actor_api_token_id.as_deref()) + .collect::>() + .len(); + if disabled_call_recorded && actor_count == 2 { + break logs; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("queued gateway logs should be stored"); + assert!(logs.items.iter().any(|log| { + log.path_snapshot.as_deref() == Some(tool.callable_path.as_str()) + && log.error_code.as_deref() == Some("tool_disabled") + })); + assert_eq!( + logs.items + .iter() + .filter_map(|log| log.actor_api_token_id.as_deref()) + .collect::>() + .len(), + 2 + ); + + let log_list = send_empty( + executor.router(), + Method::GET, + "/api/v1/request-logs?limit=1", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(log_list.status(), StatusCode::OK); + let log_body = response_json(log_list).await; + let log_id = log_body["items"][0]["requestId"] + .as_str() + .expect("log list should return request ID"); + let log_detail = send_empty( + executor.router(), + Method::GET, + &format!("/api/v1/request-logs/{log_id}"), + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(log_detail.status(), StatusCode::OK); + + let catalog_revision = executor + .app + .catalog() + .global_revision() + .await + .expect("catalog revision should read"); + let explicit_bulk = send_json( + executor.router(), + Method::PATCH, + "/api/v1/tools/modes", + json!({ + "selection": { + "type": "tool_ids", + "toolIds": [tool.id], + "expectedCatalogRevision": catalog_revision + }, + "mode": "enabled" + }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(explicit_bulk.status(), StatusCode::OK); + assert_eq!(response_json(explicit_bulk).await["updatedCount"], 1); + + let source_revision = executor + .app + .catalog() + .source(&source.id) + .await + .expect("source should exist") + .revision; + let source_bulk = send_json( + executor.router(), + Method::PATCH, + "/api/v1/tools/modes", + json!({ + "selection": { + "type": "source", + "sourceId": source.id, + "expectedSourceRevision": source_revision + }, + "mode": "ask" + }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(source_bulk.status(), StatusCode::OK); + assert_eq!(response_json(source_bulk).await["updatedCount"], 1); + + let ask_lookup = send_json( + executor.router(), + Method::POST, + "/api/v1/gateway/tools/lookup", + json!({ "path": discovered_path }), + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {second_token}"), + )], + ) + .await; + assert_eq!(ask_lookup.status(), StatusCode::OK); + assert_eq!(response_json(ask_lookup).await["requiresApproval"], true); + + let sources = send_empty( + executor.router(), + Method::GET, + "/api/v1/sources", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(sources.status(), StatusCode::OK); + assert_eq!( + response_json(sources).await["sources"] + .as_array() + .map(Vec::len), + Some(1) + ); + let source_detail = send_empty( + executor.router(), + Method::GET, + &format!("/api/v1/sources/{}", source.id), + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(source_detail.status(), StatusCode::OK); + let source_body = response_json(source_detail).await; + let source_revision = source_body["revision"] + .as_i64() + .expect("source revision should be an integer"); + let source_mode = send_json( + executor.router(), + Method::PATCH, + &format!("/api/v1/sources/{}/mode", source.id), + json!({ "mode": "disabled", "expectedRevision": source_revision }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(source_mode.status(), StatusCode::OK); + let deleted = send_empty( + executor.router(), + Method::DELETE, + &format!("/api/v1/sources/{}", source.id), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn gateway_auth_stays_responsive_during_token_timestamp_contention() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + let token = executor.create_api_token(&admin, "Contention").await; + let outside_writer = executor + .app + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .expect("outside writer should reserve SQLite"); + + for _ in 0..2 { + tokio::time::timeout( + std::time::Duration::from_secs(1), + gateway_whoami_burst(&executor, &token, 24), + ) + .await + .expect("authentication reads should not wait for the timestamp writer"); + } + + outside_writer + .commit() + .await + .expect("outside writer should commit"); + for _ in 0..100 { + let last_used = sqlx::query_scalar::<_, Option>( + "SELECT last_used_at FROM api_tokens WHERE name = 'Contention'", + ) + .fetch_one(executor.app.pool()) + .await + .expect("last-used timestamp should be readable"); + if last_used.is_some() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("the coalesced last-used update should settle after contention clears"); +} + +async fn gateway_whoami_burst(executor: &TestExecutor, token: &str, requests: usize) { + let mut requests_in_flight = tokio::task::JoinSet::new(); + for _ in 0..requests { + let router = executor.router(); + let authorization = format!("Bearer {token}"); + requests_in_flight.spawn(async move { + send_empty( + router, + Method::GET, + "/api/v1/gateway/whoami", + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await + .status() + }); + } + while let Some(response) = requests_in_flight.join_next().await { + assert_eq!( + response.expect("gateway authentication task should not panic"), + StatusCode::OK + ); + } +} + +async fn gateway_search_request(executor: &TestExecutor, token: &str, query: &str) -> Value { + let response = send_json( + executor.router(), + Method::POST, + "/api/v1/gateway/tools/search", + json!({ "query": query, "limit": 10, "offset": 0 }), + &[(header::AUTHORIZATION.as_str(), &format!("Bearer {token}"))], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + response_json(response).await +} + +async fn send_json( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn send_empty( + router: Router, + method: Method, + uri: &str, + headers: &[(&str, &str)], +) -> Response { + let mut request = Request::builder().method(method).uri(uri); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot(request.body(Body::empty()).expect("request should build")) + .await + .expect("router should answer") +} + +fn response_cookie_header(response: &Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("set-cookie should be text") + .split(';') + .next() + .expect("cookie should contain a value") + }) + .collect::>() + .join("; ") +} + +async fn response_json(response: Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +async fn assert_error(response: Response, status: StatusCode, code: &str) { + assert_eq!(response.status(), status); + let request_id = response + .headers() + .get("x-request-id") + .expect("error should have a request ID") + .to_str() + .expect("request ID should be text") + .to_owned(); + let body = response_json(response).await; + assert_eq!(body["error"]["code"], code); + assert_eq!(body["error"]["requestId"], request_id); +} diff --git a/tests/config.rs b/tests/config.rs new file mode 100644 index 000000000..5d158f453 --- /dev/null +++ b/tests/config.rs @@ -0,0 +1,68 @@ +use std::net::SocketAddr; + +use executor::{AppConfig, ConfigError}; + +#[test] +fn public_origins_are_canonicalized_and_reject_extra_components() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let config = AppConfig::new(directory.path().to_path_buf()) + .with_origin("HTTPS://Example.COM:443/") + .expect("origin should be valid"); + assert_eq!(config.public_origin(), "https://example.com"); + + for invalid in [ + "https://user@example.com", + "https://example.com/path", + "https://example.com?query=1", + "https://example.com#fragment", + ] { + assert!(matches!( + AppConfig::new(directory.path().to_path_buf()).with_origin(invalid), + Err(ConfigError::PublicOriginHasExtraComponents) + )); + } + assert!(matches!( + AppConfig::new(directory.path().to_path_buf()).with_origin("ftp://example.com"), + Err(ConfigError::UnsupportedPublicOriginScheme) + )); +} + +#[test] +fn plaintext_http_requires_an_explicit_unsafe_non_loopback_override() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let http = AppConfig::new(directory.path().to_path_buf()); + let loopback: SocketAddr = "127.0.0.1:4788".parse().expect("address should parse"); + let public: SocketAddr = "0.0.0.0:4788".parse().expect("address should parse"); + assert!(http.validate_bind(loopback, false).is_ok()); + assert!(matches!( + http.validate_bind(public, false), + Err(ConfigError::UnsafePlaintextNonLoopbackBind(_)) + )); + assert!(http.validate_bind(public, true).is_ok()); + + let https = AppConfig::new(directory.path().to_path_buf()) + .with_origin("https://executor.example.com") + .expect("HTTPS origin should be valid"); + assert!(https.validate_bind(public, false).is_ok()); +} + +#[test] +fn default_public_origin_tracks_the_actual_bind_address() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let loopback: SocketAddr = "127.0.0.1:5888".parse().expect("address should parse"); + let config = AppConfig::new(directory.path().to_path_buf()) + .with_default_origin_for_bind(loopback) + .expect("loopback bind should produce an origin"); + assert_eq!(config.public_origin(), "http://127.0.0.1:5888"); + + let unspecified: SocketAddr = "0.0.0.0:5888".parse().expect("address should parse"); + assert!(matches!( + AppConfig::new(directory.path().to_path_buf()).with_default_origin_for_bind(unspecified), + Err(ConfigError::PublicOriginRequiredForUnspecifiedBind(_)) + )); + + let explicit = AppConfig::new(directory.path().to_path_buf()) + .with_origin("https://executor.example.com") + .expect("explicit public origin should remain supported"); + assert_eq!(explicit.public_origin(), "https://executor.example.com"); +} diff --git a/tests/control_plane.rs b/tests/control_plane.rs new file mode 100644 index 000000000..bfd7414f8 --- /dev/null +++ b/tests/control_plane.rs @@ -0,0 +1,1452 @@ +use std::{ + fs, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use axum::{ + Router, + body::Body, + extract::ConnectInfo, + http::{Method, Request, Response, StatusCode, header}, +}; +use executor::{ + AppConfig, DatabaseError, ExecutorApp, + catalog::{RequestOutcome, RequestSurface}, +}; +use http_body_util::BodyExt; +use ipnet::IpNet; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; +const PASSWORD: &str = "correct-horse-battery-staple"; +static TOKEN_IDEMPOTENCY_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +struct TestExecutor { + _directory: TempDir, + app: Arc, +} + +struct AdminSession { + cookie: String, + csrf: String, +} + +impl TestExecutor { + async fn new() -> Self { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("test Executor should open"); + Self { + _directory: directory, + app: Arc::new(app), + } + } + + async fn with_trusted_proxies(networks: &[&str]) -> Self { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let trusted_proxies = networks + .iter() + .map(|network| { + network + .parse::() + .expect("trusted proxy network should parse") + }) + .collect(); + let app = ExecutorApp::open( + AppConfig::new(directory.path().to_path_buf()).with_trusted_proxies(trusted_proxies), + ) + .await + .expect("test Executor should open"); + Self { + _directory: directory, + app: Arc::new(app), + } + } + + fn router(&self) -> Router { + self.app.router() + } + + fn setup_token(&self) -> String { + self.app + .setup_token() + .expect("fresh test app should issue a setup token") + .to_owned() + } + + async fn setup_admin(&self) { + let response = send_json( + self.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": self.setup_token(), + "username": "admin", + "password": PASSWORD + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + } + + async fn login(&self) -> AdminSession { + let response = send_json( + self.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = response_cookie_header(&response); + let body = response_json(response).await; + let csrf = body["csrfToken"] + .as_str() + .expect("login should reveal a CSRF token") + .to_owned(); + AdminSession { cookie, csrf } + } +} + +#[tokio::test] +async fn migrations_and_sqlite_safety_pragmas_are_active() { + let executor = TestExecutor::new().await; + let table_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN \ + ('instance_metadata', 'setup_state', 'admins', 'admin_sessions', 'api_tokens')", + ) + .fetch_one(executor.app.pool()) + .await + .expect("schema query should succeed"); + assert_eq!(table_count, 5); + + let foreign_keys = sqlx::query_scalar::<_, i64>("PRAGMA foreign_keys") + .fetch_one(executor.app.pool()) + .await + .expect("foreign_keys pragma should be readable"); + let journal_mode = sqlx::query_scalar::<_, String>("PRAGMA journal_mode") + .fetch_one(executor.app.pool()) + .await + .expect("journal mode should be readable"); + let synchronous = sqlx::query_scalar::<_, i64>("PRAGMA synchronous") + .fetch_one(executor.app.pool()) + .await + .expect("synchronous pragma should be readable"); + assert_eq!(foreign_keys, 1); + assert_eq!(journal_mode, "wal"); + assert_eq!(synchronous, 2); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let directory_mode = fs::metadata(executor._directory.path()) + .expect("data directory metadata should be readable") + .permissions() + .mode() + & 0o777; + let key_mode = fs::metadata(executor._directory.path().join("master.key")) + .expect("master key metadata should be readable") + .permissions() + .mode() + & 0o777; + let database_mode = fs::metadata(executor._directory.path().join("executor.db")) + .expect("database metadata should be readable") + .permissions() + .mode() + & 0o777; + let lock_mode = fs::metadata(executor._directory.path().join("executor.lock")) + .expect("lock metadata should be readable") + .permissions() + .mode() + & 0o777; + assert_eq!(directory_mode, 0o700); + assert_eq!(key_mode, 0o600); + assert_eq!(database_mode, 0o600); + assert_eq!(lock_mode, 0o600); + } +} + +#[tokio::test] +async fn one_process_holds_the_data_directory_lock_for_its_lifetime() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let config = AppConfig::new(directory.path().to_path_buf()); + let first = ExecutorApp::open(config.clone()) + .await + .expect("first process should acquire the lock"); + let Err(error) = ExecutorApp::open(config.clone()).await else { + panic!("a second process must not acquire the same data directory"); + }; + assert!(matches!(error, DatabaseError::AlreadyRunning(_))); + + first.pool().close().await; + drop(first); + let reopened = ExecutorApp::open(config) + .await + .expect("dropping the app should release the lock"); + reopened.pool().close().await; +} + +#[tokio::test] +async fn initialized_database_fails_closed_for_missing_or_wrong_keys() { + let missing_key_directory = tempfile::tempdir().expect("temporary directory should be created"); + let missing_config = AppConfig::new(missing_key_directory.path().to_path_buf()); + let missing_app = ExecutorApp::open(missing_config.clone()) + .await + .expect("initial open should succeed"); + missing_app.pool().close().await; + drop(missing_app); + fs::remove_file(missing_key_directory.path().join("master.key")) + .expect("master key should be removable in the test"); + let Err(missing_error) = ExecutorApp::open(missing_config).await else { + panic!("missing key must fail closed"); + }; + assert!(matches!( + missing_error, + DatabaseError::Crypto(executor::crypto::CryptoError::MissingMasterKey(_)) + )); + + let wrong_key_directory = tempfile::tempdir().expect("temporary directory should be created"); + let wrong_config = AppConfig::new(wrong_key_directory.path().to_path_buf()); + let wrong_app = ExecutorApp::open(wrong_config.clone()) + .await + .expect("initial open should succeed"); + wrong_app.pool().close().await; + drop(wrong_app); + fs::write(wrong_key_directory.path().join("master.key"), [9_u8; 32]) + .expect("test should replace the key"); + let Err(wrong_error) = ExecutorApp::open(wrong_config).await else { + panic!("wrong key must fail closed"); + }; + assert!(matches!(wrong_error, DatabaseError::InvalidBootSentinel)); +} + +#[tokio::test] +async fn tampered_boot_sentinel_fails_closed() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let config = AppConfig::new(directory.path().to_path_buf()); + let app = ExecutorApp::open(config.clone()) + .await + .expect("initial open should succeed"); + let mut sentinel = sqlx::query_scalar::<_, Vec>( + "SELECT value FROM instance_metadata WHERE key = 'boot_sentinel'", + ) + .fetch_one(app.pool()) + .await + .expect("sentinel should exist"); + let last = sentinel.len() - 1; + sentinel[last] ^= 0x01; + sqlx::query("UPDATE instance_metadata SET value = ? WHERE key = 'boot_sentinel'") + .bind(sentinel) + .execute(app.pool()) + .await + .expect("sentinel should be updateable in the test"); + app.pool().close().await; + drop(app); + + let Err(error) = ExecutorApp::open(config).await else { + panic!("tampered sentinel must fail closed"); + }; + assert!(matches!(error, DatabaseError::InvalidBootSentinel)); +} + +#[tokio::test] +async fn interrupted_empty_initialization_recreates_the_boot_sentinel() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let config = AppConfig::new(directory.path().to_path_buf()); + let app = ExecutorApp::open(config.clone()) + .await + .expect("initial open should succeed"); + sqlx::query("DELETE FROM setup_state") + .execute(app.pool()) + .await + .expect("test should remove setup state"); + sqlx::query("DELETE FROM instance_metadata WHERE key = 'boot_sentinel'") + .execute(app.pool()) + .await + .expect("test should remove the sentinel"); + app.pool().close().await; + drop(app); + + let recovered = ExecutorApp::open(config) + .await + .expect("empty post-migration state should recover"); + assert!(recovered.setup_token().is_some()); + let sentinel_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM instance_metadata WHERE key = 'boot_sentinel'", + ) + .fetch_one(recovered.pool()) + .await + .expect("sentinel should be readable"); + assert_eq!(sentinel_count, 1); +} + +#[tokio::test] +async fn missing_sentinel_with_application_state_still_fails_closed() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let config = AppConfig::new(directory.path().to_path_buf()); + let app = ExecutorApp::open(config.clone()) + .await + .expect("initial open should succeed"); + sqlx::query("DELETE FROM instance_metadata WHERE key = 'boot_sentinel'") + .execute(app.pool()) + .await + .expect("test should remove the sentinel"); + app.pool().close().await; + drop(app); + + let Err(error) = ExecutorApp::open(config).await else { + panic!("setup state without a sentinel must fail closed"); + }; + assert!(matches!(error, DatabaseError::MissingBootSentinel)); +} + +#[tokio::test] +async fn expired_setup_tokens_are_rejected_before_password_work() { + let executor = TestExecutor::new().await; + sqlx::query("UPDATE setup_state SET created_at = 0 WHERE id = 1") + .execute(executor.app.pool()) + .await + .expect("test should expire the setup token"); + let response = send_json( + executor.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": executor.setup_token(), + "username": "admin", + "password": PASSWORD + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + + assert_error(response, StatusCode::UNAUTHORIZED, "unauthorized").await; +} + +#[tokio::test] +async fn concurrent_setup_claims_create_exactly_one_admin() { + let executor = TestExecutor::new().await; + let token = executor.setup_token(); + let first_headers = [(header::ORIGIN.as_str(), ORIGIN)]; + let second_headers = [(header::ORIGIN.as_str(), ORIGIN)]; + let first = send_json( + executor.router(), + Method::POST, + "/api/v1/setup", + json!({ "setupToken": token, "username": "first", "password": PASSWORD }), + &first_headers, + ); + let second = send_json( + executor.router(), + Method::POST, + "/api/v1/setup", + json!({ "setupToken": executor.setup_token(), "username": "second", "password": PASSWORD }), + &second_headers, + ); + let (first_response, second_response) = tokio::join!(first, second); + let mut statuses = [first_response.status(), second_response.status()]; + statuses.sort_by_key(|status| status.as_u16()); + assert_eq!(statuses, [StatusCode::CREATED, StatusCode::CONFLICT]); + + let admin_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM admins") + .fetch_one(executor.app.pool()) + .await + .expect("admin count should be readable"); + assert_eq!(admin_count, 1); +} + +#[tokio::test] +async fn sessions_enforce_cookie_csrf_origin_and_logout() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + + let session_response = send_empty( + executor.router(), + Method::GET, + "/api/v1/session", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(session_response.status(), StatusCode::OK); + + let missing_origin = create_token_request(&executor, &admin, None, Some(&admin.csrf)).await; + assert_error(missing_origin, StatusCode::FORBIDDEN, "invalid_origin").await; + + let wrong_origin = create_token_request( + &executor, + &admin, + Some("https://attacker.invalid"), + Some(&admin.csrf), + ) + .await; + assert_error(wrong_origin, StatusCode::FORBIDDEN, "invalid_origin").await; + + let missing_csrf = create_token_request(&executor, &admin, Some(ORIGIN), None).await; + assert_error(missing_csrf, StatusCode::FORBIDDEN, "invalid_csrf").await; + + let wrong_csrf = + create_token_request(&executor, &admin, Some(ORIGIN), Some("csrf_wrong")).await; + assert_error(wrong_csrf, StatusCode::FORBIDDEN, "invalid_csrf").await; + + let created = create_token_request(&executor, &admin, Some(ORIGIN), Some(&admin.csrf)).await; + assert_eq!(created.status(), StatusCode::CREATED); + + let logout = send_empty( + executor.router(), + Method::DELETE, + "/api/v1/session", + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(logout.status(), StatusCode::NO_CONTENT); + + let after_logout = send_empty( + executor.router(), + Method::GET, + "/api/v1/session", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_error(after_logout, StatusCode::UNAUTHORIZED, "unauthorized").await; +} + +#[tokio::test] +async fn usernames_are_trimmed_and_credential_fields_are_bounded() { + let executor = TestExecutor::new().await; + let setup = send_json( + executor.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": executor.setup_token(), + "username": " admin ", + "password": PASSWORD + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(setup.status(), StatusCode::CREATED); + let stored_username = + sqlx::query_scalar::<_, String>("SELECT username FROM admins WHERE id = 1") + .fetch_one(executor.app.pool()) + .await + .expect("admin should be stored"); + assert_eq!(stored_username, "admin"); + + let login = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + json!({ "username": " admin ", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(login.status(), StatusCode::OK); + + let oversized_password = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": "x".repeat(1025) }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_error( + oversized_password, + StatusCode::BAD_REQUEST, + "invalid_password", + ) + .await; + + let oversized_username = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "x".repeat(65), "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_error( + oversized_username, + StatusCode::BAD_REQUEST, + "invalid_username", + ) + .await; +} + +#[tokio::test] +async fn https_public_origins_produce_secure_session_cookies() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open( + AppConfig::new(directory.path().to_path_buf()) + .with_origin("https://executor.example.com") + .expect("HTTPS origin should be valid"), + ) + .await + .expect("test Executor should open"); + let setup = send_json( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": app.setup_token().expect("setup token should exist"), + "username": "admin", + "password": PASSWORD + }), + &[(header::ORIGIN.as_str(), "https://executor.example.com")], + ) + .await; + assert_eq!(setup.status(), StatusCode::CREATED); + let login = send_json( + app.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), "https://executor.example.com")], + ) + .await; + assert_eq!(login.status(), StatusCode::OK); + assert!( + login + .headers() + .get_all(header::SET_COOKIE) + .iter() + .all(|value| { + value + .to_str() + .expect("set-cookie should be text") + .contains("Secure") + }) + ); +} + +#[tokio::test] +async fn password_work_is_non_waiting_and_bounded() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let headers = [(header::ORIGIN.as_str(), ORIGIN)]; + let login_body = json!({ "username": "missing", "password": PASSWORD }); + let first = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + login_body.clone(), + &headers, + ); + let second = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + login_body.clone(), + &headers, + ); + let third = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + login_body.clone(), + &headers, + ); + let fourth = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + login_body, + &headers, + ); + let responses = tokio::join!(first, second, third, fourth); + let statuses = [ + responses.0.status(), + responses.1.status(), + responses.2.status(), + responses.3.status(), + ]; + assert!(statuses.contains(&StatusCode::TOO_MANY_REQUESTS)); + assert!(statuses.iter().all(|status| { + matches!( + *status, + StatusCode::UNAUTHORIZED | StatusCode::TOO_MANY_REQUESTS + ) + })); +} + +#[tokio::test] +async fn login_rate_limit_uses_peer_ip_and_ignores_forwarded_headers() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let first_peer_ip = "192.0.2.10"; + + for attempt in 0..5 { + let peer: SocketAddr = format!("{first_peer_ip}:{}", 4100 + attempt) + .parse() + .expect("peer address should parse"); + let forwarded = format!("203.0.113.{}", attempt + 1); + let response = send_json_from( + executor.router(), + peer, + Method::POST, + "/api/v1/session", + json!({ "username": format!("missing-{attempt}"), "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", &forwarded), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let limited_peer: SocketAddr = format!("{first_peer_ip}:4200") + .parse() + .expect("peer address should parse"); + let limited = send_json_from( + executor.router(), + limited_peer, + Method::POST, + "/api/v1/session", + json!({ "username": "another-missing-user", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "198.51.100.200"), + ], + ) + .await; + let retry_after = limited + .headers() + .get(header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + assert!(retry_after.is_some_and(|seconds| seconds > 0)); + assert_error(limited, StatusCode::TOO_MANY_REQUESTS, "login_rate_limited").await; + + let second_peer: SocketAddr = "192.0.2.11:4100" + .parse() + .expect("peer address should parse"); + let independent = send_json_from( + executor.router(), + second_peer, + Method::POST, + "/api/v1/session", + json!({ "username": "missing", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", first_peer_ip), + ], + ) + .await; + assert_eq!(independent.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn trusted_proxy_separates_clients_and_avoids_proxy_wide_lockout() { + let executor = TestExecutor::with_trusted_proxies(&["10.0.0.0/8"]).await; + executor.setup_admin().await; + let proxy: SocketAddr = "10.20.30.40:443" + .parse() + .expect("proxy address should parse"); + + for attempt in 0..5 { + let response = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": format!("missing-{attempt}"), "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "203.0.113.10"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let limited = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "still-missing", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "203.0.113.10"), + ], + ) + .await; + assert_error(limited, StatusCode::TOO_MANY_REQUESTS, "login_rate_limited").await; + + let independent_client = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "another-client", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "203.0.113.11"), + ], + ) + .await; + assert_eq!(independent_client.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn trusted_proxy_chain_walks_right_to_left_and_stops_at_the_client() { + let executor = TestExecutor::with_trusted_proxies(&["10.0.0.0/8", "192.168.0.0/16"]).await; + executor.setup_admin().await; + let edge_proxy: SocketAddr = "10.0.0.5:443".parse().expect("proxy address should parse"); + + for attempt in 0..5 { + let forwarded = format!("not-an-ip-{}, 203.0.113.20, 192.168.1.10", attempt + 1); + let response = send_json_from( + executor.router(), + edge_proxy, + Method::POST, + "/api/v1/session", + json!({ "username": format!("missing-{attempt}"), "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", &forwarded), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let limited = send_json_from( + executor.router(), + edge_proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "still-missing", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "not-an-ip, 203.0.113.20, 192.168.1.10"), + ], + ) + .await; + assert_error(limited, StatusCode::TOO_MANY_REQUESTS, "login_rate_limited").await; + + let independent_client = send_json_from( + executor.router(), + edge_proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "another-client", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "not-an-ip, 203.0.113.21, 192.168.1.10"), + ], + ) + .await; + assert_eq!(independent_client.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn malformed_forwarded_chains_are_rejected_without_proxy_lockout() { + let executor = TestExecutor::with_trusted_proxies(&["10.0.0.0/8"]).await; + executor.setup_admin().await; + let proxy: SocketAddr = "10.0.0.8:443".parse().expect("proxy address should parse"); + + for attempt in 0..6 { + let malformed = format!("203.0.113.{}, not-an-ip", attempt + 1); + let response = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": format!("missing-{attempt}"), "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", &malformed), + ], + ) + .await; + assert_error(response, StatusCode::UNAUTHORIZED, "unauthorized").await; + } + + let missing = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "missing-header", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_error(missing, StatusCode::UNAUTHORIZED, "unauthorized").await; + + let all_trusted = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "all-trusted", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "10.0.0.9"), + ], + ) + .await; + assert_error(all_trusted, StatusCode::UNAUTHORIZED, "unauthorized").await; + + let valid_client = send_json_from( + executor.router(), + proxy, + Method::POST, + "/api/v1/session", + json!({ "username": "valid-client", "password": PASSWORD }), + &[ + (header::ORIGIN.as_str(), ORIGIN), + ("x-forwarded-for", "203.0.113.100"), + ], + ) + .await; + assert_eq!(valid_client.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn successful_login_clears_the_in_process_rate_limit_window() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let headers = [(header::ORIGIN.as_str(), ORIGIN)]; + + for attempt in 0..4 { + let response = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + json!({ "username": format!("missing-{attempt}"), "password": PASSWORD }), + &headers, + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let success = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": PASSWORD }), + &headers, + ) + .await; + assert_eq!(success.status(), StatusCode::OK); + + let after_success = send_json( + executor.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "missing-again", "password": PASSWORD }), + &headers, + ) + .await; + assert_eq!(after_success.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn oversized_api_bodies_are_rejected_with_the_stable_envelope() { + let executor = TestExecutor::new().await; + let response = send_raw( + executor.router(), + Method::POST, + "/api/v1/setup", + format!("{{\"padding\":\"{}\"}}", "x".repeat(17 * 1024)), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + + assert_error(response, StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large").await; +} + +#[tokio::test] +async fn session_database_failures_are_internal_errors_not_logged_out_states() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + executor.app.pool().close().await; + + let response = send_empty( + executor.router(), + Method::GET, + "/api/v1/session", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_error( + response, + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + ) + .await; +} + +#[tokio::test] +async fn api_tokens_are_revealed_once_revocable_and_gateway_only() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + let created = create_token_request(&executor, &admin, Some(ORIGIN), Some(&admin.csrf)).await; + assert_eq!(created.status(), StatusCode::CREATED); + let created_body = response_json(created).await; + let token = created_body["token"] + .as_str() + .expect("create should reveal the token") + .to_owned(); + let token_id = created_body["id"] + .as_str() + .expect("create should return the ID") + .to_owned(); + + let listed = send_empty( + executor.router(), + Method::GET, + "/api/v1/tokens", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(listed.status(), StatusCode::OK); + let listed_body = response_json(listed).await; + assert_eq!(listed_body["tokens"].as_array().map(Vec::len), Some(1)); + assert!(listed_body.to_string().contains("maskedToken")); + assert!(!listed_body.to_string().contains(&token)); + + let gateway = send_empty( + executor.router(), + Method::GET, + "/api/v1/gateway/whoami", + &[(header::AUTHORIZATION.as_str(), &format!("bEaReR {token}"))], + ) + .await; + assert_eq!(gateway.status(), StatusCode::OK); + let gateway_body = response_json(gateway).await; + assert_eq!(gateway_body["tokenId"], token_id); + let mut last_used = None; + for _ in 0..100 { + last_used = sqlx::query_scalar::<_, Option>( + "SELECT last_used_at FROM api_tokens WHERE id = ?", + ) + .bind(&token_id) + .fetch_one(executor.app.pool()) + .await + .expect("last-used timestamp should be readable"); + if last_used.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(last_used.is_some()); + + let bearer_on_control_plane = send_empty( + executor.router(), + Method::GET, + "/api/v1/tokens", + &[(header::AUTHORIZATION.as_str(), &format!("Bearer {token}"))], + ) + .await; + assert_error( + bearer_on_control_plane, + StatusCode::UNAUTHORIZED, + "unauthorized", + ) + .await; + + let revoke = send_empty( + executor.router(), + Method::DELETE, + &format!("/api/v1/tokens/{token_id}"), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(revoke.status(), StatusCode::NO_CONTENT); + + let revoked_gateway = send_empty( + executor.router(), + Method::GET, + "/api/v1/gateway/whoami", + &[(header::AUTHORIZATION.as_str(), &format!("Bearer {token}"))], + ) + .await; + assert_error(revoked_gateway, StatusCode::UNAUTHORIZED, "unauthorized").await; +} + +#[tokio::test] +async fn gateway_request_logging_does_not_wait_for_the_sqlite_writer() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + let created = create_token_request(&executor, &admin, Some(ORIGIN), Some(&admin.csrf)).await; + assert_eq!(created.status(), StatusCode::CREATED); + let created_body = response_json(created).await; + let token = created_body["token"] + .as_str() + .expect("create should reveal the token") + .to_owned(); + let token_id = created_body["id"] + .as_str() + .expect("create should return the token ID") + .to_owned(); + + let mut writer = executor + .app + .pool() + .acquire() + .await + .expect("test writer connection should be acquired"); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *writer) + .await + .expect("test writer should hold the SQLite write lock"); + + let authorization = format!("Bearer {token}"); + let response = tokio::time::timeout( + std::time::Duration::from_millis(200), + send_json( + executor.router(), + Method::POST, + "/api/v1/gateway/tools/search", + json!({ "query": "nothing" }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ), + ) + .await + .expect("gateway reads must return without waiting for request-log persistence"); + assert_eq!(response.status(), StatusCode::OK); + let request_id = response + .headers() + .get("x-request-id") + .expect("gateway response should have a request ID") + .to_str() + .expect("request ID should be text") + .to_owned(); + + sqlx::query("COMMIT") + .execute(&mut *writer) + .await + .expect("test writer should release the SQLite write lock"); + drop(writer); + + let stored = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + if let Ok(stored) = executor.app.catalog().request_log(&request_id).await { + break stored; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("queued request log should persist after writer contention clears"); + assert_eq!( + stored.actor_api_token_id.as_deref(), + Some(token_id.as_str()) + ); + assert_eq!(stored.surface, RequestSurface::Gateway); + assert_eq!(stored.path_snapshot.as_deref(), Some("tools.search")); + assert_eq!(stored.outcome, RequestOutcome::Succeeded); +} + +#[tokio::test] +async fn token_last_used_retries_after_write_slot_contention() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + + let first_created = + create_token_request(&executor, &admin, Some(ORIGIN), Some(&admin.csrf)).await; + assert_eq!(first_created.status(), StatusCode::CREATED); + let first_body = response_json(first_created).await; + let first_token = first_body["token"] + .as_str() + .expect("first token should be revealed") + .to_owned(); + let first_token_id = first_body["id"] + .as_str() + .expect("first token ID should be returned") + .to_owned(); + + let second_created = + create_token_request(&executor, &admin, Some(ORIGIN), Some(&admin.csrf)).await; + assert_eq!(second_created.status(), StatusCode::CREATED); + let second_body = response_json(second_created).await; + let second_token = second_body["token"] + .as_str() + .expect("second token should be revealed") + .to_owned(); + let second_token_id = second_body["id"] + .as_str() + .expect("second token ID should be returned") + .to_owned(); + + let mut writer = executor + .app + .pool() + .acquire() + .await + .expect("test writer connection should be acquired"); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *writer) + .await + .expect("test writer should hold the SQLite write lock"); + + let first_gateway = send_empty( + executor.router(), + Method::GET, + "/api/v1/gateway/whoami", + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {first_token}"), + )], + ) + .await; + assert_eq!(first_gateway.status(), StatusCode::OK); + + let mut telemetry_writer_started = false; + for _ in 0..100 { + let checked_out_connections = + executor.app.pool().size() as usize - executor.app.pool().num_idle(); + if checked_out_connections >= 2 { + telemetry_writer_started = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(telemetry_writer_started); + + let contended_gateway = send_empty( + executor.router(), + Method::GET, + "/api/v1/gateway/whoami", + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {second_token}"), + )], + ) + .await; + assert_eq!(contended_gateway.status(), StatusCode::OK); + + sqlx::query("COMMIT") + .execute(&mut *writer) + .await + .expect("test writer should release the SQLite write lock"); + drop(writer); + + let mut first_last_used = None; + for _ in 0..100 { + first_last_used = sqlx::query_scalar::<_, Option>( + "SELECT last_used_at FROM api_tokens WHERE id = ?", + ) + .bind(&first_token_id) + .fetch_one(executor.app.pool()) + .await + .expect("first token last-used timestamp should be readable"); + if first_last_used.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(first_last_used.is_some()); + + let retry_gateway = send_empty( + executor.router(), + Method::GET, + "/api/v1/gateway/whoami", + &[( + header::AUTHORIZATION.as_str(), + &format!("Bearer {second_token}"), + )], + ) + .await; + assert_eq!(retry_gateway.status(), StatusCode::OK); + + let mut second_last_used = None; + for _ in 0..100 { + second_last_used = sqlx::query_scalar::<_, Option>( + "SELECT last_used_at FROM api_tokens WHERE id = ?", + ) + .bind(&second_token_id) + .fetch_one(executor.app.pool()) + .await + .expect("second token last-used timestamp should be readable"); + if second_last_used.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(second_last_used.is_some()); +} + +#[tokio::test] +async fn token_names_are_counted_by_characters_and_stored_trimmed() { + let executor = TestExecutor::new().await; + executor.setup_admin().await; + let admin = executor.login().await; + let unicode_name = "é".repeat(80); + let idempotency_key = next_token_idempotency_key(); + let created = send_json( + executor.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": format!(" {unicode_name} ") }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ("idempotency-key", idempotency_key.as_str()), + ], + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let body = response_json(created).await; + assert_eq!(body["name"], unicode_name); +} + +#[tokio::test] +async fn health_bootstrap_and_errors_have_stable_shapes() { + let executor = TestExecutor::new().await; + let health = send_empty(executor.router(), Method::GET, "/healthz", &[]).await; + assert_eq!(health.status(), StatusCode::OK); + assert!(health.headers().contains_key("x-request-id")); + assert_eq!(response_json(health).await, json!({ "status": "ok" })); + + let health_method_mismatch = send_empty(executor.router(), Method::POST, "/healthz", &[]).await; + assert_error( + health_method_mismatch, + StatusCode::METHOD_NOT_ALLOWED, + "method_not_allowed", + ) + .await; + + let before = send_empty(executor.router(), Method::GET, "/api/v1/bootstrap", &[]).await; + assert_eq!( + response_json(before).await, + json!({ "setupRequired": true, "authenticated": false }) + ); + + executor.setup_admin().await; + let admin = executor.login().await; + let after = send_empty( + executor.router(), + Method::GET, + "/api/v1/bootstrap", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!( + response_json(after).await, + json!({ "setupRequired": false, "authenticated": true }) + ); + + let missing = send_empty(executor.router(), Method::GET, "/api/v1/missing", &[]).await; + assert_error(missing, StatusCode::NOT_FOUND, "not_found").await; +} + +#[tokio::test] +async fn catalog_route_rejections_have_stable_error_envelopes() { + let executor = TestExecutor::new().await; + + let method_mismatch = send_empty(executor.router(), Method::POST, "/api/v1/tools", &[]).await; + assert_error( + method_mismatch, + StatusCode::METHOD_NOT_ALLOWED, + "method_not_allowed", + ) + .await; + + executor.setup_admin().await; + let admin = executor.login().await; + let headers = [(header::COOKIE.as_str(), admin.cookie.as_str())]; + + let invalid_query = send_empty( + executor.router(), + Method::GET, + "/api/v1/tools?limit=not-a-number", + &headers, + ) + .await; + assert_error(invalid_query, StatusCode::BAD_REQUEST, "invalid_query").await; + + let invalid_path = send_empty( + executor.router(), + Method::GET, + "/api/v1/tools/%FF", + &headers, + ) + .await; + assert_error(invalid_path, StatusCode::BAD_REQUEST, "invalid_path").await; +} + +async fn create_token_request( + executor: &TestExecutor, + admin: &AdminSession, + origin: Option<&str>, + csrf: Option<&str>, +) -> Response { + let idempotency_key = next_token_idempotency_key(); + let mut headers = vec![(header::COOKIE.as_str(), admin.cookie.as_str())]; + if let Some(origin) = origin { + headers.push((header::ORIGIN.as_str(), origin)); + } + if let Some(csrf) = csrf { + headers.push(("x-executor-csrf", csrf)); + } + headers.push(("idempotency-key", idempotency_key.as_str())); + send_json( + executor.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "Automation" }), + &headers, + ) + .await +} + +fn next_token_idempotency_key() -> String { + format!( + "control-plane-token-{}", + TOKEN_IDEMPOTENCY_SEQUENCE.fetch_add(1, Ordering::Relaxed) + ) +} + +async fn send_json( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> Response { + send_raw(router, method, uri, body.to_string(), headers).await +} + +async fn send_json_from( + router: Router, + peer: SocketAddr, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> Response { + send_raw_with_peer(router, method, uri, body.to_string(), headers, Some(peer)).await +} + +async fn send_raw( + router: Router, + method: Method, + uri: &str, + body: String, + headers: &[(&str, &str)], +) -> Response { + send_raw_with_peer(router, method, uri, body, headers, None).await +} + +async fn send_raw_with_peer( + router: Router, + method: Method, + uri: &str, + body: String, + headers: &[(&str, &str)], + peer: Option, +) -> Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + let mut request = request + .body(Body::from(body)) + .expect("test request should be valid"); + if let Some(peer) = peer { + request.extensions_mut().insert(ConnectInfo(peer)); + } + router.oneshot(request).await.expect("router should answer") +} + +async fn send_empty( + router: Router, + method: Method, + uri: &str, + headers: &[(&str, &str)], +) -> Response { + let mut request = Request::builder().method(method).uri(uri); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::empty()) + .expect("test request should be valid"), + ) + .await + .expect("router should answer") +} + +fn response_cookie_header(response: &Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("set-cookie should be text") + .split(';') + .next() + .expect("set-cookie should contain a value") + }) + .collect::>() + .join("; ") +} + +async fn response_json(response: Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +async fn assert_error(response: Response, status: StatusCode, code: &str) { + assert_eq!(response.status(), status); + assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&header::HeaderValue::from_static("no-store")) + ); + let header_request_id = response + .headers() + .get("x-request-id") + .expect("error should have a request ID") + .to_str() + .expect("request ID should be text") + .to_owned(); + let body = response_json(response).await; + assert_eq!(body["error"]["code"], code); + assert_eq!(body["error"]["requestId"], header_request_id); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| !message.is_empty()) + ); +} diff --git a/tests/crypto.rs b/tests/crypto.rs new file mode 100644 index 000000000..233a98c0a --- /dev/null +++ b/tests/crypto.rs @@ -0,0 +1,61 @@ +use executor::crypto::{CryptoError, Keyring}; + +#[test] +fn xchacha_ciphertext_rejects_tampering_and_wrong_aad() { + let keyring = Keyring::from_master_key([7_u8; 32]).expect("key derivation should succeed"); + let sealed = keyring + .encrypt("oauth-token", "source-1", b"refresh-secret") + .expect("encryption should succeed"); + assert_eq!(sealed.first(), Some(&1)); + + let plaintext = keyring + .decrypt("oauth-token", "source-1", &sealed) + .expect("matching AAD should decrypt"); + assert_eq!(plaintext, b"refresh-secret"); + + let mut tampered = sealed.clone(); + let last = tampered.len() - 1; + tampered[last] ^= 0x01; + assert!( + keyring + .decrypt("oauth-token", "source-1", &tampered) + .is_err() + ); + assert!(keyring.decrypt("oauth-token", "source-2", &sealed).is_err()); + assert!(keyring.decrypt("api-key", "source-1", &sealed).is_err()); +} + +#[test] +fn envelope_rejects_unsupported_versions() { + let keyring = Keyring::from_master_key([7_u8; 32]).expect("key derivation should succeed"); + let mut sealed = keyring + .encrypt("credential", "record-1", b"secret") + .expect("encryption should succeed"); + sealed[0] = 99; + + assert!(matches!( + keyring.decrypt("credential", "record-1", &sealed), + Err(CryptoError::UnsupportedEnvelopeVersion(99)) + )); +} + +#[test] +fn structured_aad_has_no_delimiter_collisions() { + let keyring = Keyring::from_master_key([7_u8; 32]).expect("key derivation should succeed"); + let sealed = keyring + .encrypt("a:b", "c", b"secret") + .expect("encryption should succeed"); + + assert!(keyring.decrypt("a", "b:c", &sealed).is_err()); +} + +#[test] +fn a_different_master_key_cannot_decrypt_ciphertext() { + let first = Keyring::from_master_key([1_u8; 32]).expect("key derivation should succeed"); + let second = Keyring::from_master_key([2_u8; 32]).expect("key derivation should succeed"); + let sealed = first + .encrypt("credential", "record-1", b"secret") + .expect("encryption should succeed"); + + assert!(second.decrypt("credential", "record-1", &sealed).is_err()); +} diff --git a/tests/daemon-bootstrap.test.ts b/tests/daemon-bootstrap.test.ts index 949cb45f4..b94e781e7 100644 --- a/tests/daemon-bootstrap.test.ts +++ b/tests/daemon-bootstrap.test.ts @@ -8,7 +8,7 @@ import { chooseDaemonPort, isDevCliEntrypoint, parseDaemonBaseUrl, -} from "../apps/cli/src/daemon"; +} from "../legacy/cli/src/daemon"; describe("daemon bootstrap helpers", () => { it("parses default port when none is provided", () => { @@ -35,8 +35,8 @@ describe("daemon bootstrap helpers", () => { }); it("treats source entrypoints as dev mode but excludes bun embedded paths", () => { - expect(isDevCliEntrypoint("/repo/apps/cli/src/main.ts")).toBe(true); - expect(isDevCliEntrypoint("/repo/apps/cli/src/main.js")).toBe(true); + expect(isDevCliEntrypoint("/repo/legacy/cli/src/main.ts")).toBe(true); + expect(isDevCliEntrypoint("/repo/legacy/cli/src/main.js")).toBe(true); expect(isDevCliEntrypoint("/$bunfs/root/main.js")).toBe(false); expect(isDevCliEntrypoint("/usr/local/bin/executor")).toBe(false); expect(isDevCliEntrypoint(undefined)).toBe(false); @@ -47,14 +47,14 @@ describe("daemon bootstrap helpers", () => { port: 4788, hostname: "localhost", isDevMode: true, - scriptPath: "/repo/apps/cli/src/main.ts", + scriptPath: "/repo/legacy/cli/src/main.ts", executablePath: "/ignored", }); expect(spec.command).toBe("bun"); expect(spec.args).toEqual([ "run", - "/repo/apps/cli/src/main.ts", + "/repo/legacy/cli/src/main.ts", "daemon", "run", "--port", diff --git a/tests/daemon-state.test.ts b/tests/daemon-state.test.ts index 5d0584362..c49c952f1 100644 --- a/tests/daemon-state.test.ts +++ b/tests/daemon-state.test.ts @@ -17,7 +17,7 @@ import { removeDaemonRecord, writeDaemonPointer, writeDaemonRecord, -} from "../apps/cli/src/daemon-state"; +} from "../legacy/cli/src/daemon-state"; const fileSystemError = (method: string, cause: unknown) => PlatformError.systemError({ @@ -79,7 +79,7 @@ describe("daemon state", () => { expect(canonicalDaemonHost("localhost")).toBe("localhost"); expect(canonicalDaemonHost("127.0.0.1")).toBe("localhost"); expect(canonicalDaemonHost("::1")).toBe("localhost"); - expect(canonicalDaemonHost("0.0.0.0")).toBe("localhost"); + expect(canonicalDaemonHost("0.0.0.0")).toBe("0.0.0.0"); expect(canonicalDaemonHost("api.example.com")).toBe("api.example.com"); }); diff --git a/tests/fixtures/warden-zero-match.toml b/tests/fixtures/warden-zero-match.toml new file mode 100644 index 000000000..8df963b9f --- /dev/null +++ b/tests/fixtures/warden-zero-match.toml @@ -0,0 +1,8 @@ +version = 1 + +[defaults] +ignorePaths = [] + +[[skills]] +name = "zero-match-fixture" +paths = ["tests/fixtures/does-not-exist/**/*.ts"] diff --git a/tests/fixtures/web-assets/_app/immutable/assets/app.fixture.css b/tests/fixtures/web-assets/_app/immutable/assets/app.fixture.css new file mode 100644 index 000000000..da042281f --- /dev/null +++ b/tests/fixtures/web-assets/_app/immutable/assets/app.fixture.css @@ -0,0 +1,3 @@ +body { + color: #111827; +} diff --git a/tests/fixtures/web-assets/_app/immutable/entry/start.fixture.js b/tests/fixtures/web-assets/_app/immutable/entry/start.fixture.js new file mode 100644 index 000000000..fc985cbea --- /dev/null +++ b/tests/fixtures/web-assets/_app/immutable/entry/start.fixture.js @@ -0,0 +1 @@ +document.documentElement.dataset.executorFixture = "ready"; diff --git a/tests/fixtures/web-assets/index.html b/tests/fixtures/web-assets/index.html new file mode 100644 index 000000000..b014e42e1 --- /dev/null +++ b/tests/fixtures/web-assets/index.html @@ -0,0 +1,15 @@ + + + + + Executor fixture + + + +
Executor embedded web fixture
+ + + + diff --git a/tests/gateway_sources.rs b/tests/gateway_sources.rs new file mode 100644 index 000000000..c813bd7b6 --- /dev/null +++ b/tests/gateway_sources.rs @@ -0,0 +1,309 @@ +use std::collections::BTreeMap; + +use axum::{ + Router, + body::Body, + http::{Method, Request, StatusCode, header}, + response::Response, +}; +use executor::{ + AppConfig, ExecutorApp, + catalog::{ + AuditContext, CatalogSnapshot, CreateSource, SourceKind, StagedTool, StagedToolBinding, + ToolBinding, ToolMode, + }, + graphql::{GraphqlBindingV1, GraphqlOperation}, +}; +use http_body_util::BodyExt; +use serde_json::{Map, Value, json}; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; +const PASSWORD: &str = "correct horse battery staple"; + +struct AdminSession { + cookie: String, + csrf: String, +} + +async fn send( + router: Router, + method: Method, + uri: &str, + body: Body, + headers: &[(&str, &str)], +) -> Response { + let mut request = Request::builder().method(method).uri(uri); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot(request.body(body).expect("request should build")) + .await + .expect("router should answer") +} + +async fn send_json( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> Response { + let mut headers = headers.to_vec(); + headers.push((header::CONTENT_TYPE.as_str(), "application/json")); + send(router, method, uri, Body::from(body.to_string()), &headers).await +} + +async fn response_json(response: Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +fn response_cookies(response: &Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie should be text") + .split(';') + .next() + .expect("cookie should contain a value") + }) + .collect::>() + .join("; ") +} + +async fn setup_admin(app: &ExecutorApp) -> AdminSession { + let response = send_json( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": app.setup_token().expect("fresh app should expose a setup token"), + "username": "admin", + "password": PASSWORD, + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send_json( + app.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": PASSWORD }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = response_cookies(&response); + let csrf = response_json(response).await["csrfToken"] + .as_str() + .expect("login should return a CSRF token") + .to_owned(); + AdminSession { cookie, csrf } +} + +async fn create_gateway_token(app: &ExecutorApp, admin: &AdminSession) -> String { + let response = send_json( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "Gateway source discovery" }), + &[ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ("idempotency-key", "gateway-sources-token"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + response_json(response).await["token"] + .as_str() + .expect("token should be revealed once") + .to_owned() +} + +fn staged_tool(stable_key: &str, mode: ToolMode) -> StagedTool { + StagedTool { + stable_key: format!("graphql:v1:query:{stable_key}"), + preferred_name: stable_key.to_owned(), + display_name: stable_key.to_owned(), + description: None, + input_schema: json!({ "type": "object" }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: mode, + } +} + +async fn create_source( + app: &ExecutorApp, + slug: &str, + display_name: &str, + description: Option<&str>, + modes: &[ToolMode], +) { + let source = app + .catalog() + .create_source( + CreateSource { + kind: SourceKind::Graphql, + preferred_slug: slug.to_owned(), + display_name: display_name.to_owned(), + description: description.map(str::to_owned), + configuration: Map::new(), + }, + AuditContext::system(None), + ) + .await + .expect("source should be created"); + let tools = modes + .iter() + .enumerate() + .map(|(index, mode)| staged_tool(&format!("tool_{index}"), *mode)) + .collect::>(); + let bindings = tools + .iter() + .map(|tool| StagedToolBinding { + stable_key: tool.stable_key.clone(), + binding: ToolBinding::GraphqlV1(GraphqlBindingV1 { + version: 1, + operation: GraphqlOperation::Query, + field_name: tool.preferred_name.clone(), + operation_name: "ExecutorOperation".to_owned(), + variables: Vec::new(), + selection: Vec::new(), + document: format!( + "query ExecutorOperation {{ result: {} }}", + tool.preferred_name + ), + }), + }) + .collect(); + app.catalog() + .sync_catalog_with_bindings( + &source.id, + CatalogSnapshot { + expected_source_revision: source.revision, + expected_credential_revision: None, + artifacts: Vec::new(), + tools, + }, + bindings, + AuditContext::system(None), + ) + .await + .expect("catalog should be synchronized"); +} + +#[tokio::test] +async fn gateway_sources_returns_only_the_exact_public_dto_for_callable_sources() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup_admin(&app).await; + let token = create_gateway_token(&app, &admin).await; + + create_source( + &app, + "alpha", + "Alpha API", + Some("Enabled and approval-gated operations"), + &[ToolMode::Enabled, ToolMode::Ask, ToolMode::Disabled], + ) + .await; + create_source( + &app, + "disabled_only", + "Disabled API", + Some("Must not be discoverable"), + &[ToolMode::Disabled, ToolMode::Disabled], + ) + .await; + create_source(&app, "zulu", "Zulu API", None, &[ToolMode::Ask]).await; + + let authorization = format!("Bearer {token}"); + let response = send( + app.router(), + Method::GET, + "/api/v1/gateway/sources", + Body::empty(), + &[(header::AUTHORIZATION.as_str(), authorization.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response_json(response).await, + json!({ + "sources": [ + { + "slug": "alpha", + "displayName": "Alpha API", + "description": "Enabled and approval-gated operations", + "kind": "graphql", + "toolCount": 2, + }, + { + "slug": "zulu", + "displayName": "Zulu API", + "description": null, + "kind": "graphql", + "toolCount": 1, + }, + ], + }) + ); + + app.shutdown().await; +} + +#[tokio::test] +async fn gateway_sources_rejects_missing_authentication_before_catalog_work() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + app.pool().close().await; + + let response = send( + app.router(), + Method::GET, + "/api/v1/gateway/sources", + Body::empty(), + &[], + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let request_id = response + .headers() + .get("x-request-id") + .expect("authentication error should have a request ID") + .to_str() + .expect("request ID should be text") + .to_owned(); + assert_eq!( + response_json(response).await, + json!({ + "error": { + "code": "unauthorized", + "message": "A valid Executor API token is required.", + "requestId": request_id, + }, + }) + ); +} diff --git a/tests/graphql_api.rs b/tests/graphql_api.rs new file mode 100644 index 000000000..03f5cea77 --- /dev/null +++ b/tests/graphql_api.rs @@ -0,0 +1,242 @@ +use axum::{ + Json, Router, + body::Body, + http::{Method, Request, StatusCode, header}, + routing::post, +}; +use executor::{AppConfig, ExecutorApp}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tokio::net::TcpListener; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; + +struct Admin { + cookie: String, + csrf: String, +} + +#[tokio::test] +async fn graphql_create_redacts_the_locator_and_query_tools_invoke() { + let upstream = TcpListener::bind("127.0.0.1:0") + .await + .expect("GraphQL upstream binds"); + let address = upstream.local_addr().expect("upstream address reads"); + let upstream_router = Router::new().route( + "/secret-path/graphql", + post(|Json(request): Json| async move { + if request["query"] + .as_str() + .is_some_and(|query| query.contains("__schema")) + { + Json(introspection()) + } else { + Json(json!({ "data": { "result": "hello" } })) + } + }), + ); + let upstream_task = tokio::spawn(async move { + axum::serve(upstream, upstream_router) + .await + .expect("GraphQL upstream serves"); + }); + + let directory = tempfile::tempdir().expect("temporary directory is created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor opens"); + let admin = setup(&app).await; + let endpoint = format!("http://{address}/secret-path/graphql?token=query-secret"); + let response = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "graphql", + "displayName": "GraphQL test", + "preferredSlug": "graphql-test", + "endpoint": endpoint, + "allowPrivateNetwork": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let source = body(response).await; + assert_eq!( + source["configuration"]["endpoint"], + format!("http://{address}/") + ); + let encoded_source = source.to_string(); + assert!(!encoded_source.contains("secret-path")); + assert!(!encoded_source.contains("query-secret")); + let source_slug = source["slug"] + .as_str() + .expect("created source has a slug") + .to_owned(); + + let tools = app + .catalog() + .list_tools(Default::default()) + .await + .expect("GraphQL tools list"); + assert_eq!(tools.items.len(), 1); + assert_eq!(tools.items[0].stable_key, "graphql:v1:query:hello"); + + let token_response = send( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "GraphQL test" }), + &[ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ("idempotency-key", "graphql-api-token"), + ], + ) + .await; + let token = body(token_response).await["token"] + .as_str() + .expect("API token is returned") + .to_owned(); + let authorization = format!("Bearer {token}"); + let invoked = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": format!("{source_slug}.hello"), "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), authorization.as_str())], + ) + .await; + assert_eq!(invoked.status(), StatusCode::OK); + let invoked = body(invoked).await; + assert_eq!(invoked["ok"], true); + assert_eq!(invoked["data"], "hello"); + + app.shutdown().await; + upstream_task.abort(); +} + +fn introspection() -> Value { + json!({ + "data": { + "__schema": { + "queryType": { "name": "Query" }, + "mutationType": null, + "subscriptionType": null, + "types": [ + { + "kind": "SCALAR", + "name": "String", + "description": null, + "fields": null, + "inputFields": null, + "enumValues": null + }, + { + "kind": "OBJECT", + "name": "Query", + "description": null, + "inputFields": null, + "enumValues": null, + "fields": [{ + "name": "hello", + "description": "Say hello", + "isDeprecated": false, + "args": [], + "type": { "kind": "SCALAR", "name": "String", "ofType": null } + }] + } + ] + } + } + }) +} + +async fn setup(app: &ExecutorApp) -> Admin { + let setup_token = app.setup_token().expect("fresh instance has a setup token"); + let response = send( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": setup_token, + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = send( + app.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": "correct horse battery staple" }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + let cookie = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie is text") + .split(';') + .next() + .expect("cookie has a value") + }) + .collect::>() + .join("; "); + let csrf = body(response).await["csrfToken"] + .as_str() + .expect("login returns CSRF") + .to_owned(); + Admin { cookie, csrf } +} + +fn admin_headers(admin: &Admin) -> [(&str, &str); 3] { + [ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ] +} + +async fn send( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request builds"), + ) + .await + .expect("router answers") +} + +async fn body(response: axum::response::Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response collects") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response contains JSON") +} diff --git a/tests/invocation_preparation.rs b/tests/invocation_preparation.rs new file mode 100644 index 000000000..110f40d27 --- /dev/null +++ b/tests/invocation_preparation.rs @@ -0,0 +1,273 @@ +use std::collections::BTreeMap; + +use executor::{ + AppConfig, ExecutorApp, + catalog::{ + ArtifactKind, AuditContext, CreateSource, CredentialPayload, InitialCatalogSnapshot, + SourceKind, StagedArtifact, StagedTool, StagedToolBinding, ToolBinding, ToolMode, + }, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, +}; +use serde_json::{Map, json}; + +async fn imported_source() -> (tempfile::TempDir, ExecutorApp, String, String) { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let (source, _) = app + .catalog() + .create_source_with_catalog( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: "weather".to_owned(), + display_name: "Weather".to_owned(), + description: None, + configuration: Map::from_iter([( + "displayUrl".to_owned(), + json!("https://weather.example.test/openapi.json"), + )]), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({ "credentials": { "schemes": {} } }), + }, + InitialCatalogSnapshot { + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![StagedTool { + stable_key: "get-weather".to_owned(), + preferred_name: "getWeather".to_owned(), + display_name: "Get weather".to_owned(), + description: None, + input_schema: json!({ + "type": "object", + "additionalProperties": false, + "properties": { "query": { "type": "object" } } + }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: ToolMode::Enabled, + }], + }, + vec![StagedToolBinding { + stable_key: "get-weather".to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: "GET".to_owned(), + path_template: "/weather".to_owned(), + server_url: "https://weather.example.test".to_owned(), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + }], + AuditContext::system(Some("initial-import")), + ) + .await + .expect("source should import atomically"); + let tool = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tool should list") + .items + .pop() + .expect("tool should exist"); + (directory, app, source.id, tool.id) +} + +#[tokio::test] +async fn invocation_preparation_is_one_typed_catalog_snapshot() { + let (_directory, app, source_id, tool_id) = imported_source().await; + let lease = app + .catalog() + .prepare_invocation("weather.get_weather") + .await + .expect("invocation should prepare"); + + assert_eq!(lease.lookup().source_id, source_id); + assert_eq!(lease.lookup().tool_id, tool_id); + assert_eq!(lease.source_kind(), SourceKind::Openapi); + assert_eq!(lease.lookup().effective_mode, ToolMode::Enabled); + assert!(!lease.lookup().requires_approval); + assert_eq!(lease.revisions().source_revision, 1); + assert_eq!(lease.revisions().catalog_revision, 1); + assert_eq!(lease.revisions().tool_revision, 0); + assert_eq!(lease.revisions().binding_revision, 0); + assert_eq!(lease.revisions().credential_revision, Some(0)); + assert_eq!(lease.input_schema()["additionalProperties"], false); + assert!(lease.arguments_are_valid(&json!({ "query": {} }))); + assert!(!lease.arguments_are_valid(&json!({ "unknown": true }))); + assert_eq!( + lease.source_configuration()["displayUrl"], + "https://weather.example.test/openapi.json" + ); + assert_eq!( + lease + .credential() + .expect("credential snapshot should exist") + .credential + .payload["credentials"]["schemes"], + json!({}) + ); + assert_eq!( + lease + .binding() + .openapi() + .expect("binding should be OpenAPI") + .method, + "GET" + ); +} + +#[tokio::test] +async fn invocation_source_kind_corruption_fails_closed_for_prepare_and_revalidation() { + let (_directory, app, source_id, _tool_id) = imported_source().await; + let lease = app + .catalog() + .prepare_invocation("weather.get_weather") + .await + .expect("invocation should prepare before corruption"); + let token = lease.revisions().clone(); + drop(lease); + + let mut connection = app + .pool() + .acquire() + .await + .expect("database connection should be acquired"); + sqlx::query("PRAGMA ignore_check_constraints = ON") + .execute(&mut *connection) + .await + .expect("test should temporarily permit corrupt data"); + sqlx::query("UPDATE sources SET kind = 'corrupt' WHERE id = ?") + .bind(source_id) + .execute(&mut *connection) + .await + .expect("source kind should be corrupted for the test"); + sqlx::query("PRAGMA ignore_check_constraints = OFF") + .execute(&mut *connection) + .await + .expect("source kind constraint should be restored"); + drop(connection); + + let prepare_error = match app + .catalog() + .prepare_invocation("weather.get_weather") + .await + { + Err(error) => error, + Ok(_) => panic!("fresh preparation must reject an unknown source kind"), + }; + assert!(matches!( + prepare_error, + executor::catalog::CatalogError::CorruptData("unknown source kind") + )); + + let revalidation_error = match app.catalog().revalidate_invocation(&token).await { + Err(error) => error, + Ok(_) => panic!("revalidation must reject an unknown source kind"), + }; + assert!(matches!( + revalidation_error, + executor::catalog::CatalogError::CorruptData("unknown source kind") + )); +} + +#[tokio::test] +async fn invocation_lease_blocks_mutation_and_old_token_revalidation_fails_after_release() { + let (_directory, app, _source_id, tool_id) = imported_source().await; + let lease = app + .catalog() + .prepare_invocation("tools.weather.get_weather") + .await + .expect("invocation should prepare"); + let token = lease.revisions().clone(); + let catalog = app.catalog().clone(); + let mutation = tokio::spawn(async move { + catalog + .set_tool_mode( + &tool_id, + Some(ToolMode::Ask), + token.tool_revision, + AuditContext::system(Some("mode-during-invocation")), + ) + .await + }); + + tokio::task::yield_now().await; + assert!(!mutation.is_finished()); + let token = lease.revisions().clone(); + drop(lease); + let changed = tokio::time::timeout(std::time::Duration::from_secs(2), mutation) + .await + .expect("writer should resume after lease release") + .expect("writer task should not panic") + .expect("mode change should succeed"); + assert_eq!(changed.effective_mode.mode, ToolMode::Ask); + + assert!( + app.catalog() + .revalidate_invocation(&token) + .await + .expect("revalidation should read") + .is_none() + ); + let fresh = app + .catalog() + .prepare_invocation("weather.get_weather") + .await + .expect("fresh invocation should prepare"); + assert!(fresh.lookup().requires_approval); + assert!(fresh.revisions().tool_revision > token.tool_revision); + assert!(fresh.revisions().source_revision > token.source_revision); +} + +#[tokio::test] +async fn ask_preflight_does_not_decrypt_source_credentials() { + let (_directory, app, _source_id, tool_id) = imported_source().await; + let tool = app + .catalog() + .tool(&tool_id) + .await + .expect("tool should read"); + app.catalog() + .set_tool_mode( + &tool_id, + Some(ToolMode::Ask), + tool.revision, + AuditContext::system(Some("ask-preflight")), + ) + .await + .expect("tool should become ask mode"); + sqlx::query("UPDATE source_credentials SET payload_ciphertext = x'01020304'") + .execute(app.pool()) + .await + .expect("credential ciphertext should be corrupted for the test"); + + let preflight = app + .catalog() + .preflight_invocation("weather.get_weather") + .await + .expect("ask preflight must not decrypt credentials"); + assert!(preflight.lookup().requires_approval); + assert!(preflight.arguments_are_valid(&json!({ "query": {} }))); + + let result = app + .catalog() + .revalidate_invocation(preflight.revisions()) + .await; + let error = match result { + Err(error) => error, + Ok(_) => panic!("credential decryption is deferred until approved execution"), + }; + assert!(matches!(error, executor::catalog::CatalogError::Crypto(_))); +} diff --git a/tests/oauth_api.rs b/tests/oauth_api.rs new file mode 100644 index 000000000..f09e4ff14 --- /dev/null +++ b/tests/oauth_api.rs @@ -0,0 +1,895 @@ +use std::{ + process::Stdio, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use axum::{ + Json, Router, + body::Body, + http::{Method, Request, Response, StatusCode, header}, + routing::{get, post}, +}; +use executor::{AppConfig, ExecutorApp}; +use http_body_util::BodyExt as _; +use reqwest::{Client, redirect::Policy}; +use serde_json::{Value, json}; +use tokio::{net::TcpListener, process::Command, time::sleep}; +use tower::ServiceExt as _; +use url::Url; + +const ORIGIN: &str = "http://127.0.0.1:4788"; + +struct TestExecutor { + app: ExecutorApp, + setup_token: String, +} + +struct AdminSession { + cookie: String, + csrf: String, +} + +struct OAuthTestProvider { + issuer: String, + client: Client, + child: tokio::process::Child, +} + +struct MalformedTokenProvider { + issuer: String, + token_requests: Arc, + task: tokio::task::JoinHandle<()>, +} + +impl OAuthTestProvider { + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test port reserves"); + let port = listener.local_addr().expect("test port resolves").port(); + drop(listener); + + let executable = format!( + "{}/e2e/node_modules/.bin/emulate", + env!("CARGO_MANIFEST_DIR") + ); + let mut child = Command::new(executable) + .args(["start", "--service", "mcp", "--port", &port.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .expect("OAuth emulator starts"); + let client = Client::builder() + .redirect(Policy::none()) + .build() + .expect("test HTTP client builds"); + let metadata_url = + format!("http://127.0.0.1:{port}/.well-known/oauth-authorization-server"); + for _ in 0..200 { + if let Some(status) = child.try_wait().expect("OAuth emulator status reads") { + panic!("OAuth emulator exited before readiness with {status}"); + } + if let Ok(response) = client.get(&metadata_url).send().await + && response.status().is_success() + && let Ok(metadata) = response.json::().await + && let Some(issuer) = metadata["issuer"].as_str() + { + return Self { + issuer: issuer.to_owned(), + client, + child, + }; + } + sleep(Duration::from_millis(25)).await; + } + let _ = child.start_kill(); + panic!("OAuth emulator did not become ready"); + } + + async fn register_client(&self, callback_url: &str) -> String { + let response = self + .client + .post(format!("{}/register", self.issuer)) + .json(&json!({ + "client_name": "Executor OAuth API test", + "redirect_uris": [callback_url], + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" + })) + .send() + .await + .expect("OAuth client registration responds"); + assert_eq!(response.status(), reqwest::StatusCode::CREATED); + response + .json::() + .await + .expect("OAuth registration is JSON")["client_id"] + .as_str() + .expect("OAuth registration returns client ID") + .to_owned() + } + + async fn approve(&self, authorization_url: &str) -> Url { + let authorization_url = Url::parse(authorization_url).expect("authorization URL parses"); + assert_eq!(authorization_url.path(), "/authorize"); + let mut form = authorization_url + .query_pairs() + .into_owned() + .collect::>(); + form.push(("login".to_owned(), "admin".to_owned())); + let response = self + .client + .post(format!("{}/authorize/approve", self.issuer)) + .form(&form) + .send() + .await + .expect("OAuth approval responds"); + assert_eq!(response.status(), reqwest::StatusCode::FOUND); + Url::parse( + response + .headers() + .get(reqwest::header::LOCATION) + .expect("OAuth approval redirects") + .to_str() + .expect("OAuth redirect is text"), + ) + .expect("OAuth callback URL parses") + } +} + +impl Drop for OAuthTestProvider { + fn drop(&mut self) { + let _ = self.child.start_kill(); + } +} + +impl MalformedTokenProvider { + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test OAuth provider binds"); + let issuer = format!( + "http://{}", + listener.local_addr().expect("test provider address reads") + ); + let metadata_issuer = issuer.clone(); + let token_requests = Arc::new(AtomicUsize::new(0)); + let token_request_counter = token_requests.clone(); + let app = Router::new() + .route( + "/.well-known/oauth-authorization-server", + get(move || { + let issuer = metadata_issuer.clone(); + async move { + Json(json!({ + "issuer": issuer.clone(), + "authorization_endpoint": format!("{issuer}/authorize"), + "token_endpoint": format!("{issuer}/token"), + "scopes_supported": ["read"], + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"] + })) + } + }), + ) + .route( + "/token", + post(move || { + let attempt = token_request_counter.fetch_add(1, Ordering::SeqCst); + async move { + if attempt == 0 { + Json(json!({ + "access_token": "malformed-access-token-marker", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read unrequested" + })) + } else { + Json(json!({ + "access_token": "valid-access-token-marker", + "refresh_token": "valid-refresh-token-marker", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read" + })) + } + } + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("test OAuth provider serves"); + }); + Self { + issuer, + token_requests, + task, + } + } +} + +impl Drop for MalformedTokenProvider { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl TestExecutor { + async fn new() -> Self { + let directory = tempfile::tempdir().expect("temporary data directory"); + let path = directory.keep(); + let app = ExecutorApp::open(AppConfig::new(path)) + .await + .expect("Executor opens"); + let setup_token = app + .setup_token() + .expect("fresh Executor has a setup token") + .to_owned(); + Self { app, setup_token } + } + + async fn setup_and_login(&self) -> AdminSession { + let setup = send_json( + self.app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": self.setup_token, + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(setup.status(), StatusCode::CREATED); + + let login = send_json( + self.app.router(), + Method::POST, + "/api/v1/session", + json!({ + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(login.status(), StatusCode::OK); + let cookie = response_cookie_header(&login); + let body = response_json(login).await; + let csrf = body["csrfToken"] + .as_str() + .expect("login returns CSRF token") + .to_owned(); + AdminSession { cookie, csrf } + } + + async fn insert_source(&self) { + self.insert_source_at("https://api.example.test/mcp", false) + .await; + } + + async fn insert_source_at(&self, endpoint: &str, allow_private_network: bool) { + sqlx::query( + "INSERT INTO sources ( + id, kind, slug, display_name, configuration_json, + revision, catalog_revision, created_at, updated_at + ) VALUES (?, 'mcp_http', 'oauth_test', 'OAuth test', ?, 0, 0, 1, 1)", + ) + .bind("source-oauth-test") + .bind( + json!({ + "endpoint": endpoint, + "allowPrivateNetwork": allow_private_network, + "negotiatedProtocolVersion": "2025-11-25" + }) + .to_string(), + ) + .execute(self.app.pool()) + .await + .expect("test source inserts"); + } + + async fn insert_oauth_connection(&self) { + let mut transaction = self.app.pool().begin().await.expect("transaction begins"); + sqlx::query( + "INSERT INTO oauth_connections ( + id, source_id, credential_key, revision, current_config_revision, + current_secret_revision, status, granted_scopes_json, + has_client_secret, has_refresh_token, created_at, updated_at + ) VALUES (?, ?, 'default', 1, 1, NULL, 'pending_authorization', + '[]', 0, 0, 1, 1)", + ) + .bind("oauth-connection-test") + .bind("source-oauth-test") + .execute(&mut *transaction) + .await + .expect("OAuth connection inserts"); + sqlx::query( + "INSERT INTO oauth_connection_config_revisions ( + connection_id, revision, config_json, created_at + ) VALUES (?, 1, ?, 1)", + ) + .bind("oauth-connection-test") + .bind( + json!({ + "issuer": "https://issuer.example.test", + "authorization_endpoint": "https://issuer.example.test/authorize", + "token_endpoint": "https://issuer.example.test/token", + "client_id": "public-client", + "client_authentication": "none", + "token_endpoint_auth_methods_supported": ["none"], + "scopes": ["read"], + "allow_private_network": false + }) + .to_string(), + ) + .execute(&mut *transaction) + .await + .expect("OAuth config inserts"); + transaction.commit().await.expect("transaction commits"); + } +} + +#[tokio::test] +async fn oauth_routes_use_admin_and_csrf_boundaries() { + let executor = TestExecutor::new().await; + let admin = executor.setup_and_login().await; + executor.insert_source().await; + + let unauthorized = send_empty( + executor.app.router(), + Method::GET, + "/api/v1/sources/source-oauth-test/oauth", + &[], + ) + .await; + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + + let listed = send_empty( + executor.app.router(), + Method::GET, + "/api/v1/sources/source-oauth-test/oauth", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(listed.status(), StatusCode::OK); + assert_eq!( + response_json(listed).await, + json!({ + "connections": [], + "availableCredentials": [{ + "credentialKey": "default", + "protocol": "mcp_http", + "requestedScopes": [], + "managedOAuthEligible": true + }] + }) + ); + + let input = json!({ + "expectedRevision": 0, + "discovery": { "type": "issuer", "issuer": "https://issuer.example.test" }, + "client": { "clientId": "public-client", "authentication": "none" }, + "scopes": ["openid"] + }); + let missing_csrf = send_json( + executor.app.router(), + Method::PUT, + "/api/v1/sources/source-oauth-test/oauth/default", + input.clone(), + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(missing_csrf.status(), StatusCode::FORBIDDEN); + + let malformed_issuer = send_json( + executor.app.router(), + Method::PUT, + "/api/v1/sources/source-oauth-test/oauth/default", + json!({ + "expectedRevision": 0, + "discovery": { "type": "issuer", "issuer": "not a valid issuer" }, + "client": { "clientId": "public-client", "authentication": "none" }, + "scopes": [] + }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(malformed_issuer.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_json(malformed_issuer).await["error"]["code"], + "invalid_oauth_url" + ); + + let ineligible = send_json( + executor.app.router(), + Method::PUT, + "/api/v1/sources/source-oauth-test/oauth/not-a-protocol-credential", + input.clone(), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(ineligible.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_json(ineligible).await["error"]["code"], + "oauth_credential_ineligible" + ); + + let ineligible_authorization = send_json( + executor.app.router(), + Method::POST, + "/api/v1/sources/source-oauth-test/oauth/not-a-protocol-credential/authorize", + json!({ "expectedRevision": 0 }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(ineligible_authorization.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_json(ineligible_authorization).await["error"]["code"], + "oauth_credential_ineligible" + ); + + let rejected_scope = send_json( + executor.app.router(), + Method::PUT, + "/api/v1/sources/source-oauth-test/oauth/default", + input, + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(rejected_scope.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_json(rejected_scope).await["error"]["code"], + "oauth_openid_unsupported" + ); + + executor.insert_oauth_connection().await; + let disconnected = send_json( + executor.app.router(), + Method::POST, + "/api/v1/sources/source-oauth-test/oauth/default/disconnect", + json!({ "expectedRevision": 1 }), + &[ + (header::COOKIE.as_str(), &admin.cookie), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", &admin.csrf), + ], + ) + .await; + assert_eq!(disconnected.status(), StatusCode::OK); + let disconnected = response_json(disconnected).await; + assert_eq!(disconnected["credentialKey"], "default"); + assert_eq!(disconnected["managedOAuthEligible"], true); +} + +#[tokio::test] +async fn callback_failures_redirect_without_reflecting_callback_secrets() { + let executor = TestExecutor::new().await; + let response = send_empty( + executor.app.router(), + Method::GET, + "/api/v1/oauth/callback/opaque-connection?state=state-secret&code=code-secret", + &[], + ) + .await; + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let location = response + .headers() + .get(header::LOCATION) + .expect("callback redirects") + .to_str() + .expect("redirect is text"); + assert_eq!( + location, + "http://127.0.0.1:4788/sources?oauth=opaque%2Dconnection&result=failed" + ); + assert!(!location.contains("state-secret")); + assert!(!location.contains("code-secret")); + assert_eq!( + response.headers().get(header::REFERRER_POLICY), + Some(&header::HeaderValue::from_static("no-referrer")) + ); + assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&header::HeaderValue::from_static("no-store")) + ); +} + +#[tokio::test] +async fn malformed_successful_token_response_terminalizes_the_claim_and_allows_retry() { + let provider = MalformedTokenProvider::start().await; + let executor = TestExecutor::new().await; + let admin = executor.setup_and_login().await; + executor + .insert_source_at(&format!("{}/mcp", provider.issuer), true) + .await; + let mutation_headers = [ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ]; + let configured = send_json( + executor.app.router(), + Method::PUT, + "/api/v1/sources/source-oauth-test/oauth/default", + json!({ + "expectedRevision": 0, + "discovery": { "type": "issuer", "issuer": provider.issuer.clone() }, + "client": { "clientId": "public-client", "authentication": "none" }, + "scopes": ["read"] + }), + &mutation_headers, + ) + .await; + assert_eq!(configured.status(), StatusCode::OK); + let configured = response_json(configured).await; + let connection_id = configured["id"] + .as_str() + .expect("configured connection has ID") + .to_owned(); + + let malformed = begin_and_complete_authorization( + &executor, + &admin, + &connection_id, + configured["revision"] + .as_i64() + .expect("configured connection has revision"), + "first-code", + ) + .await; + assert_eq!(malformed.status(), StatusCode::SEE_OTHER); + let malformed_location = malformed + .headers() + .get(header::LOCATION) + .expect("failed callback redirects") + .to_str() + .expect("failed callback location is text"); + assert!(malformed_location.ends_with("&result=failed")); + assert!(!malformed_location.contains("malformed-access-token-marker")); + + let failed = sqlx::query_as::<_, (i64, String, Option, Option)>( + "SELECT revision, status, current_secret_revision, error_code + FROM oauth_connections WHERE id = ?", + ) + .bind(&connection_id) + .fetch_one(executor.app.pool()) + .await + .expect("failed connection state reads"); + assert_eq!(failed.1, "pending_authorization"); + assert_eq!(failed.2, None); + assert_eq!(failed.3.as_deref(), Some("oauth_scope_escalation")); + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT status FROM oauth_authorization_transactions + WHERE connection_id = ? ORDER BY created_at DESC LIMIT 1", + ) + .bind(&connection_id) + .fetch_one(executor.app.pool()) + .await + .expect("failed authorization transaction reads"), + "failed" + ); + + let retried = begin_and_complete_authorization( + &executor, + &admin, + &connection_id, + failed.0, + "second-code", + ) + .await; + assert_eq!(retried.status(), StatusCode::SEE_OTHER); + let retry_location = retried + .headers() + .get(header::LOCATION) + .expect("successful callback redirects") + .to_str() + .expect("successful callback location is text"); + assert!(retry_location.ends_with("&result=success_refresh_failed")); + + let active = sqlx::query_as::<_, (String, Option, Option)>( + "SELECT status, current_secret_revision, error_code + FROM oauth_connections WHERE id = ?", + ) + .bind(&connection_id) + .fetch_one(executor.app.pool()) + .await + .expect("retried connection state reads"); + assert_eq!(active.0, "active"); + assert!(active.1.is_some()); + assert_eq!(active.2, None); + assert_eq!(provider.token_requests.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn callback_catalog_refresh_failure_preserves_the_authorized_connection() { + let provider = OAuthTestProvider::start().await; + let executor = TestExecutor::new().await; + let admin = executor.setup_and_login().await; + executor + .insert_source_at(&format!("{}/missing", provider.issuer), true) + .await; + let mutation_headers = [ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ]; + let connection_input = |expected_revision, client_id: &str| { + json!({ + "expectedRevision": expected_revision, + "discovery": { "type": "issuer", "issuer": provider.issuer }, + "client": { "clientId": client_id, "authentication": "none" }, + "scopes": ["repo", "read:user"] + }) + }; + + let placeholder = send_json( + executor.app.router(), + Method::PUT, + "/api/v1/sources/source-oauth-test/oauth/default", + connection_input(0, "pending-dynamic-registration"), + &mutation_headers, + ) + .await; + assert_eq!(placeholder.status(), StatusCode::OK); + let placeholder = response_json(placeholder).await; + let connection_id = placeholder["id"] + .as_str() + .expect("placeholder connection has ID") + .to_owned(); + let callback_url = placeholder["callbackUrl"] + .as_str() + .expect("placeholder connection has callback URL"); + let client_id = provider.register_client(callback_url).await; + + let configured = send_json( + executor.app.router(), + Method::PUT, + "/api/v1/sources/source-oauth-test/oauth/default", + connection_input( + placeholder["revision"] + .as_i64() + .expect("placeholder has revision"), + &client_id, + ), + &mutation_headers, + ) + .await; + assert_eq!(configured.status(), StatusCode::OK); + let configured = response_json(configured).await; + let authorization = send_json( + executor.app.router(), + Method::POST, + "/api/v1/sources/source-oauth-test/oauth/default/authorize", + json!({ + "expectedRevision": configured["revision"] + .as_i64() + .expect("configured connection has revision") + }), + &mutation_headers, + ) + .await; + assert_eq!(authorization.status(), StatusCode::OK); + let authorization = response_json(authorization).await; + let provider_callback = provider + .approve( + authorization["authorizationUrl"] + .as_str() + .expect("authorization URL returns"), + ) + .await; + assert_eq!(provider_callback.origin().ascii_serialization(), ORIGIN); + let callback_uri = provider_callback.query().map_or_else( + || provider_callback.path().to_owned(), + |query| format!("{}?{query}", provider_callback.path()), + ); + + let callback = send_empty( + executor.app.router(), + Method::GET, + &callback_uri, + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(callback.status(), StatusCode::SEE_OTHER); + assert_eq!( + callback.headers().get(header::REFERRER_POLICY), + Some(&header::HeaderValue::from_static("no-referrer")) + ); + assert_eq!( + callback.headers().get(header::CACHE_CONTROL), + Some(&header::HeaderValue::from_static("no-store")) + ); + let redirect = Url::parse( + callback + .headers() + .get(header::LOCATION) + .expect("callback redirects") + .to_str() + .expect("callback redirect is text"), + ) + .expect("callback redirect parses"); + assert_eq!(redirect.query_pairs().count(), 2); + assert_eq!( + redirect + .query_pairs() + .find(|(key, _)| key == "oauth") + .unwrap() + .1, + connection_id + ); + assert_eq!( + redirect + .query_pairs() + .find(|(key, _)| key == "result") + .unwrap() + .1, + "success_refresh_failed" + ); + assert!(!redirect.as_str().contains("code=")); + assert!(!redirect.as_str().contains("state=")); + + let listed = send_empty( + executor.app.router(), + Method::GET, + "/api/v1/sources/source-oauth-test/oauth", + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(listed.status(), StatusCode::OK); + let listed = response_json(listed).await; + assert_eq!(listed["connections"][0]["id"], connection_id); + assert_eq!(listed["connections"][0]["status"], "connected"); + assert!(listed["connections"][0]["authorizedAt"].is_i64()); + assert_eq!(listed["connections"][0]["errorCode"], Value::Null); + + let persisted = sqlx::query_as::<_, (String, Option, Option)>( + "SELECT status, authorized_at, error_code FROM oauth_connections WHERE id = ?", + ) + .bind(&connection_id) + .fetch_one(executor.app.pool()) + .await + .expect("authorized connection persists"); + assert_eq!(persisted.0, "active"); + assert!(persisted.1.is_some()); + assert_eq!(persisted.2, None); +} + +async fn begin_and_complete_authorization( + executor: &TestExecutor, + admin: &AdminSession, + connection_id: &str, + expected_revision: i64, + code: &str, +) -> Response { + let authorization = send_json( + executor.app.router(), + Method::POST, + "/api/v1/sources/source-oauth-test/oauth/default/authorize", + json!({ "expectedRevision": expected_revision }), + &[ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ], + ) + .await; + assert_eq!(authorization.status(), StatusCode::OK); + let authorization = response_json(authorization).await; + let authorization_url = Url::parse( + authorization["authorizationUrl"] + .as_str() + .expect("authorization URL returns"), + ) + .expect("authorization URL parses"); + let state = authorization_url + .query_pairs() + .find(|(key, _)| key == "state") + .expect("authorization URL has state") + .1 + .into_owned(); + let mut query = url::form_urlencoded::Serializer::new(String::new()); + query.append_pair("state", &state).append_pair("code", code); + let query = query.finish(); + send_empty( + executor.app.router(), + Method::GET, + &format!("/api/v1/oauth/callback/{connection_id}?{query}"), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await +} + +async fn send_json( + router: axum::Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> Response { + send_raw(router, method, uri, Body::from(body.to_string()), headers).await +} + +async fn send_empty( + router: axum::Router, + method: Method, + uri: &str, + headers: &[(&str, &str)], +) -> Response { + send_raw(router, method, uri, Body::empty(), headers).await +} + +async fn send_raw( + router: axum::Router, + method: Method, + uri: &str, + body: Body, + headers: &[(&str, &str)], +) -> Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot(request.body(body).expect("request is valid")) + .await + .expect("router responds") +} + +fn response_cookie_header(response: &Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie is text") + .split(';') + .next() + .expect("cookie has value") + }) + .collect::>() + .join("; ") +} + +async fn response_json(response: Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("body collects") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response is JSON") +} diff --git a/tests/openapi_api.rs b/tests/openapi_api.rs new file mode 100644 index 000000000..6a22b1acc --- /dev/null +++ b/tests/openapi_api.rs @@ -0,0 +1,1156 @@ +use std::net::SocketAddr; + +use axum::{ + Json, Router, + body::Body, + http::{Method, Request, StatusCode, header}, + routing::get, +}; +use executor::catalog::{AuditContext, ToolMode}; +use executor::{AppConfig, ExecutorApp}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tokio::net::TcpListener; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; + +struct Admin { + cookie: String, + csrf: String, +} + +#[tokio::test] +async fn source_import_rejects_an_unsupported_openapi_31_schema_dialect_without_writes() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let specification = json!({ + "openapi": "3.1.0", + "jsonSchemaDialect": "https://example.test/custom-dialect", + "info": { "title": "Unsupported dialect", "version": "1" }, + "paths": { + "/items": { + "get": { + "operationId": "listItems", + "responses": { "200": { "description": "ok" } } + } + } + } + }); + let response = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Unsupported dialect", + "preferredSlug": "unsupported-dialect", + "spec": { "type": "inline", "content": specification.to_string() } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(response).await["error"]["code"], + "invalid_openapi_document" + ); + assert!( + app.catalog() + .list_sources() + .await + .expect("sources should list") + .is_empty() + ); +} + +async fn send( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn send_raw( + router: Router, + method: Method, + uri: &str, + raw_body: String, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(raw_body)) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn body(response: axum::response::Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +fn cookies(response: &axum::response::Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie should be text") + .split(';') + .next() + .expect("cookie should have a value") + }) + .collect::>() + .join("; ") +} + +fn assert_json_content_type(response: &axum::response::Response) { + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json") + ); +} + +async fn setup(app: &ExecutorApp) -> Admin { + let setup_token = app + .setup_token() + .expect("fresh instance should have a setup token"); + let response = send( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": setup_token, + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send( + app.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": "correct horse battery staple" }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = cookies(&response); + let csrf = body(response).await["csrfToken"] + .as_str() + .expect("login should return a CSRF token") + .to_owned(); + Admin { cookie, csrf } +} + +fn admin_headers(admin: &Admin) -> [(&str, &str); 3] { + [ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ] +} + +#[tokio::test] +async fn preview_import_and_invoke_enforce_planes_modes_and_body_limits() { + let upstream = TcpListener::bind("127.0.0.1:0") + .await + .expect("upstream listener should bind"); + let address = upstream.local_addr().expect("upstream address should read"); + let upstream_router = Router::new().route( + "/api/hello", + get(|| async { Json(json!({ "message": "hello" })) }), + ); + let upstream_task = tokio::spawn(async move { + axum::serve(upstream, upstream_router) + .await + .expect("upstream should serve"); + }); + + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let padding = "x".repeat(20_000); + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Local API", "description": padding }, + "servers": [{ "url": format!("http://{address}/api") }], + "security": [{ "ApiKey": [] }, {}], + "components": { + "securitySchemes": { + "ApiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } + } + }, + "paths": { + "/hello": { + "get": { + "operationId": "hello", + "parameters": [{ + "name": "X-Padding", + "in": "header", + "schema": { "type": "string" } + }], + "responses": { "200": { "description": "ok", "content": { "application/json": { "schema": { "type": "object" } } } } } + } + }, + "/write": { + "post": { + "operationId": "write", + "responses": { "204": { "description": "ok" } } + } + } + } + }) + .to_string(); + + let unauthenticated = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ "spec": { "type": "inline", "content": specification } }), + &[], + ) + .await; + assert_eq!(unauthenticated.status(), StatusCode::FORBIDDEN); + + let preview = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ + "spec": { "type": "inline", "content": specification }, + "allowPrivateNetwork": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(preview.status(), StatusCode::OK); + let preview = body(preview).await; + assert_eq!(preview["toolCount"], 2); + assert_eq!(preview["securitySchemes"][0]["name"], "ApiKey"); + assert_eq!(preview["securitySchemes"][0]["credentialType"], "api_key"); + assert_eq!(preview["tools"][0]["security"][0], json!(["ApiKey"])); + + let missing_inline_server = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ + "spec": { + "type": "inline", + "content": json!({ + "openapi": "3.1.0", + "info": { "title": "No server" }, + "paths": { "/x": { "get": { "responses": {} } } } + }).to_string() + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(missing_inline_server.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(missing_inline_server).await["error"]["code"], + "inline_openapi_server_required" + ); + let missing_server_create = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "No server", + "spec": { + "type": "inline", + "content": json!({ + "openapi": "3.1.0", + "info": { "title": "No server" }, + "paths": { "/x": { "get": { "responses": {} } } } + }).to_string() + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(missing_server_create.status(), StatusCode::BAD_REQUEST); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sources") + .fetch_one(app.pool()) + .await + .expect("source count should read"), + 0 + ); + + let created = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Local API", + "preferredSlug": "local", + "spec": { "type": "inline", "content": specification }, + "allowPrivateNetwork": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + + let before_refresh = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list"); + let write_tool = before_refresh + .items + .iter() + .find(|tool| tool.local_name == "write") + .expect("write tool should exist"); + let stable_write_id = write_tool.id.clone(); + app.catalog() + .set_tool_mode( + &stable_write_id, + Some(ToolMode::Ask), + write_tool.revision, + AuditContext::system(Some("test-mode-override")), + ) + .await + .expect("tool override should store"); + let source_id = app + .catalog() + .list_sources() + .await + .expect("sources should list")[0] + .id + .clone(); + let refreshed = send( + app.router(), + Method::POST, + &format!("/api/v1/sources/{source_id}/refresh"), + json!({}), + &admin_headers(&admin), + ) + .await; + assert_eq!(refreshed.status(), StatusCode::OK); + let after_refresh = app + .catalog() + .list_tools(Default::default()) + .await + .expect("refreshed tools should list"); + let write_tool = after_refresh + .items + .iter() + .find(|tool| tool.local_name == "write") + .expect("write tool should survive refresh"); + assert_eq!(write_tool.id, stable_write_id); + assert_eq!(write_tool.mode_override, Some(ToolMode::Ask)); + + let token_response = send( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "test" }), + &[ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ("idempotency-key", "openapi-preview-token"), + ], + ) + .await; + assert_eq!(token_response.status(), StatusCode::CREATED); + let token = body(token_response).await["token"] + .as_str() + .expect("token should be returned") + .to_owned(); + let authorization = format!("Bearer {token}"); + + let invoked = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "local.hello", "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(invoked.status(), StatusCode::OK); + let invoked = body(invoked).await; + assert_eq!(invoked["ok"], true); + assert_eq!(invoked["data"]["message"], "hello"); + + let large_but_allowed = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ + "path": "local.hello", + "arguments": { "headers": { "X-Padding": "x".repeat(20_000) } } + }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(large_but_allowed.status(), StatusCode::OK); + + let over_invoke_cap = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ + "path": "local.hello", + "arguments": { "padding": "x".repeat(8 * 1024 * 1024 + 128 * 1024) } + }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(over_invoke_cap.status(), StatusCode::PAYLOAD_TOO_LARGE); + + let over_import_cap = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ + "spec": { "type": "inline", "content": "x".repeat(16 * 1024 * 1024 + 128 * 1024) } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(over_import_cap.status(), StatusCode::PAYLOAD_TOO_LARGE); + + let over_create_cap = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Too large", + "spec": { "type": "inline", "content": "x".repeat(16 * 1024 * 1024 + 128 * 1024) } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(over_create_cap.status(), StatusCode::PAYLOAD_TOO_LARGE); + + let invalid = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "local.hello", "arguments": [] }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(invalid).await["error"]["code"], + "invalid_tool_arguments" + ); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let logged = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM request_logs WHERE error_code = 'invalid_tool_arguments'", + ) + .fetch_one(app.pool()) + .await + .expect("request logs should read"); + if logged == 1 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("failed invocation should be logged"); + + let ask = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "local.write", "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(ask.status(), StatusCode::ACCEPTED); + let ask = body(ask).await; + assert_eq!(ask["status"], "approval_required"); + assert_eq!(ask["approval"]["status"], "pending"); + + upstream_task.abort(); +} + +#[tokio::test] +async fn source_configuration_never_exposes_a_query_bearing_spec_url() { + let spec_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("spec listener should bind"); + let address: SocketAddr = spec_listener + .local_addr() + .expect("spec address should read"); + let server_url = format!("http://{address}"); + let spec = json!({ + "openapi": "3.0.3", + "info": { "title": "URL API" }, + "servers": [{ "url": server_url }], + "paths": {} + }); + let spec_router = Router::new().route( + "/openapi.json", + get(move || { + let spec = spec.clone(); + async move { Json(spec) } + }), + ); + let spec_task = tokio::spawn(async move { + axum::serve(spec_listener, spec_router) + .await + .expect("spec server should serve"); + }); + + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let secret_url = format!("http://{address}/openapi.json?api_key=plaintext-secret"); + let created = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "URL API", + "spec": { "type": "url", "url": secret_url }, + "allowPrivateNetwork": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let created = body(created).await; + assert!(!created.to_string().contains("plaintext-secret")); + let source_id = created["id"] + .as_str() + .expect("created source should have an ID"); + let metadata = send( + app.router(), + Method::GET, + &format!("/api/v1/sources/{source_id}/credentials"), + json!(null), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(metadata.status(), StatusCode::OK); + let metadata = body(metadata).await; + assert_eq!(metadata["revision"], 0); + assert_eq!(metadata["configuredSchemes"], json!([])); + + let static_secret = "static-secret-never-returned"; + let updated = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "expectedRevision": 0, + "credential": { + "schemes": { + "ApiKey": { "type": "api_key", "value": static_secret } + } + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(updated.status(), StatusCode::OK); + let updated = body(updated).await; + assert_eq!(updated["revision"], 1); + assert_eq!( + updated["configuredSchemes"], + json!([{ "name": "ApiKey", "credentialType": "api_key" }]) + ); + assert!(!updated.to_string().contains(static_secret)); + + let configuration = sqlx::query_scalar::<_, String>("SELECT configuration_json FROM sources") + .fetch_one(app.pool()) + .await + .expect("source config should read"); + assert!(!configuration.contains("plaintext-secret")); + let ciphertext = + sqlx::query_scalar::<_, Vec>("SELECT payload_ciphertext FROM source_credentials") + .fetch_one(app.pool()) + .await + .expect("credential ciphertext should read"); + assert!(!String::from_utf8_lossy(&ciphertext).contains("plaintext-secret")); + assert!(!String::from_utf8_lossy(&ciphertext).contains(static_secret)); + + spec_task.abort(); +} + +#[tokio::test] +async fn large_body_routes_authenticate_before_parsing_json() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let malformed = format!("{{{}", "x".repeat(64 * 1024)); + + for uri in ["/api/v1/sources", "/api/v1/sources/openapi/preview"] { + let response = send_raw(app.router(), Method::POST, uri, malformed.clone(), &[]).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body(response).await["error"]["code"], "invalid_origin"); + } + let response = send_raw( + app.router(), + Method::PUT, + "/api/v1/sources/not-a-source/credentials", + malformed.clone(), + &[], + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body(response).await["error"]["code"], "invalid_origin"); + + let response = send_raw( + app.router(), + Method::POST, + "/api/v1/sources/not-a-source/refresh", + malformed.clone(), + &[], + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body(response).await["error"]["code"], "invalid_origin"); + + let response = send_raw( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + malformed, + &[], + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(body(response).await["error"]["code"], "unauthorized"); +} + +#[tokio::test] +async fn openapi_credentials_reject_unknown_schema_versions() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Credential schema" }, + "servers": [{ "url": "https://example.com" }], + "paths": { + "/hello": { + "get": { + "operationId": "hello", + "responses": { "200": { "description": "ok" } } + } + } + } + }); + let response = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Credential schema", + "preferredSlug": "schema", + "spec": { "type": "inline", "content": specification.to_string() } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let source_id = body(response).await["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + let mut credential = app + .catalog() + .credential(&source_id) + .await + .expect("credential should read") + .expect("credential should exist"); + credential.credential.schema_version = 2; + app.catalog() + .put_credential( + &source_id, + &credential.credential, + Some(credential.revision), + AuditContext::system(Some("unsupported-credential-schema")), + ) + .await + .expect("future credential schema should store opaquely"); + + let response = send( + app.router(), + Method::GET, + &format!("/api/v1/sources/{source_id}/credentials"), + json!(null), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!( + body(response).await["error"]["code"], + "unsupported_credential_schema" + ); + + let replace = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "expectedRevision": 1, + "credential": { "schemes": { + "ApiKey": { "type": "api_key", "value": "must-not-store" } + }} + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(replace.status(), StatusCode::CONFLICT); + assert_eq!( + body(replace).await["error"]["code"], + "unsupported_credential_schema" + ); + + let clear = send( + app.router(), + Method::DELETE, + &format!("/api/v1/sources/{source_id}/credentials?expectedRevision=1"), + json!(null), + &admin_headers(&admin), + ) + .await; + assert_eq!(clear.status(), StatusCode::CONFLICT); + assert_eq!( + body(clear).await["error"]["code"], + "unsupported_credential_schema" + ); + let unchanged = app + .catalog() + .credential(&source_id) + .await + .expect("credential should remain readable") + .expect("credential should remain stored"); + assert_eq!(unchanged.revision, 1); + assert_eq!(unchanged.credential.schema_version, 2); + + let token_response = send( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "credential-schema" }), + &[ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ("idempotency-key", "openapi-credential-schema-token"), + ], + ) + .await; + let token = body(token_response).await["token"] + .as_str() + .expect("token should be returned") + .to_owned(); + let authorization = format!("Bearer {token}"); + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "schema.hello", "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(response).await["error"]["code"], + "unsupported_credential_schema" + ); +} + +#[tokio::test] +async fn source_requests_reject_typos_before_mutating_catalog_or_credentials() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Strict requests" }, + "servers": [{ "url": "https://example.com" }], + "paths": {} + }) + .to_string(); + + let preview_typo = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ + "spec": { "type": "inline", "content": specification }, + "allowPrivateNetwrok": false + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(preview_typo.status(), StatusCode::BAD_REQUEST); + assert_eq!(body(preview_typo).await["error"]["code"], "invalid_json"); + + let spec_typo = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ + "spec": { + "type": "inline", + "content": specification, + "contnet": specification + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(spec_typo.status(), StatusCode::BAD_REQUEST); + assert_eq!(body(spec_typo).await["error"]["code"], "invalid_json"); + + let create_typo = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayNmae": "Strict requests", + "spec": { "type": "inline", "content": specification } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(create_typo.status(), StatusCode::BAD_REQUEST); + assert_eq!(body(create_typo).await["error"]["code"], "invalid_json"); + assert!( + app.catalog() + .list_sources() + .await + .expect("sources should list") + .is_empty() + ); + + let malformed_credential = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Strict requests", + "spec": { "type": "inline", "content": specification }, + "credential": {} + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(malformed_credential.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(malformed_credential).await["error"]["code"], + "invalid_json" + ); + assert!( + app.catalog() + .list_sources() + .await + .expect("sources should list") + .is_empty() + ); + + let created = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Strict requests", + "preferredSlug": "strict", + "spec": { "type": "inline", "content": specification } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let source_id = body(created).await["id"] + .as_str() + .expect("created source should have an ID") + .to_owned(); + + let put_typo = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "expectedRevison": 0, + "credential": { + "schemes": { + "ApiKey": { "type": "api_key", "value": "must-not-store" } + } + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(put_typo.status(), StatusCode::BAD_REQUEST); + assert_eq!(body(put_typo).await["error"]["code"], "invalid_json"); + + let put_missing_revision = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "credential": { + "schemes": { + "ApiKey": { "type": "api_key", "value": "must-not-store" } + } + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(put_missing_revision.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body(put_missing_revision).await["error"]["code"], + "invalid_json" + ); + + let delete_typo = send( + app.router(), + Method::DELETE, + &format!("/api/v1/sources/{source_id}/credentials?expectedRevison=0"), + json!(null), + &admin_headers(&admin), + ) + .await; + assert_eq!(delete_typo.status(), StatusCode::BAD_REQUEST); + assert_json_content_type(&delete_typo); + assert_eq!(body(delete_typo).await["error"]["code"], "invalid_query"); + + let metadata = send( + app.router(), + Method::GET, + &format!("/api/v1/sources/{source_id}/credentials"), + json!(null), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(metadata.status(), StatusCode::OK); + let metadata = body(metadata).await; + assert_eq!(metadata["revision"], 0); + assert_eq!(metadata["configuredSchemes"], json!([])); +} + +#[tokio::test] +async fn credential_delete_queries_use_sanitized_json_errors_and_strict_revisions() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Credential delete", "version": "1" }, + "servers": [{ "url": "https://example.com" }], + "components": { + "securitySchemes": { + "ApiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } + } + }, + "paths": {} + }); + let created = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Credential delete", + "preferredSlug": "credential-delete", + "spec": { "type": "inline", "content": specification.to_string() } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let source_id = body(created).await["id"] + .as_str() + .expect("created source should have an ID") + .to_owned(); + + let configured = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "expectedRevision": 0, + "credential": { + "schemes": { + "ApiKey": { "type": "api_key", "value": "delete-query-secret" } + } + } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(configured.status(), StatusCode::OK); + assert_eq!(body(configured).await["revision"], 1); + + let invalid_queries = [ + ( + "malformed percent encoding", + "expectedRevision=%E0%A4%A", + "%E0%A4%A", + ), + ( + "duplicate source parameter", + "sourceId=duplicate-source-secret&sourceId=other&expectedRevision=1", + "duplicate-source-secret", + ), + ( + "duplicate revision parameter", + "expectedRevision=1&expectedRevision=1", + "expectedRevision", + ), + ( + "unknown parameter", + "expectedRevision=1&unexpected=unknown-query-secret", + "unknown-query-secret", + ), + ( + "missing revision value", + "expectedRevision=", + "expectedRevision", + ), + ("missing revision parameter", "", "expectedRevision"), + ]; + for (case, query, forbidden_fragment) in invalid_queries { + let separator = if query.is_empty() { "" } else { "?" }; + let response = send( + app.router(), + Method::DELETE, + &format!("/api/v1/sources/{source_id}/credentials{separator}{query}"), + json!(null), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{case}"); + assert_json_content_type(&response); + let response = body(response).await; + assert_eq!(response["error"]["code"], "invalid_query", "{case}"); + assert_eq!( + response["error"]["message"], "The query parameters are invalid.", + "{case}" + ); + assert!( + response["error"]["requestId"] + .as_str() + .is_some_and(|request_id| !request_id.is_empty()), + "{case}" + ); + assert!( + !response.to_string().contains(forbidden_fragment), + "{case} must not echo query input" + ); + } + + let unchanged = send( + app.router(), + Method::GET, + &format!("/api/v1/sources/{source_id}/credentials"), + json!(null), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(unchanged.status(), StatusCode::OK); + let unchanged = body(unchanged).await; + assert_eq!(unchanged["revision"], 1); + assert_eq!( + unchanged["configuredSchemes"], + json!([{ "name": "ApiKey", "credentialType": "api_key" }]) + ); + + let conflict = send( + app.router(), + Method::DELETE, + &format!("/api/v1/sources/{source_id}/credentials?expectedRevision=0"), + json!(null), + &admin_headers(&admin), + ) + .await; + assert_eq!(conflict.status(), StatusCode::CONFLICT); + assert_json_content_type(&conflict); + assert_eq!(body(conflict).await["error"]["code"], "revision_conflict"); + + let cleared = send( + app.router(), + Method::DELETE, + &format!("/api/v1/sources/{source_id}/credentials?expectedRevision=1"), + json!(null), + &admin_headers(&admin), + ) + .await; + assert_eq!(cleared.status(), StatusCode::OK); + assert_json_content_type(&cleared); + let cleared = body(cleared).await; + assert_eq!(cleared["revision"], 2); + assert_eq!(cleared["configuredSchemes"], json!([])); + assert!(!cleared.to_string().contains("delete-query-secret")); + app.shutdown().await; +} diff --git a/tests/openapi_atomic_create.rs b/tests/openapi_atomic_create.rs new file mode 100644 index 000000000..76feebfd7 --- /dev/null +++ b/tests/openapi_atomic_create.rs @@ -0,0 +1,220 @@ +use std::collections::BTreeMap; + +use executor::{ + AppConfig, ExecutorApp, + catalog::{ + ArtifactKind, AuditContext, CatalogError, CreateSource, CredentialPayload, + InitialCatalogSnapshot, SourceKind, StagedArtifact, StagedTool, StagedToolBinding, + ToolBinding, ToolMode, + }, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, +}; +use serde_json::{Map, json}; + +fn source_input() -> CreateSource { + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: "Weather API".to_owned(), + display_name: "Weather API".to_owned(), + description: Some("Forecast operations".to_owned()), + configuration: Map::from_iter([( + "displayUrl".to_owned(), + json!("https://weather.example.test/openapi.json"), + )]), + } +} + +fn credential() -> CredentialPayload { + CredentialPayload { + schema_version: 1, + payload: json!({ + "schemes": { + "weatherKey": { + "type": "api_key", + "value": "atomic-secret" + } + } + }), + } +} + +fn snapshot() -> InitialCatalogSnapshot { + InitialCatalogSnapshot { + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![StagedTool { + stable_key: "get:/weather".to_owned(), + preferred_name: "getWeather".to_owned(), + display_name: "Get weather".to_owned(), + description: Some("Read a forecast".to_owned()), + input_schema: json!({ "type": "object" }), + output_schema: Some(json!({ "type": "object" })), + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: ToolMode::Enabled, + }], + } +} + +fn bindings() -> Vec { + vec![StagedToolBinding { + stable_key: "get:/weather".to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: "GET".to_owned(), + path_template: "/weather".to_owned(), + server_url: "https://weather.example.test".to_owned(), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + }] +} + +async fn row_count(app: &ExecutorApp, table: &str) -> i64 { + sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) + .fetch_one(app.pool()) + .await + .expect("row count should read") +} + +#[tokio::test] +async fn imported_source_creation_commits_every_catalog_record_or_nothing() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + sqlx::query( + "CREATE TRIGGER reject_atomic_binding BEFORE INSERT ON tool_bindings \ + BEGIN SELECT RAISE(ABORT, 'reject atomic binding'); END", + ) + .execute(app.pool()) + .await + .expect("failure trigger should install"); + + app.catalog() + .create_source_with_catalog( + source_input(), + &credential(), + snapshot(), + bindings(), + AuditContext::system(Some("atomic-failure")), + ) + .await + .expect_err("a binding write failure should abort creation"); + + for table in [ + "sources", + "source_credentials", + "source_artifacts", + "tools", + "tool_bindings", + "tool_search", + "tool_search_trigram", + "tool_search_short", + "audit_events", + ] { + assert_eq!(row_count(&app, table).await, 0, "{table} must roll back"); + } + assert_eq!(app.catalog().global_revision().await.unwrap(), 0); + + sqlx::query("DROP TRIGGER reject_atomic_binding") + .execute(app.pool()) + .await + .expect("failure trigger should drop"); + let (source, sync) = app + .catalog() + .create_source_with_catalog( + source_input(), + &credential(), + snapshot(), + bindings(), + AuditContext::system(Some("atomic-success")), + ) + .await + .expect("atomic creation should succeed"); + + assert_eq!(source.slug, "weather_api"); + assert_eq!(source.revision, 1); + assert_eq!(source.catalog_revision, 1); + assert_eq!(source.tool_count, 1); + assert_eq!(sync.source_id, source.id); + assert_eq!(sync.global_revision, 1); + assert_eq!(sync.active_tool_count, 1); + assert_eq!( + app.catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist") + .credential + .payload["schemes"]["weatherKey"]["value"], + "atomic-secret" + ); + assert_eq!(row_count(&app, "source_artifacts").await, 1); + assert_eq!(row_count(&app, "tool_bindings").await, 1); + assert_eq!(row_count(&app, "tool_search").await, 1); + assert_eq!(row_count(&app, "tool_search_trigram").await, 1); + assert_eq!(row_count(&app, "tool_search_short").await, 1); + assert_eq!(row_count(&app, "audit_events").await, 3); +} + +#[tokio::test] +async fn malformed_input_schema_is_rejected_before_atomic_creation_writes() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let mut invalid_snapshot = snapshot(); + invalid_snapshot.tools[0].input_schema = json!({ "type": "not-a-json-schema-type" }); + + let error = app + .catalog() + .create_source_with_catalog( + source_input(), + &credential(), + invalid_snapshot, + bindings(), + AuditContext::system(Some("invalid-schema")), + ) + .await + .expect_err("invalid schema should fail before catalog creation"); + assert!(matches!( + error, + CatalogError::Validation { + code: "invalid_tool_input_schema", + .. + } + )); + assert_eq!(row_count(&app, "sources").await, 0); + assert_eq!(row_count(&app, "tools").await, 0); + assert_eq!(app.catalog().global_revision().await.unwrap(), 0); +} + +#[tokio::test] +async fn atomic_import_cannot_shadow_the_sources_builtin() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let mut source = source_input(); + source.preferred_slug = "sources".to_owned(); + let (source, _) = app + .catalog() + .create_source_with_catalog( + source, + &credential(), + snapshot(), + bindings(), + AuditContext::system(Some("reserved-sources-import")), + ) + .await + .expect("reserved source import should allocate a safe suffix"); + assert_eq!(source.slug, "sources_2"); +} diff --git a/tests/openapi_gateway_contract.rs b/tests/openapi_gateway_contract.rs new file mode 100644 index 000000000..b10b89644 --- /dev/null +++ b/tests/openapi_gateway_contract.rs @@ -0,0 +1,1472 @@ +use std::{ + collections::BTreeMap, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use axum::{ + Json, Router, + body::Body, + extract::State, + http::{Method, Request, StatusCode, header}, +}; +use executor::{ + AppConfig, ExecutorApp, + catalog::{AuditContext, ToolMode}, +}; +use http_body_util::BodyExt; +use serde_json::{Map, Value, json}; +use tokio::{ + net::TcpListener, + sync::{Mutex, Notify}, +}; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; +static TOKEN_IDEMPOTENCY_SEQUENCE: AtomicUsize = AtomicUsize::new(1); + +struct Admin { + cookie: String, + csrf: String, +} + +#[derive(Clone, Default)] +struct Recorder { + count: Arc, + requests: Arc>>, +} + +impl Recorder { + fn count(&self) -> usize { + self.count.load(Ordering::SeqCst) + } + + async fn take_last(&self) -> Value { + self.requests + .lock() + .await + .last() + .cloned() + .expect("the upstream should have recorded a request") + } +} + +async fn record_upstream(State(recorder): State, request: Request) -> Json { + recorder.count.fetch_add(1, Ordering::SeqCst); + let method = request.method().to_string(); + let path = request.uri().path().to_owned(); + let query = request.uri().query().unwrap_or_default().to_owned(); + let headers = request + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_owned(), Value::String(value.to_owned()))) + }) + .collect::>(); + let body = request + .into_body() + .collect() + .await + .expect("upstream request body should collect") + .to_bytes(); + let observed = json!({ + "method": method, + "path": path, + "query": query, + "headers": headers, + "body": String::from_utf8_lossy(&body), + }); + recorder.requests.lock().await.push(observed.clone()); + Json(observed) +} + +#[derive(Clone, Default)] +struct BlockingUpstream { + entered: Arc, + release: Arc, +} + +async fn block_upstream(State(state): State) -> Json { + state.entered.notify_one(); + state.release.notified().await; + Json(json!({ "completed": true })) +} + +async fn send( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn response_body(response: axum::response::Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +fn cookies(response: &axum::response::Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie should be text") + .split(';') + .next() + .expect("cookie should have a value") + }) + .collect::>() + .join("; ") +} + +async fn setup(app: &ExecutorApp) -> Admin { + let setup_token = app + .setup_token() + .expect("fresh instance should have a setup token"); + let response = send( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": setup_token, + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send( + app.router(), + Method::POST, + "/api/v1/session", + json!({ "username": "admin", "password": "correct horse battery staple" }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = cookies(&response); + let csrf = response_body(response).await["csrfToken"] + .as_str() + .expect("login should return a CSRF token") + .to_owned(); + Admin { cookie, csrf } +} + +fn admin_headers(admin: &Admin) -> [(&str, &str); 3] { + [ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ] +} + +async fn create_gateway_token(app: &ExecutorApp, admin: &Admin) -> String { + let idempotency_key = format!( + "openapi-contract-token-{}", + TOKEN_IDEMPOTENCY_SEQUENCE.fetch_add(1, Ordering::Relaxed) + ); + let mut headers = admin_headers(admin).to_vec(); + headers.push(("idempotency-key", idempotency_key.as_str())); + let response = send( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "OpenAPI contract" }), + &headers, + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + response_body(response).await["token"] + .as_str() + .expect("token should be returned") + .to_owned() +} + +async fn invoke( + app: &ExecutorApp, + token: &str, + path: &str, + arguments: Value, +) -> (StatusCode, Value) { + let authorization = format!("Bearer {token}"); + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": path, "arguments": arguments }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + let status = response.status(); + (status, response_body(response).await) +} + +async fn invoke_idempotent( + app: &ExecutorApp, + token: &str, + key: &str, + path: &str, + arguments: Value, +) -> (StatusCode, Value, bool) { + let authorization = format!("Bearer {token}"); + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": path, "arguments": arguments }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", key), + ], + ) + .await; + let status = response.status(); + let replayed = response + .headers() + .get("idempotency-replayed") + .is_some_and(|value| value == "true"); + (status, response_body(response).await, replayed) +} + +fn operation(method: &str, operation_id: &str, security: Value) -> Value { + json!({ + method: { + "operationId": operation_id, + "security": security, + "responses": { + "200": { + "description": "recorded", + "content": { + "application/json": { "schema": { "type": "object" } } + } + } + } + } + }) +} + +async fn set_mode(app: &ExecutorApp, local_name: &str, mode: ToolMode) { + let tool = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list") + .items + .into_iter() + .find(|tool| tool.local_name == local_name) + .unwrap_or_else(|| panic!("tool {local_name} should exist")); + app.catalog() + .set_tool_mode( + &tool.id, + Some(mode), + tool.revision, + AuditContext::system(Some("openapi-gateway-contract")), + ) + .await + .expect("tool mode should update"); +} + +async fn wait_for_log(app: &ExecutorApp, path: &str, outcome: &str, error_code: &str) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM request_logs \ + WHERE path_snapshot = ? AND outcome = ? AND error_code = ?", + ) + .bind(path) + .bind(outcome) + .bind(error_code) + .fetch_one(app.pool()) + .await + .expect("request logs should read"); + if count > 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| panic!("request log {path} {outcome} {error_code} should be written")); +} + +#[tokio::test] +async fn production_gateway_honors_openapi_security_arguments_bodies_and_modes() { + let recorder = Recorder::default(); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("upstream listener should bind"); + let address = listener.local_addr().expect("upstream address should read"); + let upstream = Router::new() + .fallback(record_upstream) + .with_state(recorder.clone()); + let upstream_task = tokio::spawn(async move { + axum::serve(listener, upstream) + .await + .expect("upstream should serve"); + }); + + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + + let mut paths = Map::new(); + for (path, operation_id, security) in [ + ("/anonymous", "anonymous", json!([{}])), + ( + "/or-fallback", + "or_fallback", + json!([{ "MissingKey": [] }, {}]), + ), + ("/header", "header_key", json!([{ "HeaderKey": [] }])), + ("/query", "query_key", json!([{ "QueryKey": [] }])), + ("/cookie", "cookie_key", json!([{ "CookieKey": [] }])), + ("/bearer", "bearer", json!([{ "BearerAuth": [] }])), + ("/basic", "basic", json!([{ "BasicAuth": [] }])), + ("/oauth", "oauth", json!([{ "OAuth": ["read"] }])), + ("/no-body", "no_body", json!([{}])), + ("/disabled", "disabled", json!([{}])), + ] { + paths.insert(path.to_owned(), operation("get", operation_id, security)); + } + paths.insert( + "/combined/{id}".to_owned(), + json!({ + "get": { + "operationId": "combined", + "security": [{ "HeaderKey": [], "QueryKey": [] }], + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "q", "in": "query", "schema": { "type": "string" } }, + { "name": "X-Input", "in": "header", "schema": { "type": "string" } }, + { "name": "flavor", "in": "cookie", "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "recorded" } } + } + }), + ); + for (path, operation_id, content) in [ + ("/json", "json_body", "application/json"), + ("/text", "text_body", "text/plain"), + ("/form", "form_body", "application/x-www-form-urlencoded"), + ("/ask", "ask", "application/json"), + ] { + let schema = match content { + "application/x-www-form-urlencoded" => json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "a": { "type": "string" }, + "b": { "type": "integer" } + } + }), + "text/plain" => json!({ "type": "string" }), + _ => json!({ + "type": "object", + "additionalProperties": false, + "required": ["hello", "mode", "nested"], + "properties": { + "hello": { "type": "string", "pattern": "^[a-z]+$" }, + "count": { "type": "integer", "minimum": 1 }, + "mode": { "enum": ["safe", "fast"] }, + "tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "nested": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { "id": { "type": "integer" } } + }, + "choice": { + "oneOf": [ + { "type": "string", "pattern": "^choice-" }, + { "type": "integer", "minimum": 1 } + ] + }, + "maybe": { "type": ["string", "null"] } + } + }), + }; + paths.insert( + path.to_owned(), + json!({ + "post": { + "operationId": operation_id, + "security": [{}], + "requestBody": { + "required": true, + "content": { content: { "schema": schema } } + }, + "responses": { "200": { "description": "recorded" } } + } + }), + ); + } + paths.insert( + "/multi".to_owned(), + json!({ + "post": { + "operationId": "multi_body", + "security": [{}], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["json"], + "properties": { "json": { "type": "boolean" } } + } + }, + "text/plain": { "schema": { "type": "string" } } + } + }, + "responses": { "200": { "description": "recorded" } } + } + }), + ); + for (path, operation_id, header_name) in [ + ( + "/method-override", + "method_override", + "X-HTTP-Method-Override", + ), + ("/http-method", "http_method", "X-HTTP-Method"), + ("/original-method", "original_method", "X-Original-Method"), + ("/forwarded", "forwarded", "X-Forwarded-For"), + ] { + paths.insert( + path.to_owned(), + json!({ + "get": { + "operationId": operation_id, + "security": [{}], + "parameters": [{ + "name": header_name, + "in": "header", + "schema": { "type": "string" } + }], + "responses": { "200": { "description": "recorded" } } + } + }), + ); + } + + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Gateway Contract" }, + "servers": [{ "url": format!("http://{address}") }], + "components": { + "securitySchemes": { + "MissingKey": { "type": "apiKey", "in": "header", "name": "X-Missing" }, + "HeaderKey": { "type": "apiKey", "in": "header", "name": "X-Header-Key" }, + "QueryKey": { "type": "apiKey", "in": "query", "name": "query_key" }, + "CookieKey": { "type": "apiKey", "in": "cookie", "name": "cookie_key" }, + "BearerAuth": { "type": "http", "scheme": "bearer" }, + "BasicAuth": { "type": "http", "scheme": "basic" }, + "OAuth": { + "type": "oauth2", + "flows": { + "clientCredentials": { + "tokenUrl": "https://identity.example.test/token", + "scopes": { "read": "Read access" } + } + } + } + } + }, + "paths": paths + }); + let created = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Gateway Contract", + "preferredSlug": "contract", + "spec": { "type": "inline", "content": specification.to_string() }, + "allowPrivateNetwork": true, + "credential": { + "schemes": { + "HeaderKey": { "type": "api_key", "value": "header-secret" }, + "QueryKey": { "type": "api_key", "value": "query-secret" }, + "CookieKey": { "type": "api_key", "value": "cookie-secret" }, + "BearerAuth": { "type": "bearer", "token": "bearer-secret" }, + "BasicAuth": { "type": "basic", "username": "aladdin", "password": "open-sesame" }, + "OAuth": { "type": "oauth_access_token", "access_token": "oauth-secret" } + } + } + }), + &admin_headers(&admin), + ) + .await; + let created_status = created.status(); + let created_body = response_body(created).await; + assert_eq!(created_status, StatusCode::CREATED, "{created_body}"); + let token = create_gateway_token(&app, &admin).await; + for tool in ["json_body", "text_body", "form_body", "multi_body"] { + set_mode(&app, tool, ToolMode::Enabled).await; + } + set_mode(&app, "disabled", ToolMode::Disabled).await; + + for path in ["anonymous", "or_fallback"] { + let (status, response) = invoke(&app, &token, &format!("contract.{path}"), json!({})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response["ok"], true); + } + + let (status, response) = invoke( + &app, + &token, + "contract.combined", + json!({ + "path": { "id": "a/b" }, + "query": { "q": "hello world" }, + "headers": { "X-Input": "header input" }, + "cookies": { "flavor": "mint chip" } + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + let observed = &response["data"]; + assert_eq!(observed["path"], "/combined/a%2Fb"); + let query = observed["query"].as_str().expect("query should be text"); + assert!(query.contains("q=hello+world")); + assert!(query.contains("query_key=query-secret")); + assert_eq!(observed["headers"]["x-header-key"], "header-secret"); + assert_eq!(observed["headers"]["x-input"], "header input"); + let cookie = observed["headers"]["cookie"] + .as_str() + .expect("cookie should be text"); + assert!(cookie.contains("flavor=mint%20chip")); + + let expected_auth = BTreeMap::from([ + ("header_key", ("x-header-key", "header-secret")), + ("bearer", ("authorization", "Bearer bearer-secret")), + ( + "basic", + ("authorization", "Basic YWxhZGRpbjpvcGVuLXNlc2FtZQ=="), + ), + ("oauth", ("authorization", "Bearer oauth-secret")), + ]); + for (tool, (name, value)) in expected_auth { + let (status, response) = invoke(&app, &token, &format!("contract.{tool}"), json!({})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response["data"]["headers"][name], value); + } + let (status, response) = invoke(&app, &token, "contract.query_key", json!({})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response["data"]["query"], "query_key=query-secret"); + let (status, response) = invoke(&app, &token, "contract.cookie_key", json!({})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + response["data"]["headers"]["cookie"], + "cookie_key=cookie-secret" + ); + + for (tool, content_type, body, expected_body) in [ + ( + "json_body", + "application/json", + json!({ "hello": "world", "mode": "safe", "nested": { "id": 1 } }), + r#"{"hello":"world","mode":"safe","nested":{"id":1}}"#, + ), + ( + "text_body", + "text/plain", + json!("plain words"), + "plain words", + ), + ( + "form_body", + "application/x-www-form-urlencoded", + json!({ "a": "one two", "b": 2 }), + "a=one+two&b=2", + ), + ] { + let (status, response) = invoke( + &app, + &token, + &format!("contract.{tool}"), + json!({ "contentType": content_type, "body": body }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response["data"]["method"], "POST"); + assert_eq!(response["data"]["headers"]["content-type"], content_type); + assert_eq!(response["data"]["body"], expected_body); + } + let (status, response) = invoke( + &app, + &token, + "contract.multi_body", + json!({ "contentType": "text/plain", "body": "plain multi" }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(response["data"]["body"], "plain multi"); + + for (tool, arguments) in [ + ("anonymous", json!({ "surprise": true })), + ("no_body", json!({ "body": { "no": "body" } })), + ("no_body", json!({ "contentType": "application/json" })), + ( + "combined", + json!({ "path": { "id": "ok" }, "query": { "q": 42 } }), + ), + ( + "combined", + json!({ "path": { "id": "ok" }, "query": { "unknown": "value" } }), + ), + ( + "json_body", + json!({ + "contentType": "application/json", + "body": { + "hello": "world", "mode": "safe", "nested": { "id": 1 }, "unknown": true + } + }), + ), + ( + "json_body", + json!({ "body": { "hello": "WORLD", "mode": "safe", "nested": { "id": 1 } } }), + ), + ( + "json_body", + json!({ "body": { "hello": "world", "count": 1.5, "mode": "safe", "nested": { "id": 1 } } }), + ), + ( + "json_body", + json!({ "body": { "hello": "world", "mode": "other", "nested": { "id": 1 } } }), + ), + ( + "json_body", + json!({ "body": { "mode": "safe", "nested": { "id": 1 } } }), + ), + ( + "json_body", + json!({ "body": { "hello": "world", "mode": "safe", "nested": {} } }), + ), + ( + "json_body", + json!({ "body": { "hello": "world", "mode": "safe", "nested": { "id": 1 }, "choice": true } }), + ), + ( + "json_body", + json!({ "body": { "hello": "world", "mode": "safe", "nested": { "id": 1 }, "tags": ["same", "same"] } }), + ), + ( + "multi_body", + json!({ "contentType": "application/json", "body": "valid text, invalid JSON body" }), + ), + ] { + let before = recorder.count(); + let (status, response) = invoke(&app, &token, &format!("contract.{tool}"), arguments).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(response["error"]["code"], "invalid_tool_arguments"); + assert_eq!(recorder.count(), before); + } + wait_for_log( + &app, + "tools.contract.json_body", + "failed", + "invalid_tool_arguments", + ) + .await; + for (tool, header_name) in [ + ("method_override", "X-HTTP-Method-Override"), + ("http_method", "X-HTTP-Method"), + ("original_method", "X-Original-Method"), + ("forwarded", "X-Forwarded-For"), + ] { + let before = recorder.count(); + let (status, response) = invoke( + &app, + &token, + &format!("contract.{tool}"), + json!({ "headers": { header_name: "DELETE" } }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(response["error"]["code"], "forbidden_tool_header"); + assert_eq!(recorder.count(), before); + } + + let before = recorder.count(); + let (status, response) = invoke( + &app, + &token, + "contract.ask", + json!({ "body": { "hello": "write", "mode": "safe", "nested": { "id": 1 } } }), + ) + .await; + assert_eq!(status, StatusCode::ACCEPTED); + assert_eq!(response["status"], "approval_required"); + assert_eq!(response["approval"]["status"], "pending"); + assert_eq!(response["approval"]["path"], "tools.contract.ask"); + assert!(response["approval"]["id"].as_str().is_some()); + assert!(response["approval"]["statusUrl"].as_str().is_some()); + let approval_id = response["approval"]["id"] + .as_str() + .expect("approval ID should be text") + .to_owned(); + assert_eq!(recorder.count(), before); + wait_for_log( + &app, + "tools.contract.ask", + "pending_approval", + "approval_required", + ) + .await; + + let detail_uri = format!("/api/v1/approvals/{approval_id}"); + let response = send( + app.router(), + Method::GET, + &detail_uri, + json!({}), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let detail = response_body(response).await; + assert_eq!(detail["status"], "pending"); + assert_eq!(detail["redactedArguments"]["body"]["hello"], "[redacted]"); + assert!(detail.get("result").is_none()); + + let second_token = create_gateway_token(&app, &admin).await; + let second_authorization = format!("Bearer {second_token}"); + let gateway_uri = format!("/api/v1/gateway/approvals/{approval_id}"); + let response = send( + app.router(), + Method::GET, + &gateway_uri, + json!({}), + &[(header::AUTHORIZATION.as_str(), &second_authorization)], + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = send( + app.router(), + Method::POST, + &format!("/api/v1/approvals/{approval_id}/decision"), + json!({ "decision": "approve", "expectedRevision": 0 }), + &[(header::COOKIE.as_str(), admin.cookie.as_str())], + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = send( + app.router(), + Method::POST, + &format!("/api/v1/approvals/{approval_id}/decision"), + json!({ "decision": "approve", "expectedRevision": 0 }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::ACCEPTED); + let replay = send( + app.router(), + Method::POST, + &format!("/api/v1/approvals/{approval_id}/decision"), + json!({ "decision": "approve", "expectedRevision": 0 }), + &admin_headers(&admin), + ) + .await; + assert!(matches!( + replay.status(), + StatusCode::OK | StatusCode::ACCEPTED + )); + + let authorization = format!("Bearer {token}"); + let mut terminal = None; + for _ in 0..100 { + let response = send( + app.router(), + Method::GET, + &gateway_uri, + json!({}), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + if body["status"] == "succeeded" { + terminal = Some(body); + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let terminal = terminal.expect("approved call should complete in the background"); + assert_eq!(terminal["result"]["ok"], true); + assert_eq!(recorder.count(), before + 1); + + let (status, response) = invoke(&app, &token, "contract.disabled", json!({})).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(response["error"]["code"], "tool_disabled"); + assert_eq!(recorder.count(), before + 1); + wait_for_log(&app, "tools.contract.disabled", "denied", "tool_disabled").await; + + let last = recorder.take_last().await; + assert_eq!(last["path"], "/ask"); + upstream_task.abort(); +} + +#[tokio::test] +async fn invocation_holds_its_catalog_lease_until_the_upstream_request_finishes() { + let blocker = BlockingUpstream::default(); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("upstream listener should bind"); + let address = listener.local_addr().expect("upstream address should read"); + let upstream = Router::new() + .fallback(block_upstream) + .with_state(blocker.clone()); + let upstream_task = tokio::spawn(async move { + axum::serve(listener, upstream) + .await + .expect("upstream should serve"); + }); + + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Lease Contract" }, + "servers": [{ "url": format!("http://{address}") }], + "paths": { + "/wait": { + "get": { + "operationId": "wait", + "security": [{}], + "responses": { "200": { "description": "completed" } } + } + } + } + }); + let created = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Lease Contract", + "preferredSlug": "lease", + "spec": { "type": "inline", "content": specification.to_string() }, + "allowPrivateNetwork": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(created.status(), StatusCode::CREATED); + let token = create_gateway_token(&app, &admin).await; + let tool = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list") + .items + .into_iter() + .find(|tool| tool.local_name == "wait") + .expect("wait tool should exist"); + + let invoke_router = app.router(); + let invoke_token = token.clone(); + let invocation = tokio::spawn(async move { + let authorization = format!("Bearer {invoke_token}"); + let response = send( + invoke_router, + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "lease.wait", "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + let status = response.status(); + (status, response_body(response).await) + }); + blocker.entered.notified().await; + + let catalog = app.catalog().clone(); + let tool_id = tool.id.clone(); + let mut mutation = tokio::spawn(async move { + catalog + .set_tool_mode( + &tool_id, + Some(ToolMode::Disabled), + tool.revision, + AuditContext::system(Some("lease-concurrency-regression")), + ) + .await + }); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut mutation) + .await + .is_err(), + "catalog mutation must wait while outbound invocation owns its lease" + ); + + blocker.release.notify_one(); + let (status, response) = invocation.await.expect("invocation task should complete"); + assert_eq!(status, StatusCode::OK); + assert_eq!(response["data"]["completed"], true); + tokio::time::timeout(Duration::from_secs(2), mutation) + .await + .expect("catalog mutation should resume after invocation") + .expect("catalog mutation task should complete") + .expect("catalog mutation should succeed"); + upstream_task.abort(); +} + +#[tokio::test] +async fn gateway_idempotency_validates_scopes_and_replays_exact_responses() { + let recorder = Recorder::default(); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("upstream listener should bind"); + let address = listener.local_addr().expect("upstream address should read"); + let upstream = Router::new() + .fallback(record_upstream) + .with_state(recorder.clone()); + let upstream_task = tokio::spawn(async move { + axum::serve(listener, upstream) + .await + .expect("upstream should serve"); + }); + + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let token = create_gateway_token(&app, &admin).await; + let authorization = format!("Bearer {token}"); + + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "missing.tool", "arguments": {}, "unexpected": true }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", "unknown-field"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(recorder.count(), 0); + let reserved = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM gateway_invocation_idempotency") + .fetch_one(app.pool()) + .await + .expect("idempotency records should count"); + assert_eq!( + reserved, 0, + "invalid JSON must not reserve an idempotency key" + ); + + for key in ["", "contains space"] { + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "missing.tool", "arguments": {} }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", key), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_body(response).await["error"]["code"], + "invalid_idempotency_key" + ); + } + let oversized_key = "a".repeat(256); + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "missing.tool", "arguments": {} }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", oversized_key.as_str()), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_body(response).await["error"]["code"], + "invalid_idempotency_key" + ); + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "missing.tool", "arguments": {} }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", "duplicate-one"), + ("idempotency-key", "duplicate-two"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_body(response).await["error"]["code"], + "invalid_idempotency_key" + ); + let response = send( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "export default 1" }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", "execute-key"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_body(response).await["error"]["code"], + "idempotency_not_supported" + ); + assert_eq!(recorder.count(), 0); + + let body_schema = json!({ + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { "value": { "type": "string" } } + }); + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": "Idempotency Contract" }, + "servers": [{ "url": format!("http://{address}") }], + "paths": { + "/enabled": { + "post": { + "operationId": "enabled", + "security": [{}], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": body_schema.clone() } } + }, + "responses": { "200": { "description": "recorded" } } + } + }, + "/ask": { + "post": { + "operationId": "ask", + "security": [{}], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": body_schema.clone() } } + }, + "responses": { "200": { "description": "recorded" } } + } + }, + "/other": { + "post": { + "operationId": "other", + "security": [{}], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": body_schema } } + }, + "responses": { "200": { "description": "recorded" } } + } + }, + "/forbidden-prepare": { + "get": { + "operationId": "forbidden_prepare", + "security": [{}], + "parameters": [{ + "name": "X-HTTP-Method-Override", + "in": "header", + "schema": { "type": "string" } + }], + "responses": { "200": { "description": "recorded" } } + } + } + } + }); + let response = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Idempotency Contract", + "preferredSlug": "idempotency", + "spec": { "type": "inline", "content": specification.to_string() }, + "allowPrivateNetwork": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + set_mode(&app, "enabled", ToolMode::Enabled).await; + set_mode(&app, "forbidden_prepare", ToolMode::Enabled).await; + + let ask_arguments = json!({ + "contentType": "application/json", + "body": { "value": "same" } + }); + let (first_status, first_body, first_replayed) = invoke_idempotent( + &app, + &token, + "ask-replay", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(first_status, StatusCode::ACCEPTED); + assert!(!first_replayed); + let (second_status, second_body, second_replayed) = invoke_idempotent( + &app, + &token, + "ask-replay", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(second_status, StatusCode::ACCEPTED); + assert!(second_replayed); + assert_eq!( + second_body, first_body, + "Ask replay must be byte-equivalent JSON" + ); + let approval_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approvals WHERE callable_path_snapshot = 'tools.idempotency.ask'", + ) + .fetch_one(app.pool()) + .await + .expect("approvals should count"); + assert_eq!(approval_count, 1); + assert_eq!(recorder.count(), 0); + + let pruned_approval_id = first_body["approval"]["id"] + .as_str() + .expect("Ask response should include an approval ID"); + let pruned_idempotency_id = sqlx::query_scalar::<_, String>( + "SELECT id FROM gateway_invocation_idempotency WHERE approval_id = ?", + ) + .bind(pruned_approval_id) + .fetch_one(app.pool()) + .await + .expect("Ask approval should be linked to its idempotency result"); + let mut prune_connection = app + .pool() + .acquire() + .await + .expect("approval retention connection should open"); + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&mut *prune_connection) + .await + .expect("approval retention should enforce foreign keys"); + let deleted = sqlx::query("DELETE FROM approvals WHERE id = ?") + .bind(pruned_approval_id) + .execute(&mut *prune_connection) + .await + .expect("approval retention should be reproducible") + .rows_affected(); + drop(prune_connection); + assert_eq!(deleted, 1); + let retained_idempotency = sqlx::query_as::<_, (String, Option)>( + "SELECT state, approval_id FROM gateway_invocation_idempotency WHERE id = ?", + ) + .bind(pruned_idempotency_id) + .fetch_one(app.pool()) + .await + .expect("completed idempotency result should outlive its pruned approval"); + assert_eq!(retained_idempotency.0, "completed"); + assert_eq!(retained_idempotency.1, None); + + let (pruned_status, pruned_body, pruned_replayed) = invoke_idempotent( + &app, + &token, + "ask-replay", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(pruned_status, StatusCode::CONFLICT); + assert_eq!(pruned_body["error"]["code"], "idempotency_outcome_unknown"); + assert!(!pruned_replayed, "a dead Ask response must not be replayed"); + let approval_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approvals WHERE callable_path_snapshot = 'tools.idempotency.ask'", + ) + .fetch_one(app.pool()) + .await + .expect("approvals should count after the retry"); + assert_eq!(approval_count, 0, "the retry must not create an approval"); + assert_eq!(recorder.count(), 0, "the retry must not call upstream"); + + sqlx::query( + "CREATE TRIGGER reject_ask_idempotency_completion \ + BEFORE UPDATE OF state ON gateway_invocation_idempotency \ + WHEN OLD.state = 'reserved' AND NEW.state = 'completed' AND NEW.approval_id IS NOT NULL \ + BEGIN SELECT RAISE(ABORT, 'forced Ask completion failure'); END", + ) + .execute(app.pool()) + .await + .expect("completion failure trigger should install"); + let (failed_status, _, failed_replayed) = invoke_idempotent( + &app, + &token, + "ask-completion-failure", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(failed_status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(!failed_replayed); + sqlx::query("DROP TRIGGER reject_ask_idempotency_completion") + .execute(app.pool()) + .await + .expect("completion failure trigger should be removed"); + let stranded = sqlx::query_as::<_, (String, i64)>( + "SELECT state, (SELECT COUNT(*) FROM approvals \ + WHERE execution_id = 'gateway-idempotency:' || gateway_invocation_idempotency.id) \ + FROM gateway_invocation_idempotency WHERE state = 'indeterminate' \ + ORDER BY sequence DESC LIMIT 1", + ) + .fetch_one(app.pool()) + .await + .expect("failed completion should fail closed with its correlated approval"); + assert_eq!(stranded, ("indeterminate".to_owned(), 1)); + + let (failed_retry_status, failed_retry_body, failed_retry_replayed) = invoke_idempotent( + &app, + &token, + "ask-completion-failure", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(failed_retry_status, StatusCode::CONFLICT); + assert_eq!( + failed_retry_body["error"]["code"], + "idempotency_outcome_unknown" + ); + assert!(!failed_retry_replayed); + let matching_approvals = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approvals WHERE callable_path_snapshot = 'tools.idempotency.ask'", + ) + .fetch_one(app.pool()) + .await + .expect("failed completion approval should count"); + assert_eq!( + matching_approvals, 1, + "failed-closed retry must not duplicate the approval" + ); + assert_eq!( + recorder.count(), + 0, + "failed-closed retry must not call upstream" + ); + + let (status, body, _) = invoke_idempotent( + &app, + &token, + "ask-replay", + "idempotency.other", + ask_arguments.clone(), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["error"]["code"], "idempotency_key_mismatch"); + let (status, body, _) = invoke_idempotent( + &app, + &token, + "ask-replay", + "idempotency.ask", + json!({ + "contentType": "application/json", + "body": { "value": "different" } + }), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["error"]["code"], "idempotency_key_mismatch"); + + let second_token = create_gateway_token(&app, &admin).await; + let (first_owner_status, first_owner, first_owner_replayed) = invoke_idempotent( + &app, + &token, + "shared-across-tokens", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(first_owner_status, StatusCode::ACCEPTED); + assert!(!first_owner_replayed); + let (second_owner_status, second_owner, second_owner_replayed) = invoke_idempotent( + &app, + &second_token, + "shared-across-tokens", + "idempotency.ask", + ask_arguments.clone(), + ) + .await; + assert_eq!(second_owner_status, StatusCode::ACCEPTED); + assert!(!second_owner_replayed); + assert_ne!( + first_owner["approval"]["id"], + second_owner["approval"]["id"] + ); + + let enabled_arguments = json!({ + "contentType": "application/json", + "body": { "value": "enabled" } + }); + let before_prepare_failure = recorder.count(); + let reserved_before_failure = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM gateway_invocation_idempotency WHERE state = 'reserved'", + ) + .fetch_one(app.pool()) + .await + .expect("reserved idempotency records should count"); + let (status, body, replayed) = invoke_idempotent( + &app, + &token, + "released-prepare-failure", + "idempotency.forbidden_prepare", + json!({ "headers": { "X-HTTP-Method-Override": "DELETE" } }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"]["code"], "forbidden_tool_header"); + assert!(!replayed); + assert_eq!(recorder.count(), before_prepare_failure); + let reserved_after_failure = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM gateway_invocation_idempotency WHERE state = 'reserved'", + ) + .fetch_one(app.pool()) + .await + .expect("reserved idempotency records should count"); + assert_eq!(reserved_after_failure, reserved_before_failure); + let (status, _, replayed) = invoke_idempotent( + &app, + &token, + "released-prepare-failure", + "idempotency.enabled", + enabled_arguments.clone(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(!replayed); + assert_eq!(recorder.count(), before_prepare_failure + 1); + + let before_enabled = recorder.count(); + let (status, first_enabled, replayed) = invoke_idempotent( + &app, + &token, + "enabled-replay", + "idempotency.enabled", + enabled_arguments.clone(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(!replayed); + let (status, second_enabled, replayed) = invoke_idempotent( + &app, + &token, + "enabled-replay", + "idempotency.enabled", + enabled_arguments.clone(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(replayed); + assert_eq!(second_enabled, first_enabled); + assert_eq!(recorder.count(), before_enabled + 1); + + let concurrent_before = recorder.count(); + let mut invocations = Vec::new(); + for _ in 0..12 { + let router = app.router(); + let token = token.clone(); + let arguments = enabled_arguments.clone(); + invocations.push(tokio::spawn(async move { + let authorization = format!("Bearer {token}"); + let response = send( + router, + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": "idempotency.enabled", "arguments": arguments }), + &[ + (header::AUTHORIZATION.as_str(), &authorization), + ("idempotency-key", "enabled-concurrent"), + ], + ) + .await; + (response.status(), response_body(response).await) + })); + } + for invocation in invocations { + let (status, body) = invocation.await.expect("invocation task should join"); + assert!( + matches!(status, StatusCode::OK | StatusCode::CONFLICT), + "unexpected concurrent response {status}: {body}" + ); + if status == StatusCode::CONFLICT { + assert_eq!(body["error"]["code"], "idempotency_in_progress"); + } + } + assert_eq!(recorder.count(), concurrent_before + 1); + let (status, _, replayed) = invoke_idempotent( + &app, + &token, + "enabled-concurrent", + "idempotency.enabled", + enabled_arguments, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!(replayed); + assert_eq!(recorder.count(), concurrent_before + 1); + + upstream_task.abort(); +} diff --git a/tests/openapi_lifecycle.rs b/tests/openapi_lifecycle.rs new file mode 100644 index 000000000..5b927610b --- /dev/null +++ b/tests/openapi_lifecycle.rs @@ -0,0 +1,2509 @@ +use std::{ + collections::BTreeMap, + path::Path, + process::Stdio, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use axum::{ + Json, Router, + body::Body, + extract::State, + http::{Method, Request, StatusCode, header}, + response::Redirect, + routing::{any, get}, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use executor::{ + AppConfig, ExecutorApp, + catalog::{AuditContext, ListToolsFilter, SourceHealth, ToolMode}, +}; +use http_body_util::BodyExt; +use reqwest::{Client, redirect::Policy}; +use serde_json::{Value, json}; +use tokio::{net::TcpListener, process::Command, sync::RwLock, time::sleep}; +use tower::ServiceExt; +use url::Url; + +const ORIGIN: &str = "http://127.0.0.1:4788"; +static TOKEN_IDEMPOTENCY_SEQUENCE: AtomicUsize = AtomicUsize::new(1); + +struct Admin { + cookie: String, + csrf: String, +} + +struct Upstream { + address: std::net::SocketAddr, + specification: Arc>, + spec_requests: Arc, + task: tokio::task::JoinHandle<()>, +} + +struct CredentialTarget { + address: std::net::SocketAddr, + requests: Arc>>, + task: tokio::task::JoinHandle<()>, +} + +struct OAuthTestProvider { + issuer: String, + client: Client, + child: tokio::process::Child, +} + +struct SpecRedirect { + address: std::net::SocketAddr, + target: Arc>, + task: tokio::task::JoinHandle<()>, +} + +#[derive(Clone, Debug)] +struct CapturedRequest { + path_and_query: String, + headers: BTreeMap, +} + +#[derive(Clone)] +struct CredentialTargetState { + requests: Arc>>, +} + +#[derive(Clone)] +struct UpstreamState { + specification: Arc>, + spec_requests: Arc, +} + +impl Upstream { + async fn start() -> Self { + Self::start_bound("127.0.0.1:0").await + } + + async fn start_non_loopback() -> Self { + Self::start_bound("0.0.0.0:0").await + } + + async fn start_bound(bind_address: &str) -> Self { + async fn serve_specification(State(state): State) -> Json { + state.spec_requests.fetch_add(1, Ordering::SeqCst); + Json(state.specification.read().await.clone()) + } + + let specification = Arc::new(RwLock::new(json!({}))); + let spec_requests = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind(bind_address) + .await + .expect("upstream listener should bind"); + let address = listener.local_addr().expect("upstream address should read"); + let router = Router::new() + .route("/openapi.json", get(serve_specification)) + .route( + "/api/hello", + get(|| async { Json(json!({ "message": "still callable" })) }), + ) + .with_state(UpstreamState { + specification: Arc::clone(&specification), + spec_requests: Arc::clone(&spec_requests), + }); + let task = tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("upstream should serve"); + }); + Self { + address, + specification, + spec_requests, + task, + } + } + + async fn set_paths(&self, paths: Value, description: &str) { + *self.specification.write().await = json!({ + "openapi": "3.1.0", + "info": { "title": "Lifecycle API", "description": description }, + "servers": [{ "url": format!("http://{}/api", self.address) }], + "paths": paths + }); + } + + fn spec_url(&self, query: Option<&str>) -> String { + match query { + Some(query) => format!("http://{}/openapi.json?{query}", self.address), + None => format!("http://{}/openapi.json", self.address), + } + } + + fn spec_request_count(&self) -> usize { + self.spec_requests.load(Ordering::SeqCst) + } +} + +impl Drop for Upstream { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl CredentialTarget { + async fn start() -> Self { + Self::start_bound("127.0.0.1:0").await + } + + async fn start_non_loopback() -> Self { + Self::start_bound("0.0.0.0:0").await + } + + async fn start_bound(bind_address: &str) -> Self { + async fn capture( + State(state): State, + request: Request, + ) -> Json { + let captured = CapturedRequest { + path_and_query: request + .uri() + .path_and_query() + .map(ToString::to_string) + .unwrap_or_else(|| request.uri().path().to_owned()), + headers: request + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_owned(), value.to_owned())) + }) + .collect(), + }; + state + .requests + .lock() + .expect("credential target ledger locks") + .push(captured); + Json(json!({ "ok": true })) + } + + let requests = Arc::new(Mutex::new(Vec::new())); + let listener = TcpListener::bind(bind_address) + .await + .expect("credential target listener should bind"); + let address = listener + .local_addr() + .expect("credential target address should read"); + let router = Router::new() + .route("/api/credential", any(capture)) + .with_state(CredentialTargetState { + requests: Arc::clone(&requests), + }); + let task = tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("credential target should serve"); + }); + Self { + address, + requests, + task, + } + } + + fn origin(&self) -> String { + format!("http://{}", self.address) + } + + fn requests(&self) -> Vec { + self.requests + .lock() + .expect("credential target ledger locks") + .clone() + } +} + +impl Drop for CredentialTarget { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl OAuthTestProvider { + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test port reserves"); + let port = listener.local_addr().expect("test port resolves").port(); + drop(listener); + let executable = format!( + "{}/e2e/node_modules/.bin/emulate", + env!("CARGO_MANIFEST_DIR") + ); + let mut child = Command::new(executable) + .args(["start", "--service", "mcp", "--port", &port.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .expect("OAuth emulator starts"); + let client = Client::builder() + .redirect(Policy::none()) + .build() + .expect("test HTTP client builds"); + let metadata_url = + format!("http://127.0.0.1:{port}/.well-known/oauth-authorization-server"); + for _ in 0..200 { + if let Some(status) = child.try_wait().expect("OAuth emulator status reads") { + panic!("OAuth emulator exited before readiness with {status}"); + } + if let Ok(response) = client.get(&metadata_url).send().await + && response.status().is_success() + && let Ok(metadata) = response.json::().await + && let Some(issuer) = metadata["issuer"].as_str() + { + return Self { + issuer: issuer.to_owned(), + client, + child, + }; + } + sleep(Duration::from_millis(25)).await; + } + let _ = child.start_kill(); + panic!("OAuth emulator did not become ready"); + } + + async fn register_client(&self, callback_url: &str) -> String { + let response = self + .client + .post(format!("{}/register", self.issuer)) + .json(&json!({ + "client_name": "Executor OpenAPI origin test", + "redirect_uris": [callback_url], + "grant_types": ["authorization_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none" + })) + .send() + .await + .expect("OAuth client registration responds"); + assert_eq!(response.status(), reqwest::StatusCode::CREATED); + response + .json::() + .await + .expect("OAuth registration is JSON")["client_id"] + .as_str() + .expect("OAuth registration returns client ID") + .to_owned() + } + + async fn approve(&self, authorization_url: &str) -> Url { + let authorization_url = Url::parse(authorization_url).expect("authorization URL parses"); + let mut form = authorization_url + .query_pairs() + .into_owned() + .collect::>(); + form.push(("login".to_owned(), "admin".to_owned())); + let response = self + .client + .post(format!("{}/authorize/approve", self.issuer)) + .form(&form) + .send() + .await + .expect("OAuth approval responds"); + assert_eq!(response.status(), reqwest::StatusCode::FOUND); + Url::parse( + response + .headers() + .get(reqwest::header::LOCATION) + .expect("OAuth approval redirects") + .to_str() + .expect("OAuth redirect is text"), + ) + .expect("OAuth callback URL parses") + } +} + +impl Drop for OAuthTestProvider { + fn drop(&mut self) { + let _ = self.child.start_kill(); + } +} + +impl SpecRedirect { + async fn start(target_url: String) -> Self { + async fn redirect(State(target): State>>) -> Redirect { + let target = target.read().await.clone(); + Redirect::temporary(&target) + } + + let target = Arc::new(RwLock::new(target_url)); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("redirect listener should bind"); + let address = listener.local_addr().expect("redirect address should read"); + let router = Router::new() + .route("/openapi.json", get(redirect)) + .with_state(Arc::clone(&target)); + let task = tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("redirect source should serve"); + }); + Self { + address, + target, + task, + } + } + + fn url(&self) -> String { + format!("http://{}/openapi.json", self.address) + } + + async fn set_target(&self, target_url: String) { + *self.target.write().await = target_url; + } +} + +impl Drop for SpecRedirect { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn send( + router: Router, + method: Method, + uri: &str, + body: Value, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn json_body(response: axum::response::Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +fn cookies(response: &axum::response::Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie should be text") + .split(';') + .next() + .expect("cookie should contain a value") + }) + .collect::>() + .join("; ") +} + +async fn setup(app: &ExecutorApp) -> Admin { + let setup_token = app + .setup_token() + .expect("fresh app should expose a setup token"); + let response = send( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": setup_token, + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send( + app.router(), + Method::POST, + "/api/v1/session", + json!({ + "username": "admin", + "password": "correct horse battery staple" + }), + &[(header::ORIGIN.as_str(), ORIGIN)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let cookie = cookies(&response); + let csrf = json_body(response).await["csrfToken"] + .as_str() + .expect("login should return a CSRF token") + .to_owned(); + Admin { cookie, csrf } +} + +fn admin_headers(admin: &Admin) -> [(&str, &str); 3] { + [ + (header::COOKIE.as_str(), admin.cookie.as_str()), + (header::ORIGIN.as_str(), ORIGIN), + ("x-executor-csrf", admin.csrf.as_str()), + ] +} + +async fn import_source( + app: &ExecutorApp, + admin: &Admin, + spec_url: &str, + preferred_slug: &str, + credential: Value, +) -> Value { + let response = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Lifecycle API", + "preferredSlug": preferred_slug, + "spec": { "type": "url", "url": spec_url }, + "allowPrivateNetwork": true, + "credential": credential + }), + &admin_headers(admin), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + json_body(response).await +} + +async fn gateway_token(app: &ExecutorApp, admin: &Admin) -> String { + let idempotency_key = format!( + "openapi-lifecycle-token-{}", + TOKEN_IDEMPOTENCY_SEQUENCE.fetch_add(1, Ordering::Relaxed) + ); + let mut headers = admin_headers(admin).to_vec(); + headers.push(("idempotency-key", idempotency_key.as_str())); + let response = send( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "lifecycle-test" }), + &headers, + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + json_body(response).await["token"] + .as_str() + .expect("token should be returned once") + .to_owned() +} + +async fn refresh(app: &ExecutorApp, admin: &Admin, source_id: &str) -> StatusCode { + send( + app.router(), + Method::POST, + &format!("/api/v1/sources/{source_id}/refresh"), + json!({}), + &admin_headers(admin), + ) + .await + .status() +} + +fn hello_paths() -> Value { + json!({ + "/hello": { + "get": { + "operationId": "sayHello", + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { "schema": { "type": "object" } } + } + } + } + } + } + }) +} + +async fn set_secured_specification( + upstream: &Upstream, + server_origin: &str, + scheme_name: &str, + scheme: Value, + operation_id: &str, +) { + *upstream.specification.write().await = json!({ + "openapi": "3.1.0", + "info": { "title": "Credential origin API" }, + "servers": [{ "url": format!("{server_origin}/api") }], + "components": { "securitySchemes": { (scheme_name): scheme } }, + "paths": { "/credential": { "get": { + "operationId": operation_id, + "security": [{ (scheme_name): [] }], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { "schema": { "type": "object" } } + } + } + } + }}} + }); +} + +async fn source_tool_path(app: &ExecutorApp, source_id: &str) -> String { + app.catalog() + .list_tools(ListToolsFilter { + source_id: Some(source_id.to_owned()), + limit: 10, + ..Default::default() + }) + .await + .expect("source tools should list") + .items + .into_iter() + .next() + .expect("source should expose a tool") + .sandbox_path +} + +async fn invoke_path(app: &ExecutorApp, token: &str, path: &str) -> axum::response::Response { + let authorization = format!("Bearer {token}"); + send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": path, "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await +} + +#[tokio::test] +async fn non_loopback_http_rejects_every_static_credential_before_catalog_or_dispatch() { + let upstream = Upstream::start().await; + let insecure_target = CredentialTarget::start_non_loopback().await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let cases = [ + ( + "insecure-header", + json!({ "type": "apiKey", "in": "header", "name": "X-API-Key" }), + json!({ "type": "api_key", "value": "header-secret" }), + ), + ( + "insecure-query", + json!({ "type": "apiKey", "in": "query", "name": "api_key" }), + json!({ "type": "api_key", "value": "query-secret" }), + ), + ( + "insecure-cookie", + json!({ "type": "apiKey", "in": "cookie", "name": "session" }), + json!({ "type": "api_key", "value": "cookie-secret" }), + ), + ( + "insecure-basic", + json!({ "type": "http", "scheme": "basic" }), + json!({ "type": "basic", "username": "user", "password": "pass" }), + ), + ( + "insecure-bearer", + json!({ "type": "http", "scheme": "bearer" }), + json!({ "type": "bearer", "token": "bearer-secret" }), + ), + ( + "insecure-manual-oauth", + json!({ "type": "oauth2", "flows": {} }), + json!({ "type": "oauth_access_token", "access_token": "oauth-secret" }), + ), + ]; + + for (slug, scheme, credential) in cases { + set_secured_specification(&upstream, &insecure_target.origin(), "Auth", scheme, slug).await; + let rejected = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Insecure transport", + "preferredSlug": slug, + "spec": { "type": "url", "url": upstream.spec_url(None) }, + "allowPrivateNetwork": true, + "credential": { "schemes": { "Auth": credential } } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(rejected.status(), StatusCode::BAD_REQUEST, "{slug}"); + assert_eq!( + json_body(rejected).await["error"]["code"], + "insecure_openapi_transport", + "{slug}" + ); + assert!(insecure_target.requests().is_empty(), "{slug}"); + } + + set_secured_specification( + &upstream, + "http://198.51.100.10", + "Auth", + json!({ "type": "http", "scheme": "bearer" }), + "insecurePublicAnonymous", + ) + .await; + let anonymous = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Public plaintext transport", + "preferredSlug": "insecure-public-anonymous", + "spec": { "type": "url", "url": upstream.spec_url(None) }, + "allowPrivateNetwork": true, + "credential": { "schemes": {} } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(anonymous.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(anonymous).await["error"]["code"], + "insecure_openapi_transport" + ); + let preview = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ + "spec": { + "type": "inline", + "content": upstream.specification.read().await.to_string() + }, + "allowPrivateNetwork": true + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(preview.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(preview).await["error"]["code"], + "insecure_openapi_transport" + ); + set_secured_specification( + &upstream, + "https://api.example.test", + "Auth", + json!({ "type": "http", "scheme": "bearer" }), + "secureHttpsPreview", + ) + .await; + let https_preview = send( + app.router(), + Method::POST, + "/api/v1/sources/openapi/preview", + json!({ + "spec": { + "type": "inline", + "content": upstream.specification.read().await.to_string() + }, + "allowPrivateNetwork": false + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(https_preview.status(), StatusCode::OK); + assert!( + app.catalog() + .list_tools(ListToolsFilter { + include_tombstoned: true, + ..Default::default() + }) + .await + .expect("catalog should remain readable") + .items + .is_empty() + ); + assert!(insecure_target.requests().is_empty()); +} + +#[tokio::test] +async fn spec_locators_and_redirect_hops_enforce_confidential_transport_before_request() { + let insecure_spec = Upstream::start_non_loopback().await; + insecure_spec + .set_paths(hello_paths(), "must not fetch") + .await; + let safe_spec = Upstream::start().await; + safe_spec.set_paths(hello_paths(), "safe loopback").await; + let second_redirect = SpecRedirect::start(insecure_spec.spec_url(None)).await; + let first_redirect = SpecRedirect::start(second_redirect.url()).await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + + for (slug, spec_url) in [ + ("private-plaintext-spec", insecure_spec.spec_url(None)), + ( + "public-plaintext-spec", + "http://198.51.100.10/openapi.json".to_owned(), + ), + ("redirect-downgrade-spec", first_redirect.url()), + ] { + let rejected = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Insecure specification", + "preferredSlug": slug, + "spec": { "type": "url", "url": spec_url }, + "allowPrivateNetwork": true, + "credential": { "schemes": {} } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(rejected.status(), StatusCode::BAD_REQUEST, "{slug}"); + assert_eq!( + json_body(rejected).await["error"]["code"], + "insecure_openapi_transport", + "{slug}" + ); + } + assert_eq!( + insecure_spec.spec_request_count(), + 0, + "the private HTTP locator and redirect target must receive no request" + ); + + first_redirect.set_target(safe_spec.spec_url(None)).await; + let created = import_source( + &app, + &admin, + &first_redirect.url(), + "loopback-redirect-spec", + json!({ "schemes": {} }), + ) + .await; + assert!(created["id"].is_string()); + assert_eq!(safe_spec.spec_request_count(), 1); +} + +#[tokio::test] +async fn refresh_rejects_per_scheme_origin_changes_for_every_static_credential_carrier() { + let upstream = Upstream::start().await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let token = gateway_token(&app, &admin).await; + let insecure_target = CredentialTarget::start_non_loopback().await; + let cases = [ + ( + "origin-header", + json!({ "type": "apiKey", "in": "header", "name": "X-API-Key" }), + json!({ "type": "api_key", "value": "header-secret" }), + "header-secret", + ), + ( + "origin-query", + json!({ "type": "apiKey", "in": "query", "name": "api_key" }), + json!({ "type": "api_key", "value": "query-secret" }), + "api_key=query-secret", + ), + ( + "origin-cookie", + json!({ "type": "apiKey", "in": "cookie", "name": "session" }), + json!({ "type": "api_key", "value": "cookie-secret" }), + "session=cookie-secret", + ), + ( + "origin-basic", + json!({ "type": "http", "scheme": "basic" }), + json!({ "type": "basic", "username": "user", "password": "pass" }), + "Basic dXNlcjpwYXNz", + ), + ( + "origin-bearer", + json!({ "type": "http", "scheme": "bearer" }), + json!({ "type": "bearer", "token": "bearer-secret" }), + "Bearer bearer-secret", + ), + ( + "origin-manual-oauth", + json!({ "type": "oauth2", "flows": {} }), + json!({ "type": "oauth_access_token", "access_token": "oauth-secret" }), + "Bearer oauth-secret", + ), + ]; + + for (slug, scheme, credential, expected_carrier) in cases { + let origin_a = CredentialTarget::start().await; + let origin_b = CredentialTarget::start().await; + set_secured_specification(&upstream, &origin_a.origin(), "Auth", scheme.clone(), slug) + .await; + let created = import_source( + &app, + &admin, + &upstream.spec_url(None), + slug, + json!({ "schemes": { "Auth": credential } }), + ) + .await; + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + let path = source_tool_path(&app, &source_id).await; + let invoked = invoke_path(&app, &token, &path).await; + assert_eq!(invoked.status(), StatusCode::OK, "{slug} should invoke"); + let first = origin_a.requests(); + assert_eq!(first.len(), 1, "{slug} should reach origin A once"); + let captured = format!("{} {:?}", first[0].path_and_query, first[0].headers); + assert!( + captured.contains(expected_carrier), + "{slug} should send its expected credential carrier" + ); + + let before_source = app + .catalog() + .source(&source_id) + .await + .expect("source should read"); + let before_binding = app + .catalog() + .tool_binding( + &app.catalog() + .list_tools(ListToolsFilter { + source_id: Some(source_id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("source tools should list") + .items[0] + .id, + ) + .await + .expect("binding should read"); + let before_global_revision = app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + + set_secured_specification(&upstream, &origin_b.origin(), "Auth", scheme, slug).await; + let rejected = send( + app.router(), + Method::POST, + &format!("/api/v1/sources/{source_id}/refresh"), + json!({}), + &admin_headers(&admin), + ) + .await; + assert_eq!(rejected.status(), StatusCode::CONFLICT, "{slug}"); + assert_eq!( + json_body(rejected).await["error"]["code"], + "openapi_credential_origin_changed" + ); + + let after_source = app + .catalog() + .source(&source_id) + .await + .expect("source should remain readable"); + assert_eq!(after_source.revision, before_source.revision, "{slug}"); + assert_eq!( + after_source.catalog_revision, before_source.catalog_revision, + "{slug}" + ); + assert_eq!( + app.catalog() + .global_revision() + .await + .expect("global revision should remain readable"), + before_global_revision, + "{slug}" + ); + assert_eq!( + app.catalog() + .tool_binding(&before_binding.tool_id) + .await + .expect("last-good binding should remain") + .binding, + before_binding.binding, + "{slug}" + ); + + let invoked = invoke_path(&app, &token, &path).await; + assert_eq!( + invoked.status(), + StatusCode::OK, + "{slug} last-good binding should remain callable" + ); + assert_eq!(origin_a.requests().len(), 2, "{slug}"); + assert!( + origin_b.requests().is_empty(), + "{slug} must not reach origin B" + ); + + sqlx::query( + "UPDATE tool_bindings SET definition_json = json_set(definition_json, '$.serverUrl', ?) \ + WHERE tool_id = ?", + ) + .bind(format!("{}/api", insecure_target.origin())) + .bind(&before_binding.tool_id) + .execute(app.pool()) + .await + .expect("stored binding should tamper"); + let blocked = invoke_path(&app, &token, &path).await; + assert_eq!(blocked.status(), StatusCode::CONFLICT, "{slug}"); + assert_eq!( + json_body(blocked).await["error"]["code"], + "insecure_openapi_transport", + "{slug}" + ); + assert!(insecure_target.requests().is_empty(), "{slug}"); + assert_eq!(origin_a.requests().len(), 2, "{slug}"); + } +} + +#[tokio::test] +async fn oauth_callback_refresh_cannot_move_managed_bearer_to_a_new_origin() { + let provider = OAuthTestProvider::start().await; + let upstream = Upstream::start().await; + let origin_a = CredentialTarget::start().await; + let origin_b = CredentialTarget::start().await; + let oauth_scheme = json!({ + "type": "oauth2", + "flows": { "authorizationCode": { + "authorizationUrl": format!("{}/authorize", provider.issuer), + "tokenUrl": format!("{}/token", provider.issuer), + "scopes": { "repo": "Read repositories" } + }} + }); + set_secured_specification( + &upstream, + &origin_a.origin(), + "oauth", + oauth_scheme.clone(), + "managedOAuthCall", + ) + .await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let created = import_source( + &app, + &admin, + &upstream.spec_url(None), + "managed_oauth_origin", + json!({ "schemes": {} }), + ) + .await; + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + let mutation_headers = admin_headers(&admin); + let connection_input = |expected_revision, client_id: &str| { + json!({ + "expectedRevision": expected_revision, + "discovery": { "type": "issuer", "issuer": provider.issuer }, + "client": { "clientId": client_id, "authentication": "none" }, + "scopes": ["repo"] + }) + }; + let placeholder = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/oauth/oauth"), + connection_input(0, "pending-dynamic-registration"), + &mutation_headers, + ) + .await; + assert_eq!(placeholder.status(), StatusCode::OK); + let placeholder = json_body(placeholder).await; + let connection_id = placeholder["id"] + .as_str() + .expect("placeholder connection has an ID") + .to_owned(); + let client_id = provider + .register_client( + placeholder["callbackUrl"] + .as_str() + .expect("placeholder has a callback URL"), + ) + .await; + let configured = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/oauth/oauth"), + connection_input( + placeholder["revision"] + .as_i64() + .expect("placeholder has a revision"), + &client_id, + ), + &mutation_headers, + ) + .await; + assert_eq!(configured.status(), StatusCode::OK); + let configured = json_body(configured).await; + let authorization = send( + app.router(), + Method::POST, + &format!("/api/v1/sources/{source_id}/oauth/oauth/authorize"), + json!({ + "expectedRevision": configured["revision"] + .as_i64() + .expect("configured connection has a revision") + }), + &mutation_headers, + ) + .await; + assert_eq!(authorization.status(), StatusCode::OK); + let authorization = json_body(authorization).await; + let provider_callback = provider + .approve( + authorization["authorizationUrl"] + .as_str() + .expect("authorization URL returns"), + ) + .await; + let before_callback_source = app + .catalog() + .source(&source_id) + .await + .expect("source should read"); + let before_callback_binding = app + .catalog() + .tool_binding( + &app.catalog() + .list_tools(ListToolsFilter { + source_id: Some(source_id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("source tools should list") + .items[0] + .id, + ) + .await + .expect("binding should read"); + + set_secured_specification( + &upstream, + &origin_b.origin(), + "oauth", + oauth_scheme, + "managedOAuthCall", + ) + .await; + let callback_uri = provider_callback.query().map_or_else( + || provider_callback.path().to_owned(), + |query| format!("{}?{query}", provider_callback.path()), + ); + let callback = send( + app.router(), + Method::GET, + &callback_uri, + json!(null), + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(callback.status(), StatusCode::SEE_OTHER); + let redirect = Url::parse( + callback + .headers() + .get(header::LOCATION) + .expect("callback redirects") + .to_str() + .expect("callback redirect is text"), + ) + .expect("callback redirect parses"); + assert_eq!( + redirect + .query_pairs() + .find(|(key, _)| key == "result") + .expect("callback includes a result") + .1, + "success_refresh_failed" + ); + assert_eq!( + redirect + .query_pairs() + .find(|(key, _)| key == "oauth") + .expect("callback includes a connection") + .1, + connection_id + ); + + let after_callback_source = app + .catalog() + .source(&source_id) + .await + .expect("source should remain readable"); + assert_eq!( + after_callback_source.revision, + before_callback_source.revision + ); + assert_eq!( + after_callback_source.catalog_revision, + before_callback_source.catalog_revision + ); + assert_eq!( + app.catalog() + .tool_binding(&before_callback_binding.tool_id) + .await + .expect("last-good binding should remain") + .binding, + before_callback_binding.binding + ); + let stored = app + .catalog() + .credential(&source_id) + .await + .expect("credential should decrypt") + .expect("credential envelope should exist"); + assert_eq!( + stored.credential.payload["credentialOrigins"]["oauth"], + json!([origin_a.origin()]) + ); + + let manual_refresh = send( + app.router(), + Method::POST, + &format!("/api/v1/sources/{source_id}/refresh"), + json!({}), + &mutation_headers, + ) + .await; + assert_eq!(manual_refresh.status(), StatusCode::CONFLICT); + assert_eq!( + json_body(manual_refresh).await["error"]["code"], + "openapi_credential_origin_changed" + ); + + let token = gateway_token(&app, &admin).await; + let path = source_tool_path(&app, &source_id).await; + let invoked = invoke_path(&app, &token, &path).await; + assert_eq!(invoked.status(), StatusCode::OK); + let requests = origin_a.requests(); + assert_eq!(requests.len(), 1); + assert!( + requests[0] + .headers + .get("authorization") + .is_some_and(|value| value.starts_with("Bearer ")) + ); + assert!(origin_b.requests().is_empty()); + + let listed = send( + app.router(), + Method::GET, + &format!("/api/v1/sources/{source_id}/oauth"), + json!(null), + &[(header::COOKIE.as_str(), &admin.cookie)], + ) + .await; + assert_eq!(listed.status(), StatusCode::OK); + let listed = json_body(listed).await; + let connection_revision = listed["connections"][0]["revision"] + .as_i64() + .expect("managed OAuth connection has a revision"); + sqlx::query( + "CREATE TRIGGER reject_oauth_origin_retire BEFORE UPDATE ON source_credentials \ + BEGIN SELECT RAISE(ABORT, 'reject OAuth origin retirement'); END", + ) + .execute(app.pool()) + .await + .expect("origin retirement failure trigger should install"); + let delete_uri = + format!("/api/v1/sources/{source_id}/oauth/oauth?expectedRevision={connection_revision}"); + let failed_delete = send( + app.router(), + Method::DELETE, + &delete_uri, + json!(null), + &mutation_headers, + ) + .await; + assert_eq!(failed_delete.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + json_body(failed_delete).await["error"]["code"], + "internal_error" + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM oauth_connections WHERE source_id = ? AND credential_key = ?", + ) + .bind(&source_id) + .bind("oauth") + .fetch_one(app.pool()) + .await + .expect("OAuth connection count should read"), + 0 + ); + let stranded = app + .catalog() + .credential(&source_id) + .await + .expect("credential should decrypt") + .expect("credential envelope should remain"); + assert_eq!( + stranded.credential.payload["credentialOrigins"]["oauth"], + json!([origin_a.origin()]) + ); + sqlx::query("DROP TRIGGER reject_oauth_origin_retire") + .execute(app.pool()) + .await + .expect("origin retirement failure trigger should drop"); + let retried_delete = send( + app.router(), + Method::DELETE, + &delete_uri, + json!(null), + &mutation_headers, + ) + .await; + assert_eq!(retried_delete.status(), StatusCode::NO_CONTENT); + let retired = app + .catalog() + .credential(&source_id) + .await + .expect("credential should decrypt") + .expect("credential envelope should remain"); + assert!( + retired + .credential + .payload + .get("credentialOrigins") + .is_none(), + "deleting the last owner must retire its origin pin" + ); + assert_eq!(refresh(&app, &admin, &source_id).await, StatusCode::OK); + assert_eq!( + app.catalog() + .tool_binding(&before_callback_binding.tool_id) + .await + .expect("refreshed binding should read") + .binding + .openapi() + .expect("binding should remain OpenAPI") + .server_url, + format!("{}/api", origin_b.origin()) + ); +} + +#[tokio::test] +async fn redirected_spec_final_origin_fences_relative_credential_destinations() { + let spec_a = Upstream::start().await; + let spec_b = Upstream::start().await; + let relative_document = json!({ + "openapi": "3.1.0", + "info": { "title": "Redirected relative API" }, + "servers": [{ "url": "./api" }], + "components": { "securitySchemes": { + "ApiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } + }}, + "paths": { "/credential": { "get": { + "operationId": "redirectedCredential", + "security": [{ "ApiKey": [] }], + "responses": { "200": { "description": "ok" } } + }}} + }); + *spec_a.specification.write().await = relative_document.clone(); + *spec_b.specification.write().await = relative_document; + let redirect = SpecRedirect::start(spec_a.spec_url(None)).await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let created = import_source( + &app, + &admin, + &redirect.url(), + "redirected_origin", + json!({ "schemes": { + "ApiKey": { "type": "api_key", "value": "secret" } + }}), + ) + .await; + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + let before = app + .catalog() + .source(&source_id) + .await + .expect("source should read"); + let stored = app + .catalog() + .credential(&source_id) + .await + .expect("credential should decrypt") + .expect("credential should exist"); + assert_eq!( + stored.credential.payload["credentialOrigins"]["ApiKey"], + json!([format!("http://{}", spec_a.address)]) + ); + + redirect.set_target(spec_b.spec_url(None)).await; + let rejected = send( + app.router(), + Method::POST, + &format!("/api/v1/sources/{source_id}/refresh"), + json!({}), + &admin_headers(&admin), + ) + .await; + assert_eq!(rejected.status(), StatusCode::CONFLICT); + assert_eq!( + json_body(rejected).await["error"]["code"], + "openapi_credential_origin_changed" + ); + let after = app + .catalog() + .source(&source_id) + .await + .expect("source should remain readable"); + assert_eq!(after.revision, before.revision); + assert_eq!(after.catalog_revision, before.catalog_revision); +} + +#[tokio::test] +async fn query_bearing_document_urls_never_reach_persisted_tool_bindings() { + let upstream = Upstream::start().await; + *upstream.specification.write().await = json!({ + "openapi": "3.1.0", + "info": { "title": "Query-safe document base" }, + "servers": [{ "url": "" }], + "paths": { "/api/hello": { "get": { + "operationId": "querySafeHello", + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { "schema": { "type": "object" } } + } + } + } + }}} + }); + let direct_secret = "direct-document-secret-4a2dc144"; + let redirect_secret = "redirect-document-secret-a576c879"; + let redirect = + SpecRedirect::start(upstream.spec_url(Some(&format!("token={redirect_secret}")))).await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let data_dir = directory.path().to_path_buf(); + let app = ExecutorApp::open(AppConfig::new(data_dir.clone())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let token = gateway_token(&app, &admin).await; + + let direct = import_source( + &app, + &admin, + &upstream.spec_url(Some(&format!("token={direct_secret}"))), + "direct-query-base", + json!({ "schemes": {} }), + ) + .await; + let redirected = import_source( + &app, + &admin, + &redirect.url(), + "redirect-query-base", + json!({ "schemes": {} }), + ) + .await; + + for created in [&direct, &redirected] { + let source_id = created["id"].as_str().expect("source should have an ID"); + let path = source_tool_path(&app, source_id).await; + assert_eq!( + invoke_path(&app, &token, &path).await.status(), + StatusCode::OK + ); + } + let definitions = sqlx::query_scalar::<_, String>( + "SELECT definition_json FROM tool_bindings ORDER BY tool_id", + ) + .fetch_all(app.pool()) + .await + .expect("tool bindings should read"); + assert_eq!(definitions.len(), 2); + for definition in definitions { + assert!(!definition.contains(direct_secret)); + assert!(!definition.contains(redirect_secret)); + let binding: Value = serde_json::from_str(&definition).expect("binding should be JSON"); + assert!( + Url::parse( + binding["serverUrl"] + .as_str() + .expect("binding should contain a server URL") + ) + .expect("server URL should parse") + .query() + .is_none() + ); + } + + sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .fetch_all(app.pool()) + .await + .expect("WAL should checkpoint"); + app.pool().close().await; + drop(app); + for file_name in ["executor.db", "executor.db-wal", "executor.db-shm"] { + let path = data_dir.join(file_name); + if path.exists() { + assert_file_excludes(&path, direct_secret); + assert_file_excludes(&path, redirect_secret); + } + } +} + +#[tokio::test] +async fn credentialless_refresh_can_move_then_later_credential_binding_pins_current_origin() { + let upstream = Upstream::start().await; + let origin_a = CredentialTarget::start().await; + let origin_b = CredentialTarget::start().await; + let insecure_origin = CredentialTarget::start_non_loopback().await; + let scheme = json!({ "type": "http", "scheme": "bearer" }); + set_secured_specification( + &upstream, + &origin_a.origin(), + "BearerAuth", + scheme.clone(), + "anonymousMove", + ) + .await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let created = import_source( + &app, + &admin, + &upstream.spec_url(None), + "credentialless_move", + json!({ "schemes": {} }), + ) + .await; + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + + let tool_id = app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source_id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("source tools should list") + .items[0] + .id + .clone(); + let original_binding = sqlx::query_scalar::<_, String>( + "SELECT definition_json FROM tool_bindings WHERE tool_id = ?", + ) + .bind(&tool_id) + .fetch_one(app.pool()) + .await + .expect("tool binding should read"); + sqlx::query( + "UPDATE tool_bindings SET definition_json = json_set(definition_json, '$.serverUrl', ?) \ + WHERE tool_id = ?", + ) + .bind(format!("{}/api", insecure_origin.origin())) + .bind(&tool_id) + .execute(app.pool()) + .await + .expect("tool binding should tamper"); + let insecure_rebind = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "expectedRevision": 0, + "credential": { "schemes": { + "BearerAuth": { "type": "bearer", "token": "must-not-bind" } + }} + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(insecure_rebind.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(insecure_rebind).await["error"]["code"], + "insecure_openapi_transport" + ); + assert_eq!( + app.catalog() + .credential(&source_id) + .await + .expect("credential should decrypt") + .expect("credential envelope should exist") + .revision, + 0 + ); + assert!(insecure_origin.requests().is_empty()); + sqlx::query("UPDATE tool_bindings SET definition_json = ? WHERE tool_id = ?") + .bind(original_binding) + .bind(&tool_id) + .execute(app.pool()) + .await + .expect("tool binding should restore"); + + set_secured_specification( + &upstream, + &origin_b.origin(), + "BearerAuth", + scheme.clone(), + "anonymousMove", + ) + .await; + assert_eq!(refresh(&app, &admin, &source_id).await, StatusCode::OK); + let saved = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "expectedRevision": 0, + "credential": { "schemes": { + "BearerAuth": { "type": "bearer", "token": "later-secret" } + }} + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(saved.status(), StatusCode::OK); + let stored = app + .catalog() + .credential(&source_id) + .await + .expect("credential should decrypt") + .expect("credential should exist"); + assert_eq!( + stored.credential.payload["credentialOrigins"]["BearerAuth"], + json!([origin_b.origin()]) + ); + + set_secured_specification( + &upstream, + &origin_a.origin(), + "BearerAuth", + scheme.clone(), + "anonymousMove", + ) + .await; + let rejected = send( + app.router(), + Method::POST, + &format!("/api/v1/sources/{source_id}/refresh"), + json!({}), + &admin_headers(&admin), + ) + .await; + assert_eq!(rejected.status(), StatusCode::CONFLICT); + assert_eq!( + json_body(rejected).await["error"]["code"], + "openapi_credential_origin_changed" + ); + assert!(origin_a.requests().is_empty()); + assert!(origin_b.requests().is_empty()); + + let cleared = send( + app.router(), + Method::DELETE, + &format!("/api/v1/sources/{source_id}/credentials?expectedRevision=1"), + json!(null), + &admin_headers(&admin), + ) + .await; + assert_eq!(cleared.status(), StatusCode::OK); + assert_eq!(json_body(cleared).await["revision"], 2); + let unbound = app + .catalog() + .credential(&source_id) + .await + .expect("credential should decrypt") + .expect("credential locator should remain"); + assert!( + unbound + .credential + .payload + .get("credentialOrigins") + .is_none(), + "clearing the final credential must explicitly release its origin pin" + ); + set_secured_specification( + &upstream, + &insecure_origin.origin(), + "BearerAuth", + scheme.clone(), + "anonymousMove", + ) + .await; + let before_insecure_refresh = app + .catalog() + .source(&source_id) + .await + .expect("source should read"); + let insecure_refresh = send( + app.router(), + Method::POST, + &format!("/api/v1/sources/{source_id}/refresh"), + json!({}), + &admin_headers(&admin), + ) + .await; + assert_eq!(insecure_refresh.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(insecure_refresh).await["error"]["code"], + "insecure_openapi_transport" + ); + assert_eq!( + app.catalog() + .source(&source_id) + .await + .expect("source should remain readable") + .revision, + before_insecure_refresh.revision + ); + assert!(insecure_origin.requests().is_empty()); + + set_secured_specification( + &upstream, + &origin_a.origin(), + "BearerAuth", + scheme, + "anonymousMove", + ) + .await; + assert_eq!(refresh(&app, &admin, &source_id).await, StatusCode::OK); + let rebound = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "expectedRevision": 2, + "credential": { "schemes": { + "BearerAuth": { "type": "bearer", "token": "rebound-secret" } + }} + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(rebound.status(), StatusCode::OK); + let rebound_stored = app + .catalog() + .credential(&source_id) + .await + .expect("credential should decrypt") + .expect("credential should exist"); + assert_eq!( + rebound_stored.credential.payload["credentialOrigins"]["BearerAuth"], + json!([origin_a.origin()]) + ); + let token = gateway_token(&app, &admin).await; + let path = source_tool_path(&app, &source_id).await; + assert_eq!( + invoke_path(&app, &token, &path).await.status(), + StatusCode::OK + ); + let captured = origin_a.requests(); + assert_eq!(captured.len(), 1); + assert_eq!( + captured[0].headers.get("authorization").map(String::as_str), + Some("Bearer rebound-secret") + ); +} + +#[tokio::test] +async fn basic_credentials_reject_ambiguous_identity_before_persistence_or_dispatch() { + let upstream = Upstream::start().await; + let target = CredentialTarget::start().await; + set_secured_specification( + &upstream, + &target.origin(), + "BasicAuth", + json!({ "type": "http", "scheme": "basic" }), + "basicIdentity", + ) + .await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + + let spec_requests = upstream.spec_request_count(); + let invalid_scheme_create = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Invalid scheme name", + "preferredSlug": "invalid-scheme-name", + "spec": { "type": "url", "url": upstream.spec_url(None) }, + "allowPrivateNetwork": true, + "credential": { "schemes": { + "Basic\nAuth": { "type": "basic", "username": "user", "password": "secret" } + }} + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(invalid_scheme_create.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(invalid_scheme_create).await["error"]["code"], + "invalid_credentials" + ); + assert_eq!(upstream.spec_request_count(), spec_requests); + let spec_requests = upstream.spec_request_count(); + + for (slug, username, password, code) in [ + ( + "invalid-basic-colon", + "admin:other", + "secret", + "invalid_basic_username", + ), + ( + "invalid-basic-user-control", + "admin\nother", + "secret", + "invalid_basic_credentials", + ), + ( + "invalid-basic-password-control", + "admin", + "secret\u{7f}", + "invalid_basic_credentials", + ), + ] { + let rejected = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Invalid Basic", + "preferredSlug": slug, + "spec": { "type": "url", "url": upstream.spec_url(None) }, + "allowPrivateNetwork": true, + "credential": { "schemes": { + "BasicAuth": { "type": "basic", "username": username, "password": password } + }} + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(rejected.status(), StatusCode::BAD_REQUEST); + assert_eq!(json_body(rejected).await["error"]["code"], code); + } + assert_eq!(upstream.spec_request_count(), spec_requests); + assert!(target.requests().is_empty()); + + let unicode_username = "δοκιμή"; + let unicode_password = "päss:word"; + let created = import_source( + &app, + &admin, + &upstream.spec_url(None), + "valid-basic-identity", + json!({ "schemes": { + "BasicAuth": { + "type": "basic", + "username": unicode_username, + "password": unicode_password + } + }}), + ) + .await; + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + let path = source_tool_path(&app, &source_id).await; + let token = gateway_token(&app, &admin).await; + assert_eq!( + invoke_path(&app, &token, &path).await.status(), + StatusCode::OK + ); + let expected_unicode = format!( + "Basic {}", + STANDARD.encode(format!("{unicode_username}:{unicode_password}")) + ); + assert_eq!( + target.requests()[0].headers.get("authorization"), + Some(&expected_unicode) + ); + + for (username, password, code) in [ + ("admin:other", "secret", "invalid_basic_username"), + ("admin\nother", "secret", "invalid_basic_credentials"), + ("admin", "secret\u{7f}", "invalid_basic_credentials"), + ] { + let rejected = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "expectedRevision": 0, + "credential": { "schemes": { + "BasicAuth": { "type": "basic", "username": username, "password": password } + }} + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(rejected.status(), StatusCode::BAD_REQUEST); + assert_eq!(json_body(rejected).await["error"]["code"], code); + } + let preserved = app + .catalog() + .credential(&source_id) + .await + .expect("credential should decrypt") + .expect("credential should exist"); + assert_eq!(preserved.revision, 0); + assert_eq!( + preserved.credential.payload["credentials"]["schemes"]["BasicAuth"]["username"], + unicode_username + ); + assert_eq!( + preserved.credential.payload["credentials"]["schemes"]["BasicAuth"]["password"], + unicode_password + ); + assert_eq!(target.requests().len(), 1); + + let invalid_scheme_replace = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "expectedRevision": 0, + "credential": { "schemes": { + "Basic\nAuth": { "type": "basic", "username": "user", "password": "secret" } + }} + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(invalid_scheme_replace.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(invalid_scheme_replace).await["error"]["code"], + "invalid_credentials" + ); + let preserved = app + .catalog() + .credential(&source_id) + .await + .expect("credential should decrypt") + .expect("credential should exist"); + assert_eq!(preserved.revision, 0); + assert_eq!( + preserved.credential.payload["credentials"]["schemes"]["BasicAuth"]["username"], + unicode_username + ); + + let empty_username = send( + app.router(), + Method::PUT, + &format!("/api/v1/sources/{source_id}/credentials"), + json!({ + "expectedRevision": 0, + "credential": { "schemes": { + "BasicAuth": { + "type": "basic", + "username": "", + "password": "password:with:colons" + } + }} + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(empty_username.status(), StatusCode::OK); + assert_eq!(json_body(empty_username).await["revision"], 1); + assert_eq!( + invoke_path(&app, &token, &path).await.status(), + StatusCode::OK + ); + assert_eq!( + target.requests()[1].headers.get("authorization"), + Some(&format!( + "Basic {}", + STANDARD.encode(":password:with:colons") + )) + ); +} + +#[tokio::test] +async fn trace_operations_never_enter_the_catalog_or_become_mode_overridable() { + let upstream = Upstream::start().await; + *upstream.specification.write().await = json!({ + "openapi": "3.1.0", + "info": { "title": "Unsupported TRACE" }, + "servers": [{ "url": format!("http://{}/api", upstream.address) }], + "paths": { "/trace": { "trace": { + "operationId": "traceRequest", + "responses": { "200": { "description": "ok" } } + }}} + }); + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let before_global_revision = app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let rejected = send( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Unsupported TRACE", + "preferredSlug": "unsupported_trace", + "spec": { "type": "url", "url": upstream.spec_url(None) }, + "allowPrivateNetwork": true, + "credential": { "schemes": {} } + }), + &admin_headers(&admin), + ) + .await; + assert_eq!(rejected.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(rejected).await["error"]["code"], + "invalid_openapi_document" + ); + assert!( + app.catalog() + .list_tools(ListToolsFilter { + include_tombstoned: true, + ..Default::default() + }) + .await + .expect("catalog should remain readable") + .items + .is_empty(), + "there must be no TRACE tool whose mode can be overridden" + ); + assert_eq!( + app.catalog() + .global_revision() + .await + .expect("global revision should remain readable"), + before_global_revision + ); +} + +#[tokio::test] +async fn refresh_rejects_unknown_fields_before_fetching_or_mutating() { + let upstream = Upstream::start().await; + upstream.set_paths(hello_paths(), "initial").await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let created = import_source( + &app, + &admin, + &upstream.spec_url(None), + "strict-refresh", + json!({ "schemes": {} }), + ) + .await; + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + let before_source = app + .catalog() + .source(&source_id) + .await + .expect("source should read"); + let before_global_revision = app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let before_spec_requests = upstream.spec_request_count(); + + let response = send( + app.router(), + Method::POST, + &format!("/api/v1/sources/{source_id}/refresh"), + json!({ "expectedRevison": before_source.revision }), + &admin_headers(&admin), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(json_body(response).await["error"]["code"], "invalid_json"); + assert_eq!(upstream.spec_request_count(), before_spec_requests); + + let after_source = app + .catalog() + .source(&source_id) + .await + .expect("source should still read"); + assert_eq!(after_source.revision, before_source.revision); + assert_eq!( + after_source.catalog_revision, + before_source.catalog_revision + ); + assert_eq!(after_source.tool_count, before_source.tool_count); + assert_eq!( + app.catalog() + .global_revision() + .await + .expect("global revision should still read"), + before_global_revision + ); +} + +#[tokio::test] +async fn removed_then_restored_openapi_tool_keeps_identity_override_and_callable_binding() { + let upstream = Upstream::start().await; + upstream.set_paths(hello_paths(), "initial").await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let created = import_source( + &app, + &admin, + &upstream.spec_url(None), + "lifecycle", + json!({ "schemes": {} }), + ) + .await; + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + let original = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list") + .items + .pop() + .expect("imported tool should exist"); + let original_binding = app + .catalog() + .tool_binding(&original.id) + .await + .expect("imported tool should have a binding"); + app.catalog() + .set_tool_mode( + &original.id, + Some(ToolMode::Enabled), + original.revision, + AuditContext::system(Some("lifecycle-mode")), + ) + .await + .expect("explicit tool mode should store"); + + upstream.set_paths(json!({}), "temporarily removed").await; + assert_eq!(refresh(&app, &admin, &source_id).await, StatusCode::OK); + let tombstone = app + .catalog() + .list_tools(ListToolsFilter { + include_tombstoned: true, + ..Default::default() + }) + .await + .expect("tool history should list") + .items + .pop() + .expect("removed tool should remain in history"); + assert_eq!(tombstone.id, original.id); + assert_eq!(tombstone.local_name, original.local_name); + assert_eq!(tombstone.mode_override, Some(ToolMode::Enabled)); + assert!(!tombstone.present); + assert!(app.catalog().tool_binding(&original.id).await.is_err()); + + upstream.set_paths(hello_paths(), "restored").await; + assert_eq!(refresh(&app, &admin, &source_id).await, StatusCode::OK); + let restored = app + .catalog() + .list_tools(Default::default()) + .await + .expect("restored tools should list") + .items + .pop() + .expect("tool should be restored"); + assert_eq!(restored.id, original.id); + assert_eq!(restored.local_name, original.local_name); + assert_eq!(restored.callable_path, original.callable_path); + assert_eq!(restored.mode_override, Some(ToolMode::Enabled)); + assert!(restored.present); + let restored_binding = app + .catalog() + .tool_binding(&restored.id) + .await + .expect("restored tool should regain a binding"); + assert_eq!(restored_binding.binding, original_binding.binding); + + let token = gateway_token(&app, &admin).await; + let authorization = format!("Bearer {token}"); + let invoked = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": restored.sandbox_path, "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(invoked.status(), StatusCode::OK); + assert_eq!( + json_body(invoked).await["data"]["message"], + "still callable" + ); +} + +#[tokio::test] +async fn refresh_binding_failure_rolls_back_catalog_data_while_recording_health() { + let upstream = Upstream::start().await; + upstream + .set_paths(hello_paths(), "before failed refresh") + .await; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let created = import_source( + &app, + &admin, + &upstream.spec_url(None), + "rollback", + json!({ "schemes": {} }), + ) + .await; + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + let before_source = app + .catalog() + .source(&source_id) + .await + .expect("source should read"); + let before_global_revision = app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let before_tool = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list") + .items + .pop() + .expect("tool should exist"); + let before_binding = app + .catalog() + .tool_binding(&before_tool.id) + .await + .expect("binding should exist"); + let before_artifact = sqlx::query_scalar::<_, String>( + "SELECT content_json FROM source_artifacts WHERE source_id = ?", + ) + .bind(&source_id) + .fetch_one(app.pool()) + .await + .expect("artifact should read"); + let before_audits = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM audit_events") + .fetch_one(app.pool()) + .await + .expect("audit count should read"); + + upstream + .set_paths( + json!({ + "/hello": hello_paths()["/hello"].clone(), + "/new": { + "get": { + "operationId": "newOperation", + "responses": { "204": { "description": "ok" } } + } + } + }), + "must roll back", + ) + .await; + sqlx::query( + "CREATE TRIGGER reject_refresh_binding BEFORE INSERT ON tool_bindings \ + BEGIN SELECT RAISE(ABORT, 'reject refresh binding'); END", + ) + .execute(app.pool()) + .await + .expect("binding failure trigger should install"); + assert_eq!( + refresh(&app, &admin, &source_id).await, + StatusCode::INTERNAL_SERVER_ERROR + ); + + let after_source = app + .catalog() + .source(&source_id) + .await + .expect("source should still read"); + assert_eq!(after_source.revision, before_source.revision + 1); + assert_eq!(after_source.health_status, SourceHealth::Error); + assert_eq!( + after_source.health_error_code.as_deref(), + Some("openapi_refresh_failed") + ); + assert_eq!( + after_source.last_refreshed_at, + before_source.last_refreshed_at + ); + assert_eq!( + after_source.catalog_revision, + before_source.catalog_revision + ); + assert_eq!(after_source.tool_count, before_source.tool_count); + assert_eq!( + app.catalog() + .global_revision() + .await + .expect("global revision should still read"), + before_global_revision + 1 + ); + let after_tools = app + .catalog() + .list_tools(ListToolsFilter { + include_tombstoned: true, + ..Default::default() + }) + .await + .expect("tool history should still list"); + assert_eq!(after_tools.items.len(), 1); + assert_eq!(after_tools.items[0].id, before_tool.id); + assert_eq!(after_tools.items[0].revision, before_tool.revision); + assert_eq!( + app.catalog() + .tool_binding(&before_tool.id) + .await + .expect("old binding should survive") + .binding, + before_binding.binding + ); + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT content_json FROM source_artifacts WHERE source_id = ?", + ) + .bind(&source_id) + .fetch_one(app.pool()) + .await + .expect("artifact should survive"), + before_artifact + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM audit_events") + .fetch_one(app.pool()) + .await + .expect("audit count should still read"), + before_audits + 1 + ); + + let token = gateway_token(&app, &admin).await; + let authorization = format!("Bearer {token}"); + let invoked = send( + app.router(), + Method::POST, + "/api/v1/gateway/tools/invoke", + json!({ "path": before_tool.sandbox_path, "arguments": {} }), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(invoked.status(), StatusCode::OK); + assert_eq!( + json_body(invoked).await["data"]["message"], + "still callable" + ); +} + +#[tokio::test] +async fn credentials_delete_uses_cas_preserves_locator_and_leaves_no_plaintext_on_disk() { + let upstream = Upstream::start().await; + upstream + .set_paths( + json!({ + "/hello": { + "get": { + "operationId": "sayHello", + "security": [{ "ApiKey": [] }], + "responses": { "200": { "description": "ok" } } + } + } + }), + "secret persistence test", + ) + .await; + { + let mut specification = upstream.specification.write().await; + specification["components"] = json!({ + "securitySchemes": { + "ApiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } + } + }); + } + let locator_secret = "locator-secret-4f08dd75512a47a6"; + let auth_secret = "auth-secret-a5477b04afe2405d"; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let data_dir = directory.path().to_path_buf(); + let app = ExecutorApp::open(AppConfig::new(data_dir.clone())) + .await + .expect("Executor should open"); + let admin = setup(&app).await; + let secret_spec_url = upstream.spec_url(Some(&format!("api_key={locator_secret}"))); + let created = import_source( + &app, + &admin, + &secret_spec_url, + "secrets", + json!({ + "schemes": { + "ApiKey": { "type": "api_key", "value": auth_secret } + } + }), + ) + .await; + assert!(!created.to_string().contains(locator_secret)); + assert!(!created.to_string().contains(auth_secret)); + let source_id = created["id"] + .as_str() + .expect("source should have an ID") + .to_owned(); + + let stale = send( + app.router(), + Method::DELETE, + &format!("/api/v1/sources/{source_id}/credentials?expectedRevision=7"), + json!(null), + &admin_headers(&admin), + ) + .await; + assert_eq!(stale.status(), StatusCode::CONFLICT); + let stale_body = json_body(stale).await; + assert_eq!(stale_body["error"]["code"], "revision_conflict"); + assert!(!stale_body.to_string().contains(locator_secret)); + assert!(!stale_body.to_string().contains(auth_secret)); + + let deleted = send( + app.router(), + Method::DELETE, + &format!("/api/v1/sources/{source_id}/credentials?expectedRevision=0"), + json!(null), + &admin_headers(&admin), + ) + .await; + assert_eq!(deleted.status(), StatusCode::OK); + let deleted_body = json_body(deleted).await; + assert_eq!(deleted_body["revision"], 1); + assert_eq!(deleted_body["configuredSchemes"], json!([])); + assert!(!deleted_body.to_string().contains(locator_secret)); + assert!(!deleted_body.to_string().contains(auth_secret)); + + let stored = app + .catalog() + .credential(&source_id) + .await + .expect("credential should decrypt") + .expect("locator envelope should remain"); + assert_eq!(stored.revision, 1); + assert_eq!(stored.credential.payload["locator"]["url"], secret_spec_url); + assert_eq!( + stored.credential.payload["credentials"]["schemes"], + json!({}) + ); + assert!(!stored.credential.payload.to_string().contains(auth_secret)); + assert_eq!(refresh(&app, &admin, &source_id).await, StatusCode::OK); + + sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .fetch_all(app.pool()) + .await + .expect("WAL should checkpoint"); + app.pool().close().await; + drop(app); + + for file_name in ["executor.db", "executor.db-wal", "executor.db-shm"] { + let path = data_dir.join(file_name); + if path.exists() { + assert_file_excludes(&path, locator_secret); + assert_file_excludes(&path, auth_secret); + } + } +} + +fn assert_file_excludes(path: &Path, secret: &str) { + let bytes = std::fs::read(path).expect("database sidecar should read"); + assert!( + !bytes + .windows(secret.len()) + .any(|window| window == secret.as_bytes()), + "{} contains plaintext secret", + path.display() + ); +} diff --git a/tests/openapi_parser.rs b/tests/openapi_parser.rs new file mode 100644 index 000000000..babe95925 --- /dev/null +++ b/tests/openapi_parser.rs @@ -0,0 +1,1644 @@ +use executor::{ + catalog::ToolMode, + openapi::{ + OpenApiBinding, OpenApiCredential, OpenApiCredentialError, OpenApiCredentialSet, + OpenApiError, OpenApiInvocationError, OpenApiParameterLocation, OpenApiSecurityScheme, + build_protocol_request, build_protocol_request_with_base, compile_document, + }, +}; +use serde_json::json; +use url::Url; + +type StaticCredentialSet = OpenApiCredentialSet; +type StaticCredentialScheme = OpenApiCredential; + +#[test] +fn compiles_yaml_with_parameter_server_media_and_security_precedence() { + let document = br#" +openapi: 3.1.0 +info: + title: Pet Service + description: Pet operations +servers: + - url: https://root.example.test/{version} + variables: + version: + default: v1 +security: + - rootKey: [] +paths: + /pets/{pet_id}: + servers: + - url: https://path.example.test + parameters: + - $ref: '#/components/parameters/PetId' + - name: view + in: query + schema: + type: string + get: + operationId: getPet + summary: Get a pet + servers: + - url: https://operation.example.test + parameters: + - name: view + in: query + required: true + schema: + enum: [summary, full] + security: + - bearerAuth: [] + rootKey: [] + - {} + responses: + '200': + description: ok + content: + application/problem+json: + schema: + type: object + application/json: + schema: + $ref: '#/components/schemas/Pet' + /pets: + post: + operationId: createPet + requestBody: + required: true + content: + text/plain: + schema: { type: string } + application/json: + schema: + $ref: '#/components/schemas/PetInput' + responses: + default: + description: response +components: + parameters: + PetId: + name: pet_id + in: path + required: true + schema: { type: string } + schemas: + Pet: + type: object + properties: + id: { type: string } + PetInput: + type: object + properties: + name: { type: string } + required: [name] + securitySchemes: + rootKey: + type: apiKey + in: header + name: X-Root-Key + bearerAuth: + type: http + scheme: bearer + oauth: + type: oauth2 + flows: {} +"#; + let compiled = compile_document(document).expect("valid YAML should compile"); + assert_eq!(compiled.title, "Pet Service"); + assert_eq!(compiled.tools.len(), 2); + + let get = compiled + .tools + .iter() + .find(|tool| tool.preferred_name == "getPet") + .expect("GET operation should exist"); + assert_eq!(get.intrinsic_mode, ToolMode::Enabled); + assert_eq!(get.binding.server_url, "https://operation.example.test"); + assert_eq!(get.binding.parameters.len(), 2); + let view = get + .binding + .parameters + .iter() + .find(|parameter| parameter.name == "view") + .expect("operation query parameter should replace the path parameter"); + assert!(view.required); + assert_eq!(view.location, OpenApiParameterLocation::Query); + assert_eq!(get.binding.security.len(), 2); + assert_eq!(get.binding.security[0].requirements.len(), 2); + assert!(get.binding.security[1].requirements.is_empty()); + assert!(matches!( + get.binding.security[0].requirements[0].scheme, + OpenApiSecurityScheme::Http { .. } + )); + assert_eq!( + get.output_schema, + Some(json!({ + "type": "object", + "properties": { "id": { "type": "string" } } + })) + ); + + let post = compiled + .tools + .iter() + .find(|tool| tool.preferred_name == "createPet") + .expect("POST operation should exist"); + assert_eq!(post.intrinsic_mode, ToolMode::Ask); + let body = post + .binding + .request_body + .as_ref() + .expect("request body should compile"); + assert_eq!(body.default_media_type, "application/json"); + assert_eq!( + body.media_types, + vec!["application/json".to_owned(), "text/plain".to_owned()] + ); + assert_eq!( + post.binding.security[0].requirements[0].scheme_name, + "rootKey" + ); +} + +#[test] +fn openapi_30_input_schemas_normalize_nullable_and_exclusive_bounds_to_2020_12() { + let compiled = compile_document( + serde_json::to_string(&json!({ + "openapi": "3.0.3", + "info": { "title": "OpenAPI 3.0 schema normalization", "version": "1" }, + "servers": [{ "url": "https://api.example.test" }], + "paths": { + "/items": { + "get": { + "operationId": "listItems", + "parameters": [{ + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "nullable": true, + "minimum": 5, + "exclusiveMinimum": true + } + }], + "responses": { "200": { "description": "ok" } } + } + } + } + })) + .expect("specification should serialize") + .as_bytes(), + ) + .expect("OpenAPI 3.0 specification should compile"); + let schema = &compiled.tools[0].input_schema; + assert_eq!( + schema["$schema"], + "https://json-schema.org/draft/2020-12/schema" + ); + let limit = &schema["properties"]["query"]["properties"]["limit"]; + let types = limit["type"] + .as_array() + .expect("nullable schema should become a type union"); + assert_eq!(types, &[json!("integer"), json!("null")]); + assert_eq!(limit["exclusiveMinimum"], 5); + assert!(limit.get("minimum").is_none()); +} + +#[test] +fn openapi_30_rejects_reference_siblings_instead_of_overriding_constraints() { + let error = compile_document( + serde_json::to_string(&json!({ + "openapi": "3.0.3", + "info": { "title": "Reference sibling", "version": "1" }, + "paths": { + "/items": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Input", + "additionalProperties": true + } + } + } + }, + "responses": { "200": { "description": "ok" } } + } + } + }, + "components": { + "schemas": { + "Input": { + "type": "object", + "additionalProperties": false, + "properties": { "name": { "type": "string" } } + } + } + } + })) + .expect("specification should serialize") + .as_bytes(), + ) + .expect_err("OpenAPI 3.0 reference siblings should be rejected"); + assert!(matches!(error, OpenApiError::InvalidDocument(_))); +} + +#[test] +fn request_projection_does_not_require_read_only_properties() { + let compiled = compile_document( + serde_json::to_string(&json!({ + "openapi": "3.1.0", + "info": { "title": "Read-only request projection", "version": "1" }, + "paths": { + "/items": { + "post": { + "operationId": "createItem", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string", "readOnly": true }, + "name": { "type": "string" } + } + } + } + } + }, + "responses": { "200": { "description": "ok" } } + } + } + } + })) + .expect("specification should serialize") + .as_bytes(), + ) + .expect("OpenAPI specification should compile"); + assert_eq!( + compiled.tools[0].input_schema["properties"]["body"]["required"], + json!(["name"]) + ); +} + +#[test] +fn openapi_31_accepts_only_implemented_json_schema_dialects() { + let make_document = |dialect: Option<&str>| { + let mut document = json!({ + "openapi": "3.1.0", + "info": { "title": "Dialect", "version": "1" }, + "paths": { + "/items": { + "get": { + "responses": { "200": { "description": "ok" } } + } + } + } + }); + if let Some(dialect) = dialect { + document["jsonSchemaDialect"] = json!(dialect); + } + document + }; + + for dialect in [ + None, + Some("https://spec.openapis.org/oas/3.1/dialect/base"), + Some("https://json-schema.org/draft/2020-12/schema"), + Some("https://json-schema.org/draft/2020-12/schema#"), + ] { + let document = make_document(dialect); + compile_document(&serde_json::to_vec(&document).unwrap_or_default()).unwrap_or_else( + |error| panic!("supported dialect {dialect:?} should compile: {error}"), + ); + } + + for dialect in [ + "https://example.test/custom-dialect", + "https://json-schema.org/draft/2019-09/schema", + ] { + let document = make_document(Some(dialect)); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap_or_default()), + Err(OpenApiError::InvalidDocument( + "the OpenAPI 3.1 JSON Schema dialect is not supported" + )) + )); + } + + let mut non_string = make_document(None); + non_string["jsonSchemaDialect"] = json!({ "uri": "not a string" }); + assert!(matches!( + compile_document(&serde_json::to_vec(&non_string).unwrap_or_default()), + Err(OpenApiError::InvalidDocument( + "jsonSchemaDialect must be a URI string" + )) + )); + + let mut schema_override = make_document(None); + schema_override["paths"]["/items"]["get"]["parameters"] = json!([{ + "name": "query", + "in": "query", + "schema": { + "$schema": "https://example.test/custom-dialect", + "type": "string", + "x-custom-assertion": true + } + }]); + assert!(matches!( + compile_document(&serde_json::to_vec(&schema_override).unwrap_or_default()), + Err(OpenApiError::InvalidDocument( + "a Schema Object uses an unsupported JSON Schema dialect" + )) + )); + + let mut nested_override = make_document(None); + nested_override["paths"]["/items"]["get"]["parameters"] = json!([{ + "name": "query", + "in": "query", + "schema": { + "type": "array", + "unevaluatedItems": { + "$schema": "https://example.test/custom-dialect", + "x-custom-assertion": true + } + } + }]); + assert!(matches!( + compile_document(&serde_json::to_vec(&nested_override).unwrap_or_default()), + Err(OpenApiError::InvalidDocument( + "a Schema Object uses an unsupported JSON Schema dialect" + )) + )); +} + +#[test] +fn local_pointer_decoding_and_ref_siblings_are_supported() { + let document = json!({ + "openapi": "3.1.0", + "info": { "title": "Refs" }, + "paths": { + "/things": { + "get": { + "parameters": [{ + "name": "filter", + "in": "query", + "schema": { + "$ref": "#/components/schemas/Encoded%20Name", + "description": "override" + } + }], + "responses": { "204": { "description": "none" } } + } + } + }, + "components": { + "schemas": { + "Encoded Name": { "type": "string" } + } + } + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + assert_eq!( + compiled.tools[0].input_schema["properties"]["query"]["properties"]["filter"], + json!({ "type": "string", "description": "override" }) + ); +} + +#[test] +fn rejects_swagger_external_refs_missing_refs_and_cycles() { + let swagger = br#"{"swagger":"2.0","info":{"title":"old"},"paths":{}}"#; + assert!(matches!( + compile_document(swagger), + Err(OpenApiError::UnsupportedVersion) + )); + + let external = br#"{ + "openapi":"3.0.3","info":{"title":"external"}, + "paths":{"/x":{"get":{"responses":{"200":{"$ref":"https://example.test/r"}}}}} + }"#; + assert!(matches!( + compile_document(external), + Err(OpenApiError::ExternalReference(_)) + )); + + let missing = br##"{ + "openapi":"3.0.3","info":{"title":"missing"}, + "paths":{"/x":{"get":{"parameters":[{"$ref":"#/components/parameters/nope"}],"responses":{}}}} + }"##; + assert!(matches!( + compile_document(missing), + Err(OpenApiError::ReferenceNotFound(_)) + )); + + let cycle = br##"{ + "openapi":"3.0.3","info":{"title":"cycle"}, + "paths":{"/x":{"get":{"parameters":[{"$ref":"#/components/parameters/a"}],"responses":{}}}}, + "components":{"parameters":{ + "a":{"$ref":"#/components/parameters/b"}, + "b":{"$ref":"#/components/parameters/a"} + }} + }"##; + assert!(matches!( + compile_document(cycle), + Err(OpenApiError::ReferenceCycle(_)) + )); + + let duplicate_yaml = br#" +openapi: 3.0.3 +info: { title: Duplicate } +paths: {} +paths: {} +"#; + assert!(matches!( + compile_document(duplicate_yaml), + Err(OpenApiError::Parse) + )); +} + +#[test] +fn rejects_path_parameters_that_do_not_match_the_template() { + let document = br#"{ + "openapi":"3.0.3","info":{"title":"paths"}, + "paths":{"/items/{id}":{"get":{"responses":{"200":{"description":"ok"}}}}} + }"#; + assert!(matches!( + compile_document(document), + Err(OpenApiError::InvalidOperation { .. }) + )); +} + +#[test] +fn trace_operations_are_rejected_before_they_can_enter_the_catalog() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Unsupported TRACE" }, + "paths": { + "/items/{id}": { + "parameters": [{ + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }], + "trace": { + "operationId": "traceItem", + "responses": { "200": { "description": "ok" } } + } + } + } + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { method, message, .. }) + if method == "TRACE" && message == "TRACE operations are not supported" + )); +} + +#[test] +fn request_credentials_override_user_carriers() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Invoke" }, + "servers": [{ "url": "https://api.example.test/v1" }], + "components": { "securitySchemes": { + "key": { "type": "apiKey", "in": "query", "name": "api_key" }, + "bearer": { "type": "http", "scheme": "bearer" } + }}, + "paths": { "/items/{id}": { "post": { + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "api_key", "in": "query", "schema": { "type": "string" } }, + { "name": "Api_Key", "in": "query", "schema": { "type": "string" } }, + { "name": "tags", "in": "query", "explode": true, "schema": { "type": "array" } }, + { "name": "X-Trace", "in": "header", "schema": { "type": "string" } } + ], + "security": [{ "key": [], "bearer": [] }], + "requestBody": { "required": true, "content": { + "application/json": { "schema": { "type": "object" } } + }}, + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let credentials = StaticCredentialSet { + schemes: [ + ( + "key".to_owned(), + StaticCredentialScheme::ApiKey { + value: "secret-key".to_owned(), + }, + ), + ( + "bearer".to_owned(), + StaticCredentialScheme::Bearer { + token: "secret-token".to_owned(), + }, + ), + ] + .into_iter() + .collect(), + }; + let request = build_protocol_request( + &compiled.tools[0].binding, + &json!({ + "path": { "id": "a/b" }, + "query": { "api_key": "attacker", "Api_Key": "case-sensitive", "tags": ["one", "two"] }, + "headers": { "X-Trace": "trace-1" }, + "body": { "name": "item" } + }), + &credentials, + ) + .unwrap(); + assert_eq!(request.method, "POST"); + assert_eq!(request.url.path(), "/v1/items/a%2Fb"); + let query = request.url.query_pairs().collect::>(); + assert_eq!( + query + .iter() + .filter(|(name, _)| name == "api_key") + .map(|(_, value)| value.as_ref()) + .collect::>(), + vec!["secret-key"] + ); + assert_eq!( + query + .iter() + .filter(|(name, _)| name == "Api_Key") + .map(|(_, value)| value.as_ref()) + .collect::>(), + vec!["case-sensitive"] + ); + assert_eq!( + query + .iter() + .filter(|(name, _)| name == "tags") + .map(|(_, value)| value.as_ref()) + .collect::>(), + vec!["one", "two"] + ); + assert_eq!( + request.headers.get("authorization").map(String::as_str), + Some("Bearer secret-token") + ); + assert_eq!( + request.headers.get("x-trace").map(String::as_str), + Some("trace-1") + ); + assert_eq!( + request.headers.get("content-type").map(String::as_str), + Some("application/json") + ); + assert_eq!(request.body, br#"{"name":"item"}"#); +} + +#[test] +fn request_construction_rejects_protected_headers_and_crlf_credentials() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Safety" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "key": { "type": "apiKey", "in": "header", "name": "X-Api-Key" } + }}, + "paths": { "/safe": { "get": { + "parameters": [{ "name": "Authorization", "in": "header", "schema": { "type": "string" } }], + "security": [{ "key": [] }], + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let credentials = |value: &str| StaticCredentialSet { + schemes: [( + "key".to_owned(), + StaticCredentialScheme::ApiKey { + value: value.to_owned(), + }, + )] + .into_iter() + .collect(), + }; + assert!(matches!( + build_protocol_request( + &compiled.tools[0].binding, + &json!({ "headers": { "Authorization": "user" } }), + &credentials("secret") + ), + Err(OpenApiInvocationError::InvalidHeader(_)) + )); + assert!( + build_protocol_request( + &compiled.tools[0].binding, + &json!({}), + &credentials("secret\r\nX-Evil: yes") + ) + .is_err() + ); +} + +#[test] +fn tiny_exponential_reference_dag_hits_the_global_resolution_budget() { + let mut schemas = serde_json::Map::new(); + schemas.insert("Node0".to_owned(), json!({ "type": "string" })); + for index in 1..=18 { + let reference = format!("#/components/schemas/Node{}", index - 1); + schemas.insert( + format!("Node{index}"), + json!([{ "$ref": reference }, { "$ref": reference }]), + ); + } + let document = json!({ + "openapi": "3.1.0", + "info": { "title": "Expansion budget" }, + "components": { "schemas": schemas }, + "paths": { "/expand": { "get": { + "parameters": [{ + "name": "value", "in": "query", + "schema": { "$ref": "#/components/schemas/Node18" } + }], + "responses": { "200": { "description": "ok" } } + }}} + }); + let result = compile_document(&serde_json::to_vec(&document).unwrap()); + assert!( + matches!( + &result, + Err(OpenApiError::LimitExceeded { + code: "resolved_nodes" + }) + ), + "unexpected expansion result: {result:?}" + ); +} + +#[test] +fn rejects_parameter_serializations_the_invoker_cannot_execute() { + let cases = [ + ("path", "label", false), + ("header", "form", false), + ("query", "matrix", false), + ("cookie", "simple", false), + ("query", "form", true), + ]; + for (location, style, allow_reserved) in cases { + let path = if location == "path" { + "/items/{value}" + } else { + "/items" + }; + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Unsupported parameter" }, + "paths": { path: { "get": { + "parameters": [{ + "name": "value", "in": location, + "required": location == "path", "style": style, + "allowReserved": allow_reserved, + "schema": { "type": "string" } + }], + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); + } +} + +#[test] +fn rejects_content_based_parameters_the_invoker_cannot_serialize() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Content parameter" }, + "paths": { "/items": { "get": { + "parameters": [{ + "name": "filter", + "in": "query", + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + }], + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); +} + +#[test] +fn rejects_request_body_media_the_invoker_cannot_encode() { + for media_type in ["multipart/form-data", "application/octet-stream"] { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Unsupported body" }, + "paths": { "/upload": { "post": { + "requestBody": { "content": { + media_type: { "schema": { "type": "object" } } + }}, + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); + } +} + +#[test] +fn request_body_schemas_match_the_production_encoders() { + let cases = [ + ("text/plain", json!({ "type": "object", "properties": {} })), + ( + "application/x-www-form-urlencoded", + json!({ + "type": "object", + "properties": { "tags": { "type": "array" } }, + "additionalProperties": false + }), + ), + ( + "application/x-www-form-urlencoded", + json!({ "type": "object", "additionalProperties": true }), + ), + ( + "application/x-www-form-urlencoded", + json!({ + "type": "object", + "properties": { "name": { "type": "string" } } + }), + ), + ]; + for (media_type, schema) in cases { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Body schema" }, + "paths": { "/submit": { "post": { + "requestBody": { "content": { media_type: { "schema": schema } } }, + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); + } + + let valid_form = json!({ + "openapi": "3.0.3", + "info": { "title": "Form body" }, + "servers": [{ "url": "https://api.example.test" }], + "paths": { "/submit": { "post": { + "requestBody": { "content": { + "application/x-www-form-urlencoded": { "schema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "additionalProperties": false + }} + }}, + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&valid_form).unwrap()).unwrap(); + assert!(matches!( + build_protocol_request( + &compiled.tools[0].binding, + &json!({ "body": { "name": { "nested": true } } }), + &StaticCredentialSet::default() + ), + Err(OpenApiInvocationError::InvalidArgument(_)) + )); +} + +#[test] +fn parameterized_json_is_preferred_over_text_request_media() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Media preference" }, + "paths": { "/submit": { "post": { + "requestBody": { "content": { + "text/plain": { "schema": { "type": "string" } }, + "application/json; charset=utf-8": { "schema": { "type": "object" } } + }}, + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + assert_eq!( + compiled.tools[0] + .binding + .request_body + .as_ref() + .map(|body| body.default_media_type.as_str()), + Some("application/json; charset=utf-8") + ); +} + +#[test] +fn rejects_server_urls_with_plaintext_secret_or_ambient_components() { + for server_url in [ + "https://user:password@api.example.test/v1", + "https://api.example.test/v1?api_key=secret", + "https://api.example.test/v1#fragment", + "ftp://api.example.test/v1", + "//user:password@api.example.test/v1", + ] { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Unsafe server" }, + "servers": [{ "url": server_url }], + "paths": { "/items": { "get": { + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); + } + + for server_url in ["https://api.example.test/v1", "/relative/v1"] { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Safe server" }, + "servers": [{ "url": server_url }], + "paths": { "/items": { "get": { + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(compile_document(&serde_json::to_vec(&document).unwrap()).is_ok()); + } + + let hidden_in_fallback = json!({ + "openapi": "3.0.3", + "info": { "title": "Unsafe fallback" }, + "servers": [ + { "url": "https://api.example.test" }, + { "url": "https://api.example.test?secret=value" } + ], + "paths": {} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&hidden_in_fallback).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); +} + +#[test] +fn rejects_and_security_requirements_that_overwrite_the_same_carrier() { + let document = |security: serde_json::Value| { + json!({ + "openapi": "3.0.3", + "info": { "title": "Carrier conflict" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "basic": { "type": "http", "scheme": "basic" }, + "bearer": { "type": "http", "scheme": "bearer" } + }}, + "paths": { "/items": { "get": { + "security": security, + "responses": { "200": { "description": "ok" } } + }}} + }) + }; + let conflicting = document(json!([{ "basic": [], "bearer": [] }])); + assert!(matches!( + compile_document(&serde_json::to_vec(&conflicting).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); + + let alternatives = document(json!([{ "basic": [] }, { "bearer": [] }])); + let compiled = compile_document(&serde_json::to_vec(&alternatives).unwrap()).unwrap(); + let mut binding = compiled.tools[0].binding.clone(); + let bearer = binding.security[1].requirements[0].clone(); + binding.security[0].requirements.push(bearer); + binding.security.truncate(1); + let credentials = StaticCredentialSet { + schemes: [ + ( + "basic".to_owned(), + StaticCredentialScheme::Basic { + username: "user".to_owned(), + password: "password".to_owned(), + }, + ), + ( + "bearer".to_owned(), + StaticCredentialScheme::Bearer { + token: "token".to_owned(), + }, + ), + ] + .into_iter() + .collect(), + }; + assert!(matches!( + build_protocol_request(&binding, &json!({}), &credentials), + Err(OpenApiInvocationError::UnsatisfiedSecurity) + )); +} + +#[test] +fn public_request_builder_rejects_cookie_name_injection() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Cookie safety" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "cookie": { "type": "apiKey", "in": "cookie", "name": "session\r\nX-Evil" } + }}, + "paths": { "/items": { "get": { + "parameters": [{ + "name": "user;admin=true", "in": "cookie", "schema": { "type": "string" } + }], + "security": [{ "cookie": [] }], + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let credentials = StaticCredentialSet { + schemes: [( + "cookie".to_owned(), + StaticCredentialScheme::ApiKey { + value: "secret".to_owned(), + }, + )] + .into_iter() + .collect(), + }; + assert!(matches!( + build_protocol_request( + &compiled.tools[0].binding, + &json!({ "cookies": { "user;admin=true": "yes" } }), + &credentials + ), + Err(OpenApiInvocationError::InvalidHeader(_)) + )); + + let without_parameter = json!({ + "openapi": "3.0.3", + "info": { "title": "Credential cookie safety" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "cookie": { "type": "apiKey", "in": "cookie", "name": "session\r\nX-Evil" } + }}, + "paths": { "/items": { "get": { + "security": [{ "cookie": [] }], + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&without_parameter).unwrap()).unwrap(); + assert!(matches!( + build_protocol_request(&compiled.tools[0].binding, &json!({}), &credentials), + Err(OpenApiInvocationError::InvalidHeader(_)) + )); +} + +#[test] +fn rejects_authentication_schemes_the_invoker_cannot_apply() { + for scheme in [ + json!({ "type": "http", "scheme": "digest" }), + json!({ "type": "mutualTLS" }), + ] { + let document = json!({ + "openapi": "3.1.0", + "info": { "title": "Unsupported authentication" }, + "components": { "securitySchemes": { "unsupported": scheme } }, + "paths": { "/items": { "get": { + "security": [{ "unsupported": [] }], + "responses": { "200": { "description": "ok" } } + }}} + }); + assert!(matches!( + compile_document(&serde_json::to_vec(&document).unwrap()), + Err(OpenApiError::InvalidOperation { .. }) + )); + } +} + +#[test] +fn public_request_builder_rejects_unknown_argument_members() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Strict arguments" }, + "servers": [{ "url": "https://api.example.test" }], + "paths": { "/items": { "get": { + "parameters": [{ + "name": "known", "in": "query", "schema": { "type": "string" } + }], + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + for arguments in [ + json!({ "unknown": true }), + json!({ "query": { "unknown": "value" } }), + json!({ "query": "not-an-object" }), + json!({ "body": { "unexpected": true } }), + json!({ "contentType": "application/json" }), + ] { + assert!(matches!( + build_protocol_request( + &compiled.tools[0].binding, + &arguments, + &StaticCredentialSet::default() + ), + Err(OpenApiInvocationError::InvalidArgument(_)) + )); + } +} + +#[test] +fn public_request_builder_rejects_identity_and_rewrite_headers() { + let names = [ + "X-HTTP-Method-Override", + "X-HTTP-Method", + "X-Method-Override", + "X-Original-Method", + "X-Real-IP", + "X-Original-URL", + "X-Rewrite-URL", + "X-Original-Host", + "X-Forwarded-Custom", + ]; + let parameters = names + .iter() + .map(|name| json!({ "name": name, "in": "header", "schema": { "type": "string" } })) + .collect::>(); + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Protected headers" }, + "servers": [{ "url": "https://api.example.test" }], + "paths": { "/items": { "post": { + "parameters": parameters, + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + for name in names { + assert!(matches!( + build_protocol_request( + &compiled.tools[0].binding, + &json!({ "headers": { (name): "spoofed" } }), + &StaticCredentialSet::default() + ), + Err(OpenApiInvocationError::InvalidHeader(_)) + )); + } +} + +#[test] +fn canonical_credentials_validate_and_round_trip_every_supported_type() { + assert!(serde_json::from_value::(json!({})).is_err()); + let credentials: OpenApiCredentialSet = serde_json::from_value(json!({ + "schemes": { + "headerKey": { "type": "api_key", "value": "key" }, + "bearer": { "type": "bearer", "token": "bearer-token" }, + "basic": { "type": "basic", "username": "user", "password": "password" }, + "oauth": { "type": "oauth_access_token", "access_token": "oauth-token" } + } + })) + .expect("the canonical credential vocabulary should deserialize"); + credentials.validate().expect("credentials should validate"); + assert_eq!( + credentials + .schemes + .values() + .map(OpenApiCredential::credential_type) + .collect::>(), + vec!["basic", "bearer", "api_key", "manual_oauth_access_token"] + ); + assert_eq!( + serde_json::to_value(&credentials).unwrap(), + json!({ + "schemes": { + "basic": { "type": "basic", "username": "user", "password": "password" }, + "bearer": { "type": "bearer", "token": "bearer-token" }, + "headerKey": { "type": "api_key", "value": "key" }, + "oauth": { "type": "oauth_access_token", "access_token": "oauth-token" } + } + }) + ); + + let invalid = OpenApiCredentialSet { + schemes: [( + String::new(), + OpenApiCredential::Bearer { + token: String::new(), + }, + )] + .into_iter() + .collect(), + }; + assert_eq!( + invalid.validate(), + Err(OpenApiCredentialError::InvalidSchemeName) + ); + for name in ["Auth\n", "Auth\r", "Auth\u{7f}"] { + let invalid = OpenApiCredentialSet { + schemes: [( + name.to_owned(), + OpenApiCredential::Bearer { + token: "token".to_owned(), + }, + )] + .into_iter() + .collect(), + }; + assert_eq!( + invalid.validate(), + Err(OpenApiCredentialError::InvalidSchemeName) + ); + } +} + +#[test] +fn public_openapi_dtos_reject_unknown_or_legacy_credential_fields() { + for value in [ + json!({ "scheme": {} }), + json!({ "schemes": {}, "unexpected": true }), + json!({ "schemes": { "key": { "type": "api_key", "value": "key", "typo": true } } }), + json!({ "schemes": { "bearer": { "type": "bearer", "token": "token", "typo": true } } }), + json!({ "schemes": { "basic": { + "type": "basic", "username": "user", "password": "password", "typo": true + } } }), + json!({ "schemes": { "oauth": { + "type": "oauth_access_token", "access_token": "token", "typo": true + } } }), + json!({ "schemes": { "oauth": { + "type": "oauth_access_token", "accessToken": "legacy-token" + } } }), + ] { + assert!( + serde_json::from_value::(value).is_err(), + "unknown and legacy fields must be rejected" + ); + } + + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Strict binding DTO" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "key": { "type": "apiKey", "in": "header", "name": "X-Api-Key" } + }}, + "paths": { "/items": { "post": { + "parameters": [{ "name": "limit", "in": "query", "schema": { "type": "integer" } }], + "requestBody": { "content": { + "application/json": { "schema": { "type": "object" } } + }}, + "security": [{ "key": [] }], + "responses": {} + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let binding = &compiled.tools[0].binding; + let serialized = serde_json::to_value(binding).unwrap(); + assert_eq!( + serde_json::from_value::(serialized.clone()).unwrap(), + *binding + ); + + for pointer in [ + "", + "/parameters/0", + "/requestBody", + "/security/0", + "/security/0/requirements/0", + "/security/0/requirements/0/scheme", + ] { + let mut invalid = serialized.clone(); + invalid + .pointer_mut(pointer) + .and_then(serde_json::Value::as_object_mut) + .expect("test pointer should select an object") + .insert("unexpected".to_owned(), json!(true)); + assert!( + serde_json::from_value::(invalid).is_err(), + "unknown field at {pointer} must be rejected" + ); + } +} + +#[test] +fn canonical_builder_applies_api_key_basic_bearer_and_oauth_credentials() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Authentication" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "headerKey": { "type": "apiKey", "in": "header", "name": "X-Api-Key" }, + "queryKey": { "type": "apiKey", "in": "query", "name": "api_key" }, + "cookieKey": { "type": "apiKey", "in": "cookie", "name": "session" }, + "basic": { "type": "http", "scheme": "basic" }, + "bearer": { "type": "http", "scheme": "bearer" }, + "oauth": { "type": "oauth2", "flows": {} } + }}, + "paths": { + "/keys": { "get": { + "security": [{ "headerKey": [], "queryKey": [], "cookieKey": [] }], + "responses": { "200": { "description": "ok" } } + }}, + "/basic": { "get": { + "security": [{ "basic": [] }], + "responses": { "200": { "description": "ok" } } + }}, + "/bearer": { "get": { + "security": [{ "bearer": [] }], + "responses": { "200": { "description": "ok" } } + }}, + "/oauth": { "get": { + "security": [{ "oauth": [] }], + "responses": { "200": { "description": "ok" } } + }} + } + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let credentials = OpenApiCredentialSet { + schemes: [ + ( + "headerKey", + OpenApiCredential::ApiKey { + value: "header-secret".to_owned(), + }, + ), + ( + "queryKey", + OpenApiCredential::ApiKey { + value: "query-secret".to_owned(), + }, + ), + ( + "cookieKey", + OpenApiCredential::ApiKey { + value: "cookie-secret".to_owned(), + }, + ), + ( + "basic", + OpenApiCredential::Basic { + username: "user".to_owned(), + password: "pass".to_owned(), + }, + ), + ( + "bearer", + OpenApiCredential::Bearer { + token: "bearer-secret".to_owned(), + }, + ), + ( + "oauth", + OpenApiCredential::OAuthAccessToken { + access_token: "oauth-secret".to_owned(), + }, + ), + ] + .into_iter() + .map(|(name, credential)| (name.to_owned(), credential)) + .collect(), + }; + let request = |path: &str| { + let binding = &compiled + .tools + .iter() + .find(|tool| tool.binding.path_template == path) + .unwrap() + .binding; + build_protocol_request(binding, &json!({}), &credentials).unwrap() + }; + + let keys = request("/keys"); + assert_eq!( + keys.headers.get("x-api-key").map(String::as_str), + Some("header-secret") + ); + assert_eq!( + keys.headers.get("cookie").map(String::as_str), + Some("session=cookie-secret") + ); + assert!( + keys.url + .query_pairs() + .any(|pair| pair == ("api_key".into(), "query-secret".into())) + ); + assert_eq!( + request("/basic").headers["authorization"], + "Basic dXNlcjpwYXNz" + ); + assert_eq!( + request("/bearer").headers["authorization"], + "Bearer bearer-secret" + ); + assert_eq!( + request("/oauth").headers["authorization"], + "Bearer oauth-secret" + ); +} + +#[test] +fn basic_credentials_reject_ambiguous_usernames_and_encode_valid_utf8_identities() { + let invalid = OpenApiCredentialSet { + schemes: [( + "basic".to_owned(), + OpenApiCredential::Basic { + username: "admin:other".to_owned(), + password: "secret".to_owned(), + }, + )] + .into_iter() + .collect(), + }; + assert_eq!( + invalid.validate(), + Err(OpenApiCredentialError::InvalidBasicUsername) + ); + + let document = json!({ + "openapi": "3.1.0", + "info": { "title": "Basic identity encoding" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "basic": { "type": "http", "scheme": "basic" } + }}, + "paths": { "/identity": { "get": { + "security": [{ "basic": [] }], + "responses": { "200": { "description": "ok" } } + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let request = |username: &str, password: &str| { + build_protocol_request( + &compiled.tools[0].binding, + &json!({}), + &OpenApiCredentialSet { + schemes: [( + "basic".to_owned(), + OpenApiCredential::Basic { + username: username.to_owned(), + password: password.to_owned(), + }, + )] + .into_iter() + .collect(), + }, + ) + .expect("valid Basic credentials should encode") + }; + + assert_eq!( + request("", "password:with:colons").headers["authorization"], + "Basic OnBhc3N3b3JkOndpdGg6Y29sb25z" + ); + assert_eq!( + request("δοκιμή", "pässword").headers["authorization"], + format!( + "Basic {}", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + "δοκιμή:pässword".as_bytes() + ) + ) + ); +} + +#[test] +fn canonical_builder_honors_or_and_anonymous_security_semantics() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Security semantics" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "first": { "type": "apiKey", "in": "header", "name": "X-First" }, + "second": { "type": "apiKey", "in": "query", "name": "second" } + }}, + "paths": { + "/or": { "get": { "security": [{ "first": [] }, { "second": [] }], "responses": {} } }, + "/and": { "get": { "security": [{ "first": [], "second": [] }], "responses": {} } }, + "/anonymous": { "get": { "security": [{ "first": [] }, {}], "responses": {} } } + } + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let second_only = OpenApiCredentialSet { + schemes: [( + "second".to_owned(), + OpenApiCredential::ApiKey { + value: "two".to_owned(), + }, + )] + .into_iter() + .collect(), + }; + let binding = |path: &str| { + &compiled + .tools + .iter() + .find(|tool| tool.binding.path_template == path) + .unwrap() + .binding + }; + assert!(build_protocol_request(binding("/or"), &json!({}), &second_only).is_ok()); + assert!(matches!( + build_protocol_request(binding("/and"), &json!({}), &second_only), + Err(OpenApiInvocationError::UnsatisfiedSecurity) + )); + assert!( + build_protocol_request( + binding("/anonymous"), + &json!({}), + &OpenApiCredentialSet::default() + ) + .is_ok() + ); +} + +#[test] +fn canonical_builder_falls_through_an_unusable_or_security_alternative() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Security fallback" }, + "servers": [{ "url": "https://api.example.test" }], + "components": { "securitySchemes": { + "badHeader": { "type": "apiKey", "in": "header", "name": "Host" }, + "bearer": { "type": "http", "scheme": "bearer" } + }}, + "paths": { "/items": { "get": { + "security": [{ "badHeader": [] }, { "bearer": [] }], + "responses": {} + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let credentials = OpenApiCredentialSet { + schemes: [ + ( + "badHeader".to_owned(), + OpenApiCredential::ApiKey { + value: "bad".to_owned(), + }, + ), + ( + "bearer".to_owned(), + OpenApiCredential::Bearer { + token: "good".to_owned(), + }, + ), + ] + .into_iter() + .collect(), + }; + let request = build_protocol_request(&compiled.tools[0].binding, &json!({}), &credentials) + .expect("the valid second OR alternative should be selected"); + assert_eq!(request.headers["authorization"], "Bearer good"); + assert!(!request.headers.contains_key("host")); +} + +#[test] +fn canonical_builder_serializes_cookie_form_explode_variants() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Cookies" }, + "servers": [{ "url": "https://api.example.test" }], + "paths": { "/cookies": { "get": { + "parameters": [ + { "name": "arrFalse", "in": "cookie", "explode": false, "schema": { "type": "array" } }, + { "name": "arrTrue", "in": "cookie", "explode": true, "schema": { "type": "array" } }, + { "name": "objectFalse", "in": "cookie", "explode": false, "schema": { "type": "object" } }, + { "name": "objectTrue", "in": "cookie", "explode": true, "schema": { "type": "object" } } + ], + "responses": {} + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let request = build_protocol_request( + &compiled.tools[0].binding, + &json!({ "cookies": { + "arrFalse": ["one", "two"], + "arrTrue": ["one", "two"], + "objectFalse": { "a": 1, "b": 2 }, + "objectTrue": { "a": 1, "b": 2 } + }}), + &OpenApiCredentialSet::default(), + ) + .unwrap(); + assert_eq!( + request.headers["cookie"], + "arrFalse=one%2Ctwo; arrTrue=one&arrTrue=two; objectFalse=a%2C1%2Cb%2C2; a=1&b=2" + ); +} + +#[test] +fn canonical_builder_encodes_bodies_resolves_relative_servers_and_validates_methods() { + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Transport" }, + "servers": [{ "url": "../v2" }], + "paths": { "/submit": { "post": { + "requestBody": { "content": { + "application/json": { "schema": { "type": "object" } }, + "application/x-www-form-urlencoded": { "schema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "additionalProperties": false + }}, + "text/plain": { "schema": { "type": "string" } } + }}, + "responses": {} + }}} + }); + let compiled = compile_document(&serde_json::to_vec(&document).unwrap()).unwrap(); + let binding = &compiled.tools[0].binding; + let base = Url::parse("https://api.example.test/specs/openapi.json").unwrap(); + let request = build_protocol_request_with_base( + binding, + &json!({ "body": { "name": "Ada" } }), + &OpenApiCredentialSet::default(), + Some(&base), + ) + .unwrap(); + assert_eq!(request.method, "POST"); + assert_eq!(request.url.as_str(), "https://api.example.test/v2/submit"); + assert_eq!(request.body, br#"{"name":"Ada"}"#); + + let form = build_protocol_request_with_base( + binding, + &json!({ "body": { "name": "Ada Lovelace" }, "contentType": "application/x-www-form-urlencoded" }), + &OpenApiCredentialSet::default(), + Some(&base), + ) + .unwrap(); + assert_eq!(form.body, b"name=Ada+Lovelace"); + let text = build_protocol_request_with_base( + binding, + &json!({ "body": "hello", "contentType": "text/plain" }), + &OpenApiCredentialSet::default(), + Some(&base), + ) + .unwrap(); + assert_eq!(text.body, b"hello"); + + let mut invalid = binding.clone(); + invalid.method = "POST\r\nX-Rewrite: yes".to_owned(); + assert!(matches!( + build_protocol_request_with_base( + &invalid, + &json!({}), + &OpenApiCredentialSet::default(), + Some(&base) + ), + Err(OpenApiInvocationError::InvalidArgument(argument)) if argument == "method" + )); +} + +#[test] +fn exact_success_response_precedes_2xx_wildcard_then_default() { + let compile = |include_exact: bool| { + let mut responses = serde_json::Map::from_iter([ + ( + "2XX".to_owned(), + json!({ + "description": "wildcard", + "content": { "application/json": { "schema": { "const": "wildcard" } } } + }), + ), + ( + "default".to_owned(), + json!({ + "description": "default", + "content": { "application/json": { "schema": { "const": "default" } } } + }), + ), + ]); + if include_exact { + responses.insert( + "201".to_owned(), + json!({ + "description": "exact", + "content": { "application/json": { "schema": { "const": "exact" } } } + }), + ); + } + let document = json!({ + "openapi": "3.0.3", + "info": { "title": "Response precedence" }, + "paths": { "/items": { "post": { "responses": responses } } } + }); + compile_document(&serde_json::to_vec(&document).unwrap()).unwrap() + }; + assert_eq!( + compile(false).tools[0].output_schema, + Some(json!({ "const": "wildcard" })) + ); + assert_eq!( + compile(true).tools[0].output_schema, + Some(json!({ "const": "exact" })) + ); +} diff --git a/tests/openapi_storage.rs b/tests/openapi_storage.rs new file mode 100644 index 000000000..3e9a34d35 --- /dev/null +++ b/tests/openapi_storage.rs @@ -0,0 +1,337 @@ +use std::collections::BTreeMap; + +use executor::{ + AppConfig, ExecutorApp, + catalog::{ + ArtifactKind, AuditContext, CatalogError, CatalogSnapshot, CreateSource, CredentialPayload, + ListToolsFilter, SourceKind, StagedArtifact, StagedTool, StagedToolBinding, ToolBinding, + ToolMode, + }, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, +}; +use serde_json::{Map, json}; + +async fn app() -> (tempfile::TempDir, ExecutorApp) { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + (directory, app) +} + +fn staged_binding(method: &str) -> StagedToolBinding { + StagedToolBinding { + stable_key: "stable-get-weather".to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: method.to_owned(), + path_template: "/weather".to_owned(), + server_url: "https://weather.example.test".to_owned(), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + } +} + +async fn source(app: &ExecutorApp) -> executor::catalog::SourceRecord { + app.catalog() + .create_source( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: "weather".to_owned(), + display_name: "Weather".to_owned(), + description: None, + configuration: Map::new(), + }, + AuditContext::system(Some("test-create")), + ) + .await + .expect("source should be created") +} + +fn snapshot(source_revision: i64, credential_revision: Option) -> CatalogSnapshot { + CatalogSnapshot { + expected_source_revision: source_revision, + expected_credential_revision: credential_revision, + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools: vec![StagedTool { + stable_key: "stable-get-weather".to_owned(), + preferred_name: "get_weather".to_owned(), + display_name: "Get weather".to_owned(), + description: None, + input_schema: json!({ "type": "object" }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: ToolMode::Enabled, + }], + } +} + +#[tokio::test] +async fn openapi_bindings_are_typed_complete_and_removed_with_the_source() { + let (_directory, app) = app().await; + let source = source(&app).await; + app.catalog() + .sync_catalog_with_bindings( + &source.id, + snapshot(source.revision, None), + vec![staged_binding("GET")], + AuditContext::system(Some("test-sync")), + ) + .await + .expect("catalog should sync"); + let tool = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list") + .items + .pop() + .expect("tool should exist"); + + let binding = app + .catalog() + .tool_binding(&tool.id) + .await + .expect("binding should read"); + assert_eq!( + binding.binding.openapi().expect("OpenAPI binding").method, + "GET" + ); + + let binding_before = sqlx::query_as::<_, (i64, i64, i64)>( + "SELECT revision, created_at, updated_at FROM tool_bindings WHERE tool_id = ?", + ) + .bind(&tool.id) + .fetch_one(app.pool()) + .await + .expect("binding metadata should read"); + let refreshed_source = app + .catalog() + .source(&source.id) + .await + .expect("source should read"); + app.catalog() + .sync_catalog_with_bindings( + &source.id, + snapshot(refreshed_source.revision, None), + vec![staged_binding("POST")], + AuditContext::system(Some("test-refresh")), + ) + .await + .expect("catalog binding should refresh in place"); + let binding_after = sqlx::query_as::<_, (i64, i64, i64, String)>( + "SELECT revision, created_at, updated_at, definition_json FROM tool_bindings WHERE tool_id = ?", + ) + .bind(&tool.id) + .fetch_one(app.pool()) + .await + .expect("refreshed binding metadata should read"); + assert_eq!(binding_after.0, binding_before.0 + 1); + assert_eq!(binding_after.1, binding_before.1); + assert!(binding_after.2 >= binding_before.2); + assert_eq!( + serde_json::from_str::(&binding_after.3) + .expect("binding should remain valid JSON")["method"], + "POST" + ); + + app.catalog() + .delete_source(&source.id, AuditContext::system(Some("test-delete"))) + .await + .expect("source should delete"); + let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM tool_bindings") + .fetch_one(app.pool()) + .await + .expect("binding count should read"); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn binding_write_failure_rolls_back_the_entire_catalog_refresh() { + let (_directory, app) = app().await; + let source = source(&app).await; + app.catalog() + .sync_catalog_with_bindings( + &source.id, + snapshot(source.revision, None), + vec![staged_binding("GET")], + AuditContext::system(Some("initial-sync")), + ) + .await + .expect("initial catalog should sync"); + let source_before = app + .catalog() + .source(&source.id) + .await + .expect("source should read"); + let global_revision_before = app + .catalog() + .global_revision() + .await + .expect("global revision should read"); + let tool_before = app + .catalog() + .list_tools(ListToolsFilter { + source_id: Some(source.id.clone()), + limit: 10, + ..Default::default() + }) + .await + .expect("tool should list") + .items + .remove(0); + let binding_before = sqlx::query_as::<_, (i64, i64, String)>( + "SELECT revision, created_at, definition_json FROM tool_bindings WHERE tool_id = ?", + ) + .bind(&tool_before.id) + .fetch_one(app.pool()) + .await + .expect("binding should read"); + let artifact_before = sqlx::query_scalar::<_, String>( + "SELECT content_json FROM source_artifacts WHERE source_id = ?", + ) + .bind(&source.id) + .fetch_one(app.pool()) + .await + .expect("artifact should read"); + sqlx::query( + "CREATE TRIGGER reject_binding_refresh BEFORE UPDATE ON tool_bindings \ + BEGIN SELECT RAISE(ABORT, 'binding write rejected'); END", + ) + .execute(app.pool()) + .await + .expect("failure trigger should install"); + + let mut replacement = snapshot(source_before.revision, None); + replacement.artifacts[0].content = json!({ "openapi": "3.1.1" }); + replacement.tools[0].display_name = "Changed weather".to_owned(); + let error = app + .catalog() + .sync_catalog_with_bindings( + &source.id, + replacement, + vec![staged_binding("POST")], + AuditContext::system(Some("failed-refresh")), + ) + .await + .expect_err("binding failure should abort refresh"); + assert!(matches!(error, CatalogError::Database(_))); + + let source_after = app + .catalog() + .source(&source.id) + .await + .expect("source should remain readable"); + assert_eq!(source_after.revision, source_before.revision); + assert_eq!( + source_after.catalog_revision, + source_before.catalog_revision + ); + assert_eq!( + app.catalog() + .global_revision() + .await + .expect("global revision should read"), + global_revision_before + ); + let tool_after = app + .catalog() + .tool(&tool_before.id) + .await + .expect("tool should remain readable"); + assert_eq!(tool_after.revision, tool_before.revision); + assert_eq!(tool_after.display_name, tool_before.display_name); + let binding_after = sqlx::query_as::<_, (i64, i64, String)>( + "SELECT revision, created_at, definition_json FROM tool_bindings WHERE tool_id = ?", + ) + .bind(&tool_before.id) + .fetch_one(app.pool()) + .await + .expect("binding should remain readable"); + assert_eq!(binding_after, binding_before); + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT content_json FROM source_artifacts WHERE source_id = ?", + ) + .bind(&source.id) + .fetch_one(app.pool()) + .await + .expect("artifact should remain readable"), + artifact_before + ); +} + +#[tokio::test] +async fn credentials_delete_with_compare_and_swap_and_never_store_plaintext() { + let (_directory, app) = app().await; + let source = source(&app).await; + let secret = "never-store-this-api-key"; + app.catalog() + .put_credential( + &source.id, + &CredentialPayload { + schema_version: 1, + payload: json!({ "schemes": { "ApiKey": { "type": "api_key", "value": secret } } }), + }, + None, + AuditContext::system(Some("test-put-credential")), + ) + .await + .expect("credential should store"); + let stored = app + .catalog() + .credential(&source.id) + .await + .expect("credential should read") + .expect("credential should exist"); + assert_eq!( + stored.credential.payload["schemes"]["ApiKey"]["value"], + secret + ); + let raw = sqlx::query_scalar::<_, Vec>( + "SELECT payload_ciphertext FROM source_credentials WHERE source_id = ?", + ) + .bind(&source.id) + .fetch_one(app.pool()) + .await + .expect("ciphertext should read"); + assert!(!String::from_utf8_lossy(&raw).contains(secret)); + + let conflict = app + .catalog() + .delete_credential( + &source.id, + stored.revision + 1, + AuditContext::system(Some("test-delete-conflict")), + ) + .await; + assert!(matches!( + conflict, + Err(executor::catalog::CatalogError::RevisionConflict { .. }) + )); + app.catalog() + .delete_credential( + &source.id, + stored.revision, + AuditContext::system(Some("test-delete-credential")), + ) + .await + .expect("matching revision should delete"); + assert!( + app.catalog() + .credential(&source.id) + .await + .expect("credential state should read") + .is_none() + ); +} diff --git a/tests/outbound.rs b/tests/outbound.rs new file mode 100644 index 000000000..7bfacc0ba --- /dev/null +++ b/tests/outbound.rs @@ -0,0 +1,483 @@ +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use executor::outbound::{ + AddressClass, HardenedHttpClient, OutboundError, OutboundPolicy, OutboundRequest, classify_ip, + parse_url, redirect_target, validate_url, +}; +use reqwest::{Method, header::HeaderValue}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; +use url::Url; + +async fn read_request(stream: &mut TcpStream) -> String { + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream + .read(&mut buffer) + .await + .expect("test request is readable"); + assert!(read > 0, "test request ended before its headers"); + request.extend_from_slice(&buffer[..read]); + } + String::from_utf8(request).expect("test request headers are UTF-8") +} + +#[test] +fn private_network_opt_in_never_allows_link_local_or_metadata_addresses() { + let denied = OutboundPolicy::default(); + let allowed = OutboundPolicy { + allow_private_networks: true, + ..OutboundPolicy::default() + }; + + let private = Url::parse("http://10.20.30.40/spec.json").expect("private URL parses"); + assert!(matches!( + validate_url(&private, &denied), + Err(OutboundError::PrivateAddress) + )); + assert!(validate_url(&private, &allowed).is_ok()); + let tailscale = Url::parse("http://100.64.1.2/openapi.json").expect("tailnet URL parses"); + assert!(validate_url(&tailscale, &allowed).is_ok()); + + for target in [ + "http://169.254.169.254/latest/meta-data/", + "http://169.254.170.2/credentials", + "http://100.100.100.200/latest/meta-data/", + "http://[fe80::1]/metadata", + "http://[fd00:ec2::254]/latest/meta-data/", + "http://[fd20:ce::254]/computeMetadata/v1/", + "http://[fd00:c1::a9fe:a9fe]/opc/v2/", + ] { + let target = Url::parse(target).expect("forbidden URL parses"); + assert!(matches!( + validate_url(&target, &allowed), + Err(OutboundError::ForbiddenAddress) + )); + } +} + +#[test] +fn ipv4_mapped_ipv6_cannot_bypass_private_address_rules() { + let mapped = IpAddr::V6(Ipv6Addr::from_bits( + (0xffff_u128 << 32) | u32::from(Ipv4Addr::new(127, 0, 0, 1)) as u128, + )); + assert_eq!(classify_ip(mapped), AddressClass::Private); +} + +#[test] +fn special_use_and_encapsulated_addresses_are_not_public() { + let cases = [ + ("100.64.0.1", AddressClass::Private), + ("100.100.100.200", AddressClass::Forbidden), + ("192.0.2.1", AddressClass::Forbidden), + ("198.18.0.1", AddressClass::Forbidden), + ("203.0.113.1", AddressClass::Forbidden), + ("224.0.0.1", AddressClass::Forbidden), + ("2001:db8::1", AddressClass::Forbidden), + ("2001:5::1", AddressClass::Forbidden), + ("3fff:0fff:ffff::1", AddressClass::Forbidden), + ("3fff:1000::1", AddressClass::Public), + ("fc00::1", AddressClass::Private), + ("fd00:ec2::254", AddressClass::Forbidden), + ("fd20:ce::254", AddressClass::Forbidden), + ("fd00:c1::a9fe:a9fe", AddressClass::Forbidden), + ("2002:7f00:0001::", AddressClass::Private), + ("2606:4700:4700::1111", AddressClass::Public), + ("8.8.8.8", AddressClass::Public), + ]; + for (address, expected) in cases { + assert_eq!( + classify_ip(address.parse().expect("test address parses")), + expected, + "unexpected class for {address}" + ); + } +} + +#[test] +fn urls_reject_credential_fragments_and_non_http_schemes() { + let policy = OutboundPolicy::default(); + let cases = [ + ( + "https://user:secret@example.com/spec", + "outbound_credentials_not_allowed", + ), + ( + "https://example.com/spec#secret", + "outbound_fragment_not_allowed", + ), + ("file:///etc/passwd", "unsupported_outbound_scheme"), + ]; + for (url, expected_code) in cases { + let error = validate_url(&Url::parse(url).expect("test URL parses"), &policy) + .expect_err("unsafe URL is denied"); + assert_eq!(error.code(), expected_code); + } +} + +#[test] +fn malformed_url_returns_a_stable_public_error() { + let error = + parse_url("not a URL", &OutboundPolicy::default()).expect_err("malformed URL is rejected"); + assert_eq!(error.code(), "invalid_outbound_url"); + assert_eq!(error.to_string(), "the outbound URL is invalid"); +} + +#[test] +fn redirects_resolve_relative_locations_and_deny_downgrades() { + let policy = OutboundPolicy::default(); + let current = Url::parse("https://example.com/apis/openapi.json").expect("URL parses"); + let relative = HeaderValue::from_static("../v2/openapi.json?revision=2"); + let next = redirect_target(¤t, Some(&relative), &policy).expect("redirect is valid"); + assert_eq!( + next.as_str(), + "https://example.com/v2/openapi.json?revision=2" + ); + + let downgrade = HeaderValue::from_static("http://example.com/openapi.json"); + assert!(matches!( + redirect_target(¤t, Some(&downgrade), &policy), + Err(OutboundError::RedirectDowngrade) + )); + + let secure_cross_origin = HeaderValue::from_static("https://cdn.example.net/openapi.json"); + assert_eq!( + redirect_target(¤t, Some(&secure_cross_origin), &policy) + .expect("HTTPS redirects remain allowed") + .as_str(), + "https://cdn.example.net/openapi.json" + ); +} + +#[test] +fn redirects_revalidate_literal_targets_at_every_hop() { + let policy = OutboundPolicy::default(); + let current = Url::parse("https://example.com/openapi.json").expect("URL parses"); + let metadata = HeaderValue::from_static("https://169.254.169.254/latest/meta-data/"); + assert!(matches!( + redirect_target(¤t, Some(&metadata), &policy), + Err(OutboundError::ForbiddenAddress) + )); +} + +#[tokio::test] +async fn cross_origin_redirects_drop_custom_credentials() { + let destination = TcpListener::bind("127.0.0.1:0") + .await + .expect("destination binds"); + let destination_address = destination + .local_addr() + .expect("destination has an address"); + let destination_task = tokio::spawn(async move { + let (mut stream, _) = destination.accept().await.expect("destination accepts"); + let request = read_request(&mut stream).await; + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") + .await + .expect("destination responds"); + request + }); + + let redirector = TcpListener::bind("127.0.0.1:0") + .await + .expect("redirector binds"); + let redirector_address = redirector.local_addr().expect("redirector has an address"); + let redirector_task = tokio::spawn(async move { + let (mut stream, _) = redirector.accept().await.expect("redirector accepts"); + let _request = read_request(&mut stream).await; + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: http://{destination_address}/final\r\nContent-Length: 0\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .expect("redirector responds"); + }); + + let policy = OutboundPolicy { + allow_private_networks: true, + ..OutboundPolicy::default() + }; + let client = HardenedHttpClient::new(policy); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("x-api-key", HeaderValue::from_static("super-secret")); + let response = client + .fetch_spec( + Url::parse(&format!("http://{redirector_address}/spec")).expect("redirect URL parses"), + headers, + ) + .await + .expect("redirected fetch succeeds"); + assert_eq!(response.body, b"{}"); + assert_eq!(response.final_url.path(), "/final"); + redirector_task.await.expect("redirector task completes"); + let destination_request = destination_task + .await + .expect("destination task completes") + .to_ascii_lowercase(); + assert!(!destination_request.contains("x-api-key")); + assert!(!destination_request.contains("super-secret")); +} + +#[tokio::test] +async fn custom_url_policy_rejects_a_multi_hop_target_before_request() { + let blocked = TcpListener::bind("127.0.0.1:0") + .await + .expect("blocked target binds"); + let blocked_address = blocked.local_addr().expect("blocked target has an address"); + + let second = TcpListener::bind("127.0.0.1:0") + .await + .expect("second redirector binds"); + let second_address = second + .local_addr() + .expect("second redirector has an address"); + let second_task = tokio::spawn(async move { + let (mut stream, _) = second.accept().await.expect("second redirector accepts"); + let _request = read_request(&mut stream).await; + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: http://{blocked_address}/blocked\r\nContent-Length: 0\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .expect("second redirector responds"); + }); + + let first = TcpListener::bind("127.0.0.1:0") + .await + .expect("first redirector binds"); + let first_address = first.local_addr().expect("first redirector has an address"); + let first_task = tokio::spawn(async move { + let (mut stream, _) = first.accept().await.expect("first redirector accepts"); + let _request = read_request(&mut stream).await; + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: http://{second_address}/next\r\nContent-Length: 0\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .expect("first redirector responds"); + }); + + let client = HardenedHttpClient::new(OutboundPolicy { + allow_private_networks: true, + ..OutboundPolicy::default() + }); + let error = match client + .fetch_spec_with_url_policy( + Url::parse(&format!("http://{first_address}/spec")).expect("spec URL parses"), + reqwest::header::HeaderMap::new(), + |candidate| { + if candidate.port_or_known_default() == Some(blocked_address.port()) { + Err(OutboundError::RedirectDowngrade) + } else { + Ok(()) + } + }, + ) + .await + { + Err(error) => error, + Ok(_) => panic!("the custom policy must reject the final redirect target"), + }; + assert!(matches!(error, OutboundError::RedirectDowngrade)); + first_task.await.expect("first redirector completes"); + second_task.await.expect("second redirector completes"); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), blocked.accept()) + .await + .is_err(), + "the rejected redirect target must receive no request" + ); +} + +#[tokio::test] +async fn ordinary_execution_never_follows_a_credential_bearing_redirect() { + let destination = TcpListener::bind("127.0.0.1:0") + .await + .expect("redirect destination binds"); + let destination_address = destination + .local_addr() + .expect("redirect destination has an address"); + let redirector = TcpListener::bind("127.0.0.1:0") + .await + .expect("redirector binds"); + let redirector_address = redirector.local_addr().expect("redirector has an address"); + let redirector_task = tokio::spawn(async move { + let (mut stream, _) = redirector.accept().await.expect("redirector accepts"); + let request = read_request(&mut stream).await; + assert!(request.to_ascii_lowercase().contains("x-api-key: secret")); + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: http://{destination_address}/leak\r\nContent-Length: 0\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .expect("redirector responds"); + }); + let client = HardenedHttpClient::new(OutboundPolicy { + allow_private_networks: true, + ..OutboundPolicy::default() + }); + let mut request = OutboundRequest::new( + Method::GET, + Url::parse(&format!("http://{redirector_address}/start")).expect("redirector URL parses"), + ); + request + .headers + .insert("x-api-key", HeaderValue::from_static("secret")); + let response = client + .execute(request) + .await + .expect("redirect response is returned without following it"); + assert_eq!(response.status, reqwest::StatusCode::FOUND); + redirector_task.await.expect("redirector completes"); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), destination.accept(),) + .await + .is_err(), + "the credential-bearing redirect target must receive no request" + ); +} + +#[tokio::test] +async fn response_size_cap_rejects_declared_oversize_before_returning_body() { + let server = TcpListener::bind("127.0.0.1:0") + .await + .expect("server binds"); + let address = server.local_addr().expect("server has an address"); + let server_task = tokio::spawn(async move { + let (mut stream, _) = server.accept().await.expect("server accepts"); + let _request = read_request(&mut stream).await; + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n12345") + .await + .expect("server responds"); + }); + let policy = OutboundPolicy { + allow_private_networks: true, + max_response_bytes: 4, + ..OutboundPolicy::default() + }; + let client = HardenedHttpClient::new(policy); + let request = OutboundRequest::new( + Method::GET, + Url::parse(&format!("http://{address}/oversize")).expect("request URL parses"), + ); + assert!(matches!( + client.execute(request).await, + Err(OutboundError::ResponseBodyTooLarge) + )); + server_task.await.expect("server task completes"); +} + +#[tokio::test] +async fn streaming_response_yields_before_connection_eof_and_keeps_byte_cap() { + let server = TcpListener::bind("127.0.0.1:0") + .await + .expect("server binds"); + let address = server.local_addr().expect("server has an address"); + let server_task = tokio::spawn(async move { + let (mut stream, _) = server.accept().await.expect("server accepts"); + let _request = read_request(&mut stream).await; + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nfirst") + .await + .expect("first chunk writes"); + stream.flush().await.expect("first chunk flushes"); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + }); + let policy = OutboundPolicy { + allow_private_networks: true, + max_response_bytes: 5, + ..OutboundPolicy::default() + }; + let client = HardenedHttpClient::new(policy); + let request = OutboundRequest::new( + Method::GET, + Url::parse(&format!("http://{address}/stream")).expect("request URL parses"), + ); + let mut response = client + .execute_streaming(request) + .await + .expect("stream headers arrive"); + let first = tokio::time::timeout(std::time::Duration::from_millis(500), response.next_chunk()) + .await + .expect("chunk arrives before EOF") + .expect("chunk is valid") + .expect("chunk exists"); + assert_eq!(first, b"first"); + server_task.abort(); +} + +#[tokio::test] +async fn long_lived_stream_uses_idle_timeout_instead_of_request_deadline() { + let server = TcpListener::bind("127.0.0.1:0") + .await + .expect("server binds"); + let address = server.local_addr().expect("server has an address"); + let server_task = tokio::spawn(async move { + let (mut stream, _) = server.accept().await.expect("server accepts"); + let _request = read_request(&mut stream).await; + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\nfirst") + .await + .expect("headers write"); + stream.flush().await.expect("headers flush"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + stream.write_all(b"again").await.expect("event writes"); + stream.flush().await.expect("event flushes"); + }); + let policy = OutboundPolicy { + allow_private_networks: true, + request_timeout: std::time::Duration::from_millis(50), + max_response_bytes: 5, + ..OutboundPolicy::default() + }; + let client = HardenedHttpClient::new(policy); + let request = OutboundRequest::new( + Method::GET, + Url::parse(&format!("http://{address}/events")).expect("request URL parses"), + ); + let mut response = client + .execute_long_lived_streaming(request, std::time::Duration::from_millis(500)) + .await + .expect("long-lived headers arrive"); + let first = response + .next_chunk() + .await + .expect("idle deadline permits delayed event") + .expect("event chunk exists"); + let second = response + .next_chunk() + .await + .expect("cumulative lifetime bytes do not exhaust the per-chunk cap") + .expect("second chunk exists"); + assert_eq!(first, b"first"); + assert_eq!(second, b"again"); + server_task.await.expect("server task completes"); +} + +#[tokio::test] +async fn trace_and_connect_are_rejected_before_network_work() { + let policy = OutboundPolicy { + allow_private_networks: true, + ..OutboundPolicy::default() + }; + let client = HardenedHttpClient::new(policy); + let url = Url::parse("http://127.0.0.1:9/").expect("request URL parses"); + for method in [Method::TRACE, Method::CONNECT] { + let error = match client + .execute(OutboundRequest::new(method, url.clone())) + .await + { + Err(error) => error, + Ok(_) => panic!("unsafe method is rejected"), + }; + assert_eq!(error.code(), "forbidden_outbound_method"); + } +} diff --git a/tests/presets-reachable.test.ts b/tests/presets-reachable.test.ts index aeca000f6..a8c720630 100644 --- a/tests/presets-reachable.test.ts +++ b/tests/presets-reachable.test.ts @@ -176,7 +176,7 @@ describe("public preset URLs are detected by the correct plugin", () => { () => Effect.gen(function* () { const executor = yield* makeExecutor(); - const results = yield* executor.sources.detect(preset.url); + const results = yield* executor.integrations.detect(preset.url); expect( results.length, diff --git a/tests/release-bootstrap-smoke.test.ts b/tests/release-bootstrap-smoke.test.ts index 7ddb6f9cc..b199189a0 100644 --- a/tests/release-bootstrap-smoke.test.ts +++ b/tests/release-bootstrap-smoke.test.ts @@ -14,7 +14,7 @@ type CommandResult = { }; const repoRoot = resolve(dirnameOf(import.meta.url), ".."); -const cliRoot = join(repoRoot, "apps/cli"); +const cliRoot = join(repoRoot, "legacy/cli"); const distDir = join(cliRoot, "dist"); function dirnameOf(url: string): string { diff --git a/tests/release-dockerfile.test.ts b/tests/release-dockerfile.test.ts new file mode 100644 index 000000000..3692d1d7e --- /dev/null +++ b/tests/release-dockerfile.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "@effect/vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const dockerfile = readFileSync(resolve(repoRoot, "Dockerfile"), "utf8"); +const nativeDockerfile = readFileSync(resolve(repoRoot, "Dockerfile.release-native"), "utf8"); +const packageDockerfile = readFileSync(resolve(repoRoot, "Dockerfile.release-package"), "utf8"); +const dockerignore = readFileSync(resolve(repoRoot, "Dockerfile.dockerignore"), "utf8"); +const serviceSource = readFileSync(resolve(repoRoot, "src/service.rs"), "utf8"); +const release = readFileSync(resolve(repoRoot, ".github/workflows/release.yml"), "utf8"); +const compose = readFileSync(resolve(repoRoot, "compose.yaml"), "utf8"); +const dockerDocs = readFileSync(resolve(repoRoot, "docs/docker.md"), "utf8"); +const installDocs = readFileSync(resolve(repoRoot, "docs/install.md"), "utf8"); + +const workflowJob = (name: string, nextName: string) => + release.slice(release.indexOf(` ${name}:`), release.indexOf(` ${nextName}:`)); + +describe("native release artifact reuse", () => { + it("builds the web payload once and downloads it into every native leg", () => { + expect(release.match(/bun run --cwd web build/gu)).toHaveLength(1); + + const web = workflowJob("web", "notices"); + const native = workflowJob("build-native", "package-native"); + expect(web).toContain("name: executor-web-build"); + expect(web).toContain("path: web/build/"); + expect(native).toContain("name: executor-web-build"); + expect(native).toContain("path: web/build"); + expect(native).not.toContain("bun run --cwd web build"); + expect(native).not.toContain("oven-sh/setup-bun"); + }); + + it("boots every packaged native archive against the canonical web payload", () => { + const smoke = workflowJob("smoke-native", "checksums"); + + for (const target of [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + ]) { + expect(smoke).toContain(`target: ${target}`); + } + expect(smoke).toContain("name: executor-release-archives"); + expect(smoke).toContain("name: executor-web-build"); + expect(smoke).toContain("shasum -a 256 --check"); + expect(smoke).toContain("Complete first-boot setup at:"); + expect(smoke).toContain("scripts/smoke-release-server.sh"); + expect(release).toContain("checksums:\n name: Assemble checksum manifest\n needs:"); + expect(release).toContain(" - smoke-native"); + }); + + it("keeps the default Docker target self-contained and adds a prebuilt release target", () => { + const stageNames = [...dockerfile.matchAll(/^FROM .+ AS (\S+)$/gmu)].map((match) => match[1]); + expect(stageNames).toEqual([ + "web-builder", + "rust-builder", + "runtime-base", + "runtime-prebuilt", + "runtime", + ]); + + const prebuilt = dockerfile.slice( + dockerfile.indexOf("FROM runtime-base AS runtime-prebuilt"), + dockerfile.indexOf("FROM runtime-base AS runtime\n"), + ); + const runtime = dockerfile.slice(dockerfile.indexOf("FROM runtime-base AS runtime\n")); + expect(prebuilt).toContain("COPY --from=prebuilt-executor"); + expect(prebuilt).not.toContain("cargo build"); + expect(prebuilt).not.toContain("bun run"); + expect(runtime).toContain("COPY --from=rust-builder /usr/local/bin/executor"); + expect(dockerfile).toContain( + "FROM oven/bun:1.3.11@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS web-builder", + ); + expect(dockerfile).toContain( + "FROM rust:1.96-bookworm@sha256:6d19f49541d185805745b8baa781b1fd482118c81a3154510ee18dcce985d005 AS rust-builder", + ); + }); + + it("enables cargo-about's CLI binary everywhere it is installed", () => { + const installs = [dockerfile, release].flatMap( + (source) => source.match(/cargo install cargo-about[^\n]*/gu) ?? [], + ); + + expect(installs.length).toBeGreaterThan(0); + for (const install of installs) { + expect(install).toContain("--features cli"); + } + }); + + it("builds each release image from its matching Linux archive and assembles a manifest", () => { + const containers = release.slice(release.indexOf(" build-container-dry-run:")); + expect(containers).toContain("name: executor-release-artifacts"); + expect(containers).toContain("target: runtime-prebuilt"); + expect(containers).toContain("prebuilt-executor=${{ runner.temp }}/prebuilt-executor"); + expect(containers).toContain("platform: linux/amd64"); + expect(containers).toContain("platform: linux/arm64"); + expect(containers).toContain("push-by-digest=true"); + expect(containers).toContain('--tag "$staging"'); + expect(containers).not.toContain("cargo build"); + expect(containers).not.toContain("bun run --cwd web build"); + }); + + it("smokes the release-only image path before promotion", () => { + const dryRun = workflowJob("build-container-dry-run", "publish-container-arch"); + const published = workflowJob("smoke-container", "promote"); + const promote = release.slice(release.indexOf(" promote:")); + + expect(dryRun).toContain("load: true"); + expect(dryRun).toContain("scripts/smoke-release-container.sh"); + expect(published).toContain('reference="$IMAGE@$IMAGE_DIGEST"'); + expect(published).toContain('docker pull "$reference"'); + expect(published).toContain("scripts/smoke-release-container.sh"); + expect(published).toContain("name: executor-web-build"); + expect(published).toContain("name: Verify exact digest is anonymously pullable"); + expect(published).toContain('DOCKER_CONFIG="$anonymous_config" docker pull'); + expect(promote).toContain(" - smoke-container"); + }); + + it("documents and configures a published prebuilt Compose start flow", () => { + expect(compose).toContain('image: "${EXECUTOR_IMAGE:-executor:local}"'); + expect(dockerDocs).toContain("export EXECUTOR_IMAGE=ghcr.io/davis7dotsh/executor:v0.1.0"); + expect(dockerDocs).toContain("docker compose up --detach --no-build executor"); + expect(dockerDocs).toContain("curl --fail --silent --show-error"); + expect(installDocs).toContain("shasum -a 256 --check SHA256SUMS"); + }); + + it("pins runtime inputs and normalizes image timestamps for retry-safe digests", () => { + const containers = release.slice(release.indexOf(" build-container-dry-run:")); + const native = workflowJob("build-native", "smoke-native"); + const promote = release.slice(release.indexOf(" promote:")); + + expect(dockerfile).toContain( + "FROM debian:bookworm-20260623-slim@sha256:60eac759739651111db372c07be67863818726f754804b8707c90979bda511df AS runtime-base", + ); + expect(dockerfile).toContain( + "# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e", + ); + expect(dockerfile).toContain("ARG DEBIAN_SNAPSHOT=20260623T000000Z"); + expect(dockerfile).toContain("snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}"); + expect(release).toContain('source_date_epoch="$(git show -s --format=%ct HEAD)"'); + expect(release).toContain( + "BUILDKIT_IMAGE: moby/buildkit:v0.30.0@sha256:0168606be2315b7c807a03b3d8aa79beefdb31c98740cebdffdfeebf31190c9f", + ); + expect(release).toContain("BUILDX_VERSION: v0.34.1"); + expect(release.match(/uses: docker\/setup-buildx-action@/gu)).toHaveLength( + release.match(/driver-opts: image=\$\{\{ env\.BUILDKIT_IMAGE \}\}/gu)?.length ?? 0, + ); + expect(release.match(/uses: docker\/setup-buildx-action@/gu)).toHaveLength( + release.match(/version: \$\{\{ env\.BUILDX_VERSION \}\}/gu)?.length ?? 0, + ); + expect(nativeDockerfile).toContain( + "FROM ubuntu:22.04@sha256:4f838adc7181d9039ac795a7d0aba05a9bd9ecd480d294483169c5def983b64d AS builder", + ); + expect(nativeDockerfile).toContain("ARG UBUNTU_SNAPSHOT=20260623T000000Z"); + expect(nativeDockerfile).toContain( + "checksum=c295047583a56238ea06b43f849f4b877fa12bfd4c7103f8d9a74c94c9c4e108", + ); + expect(nativeDockerfile).toContain( + "checksum=371eadcca97062219cbd8593628eb5d2802bc370515d085fedce1b56b2baed57", + ); + expect(native).toContain("SOURCE_DATE_EPOCH: ${{ needs.validate.outputs.source_date_epoch }}"); + expect(native).toContain("file: Dockerfile.release-native"); + expect(native).toContain("driver-opts: image=${{ env.BUILDKIT_IMAGE }}"); + expect(native).toContain("Verify Linux glibc 2.35 compatibility ceiling"); + expect(native).not.toContain("python3 scripts/package-release-archive.py"); + expect(native).not.toContain('tar -C "$stage" -czf'); + expect(release).toContain("name: Package native archives in pinned compressor environment"); + expect(release).toContain("file: Dockerfile.release-package"); + expect(release).toContain("name: Repeat pinned archive build"); + expect(release).toContain("REPRODUCIBILITY_RUN=first"); + expect(release).toContain("REPRODUCIBILITY_RUN=second"); + expect(release).toContain('cmp "$first/$archive" "$second/$archive"'); + expect(packageDockerfile).toContain( + "# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e", + ); + expect(packageDockerfile).toContain( + "FROM ubuntu:22.04@sha256:4f838adc7181d9039ac795a7d0aba05a9bd9ecd480d294483169c5def983b64d AS packager", + ); + expect(packageDockerfile).toContain("ARG UBUNTU_SNAPSHOT=20260623T000000Z"); + expect(packageDockerfile).toContain("python3-minimal"); + expect(packageDockerfile).toContain("python3 /usr/local/bin/package-release-archive.py"); + expect(packageDockerfile).toContain("> /output/BUILD-TOOLCHAINS.txt"); + expect(release).toContain('cmp "$first/BUILD-TOOLCHAINS.txt"'); + expect(release).toContain("sha256sum BUILD-TOOLCHAINS.txt >> SHA256SUMS"); + expect(release).not.toContain("actions/attest-build-provenance@"); + expect(containers.match(/SOURCE_DATE_EPOCH:/gu)).toHaveLength(2); + expect(containers.match(/provenance: false/gu)).toHaveLength(2); + expect(containers.match(/sbom: false/gu)).toHaveLength(2); + expect(containers).toContain("push=true,rewrite-timestamp=true"); + expect(dockerfile).toContain( + "FROM debian:bookworm-20260623-slim@sha256:60eac759739651111db372c07be67863818726f754804b8707c90979bda511df AS runtime-base\n\nARG SOURCE_DATE_EPOCH", + ); + expect(dockerfile).toContain('date --utc --date="@${SOURCE_DATE_EPOCH:-0}"'); + expect(promote).toContain("name: Verify published GitHub release is immutable"); + expect(promote).toContain('gh release verify "$RELEASE_TAG"'); + }); + + it("stages every embedded service asset in both Rust Docker build contexts", () => { + const embeddedPaths = [...serviceSource.matchAll(/include_bytes!\("\.\.\/([^"\n]+)"\)/gu)].map( + (match) => match[1], + ); + + expect(embeddedPaths.length).toBeGreaterThan(0); + for (const path of embeddedPaths) { + expect(dockerfile).toContain(`COPY ${path} ./${path}`); + expect(nativeDockerfile).toContain(`COPY ${path} ./${path}`); + expect(dockerignore).toContain(`!${path}`); + } + }); + + it("preflights immutable version tags and allows only the channel tag to move", () => { + const promote = release.slice(release.indexOf(" promote:")); + const preflight = promote.indexOf( + 'immutable_tags=("$RELEASE_TAG" "$VERSION" "sha-$RELEASE_SHA")', + ); + const assignment = promote.indexOf('for tag in "${missing_tags[@]}"'); + const publish = promote.indexOf("name: Publish GitHub release"); + const verifyImmutable = promote.indexOf("name: Verify published GitHub release is immutable"); + const verifyAttestation = promote.indexOf('gh release verify "$RELEASE_TAG"'); + const channelStep = promote.indexOf( + "name: Move mutable channel after GitHub release publication", + ); + const channelMove = promote.indexOf('--tag "$channel_reference"'); + + expect(promote).toContain('existing="$(inspect_digest "$reference")"'); + expect(promote).toContain('if [ "$existing" != "$IMAGE_DIGEST" ]'); + expect(promote).toContain("already points to the expected digest"); + expect(release).toContain('staging="$IMAGE:staging-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"'); + expect(preflight).toBeGreaterThan(-1); + expect(assignment).toBeGreaterThan(preflight); + expect(publish).toBeGreaterThan(assignment); + expect(verifyImmutable).toBeGreaterThan(publish); + expect(verifyAttestation).toBeGreaterThan(verifyImmutable); + expect(channelStep).toBeGreaterThan(verifyAttestation); + expect(channelMove).toBeGreaterThan(channelStep); + }); +}); diff --git a/tests/release-workflows.test.ts b/tests/release-workflows.test.ts index 310bbe73c..b059ceb39 100644 --- a/tests/release-workflows.test.ts +++ b/tests/release-workflows.test.ts @@ -1,11 +1,39 @@ import { describe, expect, it } from "@effect/vitest"; -import { readFileSync } from "node:fs"; +import { Schema } from "effect"; +import { spawnSync } from "node:child_process"; +import { existsSync, globSync, readFileSync, readdirSync } from "node:fs"; import { resolve } from "node:path"; import { validateReleaseTag, validateReleaseVersion } from "../scripts/validate-release-ref"; -const workflow = (name: string): string => - readFileSync(resolve(import.meta.dirname, "..", ".github/workflows", name), "utf8"); +const repoRoot = resolve(import.meta.dirname, ".."); +const workflowsRoot = resolve(repoRoot, ".github/workflows"); +const workflow = (name: string) => readFileSync(resolve(workflowsRoot, name), "utf8"); +const repositoryFile = (name: string) => readFileSync(resolve(repoRoot, name), "utf8"); +const privatePackageManifest = Schema.fromJsonString( + Schema.Struct({ private: Schema.Literal(true) }), +); +const decodePrivatePackageManifest = Schema.decodeUnknownSync(privatePackageManifest); +const workspaceRootManifest = Schema.fromJsonString( + Schema.Struct({ + private: Schema.Literal(true), + workspaces: Schema.Array(Schema.String), + }), +); +const decodeWorkspaceRootManifest = Schema.decodeUnknownSync(workspaceRootManifest); + +const ownedWorkflows = readdirSync(workflowsRoot) + .filter((name) => /\.ya?ml$/u.test(name)) + .sort(); + +const workflowJob = (contents: string, name: string) => { + const marker = `\n ${name}:\n`; + const start = contents.indexOf(marker); + const bodyStart = start + marker.length; + const remaining = contents.slice(bodyStart); + const nextJob = remaining.search(/\n [a-z][a-z0-9-]*:\n/u); + return contents.slice(start, nextJob === -1 ? contents.length : bodyStart + nextJob); +}; describe("release workflow hardening", () => { it("accepts only semver release versions and v-prefixed semver tags", () => { @@ -21,35 +49,661 @@ describe("release workflow hardening", () => { expect(() => validateReleaseTag("v01.2.3")).toThrow(); }); - it("validates release values through environment variables before shell use", () => { - const publishExecutor = workflow("publish-executor-package.yml"); + it("has one explicit native product release entrypoint", () => { const release = workflow("release.yml"); - const publishDesktop = workflow("publish-desktop.yml"); + const triggerBlock = release.slice(release.indexOf("on:"), release.indexOf("\npermissions:")); + + expect(triggerBlock).toContain("workflow_dispatch:"); + expect(triggerBlock).toContain("dry-run"); + expect(triggerBlock).toContain("publish"); + expect(triggerBlock).toContain("commit_sha:"); + expect(triggerBlock).toContain("tag:"); + expect(triggerBlock).not.toContain("push:"); + + expect(existsSync(resolve(workflowsRoot, "build-release-artifacts.yml"))).toBe(false); + expect(existsSync(resolve(workflowsRoot, "publish-selfhost-docker.yml"))).toBe(false); + expect(existsSync(resolve(workflowsRoot, "publish-executor-package.yml"))).toBe(false); + expect(existsSync(resolve(workflowsRoot, "pkg-pr-new.yml"))).toBe(false); + }); + + it("runs the static release and installer contracts in CI", () => { + const ci = workflow("ci.yml"); + + expect(ci).toContain("run: bun run test:release:static"); + expect(ci).toContain("run: bun run test:release:installer"); + expect(ci).toContain("run: bun run test:release:systemd"); + expect(ci).toContain("run: bun run test:release:shell"); + }); - expect(publishExecutor).toContain( - "bun run scripts/validate-release-ref.ts --tag-env RAW_RELEASE_TAG --write-env RELEASE_TAG", + it("installs the locked OAuth emulator before Rust integration tests", () => { + const rust = workflowJob(workflow("ci.yml"), "rust"); + const setup = rust.indexOf("uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76"); + const install = rust.indexOf("run: bun install --frozen-lockfile --ignore-scripts"); + const verify = rust.indexOf("run: test -x e2e/node_modules/.bin/emulate"); + const test = rust.indexOf("run: cargo test --locked --all-targets --all-features"); + + expect(setup).toBeGreaterThanOrEqual(0); + expect(rust).toContain("bun-version: 1.3.11"); + expect(install).toBeGreaterThan(setup); + expect(verify).toBeGreaterThan(install); + expect(test).toBeGreaterThan(verify); + expect(repositoryFile("e2e/package.json")).toContain('"@executor-js/emulate": "^0.7.5"'); + }); + + it("serializes installer mutations and preserves crash-durability ordering", () => { + const installer = repositoryFile("scripts/install.sh"); + const beginTransaction = installer.slice( + installer.indexOf("begin_install_transaction()"), + installer.indexOf("recover_install_transaction()"), ); - expect(release).toContain( - "bun run scripts/validate-release-ref.ts --version-env RELEASE_VERSION --output tag", + const rollback = installer.slice( + installer.indexOf("recover_install_transaction()"), + installer.indexOf("commit_install_transaction()"), ); - expect(publishDesktop).toContain( - "bun run scripts/validate-release-ref.ts --tag-env RAW_RELEASE_TAG --write-env RELEASE_TAG", + const commit = installer.slice( + installer.indexOf("commit_install_transaction()"), + installer.indexOf("remove_owned_file()"), + ); + const uninstall = installer.slice( + installer.indexOf("uninstall_executor()"), + installer.indexOf("\nprepare_install_root\n"), + ); + const entrypoint = installer.slice( + installer.indexOf("\nprepare_install_root\n"), + installer.indexOf('\ncase "$REPOSITORY" in'), + ); + + const beginOrder = [ + 'durability_barrier "recovery-tree-durable:$transaction_label"', + 'mv "$temporary" "$recovery"', + 'durability_barrier "recovery-durable:$transaction_label"', + ]; + const rollbackOrder = [ + 'durability_barrier "rollback-managed-durable:$name"', + 'durability_barrier "rollback-manifest-durable"', + "retire_install_recovery rollback", + ]; + const commitOrder = [ + '"managed-file-durable:$name"', + '"manifest-durable:$transaction_label"', + 'retire_install_recovery "$transaction_label"', + ]; + const uninstallOrder = [ + 'remove_owned_file "$name" "$hash"', + 'rm -f -- "$manifest"', + 'durability_barrier "uninstall-manifest-durable"', + ]; + + for (const [section, fragments] of [ + [beginTransaction, beginOrder], + [rollback, rollbackOrder], + [commit, commitOrder], + [uninstall, uninstallOrder], + ] as const) { + for (let index = 1; index < fragments.length; index += 1) { + expect(section.indexOf(fragments[index - 1])).toBeGreaterThanOrEqual(0); + expect(section.indexOf(fragments[index])).toBeGreaterThan( + section.indexOf(fragments[index - 1]), + ); + } + } + + expect(entrypoint).toContain( + "prepare_install_root\ntrap installer_exit_cleanup EXIT\ntest_pause_if_requested after-install-root\nacquire_install_lock\nrecover_install_transaction", ); + expect(installer).toContain("validate_install_path_component /"); + expect(installer).toContain("install_root_parent_identity"); + expect(installer).toContain("could not verify live install-lock owner pid"); + expect(installer).toContain("executor-path-edit-lock-v1"); + expect(installer).toContain("acquire_path_edit_lock"); + expect(installer).toContain("command -v sync"); + expect(installer).not.toContain("python3"); + }); - expect(release).not.toContain('tag="v${{ steps.detect_release.outputs.version }}"'); - expect(publishDesktop).not.toContain("ref: refs/tags/${{ inputs.tag }}"); - expect(publishDesktop).not.toContain('gh release download "${{ inputs.tag }}"'); - expect(publishExecutor).not.toMatch(/\n\s+RELEASE_TAG: \$\{\{ github\.event_name/u); + it("binds the release tag to Cargo.toml and an exact commit", () => { + const release = workflow("release.yml"); + const cargoManifest = repositoryFile("Cargo.toml"); + const releasing = repositoryFile("RELEASING.md"); + + expect(release).toContain("version=\"$(python3 - <<'PY'"); + expect(release).toContain('if [ "$RELEASE_TAG" != "v$version" ]'); + expect(release).toContain('[[ ! "$INPUT_COMMIT_SHA" =~ ^[0-9a-f]{40}$ ]]'); + expect(release).toContain('if [ "$WORKFLOW_SHA" != "$INPUT_COMMIT_SHA" ]'); + expect(release).toContain("RUN_ATTEMPT: ${{ github.run_attempt }}"); + expect(release).toContain('if [ "$RUN_ATTEMPT" = 1 ] \\'); + expect(release).toContain("The first release attempt must use the current origin/main commit"); + expect(release).toContain('actual_sha="$(git rev-parse HEAD)"'); + expect(release).toContain('git rev-parse "$RELEASE_TAG^{commit}"'); + expect(release).toContain('if [ "$RELEASE_MODE" = "publish" ] \\'); + expect(release).toContain('[ "$WORKFLOW_REF" != refs/heads/main ]'); + expect(release).toContain('echo "commit_sha=$actual_sha"'); + expect(release).toContain("ref: ${{ needs.validate.outputs.commit_sha }}"); + expect(release.match(/inputs\.commit_sha/gu)).toHaveLength(2); + expect(release).toContain("is not on origin/main"); + expect(release).toContain('[[ "$version" == *+* ]]'); + expect(release).not.toContain("legacy/cli/package.json"); + expect(cargoManifest).toContain('rust-version = "1.96"'); + expect(releasing).toContain("gh workflow run release.yml --ref main"); + expect(releasing).toContain('gh run rerun "$run_id"'); + expect(releasing).not.toContain('gh run rerun "$run_id" --failed'); + expect(releasing).toContain("workflow source revision"); }); - it("does not persist checkout credentials in release workflows", () => { + it("gates every native publisher with the exact reviewed environment and token", () => { + const release = workflow("release.yml"); + const validate = workflowJob(release, "validate"); + const releasing = repositoryFile("RELEASING.md"); + + expect(validate).toContain("Verify exact native release environment policy"); + expect(validate).toContain('"repos/$EXECUTOR_REPOSITORY/environments/native-release"'); + expect(validate).toContain( + '"repos/$EXECUTOR_REPOSITORY/environments/native-release/deployment-branch-policies?per_page=100"', + ); + expect(validate).toContain('rule_types != ["branch_policy", "required_reviewers"]'); + expect(validate).toContain('environment.get("can_admins_bypass") is not False'); + expect(validate).toContain('identity.get("login") != "bmdavis419"'); + expect(validate).toContain('identity.get("id") != 45952064'); + expect(validate).toContain('branch_policies[0].get("name") != "main"'); + expect(validate).toContain('branch_policies[0].get("type") != "branch"'); + + const protectedJobs = [ + "authorize-publish", + "stage-release", + "publish-container-arch", + "assemble-container", + "smoke-container", + "promote", + ]; + for (const name of protectedJobs) { + const job = workflowJob(release, name); + expect(job, name).toContain("if: inputs.mode == 'publish'"); + expect(job, name).toContain("environment: native-release"); + expect(job, name).not.toContain("contents: write"); + expect(job, name).not.toContain("packages: write"); + expect(job, name).not.toContain("attestations: write"); + expect(job, name).not.toContain("${{ github.token }}"); + } + for (const name of [ - "publish-executor-package.yml", - "release.yml", - "publish-desktop.yml", - "pkg-pr-new.yml", + "stage-release", + "publish-container-arch", + "assemble-container", + "smoke-container", + "promote", ]) { - expect(workflow(name)).toContain("persist-credentials: false"); + expect(workflowJob(release, name), name).toContain("- authorize-publish"); + } + + const authorization = workflowJob(release, "authorize-publish"); + expect(authorization).toContain("GH_TOKEN: ${{ secrets.NATIVE_RELEASE_TOKEN }}"); + expect(authorization).toContain('expected_scopes = {"public_repo", "write:packages"}'); + expect(authorization).toContain("if actual_scopes != expected_scopes:"); + expect(authorization).toContain( + "NATIVE_RELEASE_TOKEN must have exactly public_repo and write:packages scopes", + ); + expect(authorization).not.toContain("for required_scope in"); + expect(authorization).toContain('"repos/$EXECUTOR_REPOSITORY/immutable-releases"'); + expect(authorization).toContain("docker login ghcr.io"); + expect(authorization.indexOf("Verify exact immutable release tag ruleset")).toBeGreaterThan( + authorization.indexOf("Require the environment-scoped release token and capabilities"), + ); + expect(release).not.toContain("actions/attest-build-provenance@"); + expect(release).not.toContain("id-token: write"); + expect(release).not.toContain("attestations: write"); + expect(releasing).toContain("reviewer the GitHub user `bmdavis419`"); + expect(releasing).toContain("classic personal access token owned by `bmdavis419`"); + expect(releasing).toContain("exact scopes `public_repo` and `write:packages`, and no others"); + expect(releasing).toContain( + "https://github.com/settings/tokens/new?scopes=public_repo,write:packages", + ); + expect(releasing).toContain("`GITHUB_TOKEN` remains read-only"); + }); + + it("requires immutable v tags and rechecks the tag at both mutation boundaries", () => { + const release = workflow("release.yml"); + const validate = workflowJob(release, "validate"); + const authorization = workflowJob(release, "authorize-publish"); + const stage = workflowJob(release, "stage-release"); + const promote = workflowJob(release, "promote"); + const releasing = repositoryFile("RELEASING.md"); + + expect(validate).not.toContain("bypass_actors"); + expect(validate).not.toContain("release-tag-rulesets"); + expect(authorization).toContain("Verify exact immutable release tag ruleset"); + expect(authorization).toContain('ref_name.get("include") == ["refs/tags/v*"]'); + expect(authorization).toContain('ruleset.get("bypass_actors") == []'); + expect(authorization).toContain('rule_types == ["deletion", "update"]'); + expect(authorization).toContain('== {"update_allows_fetch_and_merge": False}'); + + const stageFetch = stage.indexOf("git fetch --force --no-tags origin"); + const stageMutation = stage.indexOf('gh release view "$RELEASE_TAG"'); + expect(stageFetch).toBeGreaterThanOrEqual(0); + expect(stageMutation).toBeGreaterThan(stageFetch); + expect(stage).toContain('if [ "$tag_sha" != "$RELEASE_SHA" ]'); + + const promotionCheck = promote.indexOf("Revalidate remote release tag before promotion"); + const firstPromotionMutation = promote.indexOf( + "Assign immutable tags and verify channel eligibility", + ); + expect(promotionCheck).toBeGreaterThanOrEqual(0); + expect(firstPromotionMutation).toBeGreaterThan(promotionCheck); + expect(promote).toContain('if [ "$tag_sha" != "$RELEASE_SHA" ]'); + expect(release.match(/git fetch --force --no-tags origin/gu)).toHaveLength(2); + expect(releasing).toContain("active tag ruleset for exactly `refs/tags/v*`"); + }); + + it("uses the validated source and checksums throughout the release graph", () => { + const release = workflow("release.yml"); + const stage = workflowJob(release, "stage-release"); + const checkoutCount = release.match(/uses: actions\/checkout@/gu)?.length ?? 0; + const workflowCheckoutCount = release.match(/ref: \$\{\{ github\.sha \}\}/gu)?.length ?? 0; + const validatedCheckoutCount = + release.match(/ref: \$\{\{ needs\.validate\.outputs\.commit_sha \}\}/gu)?.length ?? 0; + + expect(workflowCheckoutCount).toBe(1); + expect(validatedCheckoutCount).toBe(checkoutCount - 1); + expect(release).not.toContain("ref: ${{ inputs.commit_sha }}"); + expect( + release.match( + /org\.opencontainers\.image\.revision=\$\{\{ needs\.validate\.outputs\.commit_sha \}\}/gu, + ), + ).toHaveLength(2); + expect(release).not.toContain("org.opencontainers.image.revision=${{ inputs.commit_sha }}"); + expect(stage).toContain("RELEASE_SHA: ${{ needs.validate.outputs.commit_sha }}"); + expect(stage).toContain('--target "$RELEASE_SHA"'); + expect(stage).toContain("release-artifacts/SHA256SUMS"); + expect(release).toContain("sha256sum --check SHA256SUMS"); + expect(release).not.toContain("subject-path:"); + expect(release).not.toContain("subject-digest:"); + }); + + it("publishes GitHub before moving the mutable container channel", () => { + const promote = workflowJob(workflow("release.yml"), "promote"); + const immutableStep = promote.slice( + promote.indexOf("Assign immutable tags and verify channel eligibility"), + promote.indexOf("Publish GitHub release"), + ); + const verifyBindings = promote.indexOf( + "Verify release ownership and image binding before publication", + ); + const publish = promote.indexOf("Publish GitHub release"); + const verifyImmutable = promote.indexOf("Verify published GitHub release is immutable"); + const moveChannel = promote.indexOf("Move mutable channel after GitHub release publication"); + + expect(verifyBindings).toBeGreaterThanOrEqual(0); + expect(publish).toBeGreaterThan(verifyBindings); + expect(publish).toBeGreaterThanOrEqual(0); + expect(verifyImmutable).toBeGreaterThan(publish); + expect(moveChannel).toBeGreaterThan(verifyImmutable); + expect(immutableStep).not.toContain('--tag "$IMAGE:$CHANNEL"'); + expect(promote.slice(moveChannel)).toContain('--tag "$channel_reference"'); + expect(promote.slice(moveChannel)).toContain("--json isDraft"); + expect(promote.slice(verifyImmutable, moveChannel)).toContain( + 'release.get("immutable") is not True', + ); + expect(promote.slice(verifyImmutable, moveChannel)).toContain( + 'gh release verify "$RELEASE_TAG"', + ); + expect(promote.slice(verifyImmutable, moveChannel)).toContain("for attempt in {1..24}"); + expect(promote.slice(publish, verifyImmutable)).not.toContain('--tag "$channel_reference"'); + expect(promote.slice(verifyImmutable, moveChannel)).not.toContain('--tag "$channel_reference"'); + }); + + it("binds retry convergence and release assets to the original workflow run", () => { + const release = workflow("release.yml"); + const validate = workflowJob(release, "validate"); + const publishContainer = workflowJob(release, "publish-container-arch"); + const stage = workflowJob(release, "stage-release"); + const promote = workflowJob(release, "promote"); + const releasing = repositoryFile("RELEASING.md"); + + expect(validate).toContain('if [ "$RUN_ATTEMPT" = 1 ]'); + expect(validate).toContain("RUN_ID: ${{ github.run_id }}"); + expect(validate).toContain('release_title="$RELEASE_TAG [executor-run:$RUN_ID]"'); + expect(validate).toContain('release.get("immutable") is not True'); + expect(validate).toContain('select(.name == "RELEASE-RUN.json")'); + expect(validate).toContain( + "Same-run draft is missing RELEASE-RUN.json; staging will repair it", + ); + expect(validate).toContain("Same-run retry will reuse immutable published release"); + + expect(stage).toContain("- assemble-container"); + expect(stage).toContain("RELEASE-RUN.json"); + expect(stage).toContain("RELEASE-IMAGE.json"); + expect(stage).toContain('"run_id": sys.argv[6]'); + expect(stage).toContain('"digest": sys.argv[8]'); + expect(stage).toContain('"image": sys.argv[7]'); + expect(stage).toContain('--title "$release_title"'); + expect(stage).not.toContain('--title "$RELEASE_TAG"'); + expect(stage).toContain("Recovering same-run draft created before its binding asset uploaded"); + expect(stage).toContain('if [ "$published_release" = false ]; then'); + expect(stage).toContain('gh release upload "$RELEASE_TAG"'); + expect(stage).toContain("--clobber"); + expect(stage).toContain('cmp "$run_binding" "$ownership/RELEASE-RUN.json"'); + expect(stage).toContain('cmp "$asset" "$verified_assets/$(basename "$asset")"'); + expect(publishContainer).toContain("name: executor-container-digest-${{ matrix.artifact }}"); + expect( + publishContainer.slice(publishContainer.indexOf("- name: Upload architecture digest")), + ).toContain("overwrite: true"); + + const validateTitle = validate.indexOf('actual_title="$(jq -r'); + const validateBinding = validate.indexOf("--pattern RELEASE-RUN.json"); + const stageTitle = stage.indexOf('actual_title="$(jq -r'); + const stageUpload = stage.indexOf('gh release upload "$RELEASE_TAG"'); + expect(validateBinding).toBeGreaterThan(validateTitle); + expect(stageUpload).toBeGreaterThan(stageTitle); + + expect(promote).toContain('release.get("name") != sys.argv[4]'); + expect(promote).toContain('cmp "$asset" "$verified_assets/$(basename "$asset")"'); + expect(promote).toContain('release.get("name") != sys.argv[5]'); + expect(validate).toContain('release.get("immutable") is not True'); + expect(promote).toContain("GitHub release $RELEASE_TAG is already published"); + expect(releasing).toContain("exact current\nworkflow run ID"); + expect(releasing).toContain('gh run rerun "$run_id"'); + expect(releasing).not.toContain("--failed"); + }); + + it("keeps the release and installer repository identity coherent", () => { + const canonicalRepository = "davis7dotsh/executor-fork-testing"; + const installer = repositoryFile("scripts/install.sh"); + const installDocs = repositoryFile("docs/install.md"); + const hostedDocs = repositoryFile("apps/docs/self-hosting.mdx"); + const docsIndex = repositoryFile("apps/docs/index.mdx"); + const docsConfig = repositoryFile("apps/docs/docs.json"); + const legacyCloudflareDocs = repositoryFile("apps/docs/hosted/cloudflare.mdx"); + const marketingIndex = repositoryFile("apps/marketing/src/pages/index.astro"); + const marketingLegal = repositoryFile("apps/marketing/src/components/LegalLayout.astro"); + const marketingPrivacy = repositoryFile("apps/marketing/src/pages/privacy.astro"); + const marketingTerms = repositoryFile("apps/marketing/src/pages/terms.astro"); + const rootPackage = repositoryFile("package.json"); + const release = workflow("release.yml"); + + expect(installer).toContain(`REPOSITORY="\${EXECUTOR_REPOSITORY:-${canonicalRepository}}"`); + expect(installer).toContain("EXECUTOR_REPOSITORY GitHub owner/repository"); + expect(installDocs).toContain( + `raw.githubusercontent.com/${canonicalRepository}/main/scripts/install.sh`, + ); + expect(hostedDocs).toContain( + `raw.githubusercontent.com/${canonicalRepository}/main/scripts/install.sh`, + ); + expect(installDocs).toContain("export EXECUTOR_REPOSITORY=owner/repository"); + expect(installDocs).toContain("bash -s -- --uninstall"); + expect(installDocs).toContain("It preserves all databases, master keys"); + expect(hostedDocs).toContain("export EXECUTOR_REPOSITORY=owner/repository"); + expect(docsIndex).toContain(`https://github.com/${canonicalRepository}`); + expect(docsConfig).toContain(`https://github.com/${canonicalRepository}`); + expect(legacyCloudflareDocs).toContain(`https://github.com/${canonicalRepository}`); + for (const activeSurface of [ + docsIndex, + docsConfig, + legacyCloudflareDocs, + marketingIndex, + marketingLegal, + marketingPrivacy, + marketingTerms, + rootPackage, + ]) { + expect(activeSurface).toContain(`github.com/${canonicalRepository}`); + expect(activeSurface).not.toContain("github.com/RhysSullivan/executor"); + } + expect(installDocs).toContain("not Developer ID signed or notarized"); + expect(release).toContain("EXECUTOR_REPOSITORY: ${{ github.repository }}"); + expect(release).toContain('--repo "$EXECUTOR_REPOSITORY"'); + }); + + it("packages four native targets and promotes only after assets and Docker succeed", () => { + const release = workflow("release.yml"); + + for (const target of [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + ]) { + expect(release).toContain(`executor-${target}.tar.gz`); + } + + expect(release).toContain("SHA256SUMS"); + expect(release).not.toContain("actions/attest-build-provenance@"); + expect(release).toContain("file: Dockerfile"); + expect(release).toContain("platform: linux/amd64"); + expect(release).toContain("platform: linux/arm64"); + expect(release).toContain("target: runtime-prebuilt"); + const promote = workflowJob(release, "promote"); + expect(promote).toContain("- assemble-container"); + expect(promote).toContain("- authorize-publish"); + expect(promote).toContain("- smoke-container"); + expect(promote).toContain("- stage-release"); + expect(promote).toContain('args=(release edit "$RELEASE_TAG"'); + }); + + it("serializes releases and rejects a v0.1 channel rollback after v0.2", () => { + const release = workflow("release.yml"); + const rollback = spawnSync( + "python3", + [ + resolve(repoRoot, "scripts/check-release-channel-order.py"), + "--channel", + "latest", + "--current", + "0.2.0", + "--candidate", + "0.1.0", + ], + { encoding: "utf8" }, + ); + const historicalRollback = spawnSync( + "python3", + [ + resolve(repoRoot, "scripts/check-release-channel-order.py"), + "--channel", + "latest", + "--history-file", + "-", + "--candidate", + "0.1.0", + ], + { + encoding: "utf8", + input: JSON.stringify([[{ draft: false, tag_name: "v0.2.0" }]]), + }, + ); + const betaUpgrade = spawnSync( + "python3", + [ + resolve(repoRoot, "scripts/check-release-channel-order.py"), + "--channel", + "beta", + "--current", + "1.0.0-beta.2", + "--candidate", + "1.0.0-beta.10", + ], + { encoding: "utf8" }, + ); + + expect(release).toContain("group: native-release\n"); + expect(release).not.toContain("group: native-release-${{ inputs.tag }}"); + expect(release).toContain("index:org.opencontainers.image.version=$VERSION"); + expect(release).toContain("scripts/check-release-channel-order.py"); + expect(release).toContain('--history-file "$release_history"'); + expect(rollback.status).toBe(1); + expect(rollback.stderr).toContain("would move latest backward from 0.2.0 to 0.1.0"); + expect(historicalRollback.status).toBe(1); + expect(historicalRollback.stderr).toContain("would move latest backward from 0.2.0 to 0.1.0"); + expect(betaUpgrade.status).toBe(0); + expect(betaUpgrade.stdout.trim()).toBe("newer"); + }); + + it("verifies the version annotation on a synthetic raw release index", () => { + const release = workflow("release.yml"); + const verifier = resolve(repoRoot, "scripts/verify-release-index.py"); + const index = { + annotations: { "org.opencontainers.image.version": "0.2.0" }, + manifests: [ + { platform: { architecture: "amd64", os: "linux" } }, + { platform: { architecture: "arm64", os: "linux" } }, + ], + schemaVersion: 2, + }; + const valid = spawnSync("python3", [verifier, "--version", "0.2.0"], { + encoding: "utf8", + input: JSON.stringify(index), + }); + const missingAnnotation = spawnSync("python3", [verifier, "--version", "0.2.0"], { + encoding: "utf8", + input: JSON.stringify({ ...index, annotations: {} }), + }); + + expect(release).toContain('--annotation "index:org.opencontainers.image.version=$VERSION"'); + expect(release).toContain("python3 scripts/verify-release-index.py"); + expect(valid.status).toBe(0); + expect(valid.stdout.trim()).toBe("verified release index for 0.2.0"); + expect(missingAnnotation.status).toBe(1); + expect(missingAnnotation.stderr).toContain("version annotation does not equal 0.2.0"); + }); + + it("declares and enforces the macOS 14 compatibility floor", () => { + const release = workflow("release.yml"); + const nativeSmoke = release.slice( + release.indexOf(" smoke-native:"), + release.indexOf(" checksums:"), + ); + const installDocs = repositoryFile("docs/install.md"); + const appleVerifier = repositoryFile("scripts/verify-apple-toolchain.sh"); + + expect(release).toContain('MACOSX_DEPLOYMENT_TARGET: "14.0"'); + expect(release).toContain( + "MACOS_BUILD_DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer", + ); + expect(release).toContain('MACOS_BUILD_XCODE_VERSION: "16.4"'); + expect(release).toContain("MACOS_BUILD_XCODE_BUILD: 16F6"); + expect(release).toContain('MACOS_BUILD_SDK_VERSION: "15.5"'); + expect(release).toContain( + "MACOS_BUILD_CLANG_VERSION: Apple clang version 17.0.0 (clang-1700.0.13.5)", + ); + expect(release).toContain('MACOS_BUILD_LD_VERSION: "1167.5"'); + expect(release).toContain( + "MACOS_FLOOR_DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer", + ); + expect(release).toContain('MACOS_FLOOR_XCODE_VERSION: "15.4"'); + expect(release).toContain("MACOS_FLOOR_XCODE_BUILD: 15F31d"); + expect(release).toContain('MACOS_FLOOR_SDK_VERSION: "14.5"'); + expect(release).toContain( + "MACOS_FLOOR_CLANG_VERSION: Apple clang version 15.0.0 (clang-1500.3.9.4)", + ); + expect(release).toContain('MACOS_FLOOR_LD_VERSION: "1053.12"'); + expect(release.match(/bash scripts\/verify-apple-toolchain\.sh/gu)).toHaveLength(2); + expect(release).toContain("DEVELOPER_DIR: ${{ env.MACOS_BUILD_DEVELOPER_DIR }}"); + expect(release).toContain("DEVELOPER_DIR: ${{ env.MACOS_FLOOR_DEVELOPER_DIR }}"); + expect(appleVerifier).toContain('xcode_info="$(xcodebuild -version)"'); + expect(appleVerifier).toContain('sdk_version="$(xcrun --sdk macosx --show-sdk-version)"'); + expect(appleVerifier).toContain('clang_info="$(xcrun clang --version)"'); + expect(appleVerifier).toContain('ld_info="$(xcrun ld -v 2>&1)"'); + expect(appleVerifier).toContain('[[ "$(xcrun --find clang)" == "${toolchain_bin}/clang" ]]'); + expect(release).toContain('xcrun vtool -show-build "$binary"'); + expect(release).toContain('if [ "$minos" != "$MACOSX_DEPLOYMENT_TARGET" ]'); + expect(release).toContain("name: Test macOS service lifecycle on compatibility floor"); + expect(release).toContain("run: cargo test --locked --lib service::tests"); + expect(release).toContain("run: bash scripts/test-install-launchd-safety.sh"); + expect(release).toContain("run: bash scripts/test-bounded-log.sh"); + expect(release).toContain("run: bash scripts/test-install-release-archive.sh"); + expect(nativeSmoke).toContain("- runner: macos-14\n target: aarch64-apple-darwin"); + expect(installDocs).toContain("macOS 14 Sonoma or newer"); + }); + + it("retires npm and Changesets publication surfaces", () => { + const rootPackage = repositoryFile("package.json"); + + for (const path of [ + ".github/workflows/publish-typescript-packages.yml", + ".changeset/config.json", + "scripts/lib/npm-tarball-equivalence.ts", + "scripts/publish-packages.ts", + "scripts/smoke-docs-install.ts", + "scripts/smoke-test-packed.ts", + ]) { + expect(existsSync(resolve(repoRoot, path)), path).toBe(false); + } + + expect(rootPackage).not.toContain("@changesets/"); + expect(rootPackage).not.toContain("changeset"); + expect(rootPackage).not.toContain("release:publish"); + expect(rootPackage).not.toContain("release:smoke:packages"); + expect(rootPackage).not.toContain("compatibility"); + expect(repositoryFile("RELEASING.md")).not.toContain("TypeScript compatibility packages"); + expect(existsSync(resolve(workflowsRoot, "publish-desktop.yml"))).toBe(false); + expect(existsSync(resolve(import.meta.dirname, "..", "legacy/cli/src/release.ts"))).toBe(false); + }); + + it("marks the root and every configured workspace package as private", () => { + const rootManifest = decodeWorkspaceRootManifest(repositoryFile("package.json")); + const manifests = new Set(["package.json"]); + + for (const workspace of rootManifest.workspaces) { + const matches = globSync(`${workspace}/package.json`, { cwd: repoRoot }); + expect(matches.length, workspace).toBeGreaterThan(0); + for (const manifest of matches) { + manifests.add(manifest); + } + } + + expect(manifests.size).toBeGreaterThan(rootManifest.workspaces.length); + for (const manifest of manifests) { + expect(decodePrivatePackageManifest(repositoryFile(manifest)), manifest).toEqual({ + private: true, + }); + } + }); + + it("requires explicit opt-in for Cloudflare previews and removes mutable package previews", () => { + expect(workflow("preview.yml")).toContain("vars.ENABLE_CLOUDFLARE_PREVIEWS == 'true'"); + expect(workflow("preview-sweep.yml")).toContain("vars.ENABLE_CLOUDFLARE_PREVIEWS == 'true'"); + expect(existsSync(resolve(workflowsRoot, "pkg-pr-new.yml"))).toBe(false); + expect(existsSync(resolve(workflowsRoot, "publish-typescript-packages.yml"))).toBe(false); + }); + + it("checks out and reports the exact pull request head commit for previews", () => { + const preview = workflow("preview.yml"); + const checkoutCount = preview.match(/uses: actions\/checkout@/gu)?.length ?? 0; + const headRefCount = + preview.match(/ref: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/gu)?.length ?? 0; + + expect(headRefCount).toBe(checkoutCount); + expect(preview).toContain("PREVIEW_SHA: ${{ steps.checkout.outputs.commit }}"); + expect(preview).toContain("${process.env.PREVIEW_SHA}"); + expect(preview).not.toContain("github.sha"); + }); + + it("does not grant publishing permissions to dry-run jobs", () => { + const release = workflow("release.yml"); + const nativeDryRun = release.slice( + release.indexOf(" build-container-dry-run:"), + release.indexOf(" publish-container-arch:"), + ); + + expect(nativeDryRun).not.toContain("packages: write"); + expect(nativeDryRun).not.toContain("id-token: write"); + expect(nativeDryRun).not.toContain("NATIVE_RELEASE_TOKEN"); + }); + + it("pins every external action in owned workflows to an immutable SHA", () => { + for (const name of ownedWorkflows) { + const contents = workflow(name); + const actionReferences = [...contents.matchAll(/^\s*-?\s*uses:\s+\S+@([^\s#]+)/gmu)]; + expect(actionReferences.length, name).toBeGreaterThan(0); + for (const reference of actionReferences) { + expect(reference[1], `${name}: ${reference[0]}`).toMatch(/^[0-9a-f]{40}$/u); + } + } + }); + + it("does not persist checkout credentials in owned workflows", () => { + for (const name of ownedWorkflows) { + const contents = workflow(name); + const checkoutCount = contents.match(/uses: actions\/checkout@/gu)?.length ?? 0; + const credentialCount = contents.match(/persist-credentials: false/gu)?.length ?? 0; + expect(credentialCount, name).toBe(checkoutCount); } }); }); diff --git a/tests/runtime.rs b/tests/runtime.rs new file mode 100644 index 000000000..978a5b67a --- /dev/null +++ b/tests/runtime.rs @@ -0,0 +1,516 @@ +use std::{ + collections::HashMap, + sync::{ + Arc, OnceLock, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use async_trait::async_trait; +use executor::runtime::{ + ExecutionCancellation, ExecutionRequest, HostToolDispatcher, RuntimeManager, ToolCall, + ToolResult, +}; +use serde_json::{Value, json}; +use tokio::sync::{Barrier, Mutex}; + +fn manager() -> RuntimeManager { + RuntimeManager::new(env!("CARGO_BIN_EXE_executor")) +} + +fn request(code: &str) -> ExecutionRequest { + ExecutionRequest { + execution_id: uuid::Uuid::new_v4().to_string(), + code: code.into(), + timeout: Duration::from_secs(5), + } +} + +async fn test_guard() -> tokio::sync::MutexGuard<'static, ()> { + static TEST_LOCK: OnceLock> = OnceLock::new(); + TEST_LOCK.get_or_init(|| Mutex::new(())).lock().await +} + +struct EchoDispatcher; + +#[async_trait] +impl HostToolDispatcher for EchoDispatcher { + async fn dispatch(&self, call: ToolCall, _: ExecutionCancellation) -> ToolResult { + ToolResult::Success { + value: json!({ "path": call.path, "arguments": call.arguments }), + } + } +} + +#[tokio::test] +async fn executes_typescript_with_proxy_console_and_emit() { + let _guard = test_guard().await; + let output = manager() + .execute( + request( + r#" + const count: number = 2; + console.info("calling", count); + emit({ phase: "ready" }); + const response = await tools.weather["forecast"]({ city: "Paris", count }); + return { response, missingThen: tools.weather.then === undefined }; + "#, + ), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("execution should succeed"); + assert_eq!(output.result["response"]["path"], "weather.forecast"); + assert_eq!(output.result["response"]["arguments"]["count"], 2); + assert_eq!(output.result["missingThen"], true); + assert_eq!(output.emits, vec![json!({ "phase": "ready" })]); + assert_eq!(output.console[0].message, "calling 2"); + assert_eq!(output.tool_calls.len(), 1); +} + +struct DependencyDispatcher; + +#[async_trait] +impl HostToolDispatcher for DependencyDispatcher { + async fn dispatch(&self, call: ToolCall, _: ExecutionCancellation) -> ToolResult { + let value = match call.path.as_str() { + "sequence.first" => json!(7), + "sequence.second" => json!(call.arguments["value"].as_i64().unwrap_or_default() * 2), + _ => Value::Null, + }; + ToolResult::Success { value } + } +} + +#[tokio::test] +async fn sequential_calls_can_depend_on_prior_results() { + let _guard = test_guard().await; + let output = manager() + .execute( + request( + "const first = await tools.sequence.first(); return await tools.sequence.second({ value: first });", + ), + Arc::new(DependencyDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("execution should succeed"); + assert_eq!(output.result, 14); + assert_eq!( + output + .tool_calls + .iter() + .map(|call| call.call_id) + .collect::>(), + vec![1, 2] + ); +} + +struct ConcurrentDispatcher { + barrier: Barrier, + active: AtomicUsize, + peak: AtomicUsize, + releases: Mutex>, +} + +#[async_trait] +impl HostToolDispatcher for ConcurrentDispatcher { + async fn dispatch(&self, call: ToolCall, _: ExecutionCancellation) -> ToolResult { + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.peak.fetch_max(active, Ordering::SeqCst); + self.barrier.wait().await; + let delay = self.releases.lock().await[&call.path]; + tokio::time::sleep(Duration::from_millis(delay)).await; + self.active.fetch_sub(1, Ordering::SeqCst); + ToolResult::Success { + value: json!(call.path), + } + } +} + +#[tokio::test] +async fn promise_all_overlaps_and_preserves_input_order() { + let _guard = test_guard().await; + let dispatcher = Arc::new(ConcurrentDispatcher { + barrier: Barrier::new(2), + active: AtomicUsize::new(0), + peak: AtomicUsize::new(0), + releases: Mutex::new(HashMap::from([ + ("parallel.slow".into(), 50), + ("parallel.fast".into(), 0), + ])), + }); + let output = manager() + .execute( + request("return await Promise.all([tools.parallel.slow(), tools.parallel.fast()]);"), + dispatcher.clone(), + ExecutionCancellation::default(), + ) + .await + .expect("execution should succeed"); + assert_eq!(dispatcher.peak.load(Ordering::SeqCst), 2); + assert_eq!(output.result, json!(["parallel.slow", "parallel.fast"])); +} + +#[tokio::test] +async fn unawaited_started_calls_settle_before_completion() { + let _guard = test_guard().await; + let output = manager() + .execute( + request( + "tools.background.started({ value: 1 }); await Promise.resolve(); return 'done';", + ), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("started call should settle before completion"); + assert_eq!(output.result, "done"); + assert_eq!(output.tool_calls.len(), 1); + assert_eq!(output.tool_calls[0].path, "background.started"); +} + +#[tokio::test] +async fn expected_tool_failures_are_values_not_rejections() { + let _guard = test_guard().await; + struct FailureDispatcher; + #[async_trait] + impl HostToolDispatcher for FailureDispatcher { + async fn dispatch(&self, _: ToolCall, _: ExecutionCancellation) -> ToolResult { + ToolResult::Failure { + code: "upstream_denied".into(), + message: "denied".into(), + } + } + } + let output = manager() + .execute( + request("return await tools.example.fail();"), + Arc::new(FailureDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("expected failure should remain a value"); + assert_eq!(output.result["error"]["code"], "upstream_denied"); +} + +#[tokio::test] +async fn cpu_loops_and_unresolved_promises_time_out_without_poisoning_next_execution() { + let _guard = test_guard().await; + let mut timed = request("while (true) {};"); + timed.timeout = Duration::from_millis(150); + assert_eq!( + manager() + .execute( + timed, + Arc::new(EchoDispatcher), + ExecutionCancellation::default() + ) + .await + .expect_err("loop must time out") + .code, + "execution_timeout" + ); + + let output = manager() + .execute( + request("return 42;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("next worker must be isolated"); + assert_eq!(output.result, 42); + + let mut unresolved = request("return await new Promise(() => {});"); + unresolved.timeout = Duration::from_millis(100); + assert_eq!( + manager() + .execute( + unresolved, + Arc::new(EchoDispatcher), + ExecutionCancellation::default() + ) + .await + .expect_err("unresolved promise must time out") + .code, + "execution_timeout" + ); +} + +#[tokio::test] +async fn cancellation_terminates_pending_dispatch() { + let _guard = test_guard().await; + struct PendingDispatcher; + #[async_trait] + impl HostToolDispatcher for PendingDispatcher { + async fn dispatch(&self, _: ToolCall, cancellation: ExecutionCancellation) -> ToolResult { + cancellation.cancelled().await; + ToolResult::Failure { + code: "cancelled".into(), + message: "cancelled".into(), + } + } + } + let cancellation = ExecutionCancellation::default(); + let cancel_from_task = cancellation.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + cancel_from_task.cancel(); + }); + let failure = manager() + .execute( + request("return await tools.wait.forever();"), + Arc::new(PendingDispatcher), + cancellation, + ) + .await + .expect_err("execution must cancel"); + assert_eq!(failure.code, "execution_cancelled"); +} + +#[tokio::test] +async fn dispatcher_failure_stops_execution_without_waiting_for_wall_timeout() { + let _guard = test_guard().await; + struct PanicDispatcher; + #[async_trait] + impl HostToolDispatcher for PanicDispatcher { + async fn dispatch(&self, _: ToolCall, _: ExecutionCancellation) -> ToolResult { + panic!("intentional dispatcher failure") + } + } + let started = std::time::Instant::now(); + let failure = manager() + .execute( + request("return await tools.failure.panic();"), + Arc::new(PanicDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("dispatcher panic should fail the execution"); + assert_eq!(failure.code, "tool_bridge_failed"); + assert!(started.elapsed() < Duration::from_secs(2)); +} + +#[tokio::test] +async fn unavailable_host_globals_stay_unavailable() { + let _guard = test_guard().await; + let output = manager() + .execute( + request( + "return [typeof fetch, typeof process, typeof require, typeof Deno, typeof Bun];", + ), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("execution should succeed"); + assert_eq!( + output.result, + json!([ + "undefined", + "undefined", + "undefined", + "undefined", + "undefined" + ]) + ); +} + +#[tokio::test] +async fn stack_memory_and_output_limits_fail_inside_one_worker() { + let _guard = test_guard().await; + for (code, forbidden_failure) in [ + ( + "function recurse() { return recurse(); }", + "execution_timeout", + ), + ( + "const values = []; while (true) { values.push('x'.repeat(1024 * 1024)); }", + "execution_timeout", + ), + ("return 'x'.repeat(9 * 1024 * 1024);", "execution_timeout"), + ] { + let mut bounded = request(code); + bounded.timeout = Duration::from_secs(2); + let failure = manager() + .execute( + bounded, + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("hostile execution must fail"); + assert_ne!( + failure.code, forbidden_failure, + "engine limit must fire: {code}" + ); + } +} + +#[tokio::test] +async fn console_emit_and_call_caps_are_enforced() { + let _guard = test_guard().await; + let output = manager() + .execute( + request("for (let i = 0; i < 1100; i++) console.log(i); return true;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("console capture should truncate safely"); + assert_eq!(output.console.len(), 1000); + + let unicode = manager() + .execute( + request("console.log('😀'.repeat(70000)); return true;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("oversized unicode console entry should truncate safely"); + assert!(unicode.console.is_empty()); + + let emit_failure = manager() + .execute( + request("for (let i = 0; i < 101; i++) emit(i); return true;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("emit cap must fail"); + assert_eq!(emit_failure.code, "execution_failed"); + + let unicode_emit_failure = manager() + .execute( + request("emit('😀'.repeat(2100000)); return true;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("UTF-8 emit cap must fail"); + assert_eq!(unicode_emit_failure.code, "execution_failed"); + + let call_failure = manager() + .execute( + request("return await Promise.all(Array.from({length: 129}, (_, i) => tools.cap.call({i})));"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("call cap must fail"); + assert_eq!(call_failure.code, "tool_call_limit_exceeded"); +} + +#[tokio::test] +async fn proxy_ignores_symbols_and_hostile_json_does_not_mutate_prototypes() { + let _guard = test_guard().await; + let output = manager() + .execute( + request( + "const result = await tools.prototype.check(JSON.parse('{\"__proto__\":{\"polluted\":true}}')); return { symbol: tools[Symbol.iterator] === undefined, polluted: ({}).polluted ?? null, result };", + ), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("hostile JSON should remain data"); + assert_eq!(output.result["symbol"], true); + assert_eq!(output.result["polluted"], Value::Null); + assert_eq!( + output.result["result"]["arguments"]["__proto__"]["polluted"], + true + ); +} + +#[tokio::test] +async fn parallel_executions_have_fresh_globals() { + let _guard = test_guard().await; + let manager = manager(); + let first = manager.execute( + request("globalThis.secret = 99; return secret;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ); + let second = manager.execute( + request("return typeof secret;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ); + let (first, second) = tokio::join!(first, second); + assert_eq!(first.expect("first execution should succeed").result, 99); + assert_eq!( + second.expect("second execution should succeed").result, + "undefined" + ); +} + +#[tokio::test] +async fn worker_crash_isolated_from_following_execution() { + let _guard = test_guard().await; + let failure = RuntimeManager::new("/bin/true") + .execute( + request("return 1;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("exited worker must fail"); + assert!(failure.internal); + + let output = manager() + .execute( + request("return 2;"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect("next worker must remain healthy"); + assert_eq!(output.result, 2); +} + +#[tokio::test] +async fn dropping_waiters_cleans_workers_before_releasing_slots() { + let _guard = test_guard().await; + let mut waiters = Vec::new(); + for _ in 0..8 { + waiters.push(tokio::spawn(async move { + manager() + .execute( + request("while (true) {}"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + })); + } + tokio::time::sleep(Duration::from_millis(50)).await; + let busy = manager() + .execute( + request("return 'no slot';"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ) + .await + .expect_err("ninth worker must fail without queueing"); + assert_eq!(busy.code, "runtime_busy"); + for waiter in waiters { + waiter.abort(); + } + tokio::time::sleep(Duration::from_millis(100)).await; + + let output = tokio::time::timeout( + Duration::from_secs(2), + manager().execute( + request("return 'reaped';"), + Arc::new(EchoDispatcher), + ExecutionCancellation::default(), + ), + ) + .await + .expect("worker slot should be released") + .expect("replacement execution should succeed"); + assert_eq!(output.result, "reaped"); +} diff --git a/tests/runtime_invocation.rs b/tests/runtime_invocation.rs new file mode 100644 index 000000000..066782cdf --- /dev/null +++ b/tests/runtime_invocation.rs @@ -0,0 +1,1383 @@ +use std::{ + collections::BTreeMap, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use axum::{ + Json, Router, + body::Body, + extract::{OriginalUri, State}, + http::{Method, Request, StatusCode, header}, +}; +use executor::{ + AppConfig, ExecutorApp, + actor::ToolActor, + approval::{ApprovalDecision, ApprovalListQuery, ApprovalStatus}, + catalog::{ + ArtifactKind, AuditContext, CreateSource, CredentialPayload, InitialCatalogSnapshot, + RequestSurface, SourceKind, StagedArtifact, StagedTool, StagedToolBinding, ToolBinding, + ToolMode, + }, + invocation::{ToolCall, ToolCallSubmission}, + openapi::{OpenApiBinding, OpenApiSecurityAlternative}, + runtime::{ + ExecutionCancellation, ExecutionRequest, HostToolDispatcher, InvocationContext, + InvocationToolDispatcher, RuntimeManager, ToolCall as RuntimeToolCall, + ToolResult as RuntimeToolResult, + }, +}; +use http_body_util::BodyExt; +use serde_json::{Map, Value, json}; +use tokio::{net::TcpListener, sync::Notify}; +use tower::ServiceExt; + +#[derive(Clone, Default)] +struct UpstreamState { + block_ask: Arc, + entered: Arc, + release: Arc, +} + +async fn upstream( + State(state): State, + OriginalUri(uri): OriginalUri, +) -> Json { + if state.block_ask.load(Ordering::Acquire) { + state.entered.notify_one(); + state.release.notified().await; + } + Json(json!({ "path": uri.path() })) +} + +async fn api_request( + router: Router, + method: Method, + uri: &str, + body: String, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + for (name, value) in headers { + request = request.header(*name, *value); + } + router + .oneshot( + request + .body(Body::from(body)) + .expect("request should build"), + ) + .await + .expect("router should answer") +} + +async fn api_body(response: axum::response::Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response should collect") + .to_bytes(); + serde_json::from_slice(&bytes).expect("response should contain JSON") +} + +fn response_cookies(response: &axum::response::Response) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie should be text") + .split(';') + .next() + .expect("cookie should contain a value") + }) + .collect::>() + .join("; ") +} + +async fn fixture() -> ( + tempfile::TempDir, + ExecutorApp, + UpstreamState, + tokio::task::JoinHandle<()>, +) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("upstream should bind"); + let address = listener.local_addr().expect("upstream address should read"); + let upstream_state = UpstreamState::default(); + let server_state = upstream_state.clone(); + let upstream_task = tokio::spawn(async move { + axum::serve( + listener, + Router::new().fallback(upstream).with_state(server_state), + ) + .await + .expect("upstream should serve"); + }); + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor should open"); + sqlx::query( + "INSERT INTO api_tokens (id, name, token_digest, token_prefix, token_suffix, created_at) \ + VALUES ('runtime-owner', 'Runtime owner', x'010203', 'exr_test', 'test', 1)", + ) + .execute(app.pool()) + .await + .expect("owner token should insert"); + sqlx::query( + "INSERT INTO admins (id, username, password_hash, created_at) \ + VALUES (1, 'admin', 'unused', 1)", + ) + .execute(app.pool()) + .await + .expect("admin should insert"); + + let definitions = [ + ("enabled", ToolMode::Enabled), + ("first", ToolMode::Ask), + ("second", ToolMode::Ask), + ("third", ToolMode::Ask), + ("deny", ToolMode::Ask), + ("cancel", ToolMode::Ask), + ("revoke", ToolMode::Ask), + ]; + let tools = definitions + .iter() + .map(|(name, mode)| StagedTool { + stable_key: (*name).to_owned(), + preferred_name: (*name).to_owned(), + display_name: (*name).to_owned(), + description: None, + input_schema: json!({ + "type": "object", + "additionalProperties": false + }), + output_schema: None, + input_typescript: None, + output_typescript: None, + typescript_definitions: BTreeMap::new(), + intrinsic_mode: *mode, + }) + .collect(); + let bindings = definitions + .iter() + .map(|(name, _)| StagedToolBinding { + stable_key: (*name).to_owned(), + binding: ToolBinding::OpenapiV1(OpenApiBinding { + version: 1, + method: "GET".to_owned(), + path_template: format!("/{name}"), + server_url: format!("http://{address}"), + parameters: Vec::new(), + request_body: None, + security: vec![OpenApiSecurityAlternative { + requirements: Vec::new(), + }], + }), + }) + .collect(); + app.catalog() + .create_source_with_catalog( + CreateSource { + kind: SourceKind::Openapi, + preferred_slug: "runtime".to_owned(), + display_name: "Runtime".to_owned(), + description: None, + configuration: Map::from_iter([ + ("spec".to_owned(), json!({ "type": "inline" })), + ("allowPrivateNetwork".to_owned(), Value::Bool(true)), + ]), + }, + &CredentialPayload { + schema_version: 1, + payload: json!({ + "locator": { "type": "inline" }, + "credentials": { "schemes": {} } + }), + }, + InitialCatalogSnapshot { + artifacts: vec![StagedArtifact { + kind: ArtifactKind::OpenapiDocument, + stable_key: "document".to_owned(), + content: json!({ "openapi": "3.1.0" }), + }], + tools, + }, + bindings, + AuditContext::system(Some("runtime-invocation-test")), + ) + .await + .expect("runtime fixture should import"); + (directory, app, upstream_state, upstream_task) +} + +fn execution( + app: &ExecutorApp, + execution_id: &str, +) -> (RuntimeManager, Arc) { + let dispatcher = Arc::new(InvocationToolDispatcher::new( + app.tool_calls().clone(), + InvocationContext { + request_id: uuid::Uuid::new_v4().to_string(), + actor: ToolActor::api_token("runtime-owner", Some("Runtime owner".to_owned())), + surface: RequestSurface::Gateway, + execution_id: execution_id.to_owned(), + }, + )); + ( + RuntimeManager::new(env!("CARGO_BIN_EXE_executor")), + dispatcher, + ) +} + +async fn pending( + app: &ExecutorApp, + execution_id: &str, + count: usize, +) -> Vec { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let approvals = app + .tool_calls() + .approvals() + .list_admin(ApprovalListQuery { + limit: 100, + status: Some(ApprovalStatus::Pending), + ..Default::default() + }) + .await + .expect("approvals should list") + .items + .into_iter() + .filter(|approval| approval.execution_id == execution_id) + .collect::>(); + if approvals.len() == count { + break approvals; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("pending approvals should appear") +} + +#[tokio::test] +async fn real_worker_dispatches_enabled_openapi_and_builtin_discovery() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + let execution_id = "runtime-enabled"; + let (manager, dispatcher) = execution(&app, execution_id); + let output = manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "const found = await tools.search({query: 'enabled'}); const described = await tools.describe({path: 'runtime.enabled'}); const sources = await tools.sources(); const result = await tools.runtime.enabled(); return {found: found.total, described: described.path, sources: sources.length, result};".to_owned(), + timeout: Duration::from_secs(5), + }, + dispatcher.clone(), + ExecutionCancellation::default(), + ) + .await + .expect("enabled execution should succeed"); + dispatcher.finish().await; + assert_eq!(output.result["described"], "runtime.enabled"); + assert_eq!(output.result["sources"], 1); + assert_eq!(output.result["result"]["path"], "/enabled"); + assert!( + output.result["found"] + .as_u64() + .is_some_and(|count| count >= 1) + ); + upstream_task.abort(); +} + +#[tokio::test] +async fn concurrent_ask_calls_settle_independently_in_javascript_order() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + let execution_id = "runtime-concurrent-ask"; + let (manager, dispatcher) = execution(&app, execution_id); + let runtime_dispatcher = dispatcher.clone(); + let execution = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await Promise.all([tools.runtime.first(), tools.runtime.second(), tools.runtime.third()]);" + .to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + ExecutionCancellation::default(), + ) + .await + }); + let approvals = pending(&app, execution_id, 3).await; + let first = approvals + .iter() + .find(|approval| approval.callable_path_snapshot.ends_with(".first")) + .expect("first approval should exist"); + let second = approvals + .iter() + .find(|approval| approval.callable_path_snapshot.ends_with(".second")) + .expect("second approval should exist"); + let third = approvals + .iter() + .find(|approval| approval.callable_path_snapshot.ends_with(".third")) + .expect("third approval should exist"); + assert!(first.worker_generation > 0 && first.worker_generation <= i64::MAX as u64); + assert_eq!(first.worker_generation, second.worker_generation); + app.tool_calls() + .decide( + &second.id, + "approve-second", + second.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("second approval should succeed"); + app.tool_calls() + .decide( + &third.id, + "deny-third", + third.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("third denial should succeed"); + app.tool_calls() + .decide( + &first.id, + "approve-first", + first.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("first approval should succeed"); + let output = execution + .await + .expect("execution task should not panic") + .expect("approved execution should succeed"); + dispatcher.finish().await; + assert_eq!(output.result[0]["path"], "/first", "{}", output.result); + assert_eq!(output.result[1]["path"], "/second"); + assert_eq!(output.result[2]["error"]["code"], "approval_denied"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let distinct = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(DISTINCT request_id) FROM request_logs \ + WHERE approval_id IN (?, ?, ?)", + ) + .bind(&first.id) + .bind(&second.id) + .bind(&third.id) + .fetch_one(app.pool()) + .await + .expect("request logs should read"); + if distinct >= 3 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("concurrent calls should have distinct request IDs"); + upstream_task.abort(); +} + +#[tokio::test] +async fn catalog_revision_change_marks_waiting_approval_stale() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + let execution_id = "runtime-stale"; + let (manager, dispatcher) = execution(&app, execution_id); + let runtime_dispatcher = dispatcher.clone(); + let execution = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await tools.runtime.first();".to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + ExecutionCancellation::default(), + ) + .await + }); + let approval = pending(&app, execution_id, 1).await.remove(0); + let tool = app + .catalog() + .list_tools(Default::default()) + .await + .expect("tools should list") + .items + .into_iter() + .find(|tool| tool.local_name == "first") + .expect("Ask tool should exist"); + app.catalog() + .set_tool_mode( + &tool.id, + Some(ToolMode::Enabled), + tool.revision, + AuditContext::system(Some("runtime-stale-test")), + ) + .await + .expect("tool revision should change"); + app.tool_calls() + .decide( + &approval.id, + "approve-stale", + approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval decision should persist"); + let output = execution + .await + .expect("execution task should not panic") + .expect("stale approval should settle as a value"); + assert_eq!(output.result["error"]["code"], "approval_stale"); + dispatcher.finish().await; + upstream_task.abort(); +} + +#[tokio::test] +async fn dropped_runtime_waiter_still_cancels_pending_approval() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + let execution_id = "runtime-dropped-waiter"; + let (manager, dispatcher) = execution(&app, execution_id); + let execution = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await tools.runtime.cancel();".to_owned(), + timeout: Duration::from_secs(5), + }, + dispatcher, + ExecutionCancellation::default(), + ) + .await + }); + let approval = pending(&app, execution_id, 1).await.remove(0); + let held_delivery = app + .tool_calls() + .submit(ToolCall { + request_id: "held-dropped-waiter-delivery".to_owned(), + actor: ToolActor::api_token("runtime-owner", Some("Runtime owner".to_owned())), + surface: RequestSurface::Gateway, + execution_id: execution_id.to_owned(), + call_id: "1".to_owned(), + worker_generation: approval.worker_generation, + path: "runtime.cancel".to_owned(), + arguments: json!({}), + }) + .await + .expect("the correlated delivery should be reusable"); + let ToolCallSubmission::ApprovalRequired(held_delivery) = held_delivery else { + panic!("the correlated delivery should remain pending"); + }; + assert_eq!(held_delivery.id, approval.id); + let pin_refs = sqlx::query_scalar::<_, i64>( + "SELECT ref_count FROM approval_delivery_pins WHERE approval_id = ?", + ) + .bind(&approval.id) + .fetch_one(app.pool()) + .await + .expect("both pending deliveries should be pinned"); + assert_eq!(pin_refs, 2); + execution.abort(); + assert!( + execution + .await + .expect_err("request waiter should be aborted") + .is_cancelled() + ); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let detail = app + .tool_calls() + .approvals() + .get_admin(&approval.id) + .await + .expect("approval should read") + .expect("approval should remain stored"); + if detail.record.status == ApprovalStatus::Canceled { + assert_eq!(detail.record.revision, 1); + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("detached actor should complete approval cleanup"); + let pins = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM approval_delivery_pins WHERE approval_id = ?", + ) + .bind(&approval.id) + .fetch_one(app.pool()) + .await + .expect("dropped waiter delivery pin should read"); + assert_eq!( + pins, 0, + "lost-execution cancellation must release pins atomically while another ticket is alive" + ); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let logged = sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM request_logs \ + WHERE approval_id = ? AND error_code = 'approval_canceled')", + ) + .bind(&approval.id) + .fetch_one(app.pool()) + .await + .expect("canceled approval log should read"); + if logged != 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("dropped waiter cancellation should remain durably logged"); + drop(held_delivery); + upstream_task.abort(); + let _ = upstream_task.await; + app.begin_shutdown(); + drop(app); +} + +#[tokio::test] +async fn cancellation_during_blocked_submission_leaves_no_delivery_pin() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + let execution_id = "runtime-cancel-during-submit"; + let (_manager, dispatcher) = execution(&app, execution_id); + let writer = app + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .expect("submission blocker should acquire the SQLite writer"); + let cancellation = ExecutionCancellation::default(); + let dispatch_cancellation = cancellation.clone(); + let dispatch = tokio::spawn({ + let dispatcher = dispatcher.clone(); + async move { + dispatcher + .dispatch( + RuntimeToolCall { + execution_id: execution_id.to_owned(), + worker_generation: 11, + call_id: 1, + path: "runtime.cancel".to_owned(), + arguments: json!({}), + }, + dispatch_cancellation, + ) + .await + } + }); + for _ in 0..16 { + tokio::task::yield_now().await; + } + tokio::time::sleep(Duration::from_millis(100)).await; + cancellation.cancel(); + writer + .commit() + .await + .expect("submission blocker should release the SQLite writer"); + + let result = tokio::time::timeout(Duration::from_secs(2), dispatch) + .await + .expect("canceled submission should finish") + .expect("dispatch task should join"); + assert!(matches!( + result, + RuntimeToolResult::InternalFailure { ref code } if code == "execution_cancelled" + )); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let (approvals, pins) = sqlx::query_as::<_, (i64, i64)>( + "SELECT \ + (SELECT COUNT(*) FROM approvals WHERE execution_id = ?), \ + (SELECT COUNT(*) FROM approval_delivery_pins)", + ) + .bind(execution_id) + .fetch_one(app.pool()) + .await + .expect("canceled submission state should read"); + if approvals == 1 && pins == 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("canceled submission ticket should release its delivery pin"); + dispatcher.finish().await; + upstream_task.abort(); +} + +#[tokio::test] +async fn cancellation_and_approval_claim_have_one_linearized_winner() { + let (_directory, app, upstream_state, upstream_task) = fixture().await; + + let canceled_execution_id = "runtime-cancel-wins"; + let (manager, dispatcher) = execution(&app, canceled_execution_id); + let cancellation = ExecutionCancellation::default(); + let runtime_cancellation = cancellation.clone(); + let runtime_dispatcher = dispatcher.clone(); + let canceled_execution = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: canceled_execution_id.to_owned(), + code: "return await tools.runtime.first();".to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + runtime_cancellation, + ) + .await + }); + let canceled_approval = pending(&app, canceled_execution_id, 1).await.remove(0); + sqlx::query( + "CREATE TRIGGER reject_cancel_winner_cleanup BEFORE UPDATE OF status ON approvals \ + WHEN OLD.execution_id = 'runtime-cancel-wins' AND NEW.status = 'canceled' BEGIN \ + SELECT RAISE(FAIL, 'hold cancellation cleanup'); END", + ) + .execute(app.pool()) + .await + .expect("cancellation cleanup blocker should install"); + cancellation.cancel(); + let canceled_decision = app + .tool_calls() + .decide( + &canceled_approval.id, + "approval-must-lose", + canceled_approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await; + assert!(canceled_decision.is_err()); + sqlx::query("DROP TRIGGER reject_cancel_winner_cleanup") + .execute(app.pool()) + .await + .expect("cancellation cleanup blocker should be removed"); + let canceled = canceled_execution + .await + .expect("canceled execution task should not panic") + .expect_err("cancellation winner should stop the runtime"); + assert_eq!(canceled.code, "execution_cancelled"); + dispatcher.finish().await; + + upstream_state.block_ask.store(true, Ordering::Release); + let claimed_execution_id = "runtime-claim-wins"; + let (manager, dispatcher) = execution(&app, claimed_execution_id); + let cancellation = ExecutionCancellation::default(); + let runtime_cancellation = cancellation.clone(); + let runtime_dispatcher = dispatcher.clone(); + let claimed_execution = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: claimed_execution_id.to_owned(), + code: "return await tools.runtime.second();".to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + runtime_cancellation, + ) + .await + }); + let claimed_approval = pending(&app, claimed_execution_id, 1).await.remove(0); + app.tool_calls() + .decide( + &claimed_approval.id, + "approval-must-win", + claimed_approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("approval should claim execution"); + tokio::time::timeout(Duration::from_secs(2), upstream_state.entered.notified()) + .await + .expect("approved request should reach upstream after claim"); + cancellation.cancel(); + upstream_state.release.notify_one(); + let stopped = claimed_execution + .await + .expect("claimed execution task should not panic") + .expect_err("lost continuation should stop waiting for claimed work"); + assert_eq!(stopped.code, "execution_cancelled"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let detail = app + .tool_calls() + .approvals() + .get_admin(&claimed_approval.id) + .await + .expect("claimed approval should read") + .expect("claimed approval should remain stored"); + if detail.record.status == ApprovalStatus::Succeeded { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("already executing approved work should finish truthfully"); + dispatcher.finish().await; + upstream_task.abort(); +} + +#[tokio::test] +async fn persistent_cleanup_failure_defers_safely_without_hanging_shutdown() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + let execution_id = "runtime-persistent-cleanup-failure"; + let (manager, dispatcher) = execution(&app, execution_id); + let cancellation = ExecutionCancellation::default(); + let runtime_cancellation = cancellation.clone(); + let execution = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await tools.runtime.cancel();".to_owned(), + timeout: Duration::from_secs(5), + }, + dispatcher, + runtime_cancellation, + ) + .await + }); + let approval = pending(&app, execution_id, 1).await.remove(0); + sqlx::query( + "CREATE TRIGGER reject_runtime_cleanup BEFORE UPDATE OF status ON approvals \ + WHEN NEW.status = 'canceled' BEGIN \ + SELECT RAISE(FAIL, 'forced persistent cleanup failure'); END", + ) + .execute(app.pool()) + .await + .expect("persistent cleanup failure trigger should install"); + cancellation.cancel(); + let failure = tokio::time::timeout(Duration::from_secs(2), execution) + .await + .expect("bounded cleanup should let the execution finish") + .expect("execution task should not panic") + .expect_err("canceled execution should fail"); + assert_eq!(failure.code, "execution_cancelled"); + + let decision = app + .tool_calls() + .decide( + &approval.id, + "must-not-approve-lost-continuation", + approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await; + assert!( + decision.is_err(), + "lost continuation must not become executable" + ); + let stored = app + .tool_calls() + .approvals() + .get_admin(&approval.id) + .await + .expect("approval should remain readable") + .expect("approval should remain stored"); + assert_eq!(stored.record.status, ApprovalStatus::Pending); + + upstream_task.abort(); + tokio::time::timeout(Duration::from_secs(2), app.shutdown()) + .await + .expect("persistent cleanup failure must not hang shutdown"); +} + +#[tokio::test] +async fn denial_revocation_cancellation_and_correlation_fail_closed() { + let (_directory, app, _upstream_state, upstream_task) = fixture().await; + + let execution_id = "runtime-denied"; + let (manager, dispatcher) = execution(&app, execution_id); + let runtime_dispatcher = dispatcher.clone(); + let denied = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await tools.runtime.deny();".to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + ExecutionCancellation::default(), + ) + .await + }); + let approval = pending(&app, execution_id, 1).await.remove(0); + app.tool_calls() + .decide( + &approval.id, + "deny-runtime", + approval.revision, + ApprovalDecision::Deny, + 1, + ) + .await + .expect("denial should succeed"); + let output = denied + .await + .expect("denied task should not panic") + .expect("denial should remain a tool value"); + assert_eq!(output.result["error"]["code"], "approval_denied"); + dispatcher.finish().await; + + let correlation = Arc::new(InvocationToolDispatcher::new( + app.tool_calls().clone(), + InvocationContext { + request_id: "correlation-request".to_owned(), + actor: ToolActor::api_token("runtime-owner", None), + surface: RequestSurface::Gateway, + execution_id: "expected-execution".to_owned(), + }, + )); + let result = correlation + .dispatch( + RuntimeToolCall { + execution_id: "wrong-execution".to_owned(), + worker_generation: 1, + call_id: 1, + path: "runtime.enabled".to_owned(), + arguments: json!({}), + }, + ExecutionCancellation::default(), + ) + .await; + assert!(matches!( + result, + RuntimeToolResult::InternalFailure { ref code } if code == "tool_correlation_failed" + )); + correlation.finish().await; + + let execution_id = "runtime-canceled"; + let (manager, dispatcher) = execution(&app, execution_id); + let cancellation = ExecutionCancellation::default(); + let runtime_cancellation = cancellation.clone(); + let runtime_dispatcher = dispatcher.clone(); + let canceled = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await tools.runtime.cancel();".to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + runtime_cancellation, + ) + .await + }); + let approval = pending(&app, execution_id, 1).await.remove(0); + cancellation.cancel(); + let failure = canceled + .await + .expect("canceled task should not panic") + .expect_err("canceled execution should fail"); + assert_eq!(failure.code, "execution_cancelled"); + dispatcher.finish().await; + let approval = app + .tool_calls() + .approvals() + .get_admin(&approval.id) + .await + .expect("approval should read") + .expect("approval should remain stored"); + assert_eq!(approval.record.status, ApprovalStatus::Canceled); + assert_eq!(approval.record.revision, 1); + + let execution_id = "runtime-revoked"; + let (manager, dispatcher) = execution(&app, execution_id); + let runtime_dispatcher = dispatcher.clone(); + let revoked = tokio::spawn(async move { + manager + .execute( + ExecutionRequest { + execution_id: execution_id.to_owned(), + code: "return await tools.runtime.revoke();".to_owned(), + timeout: Duration::from_secs(5), + }, + runtime_dispatcher, + ExecutionCancellation::default(), + ) + .await + }); + pending(&app, execution_id, 1).await; + assert!( + app.tool_calls() + .revoke_owner_token("runtime-owner") + .await + .expect("token revocation should succeed") + ); + let output = revoked + .await + .expect("revoked task should not panic") + .expect("revocation should settle the tool promise"); + assert_eq!(output.result["error"]["code"], "approval_canceled"); + dispatcher.finish().await; + upstream_task.abort(); +} + +#[tokio::test] +async fn execute_api_authenticates_before_body_and_enforces_source_and_time_limits() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("API upstream should bind"); + let upstream_address = listener.local_addr().expect("upstream address should read"); + let upstream_task = tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .fallback(upstream) + .with_state(UpstreamState::default()), + ) + .await + .expect("API upstream should serve"); + }); + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let app = ExecutorApp::open( + AppConfig::new(directory.path().to_path_buf()) + .with_runtime_executable(env!("CARGO_BIN_EXE_executor").into()), + ) + .await + .expect("Executor should open"); + + let oversized = json!({ "code": "x".repeat(2 * 1024 * 1024) }).to_string(); + let unauthenticated = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + oversized, + &[], + ) + .await; + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + + let setup = api_request( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": app.setup_token().expect("setup token should exist"), + "username": "admin", + "password": "correct horse battery staple" + }) + .to_string(), + &[(header::ORIGIN.as_str(), "http://127.0.0.1:4788")], + ) + .await; + assert_eq!(setup.status(), StatusCode::CREATED); + let login = api_request( + app.router(), + Method::POST, + "/api/v1/session", + json!({ + "username": "admin", + "password": "correct horse battery staple" + }) + .to_string(), + &[(header::ORIGIN.as_str(), "http://127.0.0.1:4788")], + ) + .await; + assert_eq!(login.status(), StatusCode::OK); + let cookies = response_cookies(&login); + let login_body = api_body(login).await; + let csrf = login_body["csrfToken"] + .as_str() + .expect("setup should return CSRF"); + let token_response = api_request( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": "Runtime API" }).to_string(), + &[ + (header::COOKIE.as_str(), &cookies), + (header::ORIGIN.as_str(), "http://127.0.0.1:4788"), + ("x-executor-csrf", csrf), + ("idempotency-key", "runtime-api-token"), + ], + ) + .await; + assert_eq!(token_response.status(), StatusCode::CREATED); + let token_body = api_body(token_response).await; + let token = token_body["token"] + .as_str() + .expect("token should be returned") + .to_owned(); + let token_id = token_body["id"] + .as_str() + .expect("token ID should be returned") + .to_owned(); + let authorization = format!("Bearer {token}"); + + let cookie_only = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "return 1" }).to_string(), + &[(header::COOKIE.as_str(), &cookies)], + ) + .await; + assert_eq!(cookie_only.status(), StatusCode::UNAUTHORIZED); + + let invalid_timeout = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "return 1", "timeoutMs": 300_001 }).to_string(), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(invalid_timeout.status(), StatusCode::BAD_REQUEST); + assert_eq!( + api_body(invalid_timeout).await["error"]["code"], + "invalid_timeout" + ); + + let invalid_json = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + "{".to_owned(), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(invalid_json.status(), StatusCode::BAD_REQUEST); + let invalid_request_id = invalid_json + .headers() + .get("x-request-id") + .expect("invalid response should have a request ID") + .to_str() + .expect("request ID should be text") + .to_owned(); + assert_eq!( + api_body(invalid_json).await["error"]["code"], + "invalid_json" + ); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let recorded = sqlx::query_scalar::<_, i64>( + "SELECT EXISTS(SELECT 1 FROM request_logs WHERE request_id = ? \ + AND path_snapshot = 'executor.execute' AND error_code = 'invalid_json')", + ) + .bind(&invalid_request_id) + .fetch_one(app.pool()) + .await + .expect("invalid execution log should read"); + if recorded != 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("authenticated invalid JSON should be logged"); + + let source_too_large = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "x".repeat(1024 * 1024 + 1) }).to_string(), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(source_too_large.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + api_body(source_too_large).await["error"]["code"], + "source_too_large" + ); + + let valid = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "emit({phase: 'ready'}); console.log('ok'); return 42" }).to_string(), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(valid.status(), StatusCode::OK); + let valid = api_body(valid).await; + assert_eq!(valid["result"], 42); + assert_eq!(valid["emits"][0]["phase"], "ready"); + assert_eq!(valid["console"][0]["message"], "ok"); + assert!(valid["executionId"].as_str().is_some()); + assert_eq!(valid["calls"], json!([])); + + let source = api_request( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Runtime API source", + "preferredSlug": "api", + "spec": { + "type": "inline", + "content": json!({ + "openapi": "3.1.0", + "info": { "title": "Runtime API" }, + "servers": [{ "url": format!("http://{upstream_address}") }], + "paths": { + "/enabled": { + "get": { + "operationId": "enabled", + "security": [{}], + "responses": { "200": { "description": "ok" } } + } + }, + "/ask": { + "post": { + "operationId": "ask", + "security": [{}], + "responses": { "200": { "description": "ok" } } + } + } + } + }).to_string() + }, + "allowPrivateNetwork": true + }) + .to_string(), + &[ + (header::COOKIE.as_str(), &cookies), + (header::ORIGIN.as_str(), "http://127.0.0.1:4788"), + ("x-executor-csrf", csrf), + ], + ) + .await; + assert_eq!( + source.status(), + StatusCode::CREATED, + "{}", + api_body(source).await + ); + + let enabled = api_request( + app.router(), + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "return await tools.api.enabled()" }).to_string(), + &[(header::AUTHORIZATION.as_str(), &authorization)], + ) + .await; + assert_eq!(enabled.status(), StatusCode::OK); + let enabled = api_body(enabled).await; + assert_eq!(enabled["result"]["path"], "/enabled"); + assert_eq!(enabled["calls"].as_array().map(Vec::len), Some(1)); + + let ask_router = app.router(); + let ask_authorization = authorization.clone(); + let ask = tokio::spawn(async move { + api_request( + ask_router, + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "return await tools.api.ask()" }).to_string(), + &[(header::AUTHORIZATION.as_str(), &ask_authorization)], + ) + .await + }); + let approval = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let mut pending = app + .tool_calls() + .approvals() + .list_admin(ApprovalListQuery { + status: Some(ApprovalStatus::Pending), + limit: 100, + ..Default::default() + }) + .await + .expect("API approvals should list") + .items; + if let Some(approval) = pending + .drain(..) + .find(|approval| approval.callable_path_snapshot.ends_with(".ask")) + { + break approval; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("API approval should appear"); + let approved_id = approval.id.clone(); + app.tool_calls() + .decide( + &approval.id, + "approve-api-runtime", + approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await + .expect("API approval should succeed"); + let ask = ask.await.expect("Ask HTTP task should not panic"); + assert_eq!(ask.status(), StatusCode::OK); + assert_eq!(api_body(ask).await["result"]["path"], "/ask"); + + let dropped_router = app.router(); + let dropped_authorization = authorization.clone(); + let dropped = tokio::spawn(async move { + api_request( + dropped_router, + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "return await tools.api.ask()" }).to_string(), + &[(header::AUTHORIZATION.as_str(), &dropped_authorization)], + ) + .await + }); + let dropped_approval = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let pending = app + .tool_calls() + .approvals() + .list_admin(ApprovalListQuery { + status: Some(ApprovalStatus::Pending), + limit: 100, + ..Default::default() + }) + .await + .expect("API approvals should list") + .items; + if let Some(approval) = pending + .into_iter() + .find(|approval| approval.id != approved_id) + { + break approval; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("dropped request approval should appear"); + sqlx::query(&format!( + "CREATE TRIGGER reject_dropped_http_cleanup BEFORE UPDATE OF status ON approvals \ + WHEN OLD.execution_id = '{}' AND NEW.status = 'canceled' BEGIN \ + SELECT RAISE(FAIL, 'hold HTTP cancellation cleanup'); END", + dropped_approval.execution_id + )) + .execute(app.pool()) + .await + .expect("HTTP cancellation cleanup blocker should install"); + dropped.abort(); + assert!( + dropped + .await + .expect_err("HTTP execution waiter should be aborted") + .is_cancelled() + ); + let dropped_decision = app + .tool_calls() + .decide( + &dropped_approval.id, + "dropped-request-must-not-execute", + dropped_approval.revision, + ApprovalDecision::Approve, + 1, + ) + .await; + assert!( + dropped_decision.is_err(), + "a disconnected HTTP execution must close its approval gate synchronously" + ); + sqlx::query("DROP TRIGGER reject_dropped_http_cleanup") + .execute(app.pool()) + .await + .expect("HTTP cancellation cleanup blocker should be removed"); + + let revoke_router = app.router(); + let revoke_authorization = authorization.clone(); + let waiting = tokio::spawn(async move { + api_request( + revoke_router, + Method::POST, + "/api/v1/gateway/execute", + json!({ "code": "return await tools.api.ask()" }).to_string(), + &[(header::AUTHORIZATION.as_str(), &revoke_authorization)], + ) + .await + }); + let pending_revoke = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let pending = app + .tool_calls() + .approvals() + .list_admin(ApprovalListQuery { + status: Some(ApprovalStatus::Pending), + limit: 100, + ..Default::default() + }) + .await + .expect("API approvals should list") + .items; + if let Some(approval) = pending + .into_iter() + .find(|approval| approval.id != approved_id && approval.id != dropped_approval.id) + { + break approval; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("revoked approval should appear"); + let revoked = api_request( + app.router(), + Method::DELETE, + &format!("/api/v1/tokens/{token_id}"), + String::new(), + &[ + (header::COOKIE.as_str(), &cookies), + (header::ORIGIN.as_str(), "http://127.0.0.1:4788"), + ("x-executor-csrf", csrf), + ], + ) + .await; + assert_eq!(revoked.status(), StatusCode::NO_CONTENT); + let waiting = waiting + .await + .expect("revoked execution task should not panic"); + assert_eq!(waiting.status(), StatusCode::CONFLICT); + assert_eq!( + api_body(waiting).await["error"]["code"], + "execution_cancelled" + ); + let pending_revoke = app + .tool_calls() + .approvals() + .get_admin(&pending_revoke.id) + .await + .expect("revoked approval should read") + .expect("revoked approval should remain stored"); + assert_eq!(pending_revoke.record.status, ApprovalStatus::Canceled); + upstream_task.abort(); + app.shutdown().await; +} diff --git a/tests/source_creation_idempotency_api.rs b/tests/source_creation_idempotency_api.rs new file mode 100644 index 000000000..44a8e3a4d --- /dev/null +++ b/tests/source_creation_idempotency_api.rs @@ -0,0 +1,1093 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use axum::{ + Json, Router, + body::Body, + http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header}, + routing::post, +}; +use executor::{AppConfig, ExecutorApp}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tokio::{net::TcpListener, sync::Semaphore}; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; +const IDEMPOTENCY_KEY: &str = "idempotency-key"; +const IDEMPOTENCY_REPLAYED: &str = "idempotency-replayed"; + +struct Admin { + cookie: String, + csrf: String, +} + +#[derive(Debug)] +struct ResponseSnapshot { + status: StatusCode, + headers: HeaderMap, + body: Vec, +} + +impl ResponseSnapshot { + fn json(&self) -> Value { + serde_json::from_slice(&self.body).expect("response contains JSON") + } +} + +#[derive(Clone)] +struct DiscoveryGate { + calls: Arc, + reached: Arc, + release: Arc, +} + +impl DiscoveryGate { + fn new() -> Self { + Self { + calls: Arc::new(AtomicUsize::new(0)), + reached: Arc::new(Semaphore::new(0)), + release: Arc::new(Semaphore::new(0)), + } + } + + async fn wait_until_reached(&self) { + self.reached + .acquire() + .await + .expect("discovery gate remains open") + .forget(); + } + + fn release(&self) { + self.release.add_permits(1); + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +#[tokio::test] +async fn keyed_source_creation_replays_exact_bytes_without_repeating_side_effects() { + let mcp_calls = Arc::new(AtomicUsize::new(0)); + let mcp_server_calls = mcp_calls.clone(); + let mcp_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("MCP listener binds"); + let mcp_address = mcp_listener.local_addr().expect("MCP address reads"); + let mcp_task = tokio::spawn(async move { + axum::serve( + mcp_listener, + Router::new().route( + "/mcp", + post(move || { + let calls = mcp_server_calls.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + StatusCode::UNAUTHORIZED + } + }), + ), + ) + .await + .expect("MCP server runs"); + }); + + let directory = tempfile::tempdir().expect("temporary directory is created"); + let templates_path = directory.path().join("mcp-templates.json"); + std::fs::write( + &templates_path, + json!({ + "templates": [{ + "name": "deferred", + "executable": "/bin/sh", + "arguments": ["-c", "exit 1"], + "secretEnvironment": ["API_TOKEN"] + }] + }) + .to_string(), + ) + .expect("MCP templates write"); + let app = ExecutorApp::open( + AppConfig::new(directory.path().to_path_buf()) + .with_mcp_stdio_templates_file(Some(templates_path)), + ) + .await + .expect("Executor opens"); + let admin = setup(&app).await; + + let openapi_spec = json!({ + "openapi": "3.1.0", + "info": { "title": "Idempotent API", "version": "1" }, + "servers": [{ "url": "https://example.com" }], + "paths": {} + }); + let requests = [ + ( + "openapi-exact-replay", + json!({ + "kind": "openapi", + "displayName": "Idempotent API", + "preferredSlug": "idempotent-api", + "spec": { "type": "inline", "content": openapi_spec.to_string() } + }), + ), + ( + "mcp-http-exact-replay", + json!({ + "kind": "mcp_http", + "displayName": "Deferred MCP HTTP", + "preferredSlug": "deferred-mcp-http", + "endpoint": format!("http://{mcp_address}/mcp"), + "allowPrivateNetwork": true + }), + ), + ( + "mcp-stdio-exact-replay", + json!({ + "kind": "mcp_stdio", + "displayName": "Deferred MCP stdio", + "preferredSlug": "deferred-mcp-stdio", + "templateName": "deferred", + "secretValues": {} + }), + ), + ]; + + for (key, request) in requests { + let first = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + request.clone(), + mutation_headers(&admin, Some(key)), + ) + .await; + assert_eq!( + first.status, + StatusCode::CREATED, + "first response for {key}" + ); + assert!( + !first.headers.contains_key(IDEMPOTENCY_REPLAYED), + "fresh response must not be marked replayed" + ); + assert_single_no_store(&first); + let revision_after_first = app + .catalog() + .global_revision() + .await + .expect("catalog revision reads"); + let audits_after_first = audit_count(&app).await; + + let replay = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + request, + mutation_headers(&admin, Some(key)), + ) + .await; + assert_eq!(replay.status, first.status, "replay status for {key}"); + assert_eq!(replay.body, first.body, "replay body for {key}"); + assert_single_no_store(&replay); + assert_eq!( + replay + .headers + .get(IDEMPOTENCY_REPLAYED) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + assert_eq!( + app.catalog() + .global_revision() + .await + .expect("catalog revision reads after replay"), + revision_after_first, + "replay must not bump the catalog revision" + ); + assert_eq!( + audit_count(&app).await, + audits_after_first, + "replay must not append an audit event" + ); + + let completed_status = send_snapshot( + app.router(), + Method::GET, + "/api/v1/sources/idempotency", + Value::Null, + authentication_headers(&admin, Some(key)), + ) + .await; + assert_eq!(completed_status.status, first.status); + assert_eq!(completed_status.body, first.body); + assert_single_no_store(&completed_status); + assert_eq!( + completed_status + .headers + .get(IDEMPOTENCY_REPLAYED) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + + let sealed_completion = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources/idempotency/seal", + Value::Null, + mutation_headers(&admin, Some(key)), + ) + .await; + assert_eq!( + sealed_completion.status, first.status, + "seal must return the completed response for {key}" + ); + assert_eq!(sealed_completion.body, first.body); + assert_single_no_store(&sealed_completion); + assert_eq!( + sealed_completion + .headers + .get(IDEMPOTENCY_REPLAYED) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + assert_eq!( + app.catalog() + .global_revision() + .await + .expect("catalog revision reads after completed seal"), + revision_after_first + ); + assert_eq!(audit_count(&app).await, audits_after_first); + } + + assert_eq!( + app.catalog() + .list_sources() + .await + .expect("sources list") + .len(), + 3 + ); + assert_eq!(mcp_calls.load(Ordering::SeqCst), 1); + app.shutdown().await; + mcp_task.abort(); +} + +#[tokio::test] +async fn concurrent_requests_detect_in_progress_and_mismatch_before_discovery() { + let gate = DiscoveryGate::new(); + let (address, upstream_task) = start_gated_graphql(gate.clone()).await; + let directory = tempfile::tempdir().expect("temporary directory is created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor opens"); + let admin = setup(&app).await; + let key = "graphql-race"; + let request = graphql_request(address, "secret-one"); + + let first_router = app.router(); + let first_headers = mutation_headers(&admin, Some(key)); + let first_request = request.clone(); + let first = tokio::spawn(async move { + send_snapshot( + first_router, + Method::POST, + "/api/v1/sources", + first_request, + first_headers, + ) + .await + }); + gate.wait_until_reached().await; + assert_eq!(gate.calls(), 1); + + let duplicate = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + request.clone(), + mutation_headers(&admin, Some(key)), + ) + .await; + assert_eq!(duplicate.status, StatusCode::CONFLICT); + assert_eq!(duplicate.json()["error"]["code"], "idempotency_in_progress"); + assert_eq!( + duplicate + .headers + .get(header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()), + Some("1") + ); + + let seal_in_progress = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources/idempotency/seal", + Value::Null, + mutation_headers(&admin, Some(key)), + ) + .await; + assert_eq!(seal_in_progress.status, StatusCode::CONFLICT); + assert_eq!( + seal_in_progress.json()["error"]["code"], + "idempotency_in_progress" + ); + let in_progress_status = send_snapshot( + app.router(), + Method::GET, + "/api/v1/sources/idempotency", + Value::Null, + authentication_headers(&admin, Some(key)), + ) + .await; + assert_eq!(in_progress_status.status, StatusCode::OK); + assert_eq!( + in_progress_status.json(), + json!({ "status": "in_progress" }) + ); + assert_single_no_store(&in_progress_status); + + let mut changed_secret = request.clone(); + changed_secret["credential"]["token"] = json!("secret-two"); + assert_mismatch(&app, &admin, key, changed_secret).await; + + let mut changed_unknown_field = request.clone(); + changed_unknown_field["futureField"] = json!({ "nested": true }); + assert_mismatch(&app, &admin, key, changed_unknown_field).await; + + let mut changed_kind = request.clone(); + changed_kind["kind"] = json!("openapi"); + assert_mismatch(&app, &admin, key, changed_kind).await; + assert_eq!(gate.calls(), 1, "mismatches must not repeat discovery"); + + gate.release(); + let first = first.await.expect("first request task joins"); + assert_eq!(first.status, StatusCode::CREATED); + let replay = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + request, + mutation_headers(&admin, Some(key)), + ) + .await; + assert_eq!(replay.status, first.status); + assert_eq!(replay.body, first.body); + assert_eq!(gate.calls(), 1); + app.shutdown().await; + upstream_task.abort(); +} + +#[tokio::test] +async fn status_seal_and_key_validation_are_fail_closed() { + let directory = tempfile::tempdir().expect("temporary directory is created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor opens"); + let admin = setup(&app).await; + + let unauthenticated = send_snapshot( + app.router(), + Method::GET, + "/api/v1/sources/idempotency", + Value::Null, + idempotency_headers("status-key"), + ) + .await; + assert_eq!(unauthenticated.status, StatusCode::UNAUTHORIZED); + assert_single_no_store(&unauthenticated); + + let missing = send_snapshot( + app.router(), + Method::GET, + "/api/v1/sources/idempotency", + Value::Null, + authentication_headers(&admin, Some("missing-key")), + ) + .await; + assert_eq!(missing.status, StatusCode::OK); + assert_eq!(missing.json(), json!({ "status": "missing" })); + assert_single_no_store(&missing); + + let missing_header = send_snapshot( + app.router(), + Method::GET, + "/api/v1/sources/idempotency?key=query-values-are-ignored", + Value::Null, + authentication_headers(&admin, None), + ) + .await; + assert_eq!(missing_header.status, StatusCode::BAD_REQUEST); + assert_eq!( + missing_header.json()["error"]["code"], + "idempotency_key_required" + ); + + let request = openapi_request("Key validation", "key-validation"); + let mut invalid_headers = Vec::new(); + invalid_headers.push(mutation_headers(&admin, Some(""))); + invalid_headers.push(mutation_headers(&admin, Some("contains space"))); + invalid_headers.push(mutation_headers(&admin, Some(&"x".repeat(256)))); + let mut non_ascii = mutation_headers(&admin, None); + non_ascii.insert( + IDEMPOTENCY_KEY, + HeaderValue::from_bytes(&[0x80]).expect("opaque header value builds"), + ); + invalid_headers.push(non_ascii); + let mut duplicate = mutation_headers(&admin, None); + duplicate.append(IDEMPOTENCY_KEY, HeaderValue::from_static("first")); + duplicate.append(IDEMPOTENCY_KEY, HeaderValue::from_static("second")); + invalid_headers.push(duplicate); + + for headers in invalid_headers { + let response = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + request.clone(), + headers, + ) + .await; + assert_eq!(response.status, StatusCode::BAD_REQUEST); + assert_eq!(response.json()["error"]["code"], "invalid_idempotency_key"); + } + + let seal_without_csrf = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources/idempotency/seal", + Value::Null, + authentication_headers(&admin, Some("sealed-before-create")), + ) + .await; + assert_eq!(seal_without_csrf.status, StatusCode::FORBIDDEN); + + let sealed = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources/idempotency/seal", + Value::Null, + mutation_headers(&admin, Some("sealed-before-create")), + ) + .await; + assert_eq!(sealed.status, StatusCode::OK); + assert_eq!(sealed.json(), json!({ "status": "abandoned" })); + assert_single_no_store(&sealed); + + let delayed_create = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + request, + mutation_headers(&admin, Some("sealed-before-create")), + ) + .await; + assert_eq!(delayed_create.status, StatusCode::CONFLICT); + assert_eq!( + delayed_create.json()["error"]["code"], + "idempotency_abandoned" + ); + + let status = send_snapshot( + app.router(), + Method::GET, + "/api/v1/sources/idempotency", + Value::Null, + authentication_headers(&admin, Some("sealed-before-create")), + ) + .await; + assert_eq!(status.json(), json!({ "status": "abandoned" })); + assert!( + app.catalog() + .list_sources() + .await + .expect("sources list") + .is_empty() + ); + app.shutdown().await; +} + +#[tokio::test] +async fn failed_and_successful_replays_remain_exact_and_private_after_source_deletion() { + let directory = tempfile::tempdir().expect("temporary directory is created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor opens"); + let admin = setup(&app).await; + let failed_key = "failed-key-must-not-be-stored"; + let secret_marker = "request-secret-must-not-be-stored"; + let failed_request = json!({ + "kind": "openapi", + "displayName": "Invalid document", + "preferredSlug": "invalid-document", + "spec": { "type": "inline", "content": secret_marker } + }); + + let failed = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + failed_request.clone(), + mutation_headers(&admin, Some(failed_key)), + ) + .await; + assert_eq!(failed.status, StatusCode::BAD_REQUEST); + assert_single_no_store(&failed); + let failed_replay = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + failed_request, + mutation_headers(&admin, Some(failed_key)), + ) + .await; + assert_eq!(failed_replay.status, failed.status); + assert_eq!(failed_replay.body, failed.body); + assert_single_no_store(&failed_replay); + assert_eq!( + failed_replay + .headers + .get(IDEMPOTENCY_REPLAYED) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + let failed_status = send_snapshot( + app.router(), + Method::GET, + "/api/v1/sources/idempotency", + Value::Null, + authentication_headers(&admin, Some(failed_key)), + ) + .await; + assert_eq!(failed_status.status, failed.status); + assert_eq!(failed_status.body, failed.body); + assert_single_no_store(&failed_status); + assert_eq!( + failed_status + .headers + .get(IDEMPOTENCY_REPLAYED) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + let failed_seal = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources/idempotency/seal", + Value::Null, + mutation_headers(&admin, Some(failed_key)), + ) + .await; + assert_eq!(failed_seal.status, failed.status); + assert_eq!(failed_seal.body, failed.body); + assert_single_no_store(&failed_seal); + assert_eq!( + failed_seal + .headers + .get(IDEMPOTENCY_REPLAYED) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + + let persisted = sqlx::query_as::<_, (Vec, Vec, Vec)>( + "SELECT key_digest, request_digest, response_ciphertext \ + FROM source_creation_idempotency WHERE state = 'failed'", + ) + .fetch_one(app.pool()) + .await + .expect("failed idempotency row reads"); + for value in [&persisted.0, &persisted.1, &persisted.2] { + let text = String::from_utf8_lossy(value); + assert!(!text.contains(failed_key)); + assert!(!text.contains(secret_marker)); + } + + let success_key = "successful-replay-after-delete"; + let successful = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + openapi_request("Delete after create", "delete-after-create"), + mutation_headers(&admin, Some(success_key)), + ) + .await; + assert_eq!(successful.status, StatusCode::CREATED); + let source_id = successful.json()["id"] + .as_str() + .expect("created source has an ID") + .to_owned(); + sqlx::query("DELETE FROM sources WHERE id = ?") + .bind(&source_id) + .execute(app.pool()) + .await + .expect("source deletes directly for retention test"); + + let replay_after_delete = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + openapi_request("Delete after create", "delete-after-create"), + mutation_headers(&admin, Some(success_key)), + ) + .await; + assert_eq!(replay_after_delete.status, successful.status); + assert_eq!(replay_after_delete.body, successful.body); + assert_single_no_store(&replay_after_delete); + assert!( + app.catalog() + .list_sources() + .await + .expect("sources list after replay") + .is_empty(), + "replay must not recreate a deleted source" + ); + app.shutdown().await; +} + +#[tokio::test] +async fn disconnecting_the_http_handler_does_not_cancel_source_creation() { + let gate = DiscoveryGate::new(); + let (address, upstream_task) = start_gated_graphql(gate.clone()).await; + let directory = tempfile::tempdir().expect("temporary directory is created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor opens"); + let admin = setup(&app).await; + let key = "disconnected-handler"; + let request = graphql_request(address, "disconnect-secret"); + let request_for_task = request.clone(); + let router = app.router(); + let headers = mutation_headers(&admin, Some(key)); + let handler = tokio::spawn(async move { + send_snapshot( + router, + Method::POST, + "/api/v1/sources", + request_for_task, + headers, + ) + .await + }); + gate.wait_until_reached().await; + handler.abort(); + gate.release(); + + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if app + .catalog() + .list_sources() + .await + .expect("sources list while waiting") + .len() + == 1 + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("detached source creation completes"); + + let replay = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + request, + mutation_headers(&admin, Some(key)), + ) + .await; + assert_eq!(replay.status, StatusCode::CREATED); + assert_eq!( + replay + .headers + .get(IDEMPOTENCY_REPLAYED) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + assert_eq!(gate.calls(), 1); + app.shutdown().await; + upstream_task.abort(); +} + +#[tokio::test] +async fn settlement_storage_failure_is_recovered_to_an_interrupted_tombstone() { + let directory = tempfile::tempdir().expect("temporary directory is created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor opens"); + let admin = setup(&app).await; + sqlx::query( + "CREATE TRIGGER reject_source_creation_failed_response \ + BEFORE UPDATE OF state ON source_creation_idempotency \ + WHEN NEW.state = 'failed' \ + BEGIN SELECT RAISE(FAIL, 'injected failed-response write error'); END", + ) + .execute(app.pool()) + .await + .expect("failure trigger installs"); + let key = "failed-response-storage-error"; + let response = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + json!({ + "kind": "openapi", + "displayName": "Invalid document", + "preferredSlug": "invalid-storage-failure", + "spec": { "type": "inline", "content": "not an OpenAPI document" } + }), + mutation_headers(&admin, Some(key)), + ) + .await; + assert_eq!(response.status, StatusCode::CONFLICT); + assert_eq!(response.json()["error"]["code"], "idempotency_interrupted"); + + let status = send_snapshot( + app.router(), + Method::GET, + "/api/v1/sources/idempotency", + Value::Null, + authentication_headers(&admin, Some(key)), + ) + .await; + assert_eq!(status.status, StatusCode::OK); + assert_eq!(status.json(), json!({ "status": "interrupted" })); + assert!( + app.catalog() + .list_sources() + .await + .expect("sources list") + .is_empty() + ); + app.shutdown().await; +} + +#[tokio::test] +async fn app_shutdown_cancels_discovery_and_spawn_rejection_terminalizes_reservations() { + let gate = DiscoveryGate::new(); + let (address, upstream_task) = start_gated_graphql(gate.clone()).await; + let directory = tempfile::tempdir().expect("temporary directory is created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor opens"); + let admin = setup(&app).await; + let key = "shutdown-during-discovery"; + let request = graphql_request(address, "shutdown-secret"); + let router = app.router(); + let headers = mutation_headers(&admin, Some(key)); + let in_flight = tokio::spawn(async move { + send_snapshot(router, Method::POST, "/api/v1/sources", request, headers).await + }); + gate.wait_until_reached().await; + app.begin_shutdown(); + let interrupted = tokio::time::timeout(Duration::from_secs(5), in_flight) + .await + .expect("shutdown joins the supervised source creation") + .expect("request task joins"); + assert_eq!(interrupted.status, StatusCode::CONFLICT); + assert_eq!( + interrupted.json()["error"]["code"], + "idempotency_interrupted" + ); + assert!( + app.catalog() + .list_sources() + .await + .expect("sources list after cancellation") + .is_empty() + ); + + let rejected_key = "spawn-rejected-during-shutdown"; + let rejected = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + openapi_request("Rejected during shutdown", "rejected-during-shutdown"), + mutation_headers(&admin, Some(rejected_key)), + ) + .await; + assert_eq!(rejected.status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + rejected.json()["error"]["code"], + "source_creation_unavailable" + ); + let rejected_status = send_snapshot( + app.router(), + Method::GET, + "/api/v1/sources/idempotency", + Value::Null, + authentication_headers(&admin, Some(rejected_key)), + ) + .await; + assert_eq!(rejected_status.json(), json!({ "status": "interrupted" })); + + gate.release(); + app.shutdown().await; + upstream_task.abort(); +} + +async fn assert_mismatch(app: &ExecutorApp, admin: &Admin, key: &str, request: Value) { + let response = send_snapshot( + app.router(), + Method::POST, + "/api/v1/sources", + request, + mutation_headers(admin, Some(key)), + ) + .await; + assert_eq!(response.status, StatusCode::CONFLICT); + assert_eq!(response.json()["error"]["code"], "idempotency_key_mismatch"); +} + +async fn start_gated_graphql( + gate: DiscoveryGate, +) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("GraphQL listener binds"); + let address = listener.local_addr().expect("GraphQL address reads"); + let task = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route( + "/graphql", + post(move |Json(_request): Json| { + let gate = gate.clone(); + async move { + gate.calls.fetch_add(1, Ordering::SeqCst); + gate.reached.add_permits(1); + gate.release + .acquire() + .await + .expect("discovery gate remains open") + .forget(); + Json(graphql_introspection()) + } + }), + ), + ) + .await + .expect("GraphQL server runs"); + }); + (address, task) +} + +fn graphql_request(address: std::net::SocketAddr, token: &str) -> Value { + json!({ + "kind": "graphql", + "displayName": "Idempotent GraphQL", + "preferredSlug": "idempotent-graphql", + "endpoint": format!("http://{address}/graphql"), + "allowPrivateNetwork": true, + "credential": { "type": "bearer", "token": token } + }) +} + +fn graphql_introspection() -> Value { + json!({ + "data": { + "__schema": { + "queryType": { "name": "Query" }, + "mutationType": null, + "subscriptionType": null, + "types": [ + { + "kind": "SCALAR", + "name": "String", + "description": null, + "fields": null, + "inputFields": null, + "enumValues": null + }, + { + "kind": "OBJECT", + "name": "Query", + "description": null, + "inputFields": null, + "enumValues": null, + "fields": [{ + "name": "hello", + "description": "Say hello", + "isDeprecated": false, + "args": [], + "type": { "kind": "SCALAR", "name": "String", "ofType": null } + }] + } + ] + } + } + }) +} + +fn openapi_request(display_name: &str, slug: &str) -> Value { + let specification = json!({ + "openapi": "3.1.0", + "info": { "title": display_name, "version": "1" }, + "servers": [{ "url": "https://example.com" }], + "paths": {} + }); + json!({ + "kind": "openapi", + "displayName": display_name, + "preferredSlug": slug, + "spec": { "type": "inline", "content": specification.to_string() } + }) +} + +async fn setup(app: &ExecutorApp) -> Admin { + let setup_token = app.setup_token().expect("fresh instance has setup token"); + let setup = send_snapshot( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": setup_token, + "username": "admin", + "password": "correct horse battery staple" + }), + origin_headers(), + ) + .await; + assert_eq!(setup.status, StatusCode::CREATED); + + let login = send_snapshot( + app.router(), + Method::POST, + "/api/v1/session", + json!({ + "username": "admin", + "password": "correct horse battery staple" + }), + origin_headers(), + ) + .await; + assert_eq!(login.status, StatusCode::OK); + let cookie = login + .headers + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie is text") + .split(';') + .next() + .expect("cookie has a value") + }) + .collect::>() + .join("; "); + let csrf = login.json()["csrfToken"] + .as_str() + .expect("login returns CSRF") + .to_owned(); + Admin { cookie, csrf } +} + +fn origin_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(header::ORIGIN, HeaderValue::from_static(ORIGIN)); + headers +} + +fn authentication_headers(admin: &Admin, key: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(&admin.cookie).expect("cookie header is valid"), + ); + if let Some(key) = key { + headers.insert( + IDEMPOTENCY_KEY, + HeaderValue::from_str(key).expect("idempotency header builds"), + ); + } + headers +} + +fn mutation_headers(admin: &Admin, key: Option<&str>) -> HeaderMap { + let mut headers = authentication_headers(admin, key); + headers.insert(header::ORIGIN, HeaderValue::from_static(ORIGIN)); + headers.insert( + "x-executor-csrf", + HeaderValue::from_str(&admin.csrf).expect("CSRF header is valid"), + ); + headers +} + +fn idempotency_headers(key: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + IDEMPOTENCY_KEY, + HeaderValue::from_str(key).expect("idempotency header is valid"), + ); + headers +} + +fn assert_single_no_store(response: &ResponseSnapshot) { + assert_eq!( + response + .headers + .get_all(header::CACHE_CONTROL) + .iter() + .count(), + 1, + "Cache-Control must be represented by exactly one header value" + ); + assert_eq!( + response + .headers + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); +} + +async fn audit_count(app: &ExecutorApp) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM audit_events") + .fetch_one(app.pool()) + .await + .expect("audit count reads") +} + +async fn send_snapshot( + router: Router, + method: Method, + uri: &str, + body: Value, + mut headers: HeaderMap, +) -> ResponseSnapshot { + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + let mut request = Request::builder() + .method(method) + .uri(uri) + .body(Body::from(body.to_string())) + .expect("request builds"); + *request.headers_mut() = headers; + let response = router.oneshot(request).await.expect("router answers"); + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .into_body() + .collect() + .await + .expect("response collects") + .to_bytes() + .to_vec(); + ResponseSnapshot { + status, + headers, + body, + } +} diff --git a/tests/token_delivery_api.rs b/tests/token_delivery_api.rs new file mode 100644 index 000000000..98b5fa5a7 --- /dev/null +++ b/tests/token_delivery_api.rs @@ -0,0 +1,675 @@ +use std::{ + path::Path, + sync::atomic::{AtomicU64, Ordering}, + time::Duration, +}; + +use axum::{ + Router, + body::Body, + http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header}, +}; +use executor::{AppConfig, ExecutorApp}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tokio::time::timeout; +use tower::ServiceExt; + +const ORIGIN: &str = "http://127.0.0.1:4788"; +const IDEMPOTENCY_KEY: &str = "idempotency-key"; +const IDEMPOTENCY_REPLAYED: &str = "idempotency-replayed"; +const REVOKE_RECONCILED: &str = "revoke-reconciled"; + +static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +struct Admin { + cookie: String, + csrf: String, +} + +#[derive(Debug)] +struct ResponseSnapshot { + status: StatusCode, + headers: HeaderMap, + body: Vec, +} + +impl ResponseSnapshot { + fn json(&self) -> Value { + serde_json::from_slice(&self.body).expect("response contains JSON") + } + + fn text(&self) -> String { + String::from_utf8_lossy(&self.body).into_owned() + } +} + +#[tokio::test] +async fn token_creation_requires_one_valid_idempotency_key_without_echoing_it() { + let directory = tempfile::tempdir().expect("temporary directory is created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor opens"); + let admin = setup(&app).await; + let name = "validation-name-must-not-be-echoed"; + + let missing = send_snapshot( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": name }), + mutation_headers(&admin, None), + ) + .await; + assert_error( + &missing, + StatusCode::BAD_REQUEST, + "idempotency_key_required", + &[name], + ); + + let oversized = "x".repeat(256); + let invalid_cases = ["", "contains space", oversized.as_str()]; + for invalid_key in invalid_cases { + let response = send_snapshot( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": name }), + mutation_headers(&admin, Some(invalid_key)), + ) + .await; + assert_error( + &response, + StatusCode::BAD_REQUEST, + "invalid_idempotency_key", + &[invalid_key, name], + ); + } + + let mut non_ascii = mutation_headers(&admin, None); + non_ascii.insert( + IDEMPOTENCY_KEY, + HeaderValue::from_bytes(&[0x80]).expect("opaque header value builds"), + ); + let response = send_snapshot( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": name }), + non_ascii, + ) + .await; + assert_error( + &response, + StatusCode::BAD_REQUEST, + "invalid_idempotency_key", + &[name], + ); + + let first_duplicate = "duplicate-first-must-not-be-echoed"; + let second_duplicate = "duplicate-second-must-not-be-echoed"; + let mut duplicate = mutation_headers(&admin, None); + duplicate.append(IDEMPOTENCY_KEY, HeaderValue::from_static(first_duplicate)); + duplicate.append(IDEMPOTENCY_KEY, HeaderValue::from_static(second_duplicate)); + let response = send_snapshot( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": name }), + duplicate, + ) + .await; + assert_error( + &response, + StatusCode::BAD_REQUEST, + "invalid_idempotency_key", + &[first_duplicate, second_duplicate, name], + ); + + let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM api_tokens") + .fetch_one(app.pool()) + .await + .expect("API token count reads"); + assert_eq!(count, 0, "invalid requests must not create API tokens"); + app.shutdown().await; +} + +#[tokio::test] +async fn token_creation_replays_exactly_rejects_mismatches_and_converges_concurrently() { + let directory = tempfile::tempdir().expect("temporary directory is created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor opens"); + let admin = setup(&app).await; + let replay_key = unique_key("exact-replay"); + let name = "Exact replay"; + + let first = create_token(&app, &admin, name, &replay_key).await; + assert_eq!(first.status, StatusCode::CREATED); + assert_json_content_type(&first); + assert_single_no_store(&first); + assert!(!first.headers.contains_key(IDEMPOTENCY_REPLAYED)); + let first_json = first.json(); + assert_eq!( + first_json + .as_object() + .expect("created token response is an object") + .len(), + 4, + "idempotency must not change the successful response shape" + ); + assert_eq!(first_json["name"], name); + assert!( + first_json["id"] + .as_str() + .is_some_and(|value| !value.is_empty()), + "created token has an ID" + ); + let plaintext_token = first_json["token"] + .as_str() + .expect("created token is revealed") + .to_owned(); + assert!(plaintext_token.starts_with("exr_")); + assert!(first_json["createdAt"].is_i64()); + + let replay = create_token(&app, &admin, name, &replay_key).await; + assert_eq!(replay.status, first.status); + assert_eq!( + replay.body, first.body, + "replay must return exact token bytes" + ); + assert_json_content_type(&replay); + assert_single_no_store(&replay); + assert_eq!( + replay + .headers + .get(IDEMPOTENCY_REPLAYED) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + + let mismatched_name = "different-name-must-not-be-echoed"; + let mismatch = create_token(&app, &admin, mismatched_name, &replay_key).await; + assert_error( + &mismatch, + StatusCode::CONFLICT, + "idempotency_mismatch", + &[mismatched_name, &replay_key, &plaintext_token], + ); + + let concurrent_key = unique_key("concurrent"); + let concurrent_name = "Concurrent token"; + let first_request = create_token(&app, &admin, concurrent_name, &concurrent_key); + let second_request = create_token(&app, &admin, concurrent_name, &concurrent_key); + let (first_concurrent, second_concurrent) = tokio::join!(first_request, second_request); + assert_eq!(first_concurrent.status, StatusCode::CREATED); + assert_eq!(second_concurrent.status, StatusCode::CREATED); + assert_eq!( + first_concurrent.body, second_concurrent.body, + "concurrent callers must converge on one token" + ); + let replay_markers = [&first_concurrent, &second_concurrent] + .into_iter() + .filter(|response| { + response + .headers + .get(IDEMPOTENCY_REPLAYED) + .is_some_and(|value| value == "true") + }) + .count(); + assert_eq!( + replay_markers, 1, + "exactly one concurrent response is replayed" + ); + let count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM api_tokens WHERE name = ? AND revoked_at IS NULL", + ) + .bind(concurrent_name) + .fetch_one(app.pool()) + .await + .expect("concurrent API token count reads"); + assert_eq!(count, 1); + + app.shutdown().await; +} + +#[tokio::test] +async fn token_replay_survives_restart_without_plaintext_and_revocation_is_reconciled() { + let directory = tempfile::tempdir().expect("temporary directory is created"); + let config = AppConfig::new(directory.path().to_path_buf()); + let app = ExecutorApp::open(config.clone()) + .await + .expect("Executor opens"); + let admin = setup(&app).await; + let replay_key = unique_key("restart-private"); + let created = create_token(&app, &admin, "Restart replay", &replay_key).await; + assert_eq!(created.status, StatusCode::CREATED); + let created_json = created.json(); + let token_id = created_json["id"] + .as_str() + .expect("created token has an ID") + .to_owned(); + let plaintext_token = created_json["token"] + .as_str() + .expect("created token is revealed") + .to_owned(); + assert_storage_omits(directory.path(), &[&replay_key, &plaintext_token]); + + app.shutdown().await; + let app = ExecutorApp::open(config).await.expect("Executor reopens"); + let replay = create_token(&app, &admin, "Restart replay", &replay_key).await; + assert_eq!(replay.status, created.status); + assert_eq!( + replay.body, created.body, + "restart replay returns exact bytes" + ); + assert_eq!( + replay + .headers + .get(IDEMPOTENCY_REPLAYED) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + assert_json_content_type(&replay); + assert_single_no_store(&replay); + assert_storage_omits(directory.path(), &[&replay_key, &plaintext_token]); + + let first_revoke = send_snapshot( + app.router(), + Method::DELETE, + &format!("/api/v1/tokens/{token_id}"), + Value::Null, + mutation_headers(&admin, None), + ) + .await; + assert_eq!(first_revoke.status, StatusCode::NO_CONTENT); + assert!(first_revoke.body.is_empty()); + assert!(!first_revoke.headers.contains_key(REVOKE_RECONCILED)); + + let repeated_revoke = send_snapshot( + app.router(), + Method::DELETE, + &format!("/api/v1/tokens/{token_id}"), + Value::Null, + mutation_headers(&admin, None), + ) + .await; + assert_eq!(repeated_revoke.status, StatusCode::NO_CONTENT); + assert!(repeated_revoke.body.is_empty()); + assert_eq!( + repeated_revoke + .headers + .get(REVOKE_RECONCILED) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + + let revoked_replay = create_token(&app, &admin, "Restart replay", &replay_key).await; + assert_error( + &revoked_replay, + StatusCode::CONFLICT, + "idempotency_replay_revoked", + &[&replay_key, &plaintext_token], + ); + assert_eq!( + revoked_replay + .headers + .get(IDEMPOTENCY_REPLAYED) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + + let unknown_id = "unknown-token-id-secret-marker"; + let unknown = send_snapshot( + app.router(), + Method::DELETE, + &format!("/api/v1/tokens/{unknown_id}"), + Value::Null, + mutation_headers(&admin, None), + ) + .await; + assert_error( + &unknown, + StatusCode::NOT_FOUND, + "token_not_found", + &[unknown_id, &plaintext_token], + ); + assert_storage_omits(directory.path(), &[&replay_key, &plaintext_token]); + + app.shutdown().await; +} + +#[tokio::test] +async fn dropped_revoke_request_finishes_durably_and_reconciles() { + let directory = tempfile::tempdir().expect("temporary directory is created"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("Executor opens"); + let admin = setup(&app).await; + let created = create_token( + &app, + &admin, + "Dropped revoke request", + &unique_key("dropped-revoke"), + ) + .await; + let token_id = created.json()["id"] + .as_str() + .expect("created token has an ID") + .to_owned(); + + let mut writer = app + .pool() + .acquire() + .await + .expect("test writer connection is acquired"); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *writer) + .await + .expect("test writer holds the SQLite write lock"); + + let mut request = Request::builder() + .method(Method::DELETE) + .uri(format!("/api/v1/tokens/{token_id}")) + .body(Body::from(Value::Null.to_string())) + .expect("revoke request builds"); + *request.headers_mut() = mutation_headers(&admin, None); + request.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + let router = app.router(); + let revoke_request = tokio::spawn(async move { router.oneshot(request).await }); + + timeout(Duration::from_secs(2), async { + loop { + let in_use = app.pool().size() as usize - app.pool().num_idle(); + if in_use >= 2 && !revoke_request.is_finished() { + tokio::time::sleep(Duration::from_millis(50)).await; + if !revoke_request.is_finished() { + return; + } + } + tokio::task::yield_now().await; + } + }) + .await + .expect("revoke reaches the blocked durable operation"); + revoke_request.abort(); + assert!( + revoke_request + .await + .expect_err("dropped request task is canceled") + .is_cancelled() + ); + + sqlx::query("COMMIT") + .execute(&mut *writer) + .await + .expect("test writer releases the SQLite write lock"); + drop(writer); + + timeout(Duration::from_secs(2), async { + loop { + let revoked_at = sqlx::query_scalar::<_, Option>( + "SELECT revoked_at FROM api_tokens WHERE id = ?", + ) + .bind(&token_id) + .fetch_one(app.pool()) + .await + .expect("token revocation state reads"); + if revoked_at.is_some() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("supervised revoke completes after its request is dropped"); + + let reconciled = send_snapshot( + app.router(), + Method::DELETE, + &format!("/api/v1/tokens/{token_id}"), + Value::Null, + mutation_headers(&admin, None), + ) + .await; + assert_eq!(reconciled.status, StatusCode::NO_CONTENT); + assert_eq!( + reconciled + .headers + .get(REVOKE_RECONCILED) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + + app.shutdown().await; +} + +async fn create_token( + app: &ExecutorApp, + admin: &Admin, + name: &str, + idempotency_key: &str, +) -> ResponseSnapshot { + send_snapshot( + app.router(), + Method::POST, + "/api/v1/tokens", + json!({ "name": name }), + mutation_headers(admin, Some(idempotency_key)), + ) + .await +} + +async fn setup(app: &ExecutorApp) -> Admin { + let setup_token = app.setup_token().expect("fresh instance has setup token"); + let setup = send_snapshot( + app.router(), + Method::POST, + "/api/v1/setup", + json!({ + "setupToken": setup_token, + "username": "admin", + "password": "correct horse battery staple" + }), + origin_headers(), + ) + .await; + assert_eq!(setup.status, StatusCode::CREATED); + + let login = send_snapshot( + app.router(), + Method::POST, + "/api/v1/session", + json!({ + "username": "admin", + "password": "correct horse battery staple" + }), + origin_headers(), + ) + .await; + assert_eq!(login.status, StatusCode::OK); + let cookie = login + .headers + .get_all(header::SET_COOKIE) + .iter() + .map(|value| { + value + .to_str() + .expect("cookie is text") + .split(';') + .next() + .expect("cookie has a value") + }) + .collect::>() + .join("; "); + let csrf = login.json()["csrfToken"] + .as_str() + .expect("login returns CSRF") + .to_owned(); + Admin { cookie, csrf } +} + +fn unique_key(scope: &str) -> String { + format!( + "token-delivery-{scope}-{}", + TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed) + ) +} + +fn origin_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(header::ORIGIN, HeaderValue::from_static(ORIGIN)); + headers +} + +fn mutation_headers(admin: &Admin, idempotency_key: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_str(&admin.cookie).expect("cookie header is valid"), + ); + headers.insert(header::ORIGIN, HeaderValue::from_static(ORIGIN)); + headers.insert( + "x-executor-csrf", + HeaderValue::from_str(&admin.csrf).expect("CSRF header is valid"), + ); + if let Some(idempotency_key) = idempotency_key { + headers.insert( + IDEMPOTENCY_KEY, + HeaderValue::from_str(idempotency_key).expect("idempotency header is valid"), + ); + } + headers +} + +fn assert_error( + response: &ResponseSnapshot, + expected_status: StatusCode, + expected_code: &str, + forbidden_values: &[&str], +) { + assert_eq!(response.status, expected_status, "{}", response.text()); + assert_json_content_type(response); + assert_single_no_store(response); + let body = response.json(); + let envelope = body.as_object().expect("error response is an object"); + assert_eq!(envelope.len(), 1); + let error = envelope["error"] + .as_object() + .expect("error envelope has an error object"); + assert_eq!(error.len(), 3); + assert_eq!(error["code"], expected_code); + assert!( + error["message"] + .as_str() + .is_some_and(|message| !message.is_empty()), + "error has a stable public message" + ); + assert!( + error["requestId"] + .as_str() + .is_some_and(|request_id| !request_id.is_empty()), + "error has a request ID" + ); + let encoded = response.text(); + for forbidden in forbidden_values { + assert!( + forbidden.is_empty() || !encoded.contains(forbidden), + "error response must not echo sensitive input {forbidden:?}: {encoded}" + ); + } +} + +fn assert_json_content_type(response: &ResponseSnapshot) { + assert_eq!( + response + .headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json") + ); +} + +fn assert_single_no_store(response: &ResponseSnapshot) { + assert_eq!( + response + .headers + .get_all(header::CACHE_CONTROL) + .iter() + .count(), + 1 + ); + assert_eq!( + response + .headers + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store") + ); +} + +fn assert_storage_omits(data_dir: &Path, forbidden_values: &[&str]) { + let mut inspected = 0; + for entry in std::fs::read_dir(data_dir).expect("data directory reads") { + let entry = entry.expect("data directory entry reads"); + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if !file_name.starts_with("executor.db") || !entry.path().is_file() { + continue; + } + inspected += 1; + let bytes = std::fs::read(entry.path()).expect("SQLite storage file reads"); + for forbidden in forbidden_values { + assert!( + !contains_bytes(&bytes, forbidden.as_bytes()), + "SQLite storage {file_name} must not contain plaintext {forbidden:?}" + ); + } + } + assert!(inspected > 0, "at least the SQLite database is inspected"); +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +async fn send_snapshot( + router: Router, + method: Method, + uri: &str, + body: Value, + mut headers: HeaderMap, +) -> ResponseSnapshot { + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + let mut request = Request::builder() + .method(method) + .uri(uri) + .body(Body::from(body.to_string())) + .expect("request builds"); + *request.headers_mut() = headers; + let response = router.oneshot(request).await.expect("router answers"); + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .into_body() + .collect() + .await + .expect("response collects") + .to_bytes() + .to_vec(); + ResponseSnapshot { + status, + headers, + body, + } +} diff --git a/tests/tools-cli.test.ts b/tests/tools-cli.test.ts index f2f2d67ed..af09b9f0e 100644 --- a/tests/tools-cli.test.ts +++ b/tests/tools-cli.test.ts @@ -14,7 +14,7 @@ import { inspectToolPath, normalizeCliErrorText, parseJsonObjectInput, -} from "../apps/cli/src/tooling"; +} from "../legacy/cli/src/tooling"; describe("CLI tooling helpers", () => { it.effect("parses empty input as an empty args object", () => diff --git a/tests/warden-targets.test.ts b/tests/warden-targets.test.ts new file mode 100644 index 000000000..296e023df --- /dev/null +++ b/tests/warden-targets.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "@effect/vitest"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const runGuard = (...args: ReadonlyArray) => + spawnSync("bun", ["run", "scripts/check-warden-targets.ts", ...args], { + cwd: repoRoot, + encoding: "utf8", + }); + +describe("Warden target guard", () => { + it("accepts every configured repository target", () => { + const result = runGuard(); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Warden target guard checked"); + }); + + it("rejects a target that resolves to zero non-ignored files", () => { + const result = runGuard("tests/fixtures/warden-zero-match.toml"); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("zero-match-fixture: tests/fixtures/does-not-exist/**/*.ts"); + }); +}); diff --git a/tests/web_assets.rs b/tests/web_assets.rs new file mode 100644 index 000000000..043f09bc0 --- /dev/null +++ b/tests/web_assets.rs @@ -0,0 +1,141 @@ +use std::fs; + +use axum::{ + Router, + body::{Body, Bytes}, + http::{Method, Request, StatusCode, header}, + response::Response, +}; +use executor::{AppConfig, ExecutorApp}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +const PRIVATE_MARKER: &str = "executor-private-data-must-never-be-served-91d94a"; + +#[tokio::test] +async fn app_router_preserves_static_policy_and_keeps_protocol_paths_json() { + let directory = tempfile::tempdir().expect("temporary data directory"); + let private_file = directory.path().join("private-marker"); + fs::write(&private_file, PRIVATE_MARKER).expect("private marker fixture is written"); + let app = ExecutorApp::open(AppConfig::new(directory.path().to_path_buf())) + .await + .expect("test Executor opens"); + let router = app.router(); + + let asset = send( + router.clone(), + Method::GET, + "/_app/immutable/entry/start.fixture.js?v=1", + ) + .await; + assert_eq!(asset.status(), StatusCode::OK); + assert_eq!( + asset.headers().get(header::CONTENT_TYPE), + Some(&header::HeaderValue::from_static( + "text/javascript; charset=utf-8" + )) + ); + assert_eq!( + asset.headers().get(header::CACHE_CONTROL), + Some(&header::HeaderValue::from_static( + "public, max-age=31536000, immutable" + )) + ); + assert!(asset.headers().contains_key("x-request-id")); + assert!( + !body(asset) + .await + .windows(PRIVATE_MARKER.len()) + .any(|window| { window == PRIVATE_MARKER.as_bytes() }) + ); + + let spa = send(router.clone(), Method::GET, "/tools/example?view=detail").await; + assert_eq!(spa.status(), StatusCode::OK); + assert_eq!( + spa.headers().get(header::CACHE_CONTROL), + Some(&header::HeaderValue::from_static("no-cache")) + ); + assert!(spa.headers().contains_key("x-request-id")); + assert!( + body(spa) + .await + .windows(16) + .any(|window| window == b"Executor fixture") + ); + + for path in [ + "/api/v1/definitely-missing", + "/mcp/definitely-missing", + "/healthz/definitely-missing", + ] { + let response = send(router.clone(), Method::GET, path).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); + assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&header::HeaderValue::from_static("no-store")), + "{path}" + ); + assert!( + response + .headers() + .get(header::CONTENT_TYPE) + .is_some_and(|value| value.as_bytes().starts_with(b"application/json")), + "{path}" + ); + let bytes = body(response).await; + assert!( + !bytes + .windows(16) + .any(|window| window == b"Executor fixture"), + "{path}" + ); + assert!( + !bytes + .windows(PRIVATE_MARKER.len()) + .any(|window| window == PRIVATE_MARKER.as_bytes()), + "{path}" + ); + } + + for path in [ + "/.env", + "/master.key", + "/executor.db", + "/%2e%2e/private-marker", + "/%252e%252e%252fprivate-marker", + ] { + let response = send(router.clone(), Method::GET, path).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); + let bytes = body(response).await; + assert!( + !bytes + .windows(PRIVATE_MARKER.len()) + .any(|window| window == PRIVATE_MARKER.as_bytes()), + "{path} leaked a data-directory file" + ); + } + + app.shutdown().await; +} + +async fn send(router: Router, method: Method, uri: &str) -> Response { + router + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .expect("test request is valid"), + ) + .await + .expect("router answers") +} + +async fn body(response: Response) -> Bytes { + response + .into_body() + .collect() + .await + .expect("response body collects") + .to_bytes() +} diff --git a/warden.toml b/warden.toml index ae63dfeae..dea545b95 100644 --- a/warden.toml +++ b/warden.toml @@ -17,18 +17,28 @@ ignorePaths = [ name = "wrdn-authz" remote = "getsentry/warden-skills" paths = [ - "apps/cloud/src/auth/**/*.ts", - "apps/cloud/src/api/**/*.ts", - "apps/cloud/src/routes/**/*.tsx", + "src/api.rs", + "src/api/**/*.rs", + "src/oauth/**/*.rs", + "legacy/cloud/src/auth/**/*.ts", + "legacy/cloud/src/api/**/*.ts", + "legacy/cloud/src/routes/**/*.tsx", "packages/core/api/src/**/*.ts", ] [[skills]] name = "wrdn-code-execution" remote = "getsentry/warden-skills" +# Explicit legacy development and test commands can still execute the archived +# local runtime, so keep its actual source tree in security scan scope. paths = [ - "apps/local/src/server/**/*.ts", - "apps/cli/src/**/*.ts", + "src/execution.rs", + "src/invocation/**/*.rs", + "src/mcp/**/*.rs", + "src/protocols/**/*.rs", + "src/runtime/**/*.rs", + "legacy/local/src/**/*.ts", + "legacy/cli/src/**/*.ts", "packages/core/execution/src/**/*.ts", "packages/core/sdk/src/**/*.ts", "packages/kernel/**/src/**/*.ts", @@ -39,10 +49,22 @@ paths = [ name = "wrdn-data-exfil" remote = "getsentry/warden-skills" paths = [ - "apps/cloud/src/api/**/*.ts", - "apps/cloud/src/routes/**/*.tsx", - "apps/local/src/server/**/*.ts", - "packages/core/storage-*/src/**/*.ts", + "src/api.rs", + "src/api/**/*.rs", + "src/catalog/**/*.rs", + "src/database.rs", + "src/invocation/**/*.rs", + "src/mcp/**/*.rs", + "src/oauth/**/*.rs", + "src/outbound.rs", + "src/protocols/**/*.rs", + "src/request_logs.rs", + "src/runtime/**/*.rs", + "legacy/cloud/src/api/**/*.ts", + "legacy/cloud/src/auth/**/*.ts", + "legacy/cloud/src/routes/**/*.tsx", + "legacy/local/src/**/*.ts", + "packages/core/api/src/**/*.ts", "packages/plugins/**/src/**/*.ts", "packages/react/src/api/**/*.tsx", ] @@ -51,19 +73,26 @@ paths = [ name = "wrdn-pii" remote = "getsentry/warden-skills" paths = [ - "apps/cloud/src/**/*.ts", - "apps/cloud/src/**/*.tsx", - "apps/local/src/**/*.ts", - "apps/local/src/**/*.tsx", - "packages/core/storage-*/src/**/*.ts", + "src/actor.rs", + "src/api.rs", + "src/api/**/*.rs", + "src/approval/**/*.rs", + "src/catalog/**/*.rs", + "src/crypto.rs", + "src/database.rs", + "src/oauth/**/*.rs", + "src/request_logs.rs", + "legacy/cloud/src/**/*.ts", + "legacy/cloud/src/**/*.tsx", + "legacy/local/src/**/*.ts", ] [[skills]] name = "wrdn-gha-workflows" remote = "getsentry/warden-skills" -paths = [".github/workflows/**/*.yml", ".github/workflows/**/*.yaml"] +paths = [".github/workflows/**/*.yml"] -# Local skill — no `remote =`, resolved from `.agents/skills//SKILL.md`. +# Local skill: no `remote =`, resolved from `.agents/skills//SKILL.md`. [[skills]] name = "wrdn-effect-atom-optimistic" paths = [ diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 000000000..3b462cb0c --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 000000000..7d74fe246 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 000000000..d59ac024f --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,7 @@ +{ + "plugins": ["prettier-plugin-svelte"], + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "all" +} diff --git a/web/.vscode/extensions.json b/web/.vscode/extensions.json new file mode 100644 index 000000000..a1f5c8a75 --- /dev/null +++ b/web/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode", "esbenp.prettier-vscode"] +} diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md new file mode 100644 index 000000000..ee3cb019f --- /dev/null +++ b/web/CHANGELOG.md @@ -0,0 +1 @@ +# @executor-js/web diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000..9c10c1c59 --- /dev/null +++ b/web/README.md @@ -0,0 +1,44 @@ +# Executor web + +The Svelte 5 and SvelteKit dashboard is a client-only SPA served from the Rust binary. It talks to +the Rust control API over the same origin. Release builds embed the generated static files, so the +installed binary has no Node.js runtime dependency. + +## Local checks + +Install workspace dependencies from the repository root with `bun install`. From this directory: + +```sh +bun run format:check +bun run check +bun run typecheck +bun run lint +bun run test +``` + +The static adapter writes the production SPA to `web/build`. Build the web output before compiling +the release binary: + +```sh +bun run --cwd web build +cargo build --release --locked +``` + +The Cargo build fails with an actionable error if the production assets are missing or malformed. +Normal checks and tests embed deterministic fixture assets instead, so they do not require a web +build. At runtime, the binary serves exact files with immutable caching and uses `index.html` only +for safe client-side navigation fallbacks. + +The Rust server sends a deny-by-default Content Security Policy. The packaging step derives exact +SHA-256 hashes for every inline script in the generated HTML, so arbitrary inline scripts remain +blocked. Inline styles stay allowed for generated attributes. Audit that style allowance against +the first approved production web build and replace it with hashes or nonces where practical. + +## First boot + +Start the Rust server using the repository instructions. Open the `/setup#token=...` link printed +to the terminal. The dashboard removes the one-time token from browser history immediately, creates +the administrator, signs in, and opens `/sources`. + +After setup, sign in at `/login`. Create gateway credentials under `/tokens`. Plaintext API tokens +are shown once and are never stored by the browser. diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000..7312c8954 --- /dev/null +++ b/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "@executor-js/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "prepare": "svelte-kit sync", + "format": "prettier --write .", + "format:check": "prettier --check .", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "lint": "oxlint src && prettier --check .", + "test": "vitest run", + "typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@effect/vitest": "4.0.0-beta.59", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.67.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@testing-library/svelte": "^5.4.2", + "effect": "4.0.0-beta.59", + "jsdom": "^29.1.1", + "oxlint": "^1.71.0", + "prettier": "^3.8.4", + "prettier-plugin-svelte": "^4.1.1", + "svelte": "^5.56.4", + "svelte-check": "^4.7.0", + "typescript": "^6.0.3", + "vite": "^8.1.0", + "vitest": "^4.1.9" + } +} diff --git a/web/src/app.d.ts b/web/src/app.d.ts new file mode 100644 index 000000000..a6911e55f --- /dev/null +++ b/web/src/app.d.ts @@ -0,0 +1,5 @@ +declare global { + namespace App {} +} + +export {}; diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 000000000..13f0663a7 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/DashboardShell.svelte b/web/src/lib/DashboardShell.svelte new file mode 100644 index 000000000..25f8a6033 --- /dev/null +++ b/web/src/lib/DashboardShell.svelte @@ -0,0 +1,111 @@ + + + + {title} | Executor + + + + +
+ + +
+ + {#if logoutError !== null} +
+ {/if} +
{@render children()}
+
+
diff --git a/web/src/lib/DashboardShell.test.ts b/web/src/lib/DashboardShell.test.ts new file mode 100644 index 000000000..5ac76097d --- /dev/null +++ b/web/src/lib/DashboardShell.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; +import { createRawSnippet } from "svelte"; + +const mocks = { + goto: vi.fn(async () => {}), + signOut: vi.fn(async () => ({ ok: true as const, value: undefined })), +}; + +vi.doMock("$app/navigation", () => ({ goto: mocks.goto })); +vi.doMock("$app/state", () => ({ page: { url: new URL("http://localhost/tokens") } })); +vi.doMock("$lib/auth.svelte", () => ({ + useAuthState: () => ({ username: "admin", signOut: mocks.signOut }), +})); + +const { default: DashboardShell } = await import("./DashboardShell.svelte"); +const children = createRawSnippet(() => ({ render: () => "

Page content

" })); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("dashboard sign out", () => { + it("exposes navigation labels without their visual ordinals", () => { + render(DashboardShell, { + title: "Sources", + description: "Manage sources.", + children, + }); + + const navigation = within(screen.getByRole("navigation", { name: "Dashboard" })); + expect(navigation.getByRole("link", { name: /^Tools$/ })).toBeDefined(); + expect(navigation.queryByRole("link", { name: /^02 Tools$/ })).toBeNull(); + }); + + it("consults a page guard before destroying the session", async () => { + let allowSignOut = false; + const beforeSignOut = vi.fn(() => allowSignOut); + render(DashboardShell, { + title: "API tokens", + description: "Manage API tokens.", + children, + beforeSignOut, + }); + + await fireEvent.click(screen.getByRole("button", { name: "Sign out" })); + expect(beforeSignOut).toHaveBeenCalledOnce(); + expect(mocks.signOut).not.toHaveBeenCalled(); + expect(mocks.goto).not.toHaveBeenCalled(); + + allowSignOut = true; + await fireEvent.click(screen.getByRole("button", { name: "Sign out" })); + await waitFor(() => expect(mocks.signOut).toHaveBeenCalledOnce()); + expect(beforeSignOut).toHaveBeenCalledTimes(2); + expect(mocks.goto).toHaveBeenCalledWith("/login", { replaceState: true }); + }); + + it("signs out normally when a page does not provide a guard", async () => { + render(DashboardShell, { + title: "Sources", + description: "Manage sources.", + children, + }); + + await fireEvent.click(screen.getByRole("button", { name: "Sign out" })); + await waitFor(() => expect(mocks.signOut).toHaveBeenCalledOnce()); + expect(mocks.goto).toHaveBeenCalledWith("/login", { replaceState: true }); + }); +}); diff --git a/web/src/lib/ErrorNotice.svelte b/web/src/lib/ErrorNotice.svelte new file mode 100644 index 000000000..159d20709 --- /dev/null +++ b/web/src/lib/ErrorNotice.svelte @@ -0,0 +1,14 @@ + + + diff --git a/web/src/lib/GraphqlCredentialEditor.svelte b/web/src/lib/GraphqlCredentialEditor.svelte new file mode 100644 index 000000000..e22a73c03 --- /dev/null +++ b/web/src/lib/GraphqlCredentialEditor.svelte @@ -0,0 +1,424 @@ + + + + +{#if notice !== null} +

+ {notice} +

+{/if} + +{#if open} +
+
+

Replace credentials

+

Existing values are hidden. Saving replaces the complete credential set.

+
+ + {#if loading} +

Loading credential metadata...

+ {:else if revision !== null} +
+ GraphQL authentication + + {#if draft.type === "api_key_header"} + + {/if} + {#if draft.type === "basic"} + + {/if} + {#if draft.type !== "none"} + + {/if} +
+ {/if} + + {#if error !== null} +
+ {/if} +
+ + + {#if confirmingClear} + + + + + {:else} + + {/if} +
+
+{/if} + + diff --git a/web/src/lib/GraphqlCredentialEditor.test.ts b/web/src/lib/GraphqlCredentialEditor.test.ts new file mode 100644 index 000000000..781cba7d2 --- /dev/null +++ b/web/src/lib/GraphqlCredentialEditor.test.ts @@ -0,0 +1,376 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { ApiError, type ApiResult, type OpenApiCredentialMetadata, type Source } from "./api"; +import GraphqlCredentialEditor from "./GraphqlCredentialEditor.svelte"; +import type { GraphqlCredential } from "./graphql-source-state"; + +function sourceFixture(id = "graphql-source"): Source { + return { + id, + kind: "graphql", + slug: "product", + displayName: "Product API", + description: null, + configuration: { + endpoint: "https://api.example.test/graphql?token=hidden", + allowPrivateNetwork: false, + }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 4, + tombstonedToolCount: 0, + }; +} + +function metadata( + revision: number, + credentialType: "bearer" | "basic" | "api_key_header" | "oauth_access_token" = "bearer", +): OpenApiCredentialMetadata { + return { + revision, + configuredSchemes: [{ name: "default", credentialType }], + }; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +afterEach(cleanup); + +describe("GraphQL credential editor", () => { + it("replaces credentials with the metadata CAS revision", async () => { + const load = vi.fn(async () => ({ ok: true, value: metadata(4) }) as const); + const saves: Array<{ revision: number; credential: GraphqlCredential }> = []; + const save = vi.fn( + async (_sourceId: string, revision: number, credential: GraphqlCredential) => { + saves.push({ revision, credential }); + return { ok: true, value: metadata(5, "api_key_header") } as const; + }, + ); + render(GraphqlCredentialEditor, { source: sourceFixture(), load, save }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const method = await screen.findByLabelText("Method"); + await fireEvent.change(method, { target: { value: "api_key_header" } }); + await fireEvent.input(screen.getByLabelText("Header name"), { + target: { value: " X-Service-Key " }, + }); + await fireEvent.input(screen.getByLabelText("Header value"), { + target: { value: " exact key " }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(screen.getByRole("status")).toBeDefined()); + expect(saves).toEqual([ + { + revision: 4, + credential: { type: "api_key_header", name: "X-Service-Key", value: " exact key " }, + }, + ]); + expect(document.body.textContent).not.toContain("exact key"); + expect(document.body.textContent).not.toContain("token=hidden"); + expect(document.activeElement).toBe( + document.getElementById("graphql-credential-status-graphql-source"), + ); + }); + + it("requires explicit confirmation and clears credentials with CAS", async () => { + const load = vi.fn(async () => ({ ok: true, value: metadata(7) }) as const); + const save = vi.fn(async () => ({ ok: true, value: metadata(8) }) as const); + render(GraphqlCredentialEditor, { source: sourceFixture(), load, save }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + await screen.findByLabelText("Method"); + await fireEvent.click(screen.getByRole("button", { name: "Clear credentials" })); + expect(document.activeElement).toBe( + document.getElementById("graphql-cancel-clear-graphql-source"), + ); + await fireEvent.click(screen.getByRole("button", { name: "Confirm clear" })); + + await waitFor(() => expect(screen.getByRole("status")).toBeDefined()); + expect(save).toHaveBeenCalledWith("graphql-source", 7, null, expect.any(AbortSignal)); + }); + + it("clears secret input and disables saving after a CAS conflict", async () => { + const load = vi.fn(async () => ({ ok: true, value: metadata(4) }) as const); + const save = vi.fn( + async () => + ({ + ok: false, + error: new ApiError({ + code: "revision_conflict", + displayMessage: "Credentials changed elsewhere.", + requestId: "request-conflict", + status: 409, + }), + }) as const, + ); + render(GraphqlCredentialEditor, { source: sourceFixture(), load, save }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const secret = await screen.findByLabelText("Bearer token"); + await fireEvent.input(secret, { target: { value: "clear-after-conflict" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + await waitFor(() => expect(screen.queryByLabelText("Bearer token")).toBeNull()); + expect(document.body.textContent).not.toContain("clear-after-conflict"); + expect(document.activeElement).toBe( + document.getElementById("graphql-credential-error-graphql-source"), + ); + expect( + screen.getByRole("button", { name: "Save replacement" }).disabled, + ).toBe(true); + }); + + it("aborts metadata loading and ignores its completion after unmount", async () => { + const response = deferred>(); + const request = { signal: null as AbortSignal | null }; + const load = vi.fn((_sourceId: string, signal: AbortSignal) => { + request.signal = signal; + return response.promise; + }); + const save = vi.fn(); + const mounted = render(GraphqlCredentialEditor, { source: sourceFixture(), load, save }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + mounted.unmount(); + + expect(request.signal?.aborted).toBe(true); + response.resolve({ ok: true, value: metadata(3) }); + await response.promise; + await Promise.resolve(); + expect(save).not.toHaveBeenCalled(); + }); + + it("reports only credential writes as mutations and clears the fence on unmount", async () => { + const response = deferred>(); + const request = { signal: null as AbortSignal | null }; + const load = vi.fn(async () => ({ ok: true, value: metadata(4) }) as const); + const save = vi.fn( + ( + _sourceId: string, + _revision: number, + _credential: GraphqlCredential, + signal: AbortSignal, + ) => { + request.signal = signal; + return response.promise; + }, + ); + const onmutationchange = vi.fn(); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture(), + load, + save, + onmutationchange, + }); + + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + expect(onmutationchange).not.toHaveBeenCalled(); + await fireEvent.input(token, { target: { value: "pending-secret" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + await waitFor(() => expect(onmutationchange).toHaveBeenLastCalledWith(true)); + + mounted.unmount(); + expect(request.signal?.aborted).toBe(true); + expect(onmutationchange).toHaveBeenLastCalledWith(false); + response.resolve({ ok: true, value: metadata(5) }); + await response.promise; + }); + + it("aborts an old metadata load and rejects it after the source changes", async () => { + const first = deferred>(); + const second = deferred>(); + const firstRequest = { signal: null as AbortSignal | null }; + const load = vi.fn((sourceId: string, signal: AbortSignal) => { + if (sourceId === "source-a") { + firstRequest.signal = signal; + return first.promise; + } + return second.promise; + }); + const save = vi.fn(); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture("source-a"), + load, + save, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + await mounted.rerender({ source: sourceFixture("source-b"), load, save }); + + await waitFor(() => expect(load).toHaveBeenCalledTimes(2)); + expect(firstRequest.signal?.aborted).toBe(true); + second.resolve({ ok: true, value: metadata(8, "basic") }); + await screen.findByLabelText("Username"); + first.resolve({ ok: true, value: metadata(3, "bearer") }); + await first.promise; + await Promise.resolve(); + expect(screen.queryByLabelText("Bearer token")).toBeNull(); + }); + + it("reloads the CAS revision before saving after an open source changes", async () => { + const load = vi.fn(async (sourceId: string) => + sourceId === "source-a" + ? ({ ok: true, value: metadata(4) } as const) + : ({ ok: true, value: metadata(9, "basic") } as const), + ); + const save = vi.fn(async () => ({ ok: true, value: metadata(10, "basic") }) as const); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture("source-a"), + load, + save, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + await screen.findByLabelText("Bearer token"); + await mounted.rerender({ source: sourceFixture("source-b"), load, save }); + await screen.findByLabelText("Username"); + await fireEvent.input(screen.getByLabelText("Username"), { target: { value: "operator" } }); + await fireEvent.input(screen.getByLabelText("Password"), { target: { value: "password" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(save).toHaveBeenCalledOnce()); + expect(save).toHaveBeenCalledWith( + "source-b", + 9, + { type: "basic", username: "operator", password: "password" }, + expect.any(AbortSignal), + ); + }); + + it("aborts a pending save and rejects its completion after the source changes", async () => { + const pendingSave = deferred>(); + const saveRequest = { signal: null as AbortSignal | null }; + const load = vi.fn(async (sourceId: string) => + sourceId === "source-a" + ? ({ ok: true, value: metadata(4) } as const) + : ({ ok: true, value: metadata(9, "basic") } as const), + ); + const save = vi.fn( + ( + _sourceId: string, + _revision: number, + _credential: GraphqlCredential, + signal: AbortSignal, + ) => { + saveRequest.signal = signal; + return pendingSave.promise; + }, + ); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture("source-a"), + load, + save, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "source-a-token" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + await mounted.rerender({ source: sourceFixture("source-b"), load, save }); + + await screen.findByLabelText("Username"); + expect(saveRequest.signal?.aborted).toBe(true); + pendingSave.resolve({ ok: true, value: metadata(5) }); + await pendingSave.promise; + await Promise.resolve(); + expect(screen.getByRole("button", { name: "Close credentials" })).toBeDefined(); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByLabelText("Bearer token")).toBeNull(); + }); + + it("clears a completed source notice when the source identity changes", async () => { + const load = vi.fn(async () => ({ ok: true, value: metadata(4) }) as const); + const save = vi.fn(async () => ({ ok: true, value: metadata(5) }) as const); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture("source-a"), + load, + save, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "source-a-token" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + await waitFor(() => expect(screen.getByRole("status")).toBeDefined()); + + await mounted.rerender({ source: sourceFixture("source-b"), load, save }); + await waitFor(() => expect(screen.queryByRole("status")).toBeNull()); + expect(screen.getByRole("button", { name: "Manage credentials" })).toBeDefined(); + }); + + it("disables an open editor while a card operation is active", async () => { + const load = vi.fn(async () => ({ ok: true, value: metadata(4) }) as const); + const save = vi.fn(); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture(), + load, + save, + disabled: false, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const method = await screen.findByLabelText("Method"); + await mounted.rerender({ source: sourceFixture(), load, save, disabled: true }); + + await waitFor(() => expect(method.closest("fieldset")?.hasAttribute("disabled")).toBe(true)); + expect( + screen.getByRole("button", { name: "Close credentials" }).disabled, + ).toBe(true); + expect( + screen.getByRole("button", { name: "Save replacement" }).disabled, + ).toBe(true); + expect( + screen.getByRole("button", { name: "Clear credentials" }).disabled, + ).toBe(true); + expect(save).not.toHaveBeenCalled(); + }); + + it("reports save activity and aborts it when an external card operation starts", async () => { + const response = deferred>(); + const request = { signal: null as AbortSignal | null }; + const load = vi.fn(async () => ({ ok: true, value: metadata(4) }) as const); + const save = vi.fn( + ( + _sourceId: string, + _revision: number, + _credential: GraphqlCredential, + signal: AbortSignal, + ) => { + request.signal = signal; + return response.promise; + }, + ); + const onbusychange = vi.fn(); + const mounted = render(GraphqlCredentialEditor, { + source: sourceFixture(), + load, + save, + onbusychange, + disabled: false, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "credential-secret" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + await waitFor(() => expect(onbusychange).toHaveBeenLastCalledWith(true)); + + await mounted.rerender({ + source: sourceFixture(), + load, + save, + onbusychange, + disabled: true, + }); + await waitFor(() => expect(request.signal?.aborted).toBe(true)); + await waitFor(() => expect(onbusychange).toHaveBeenLastCalledWith(false)); + expect(document.body.textContent).not.toContain("credential-secret"); + response.resolve({ ok: true, value: metadata(5) }); + await response.promise; + }); +}); diff --git a/web/src/lib/GraphqlSourceForm.svelte b/web/src/lib/GraphqlSourceForm.svelte new file mode 100644 index 000000000..9da1802b7 --- /dev/null +++ b/web/src/lib/GraphqlSourceForm.svelte @@ -0,0 +1,332 @@ + + +
+
+ GraphQL API +

Connect an introspection-enabled GraphQL endpoint and import its queries and mutations.

+ + {#if endpointInvalid} +

+ Use HTTPS without user info, query parameters, or fragments. Plain HTTP is allowed only for + loopback development. Put secrets in Authentication. +

+ {/if} + + + + +

+ This permits loopback and private-network targets. Link-local and cloud metadata targets stay + blocked. +

+
+ Authentication + + {#if credentialDraft.type === "api_key_header"} + + {/if} + {#if credentialDraft.type === "basic"} + + {/if} + {#if credentialDraft.type !== "none"} + + {/if} + {#if credentialDraft.type === "oauth_access_token"} +

Paste an access token managed outside Executor.

+ {/if} +

Credentials are encrypted locally and never shown again.

+
+ {#if localOptInMissing} +

+ This looks like a local or private endpoint. Enable private network access to connect it. +

+ {/if} +
+ +
+
+
Introspection
+
Required when connecting and refreshing
+
+
+
Redirects
+
Disabled
+
+
+
Proxies
+
Ignored, Executor connects directly
+
+
+ + {#if error !== null} +
+ {/if} +

+ Queries start Enabled, mutations start Ask, and deprecated operations start Disabled. Review or + change any mode from Tools after connecting. +

+ +
+ + diff --git a/web/src/lib/GraphqlSourceForm.test.ts b/web/src/lib/GraphqlSourceForm.test.ts new file mode 100644 index 000000000..f56025b7e --- /dev/null +++ b/web/src/lib/GraphqlSourceForm.test.ts @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { ApiError, type ApiResult, type Source } from "./api"; +import GraphqlSourceForm from "./GraphqlSourceForm.svelte"; +import type { GraphqlSourceInput } from "./graphql-source-state"; + +function sourceFixture(): Source { + return { + id: "graphql-source", + kind: "graphql", + slug: "product", + displayName: "Product API", + description: null, + configuration: { endpoint: "https://api.example.test/graphql", allowPrivateNetwork: false }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 4, + tombstonedToolCount: 0, + }; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +async function fillRequiredFields() { + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://api.example.test/graphql" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Product API" }, + }); +} + +afterEach(cleanup); + +describe("GraphQL source form", () => { + it("submits the stable source contract and clears the completed draft", async () => { + const inputs: GraphqlSourceInput[] = []; + const create = vi.fn(async (input: GraphqlSourceInput) => { + inputs.push(input); + return { ok: true, value: sourceFixture() } as const; + }); + const created = vi.fn(); + render(GraphqlSourceForm, { create, oncreated: created }); + await fillRequiredFields(); + await fireEvent.input(screen.getByLabelText("Preferred slug (optional)"), { + target: { value: " product " }, + }); + await fireEvent.change(screen.getByLabelText("Method"), { target: { value: "bearer" } }); + await fireEvent.input(screen.getByLabelText("Bearer token"), { + target: { value: " exact token " }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(created).toHaveBeenCalledOnce()); + expect(inputs).toEqual([ + { + kind: "graphql", + displayName: "Product API", + preferredSlug: "product", + endpoint: "https://api.example.test/graphql", + allowPrivateNetwork: false, + credential: { type: "bearer", token: " exact token " }, + }, + ]); + expect(screen.getByLabelText("Endpoint").value).toBe(""); + expect(document.body.textContent).not.toContain("exact token"); + }); + + it("rejects query-bearing endpoints before serialization", async () => { + const create = vi.fn(); + render(GraphqlSourceForm, { create, oncreated: vi.fn() }); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://api.example.test/graphql?token=do-not-store" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Product API" }, + }); + + expect(screen.getByText(/without user info, query parameters, or fragments/)).toBeDefined(); + expect(screen.getByRole("button", { name: "Connect source" }).disabled).toBe( + true, + ); + expect(create).not.toHaveBeenCalled(); + }); + + it("rejects credential-bearing remote plaintext endpoints", async () => { + const create = vi.fn(); + render(GraphqlSourceForm, { create, oncreated: vi.fn() }); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "http://api.example.test/graphql" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Product API" }, + }); + await fireEvent.change(screen.getByLabelText("Method"), { target: { value: "bearer" } }); + await fireEvent.input(screen.getByLabelText("Bearer token"), { + target: { value: "do-not-send-in-cleartext" }, + }); + + expect(screen.getByText(/Plain HTTP is allowed only for loopback/)).toBeDefined(); + expect(screen.getByRole("button", { name: "Connect source" }).disabled).toBe( + true, + ); + expect(create).not.toHaveBeenCalled(); + }); + + it("blocks an obvious private endpoint until the operator opts in", async () => { + const create = vi.fn(); + render(GraphqlSourceForm, { create, oncreated: vi.fn() }); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "http://127.0.0.1:4000/graphql" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Local GraphQL" }, + }); + + expect(screen.getByRole("button", { name: "Connect source" }).disabled).toBe( + true, + ); + expect(screen.getByText(/Enable private network access/)).toBeDefined(); + expect(create).not.toHaveBeenCalled(); + }); + + it("aborts on unmount and rejects the late completion", async () => { + const response = deferred>(); + const request = { signal: null as AbortSignal | null }; + const create = vi.fn((_input: GraphqlSourceInput, signal: AbortSignal) => { + request.signal = signal; + return response.promise; + }); + const created = vi.fn(); + const mounted = render(GraphqlSourceForm, { create, oncreated: created }); + await fillRequiredFields(); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + mounted.unmount(); + + expect(request.signal?.aborted).toBe(true); + response.resolve({ ok: true, value: sourceFixture() }); + await response.promise; + await Promise.resolve(); + expect(created).not.toHaveBeenCalled(); + }); + + it("keeps busy until the coordinator settles and focuses a recovery failure", async () => { + const response = deferred>(); + const create = vi.fn(() => response.promise); + const busy = vi.fn(); + render(GraphqlSourceForm, { + create, + onbusychange: busy, + oncreated: vi.fn(), + }); + await fillRequiredFields(); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(busy).toHaveBeenLastCalledWith(true)); + response.resolve({ + ok: false, + error: new ApiError({ + code: "source_create_recovery_pending", + displayMessage: "Source recovery is pending.", + requestId: null, + status: 0, + }), + }); + await response.promise; + await waitFor(() => expect(busy).toHaveBeenLastCalledWith(false)); + expect(screen.getByText("Source recovery is pending.")).toBeDefined(); + expect(document.activeElement).toBe(document.getElementById("graphql-source-error")); + expect(create).toHaveBeenCalledOnce(); + }); + + it("clears secrets and focuses a rejected request", async () => { + const create = vi.fn( + async () => + ({ + ok: false, + error: new ApiError({ + code: "graphql_introspection_failed", + displayMessage: "GraphQL introspection failed.", + requestId: "request-1", + status: 422, + }), + }) as const, + ); + render(GraphqlSourceForm, { create, oncreated: vi.fn() }); + await fillRequiredFields(); + await fireEvent.change(screen.getByLabelText("Method"), { target: { value: "bearer" } }); + const secret = screen.getByLabelText("Bearer token"); + await fireEvent.input(secret, { target: { value: "clear-me" } }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + expect(secret.value).toBe(""); + expect(document.body.textContent).not.toContain("clear-me"); + expect(document.activeElement).toBe(document.getElementById("graphql-source-error")); + }); +}); diff --git a/web/src/lib/McpCredentialEditor.svelte b/web/src/lib/McpCredentialEditor.svelte new file mode 100644 index 000000000..02cf1d5a5 --- /dev/null +++ b/web/src/lib/McpCredentialEditor.svelte @@ -0,0 +1,439 @@ + + + + +{#if notice !== null} +

+ {notice} +

+{/if} + +{#if open} +
+
+

Replace credentials

+

Existing values are hidden. Saving replaces the complete credential set.

+
+ + {#if loading} +

Loading credential metadata...

+ {:else if revision !== null && source.kind === "mcp_http"} +
+ HTTP authentication + + {#if httpDraft.type === "api_key_header"} + + {/if} + {#if httpDraft.type === "basic"} + + {/if} + {#if httpDraft.type !== "none"} + + {/if} +
+ {:else if revision !== null && source.kind === "mcp_stdio"} +
+ Template secrets + {#each stdioFields as field (field.key)} + + {/each} +
+ {/if} + + {#if error !== null} +
+ {/if} +
+ + +
+
+{/if} + + diff --git a/web/src/lib/McpCredentialEditor.test.ts b/web/src/lib/McpCredentialEditor.test.ts new file mode 100644 index 000000000..7154be864 --- /dev/null +++ b/web/src/lib/McpCredentialEditor.test.ts @@ -0,0 +1,444 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { Schema } from "effect"; +import McpCredentialEditor from "./McpCredentialEditor.svelte"; +import type { Source } from "./api"; + +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function deferredDecodedResponse() { + const response = deferred(); + const settled = deferred(); + return { + promise: response.promise, + settled: settled.promise, + resolve(value: unknown) { + const decodedResponse = Response.json(value); + const read = decodedResponse.text.bind(decodedResponse); + decodedResponse.text = async () => { + const text = await read(); + // The next task starts after API decoding and component promise continuations settle. + setTimeout(() => settled.resolve(undefined), 0); + return text; + }; + response.resolve(decodedResponse); + }, + }; +} + +function source(kind: "mcp_http" | "mcp_stdio"): Source { + return { + id: `${kind}-source`, + kind, + slug: kind, + displayName: kind === "mcp_http" ? "Remote MCP" : "Local MCP", + description: null, + configuration: + kind === "mcp_http" + ? { endpoint: "https://mcp.example.test/mcp", allowPrivateNetwork: false } + : { templateName: "github" }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 3, + tombstonedToolCount: 0, + }; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("MCP credential editor", () => { + it("preserves an unsaved HTTP secret across a temporary disabled state", async () => { + const fetcher = vi.fn(async () => + Response.json({ + revision: 4, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }), + ); + vi.stubGlobal("fetch", fetcher); + const mounted = render(McpCredentialEditor, { source: source("mcp_http") }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "still-unsaved" } }); + + await mounted.rerender({ source: source("mcp_http"), disabled: true }); + await waitFor(() => expect(token.closest("fieldset")?.hasAttribute("disabled")).toBe(true)); + expect(token.value).toBe("still-unsaved"); + + await mounted.rerender({ source: source("mcp_http"), disabled: false }); + await waitFor(() => expect(token.closest("fieldset")?.hasAttribute("disabled")).toBe(false)); + expect(token.value).toBe("still-unsaved"); + expect(fetcher).toHaveBeenCalledOnce(); + }); + + it("preserves an unsaved stdio secret across a temporary disabled state", async () => { + const fetcher = vi.fn(async (input) => { + if (String(input).endsWith("/credentials")) { + return Response.json({ + revision: 9, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }); + } + return Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }); + }); + vi.stubGlobal("fetch", fetcher); + const mounted = render(McpCredentialEditor, { source: source("mcp_stdio") }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const secret = await screen.findByLabelText("TOKEN"); + await fireEvent.input(secret, { target: { value: "still-unsaved" } }); + + await mounted.rerender({ source: source("mcp_stdio"), disabled: true }); + await waitFor(() => expect(secret.closest("fieldset")?.hasAttribute("disabled")).toBe(true)); + expect(secret.value).toBe("still-unsaved"); + + await mounted.rerender({ source: source("mcp_stdio"), disabled: false }); + await waitFor(() => expect(secret.closest("fieldset")?.hasAttribute("disabled")).toBe(false)); + expect(secret.value).toBe("still-unsaved"); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("aborts a submitted secret, reloads metadata, and ignores the late save completion", async () => { + const saveA = deferredDecodedResponse(); + const reloadB = deferredDecodedResponse(); + const request = { signal: null as AbortSignal | null }; + let metadataRequests = 0; + const onbusychange = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn((_input, init) => { + if (init?.method !== "PUT") { + metadataRequests += 1; + if (metadataRequests === 2) return reloadB.promise; + return Promise.resolve( + Response.json({ + revision: 4, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }), + ); + } + request.signal = init.signal ?? null; + return saveA.promise; + }), + ); + const mounted = render(McpCredentialEditor, { + source: source("mcp_http"), + onbusychange, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + onbusychange.mockClear(); + await fireEvent.input(token, { target: { value: "must-be-cleared" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + await waitFor(() => expect(request.signal).not.toBeNull()); + await waitFor(() => expect(onbusychange.mock.calls).toEqual([[true]])); + + await mounted.rerender({ + source: source("mcp_http"), + disabled: true, + onbusychange, + }); + await waitFor(() => expect(request.signal?.aborted).toBe(true)); + await waitFor(() => expect(screen.queryByLabelText("Bearer token")).toBeNull()); + await waitFor(() => expect(onbusychange.mock.calls).toEqual([[true], [false]])); + expect( + screen.getByRole("button", { name: "Save replacement" }).disabled, + ).toBe(true); + + await mounted.rerender({ + source: source("mcp_http"), + disabled: false, + onbusychange, + }); + await waitFor(() => expect(metadataRequests).toBe(2)); + await waitFor(() => expect(onbusychange.mock.calls).toEqual([[true], [false], [true]])); + reloadB.resolve({ + revision: 8, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }); + await reloadB.settled; + const reloadedToken = await screen.findByLabelText("Bearer token"); + expect(reloadedToken.value).toBe(""); + await waitFor(() => + expect(onbusychange.mock.calls).toEqual([[true], [false], [true], [false]]), + ); + + saveA.resolve({ + revision: 5, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }); + await saveA.settled; + expect(screen.getByRole("button", { name: "Close credentials" })).toBeDefined(); + expect(screen.queryByRole("status")).toBeNull(); + expect(onbusychange.mock.calls).toEqual([[true], [false], [true], [false]]); + }); + + it("reports only MCP credential writes as mutations and clears the fence on unmount", async () => { + const saveResponse = deferredDecodedResponse(); + const request = { signal: null as AbortSignal | null }; + vi.stubGlobal( + "fetch", + vi.fn((_input, init) => { + if (init?.method === "PUT") { + request.signal = init.signal ?? null; + return saveResponse.promise; + } + return Promise.resolve( + Response.json({ + revision: 4, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }), + ); + }), + ); + const onmutationchange = vi.fn(); + const mounted = render(McpCredentialEditor, { + source: source("mcp_http"), + onmutationchange, + }); + + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + expect(onmutationchange).not.toHaveBeenCalled(); + await fireEvent.input(token, { target: { value: "pending-secret" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + await waitFor(() => expect(onmutationchange).toHaveBeenLastCalledWith(true)); + + mounted.unmount(); + expect(request.signal?.aborted).toBe(true); + expect(onmutationchange).toHaveBeenLastCalledWith(false); + saveResponse.resolve({ + revision: 5, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }); + await saveResponse.settled; + }); + + it("retries an aborted metadata load and ignores its late completion", async () => { + const loadA = deferredDecodedResponse(); + const retryB = deferredDecodedResponse(); + const requests: AbortSignal[] = []; + const onbusychange = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn((_input, init) => { + if (init?.signal instanceof AbortSignal) requests.push(init.signal); + return requests.length === 1 ? loadA.promise : retryB.promise; + }), + ); + const mounted = render(McpCredentialEditor, { + source: source("mcp_http"), + onbusychange, + }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + await waitFor(() => expect(requests).toHaveLength(1)); + await waitFor(() => expect(onbusychange.mock.calls).toEqual([[true]])); + + await mounted.rerender({ + source: source("mcp_http"), + disabled: true, + onbusychange, + }); + await waitFor(() => expect(requests[0]?.aborted).toBe(true)); + await waitFor(() => expect(onbusychange.mock.calls).toEqual([[true], [false]])); + + await mounted.rerender({ + source: source("mcp_http"), + disabled: false, + onbusychange, + }); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(screen.queryByLabelText("Username")).toBeNull(); + + retryB.resolve({ + revision: 7, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }); + await retryB.settled; + await screen.findByLabelText("Bearer token"); + expect(onbusychange.mock.calls).toEqual([[true], [false], [true], [false]]); + + loadA.resolve({ + revision: 3, + configuredSchemes: [{ name: "authorization", credentialType: "basic" }], + }); + await loadA.settled; + expect(screen.queryByLabelText("Username")).toBeNull(); + expect(screen.getByLabelText("Bearer token")).toBeDefined(); + expect(onbusychange.mock.calls).toEqual([[true], [false], [true], [false]]); + }); + + it("replaces HTTP API-key auth with the viewed CAS revision", async () => { + const bodies: unknown[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input, init) => { + if (init?.method !== "PUT") { + return Response.json({ revision: 4, configuredSchemes: [] }); + } + bodies.push(decodeJson(String(init.body))); + return Response.json({ + revision: 5, + configuredSchemes: [{ name: "authorization", credentialType: "api_key_header" }], + }); + }), + ); + render(McpCredentialEditor, { source: source("mcp_http") }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const method = await screen.findByLabelText("Method"); + await fireEvent.change(method, { target: { value: "api_key_header" } }); + await fireEvent.input(screen.getByLabelText("Header name"), { + target: { value: "X-Service-Key" }, + }); + await fireEvent.input(screen.getByLabelText("Header value"), { + target: { value: " exact key " }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(screen.getByRole("status")).toBeDefined()); + expect(bodies).toEqual([ + { + expectedRevision: 4, + credential: { + credential: { + type: "api_key_header", + name: "X-Service-Key", + value: " exact key ", + }, + }, + }, + ]); + expect(document.body.textContent).not.toContain("exact key"); + expect(document.activeElement).toBe( + document.getElementById("mcp-credential-status-mcp_http-source"), + ); + }); + + it("uses only template-approved stdio fields and preserves secret bytes", async () => { + const bodies: unknown[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input, init) => { + if (String(input).endsWith("/credentials") && init?.method !== "PUT") { + return Response.json({ + revision: 9, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }); + } + if (String(input) === "/api/v1/mcp/stdio/templates") { + return Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }); + } + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + revision: 10, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }); + }), + ); + render(McpCredentialEditor, { source: source("mcp_stdio") }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const secret = await screen.findByLabelText("TOKEN"); + await fireEvent.input(secret, { target: { value: " exact token " } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(screen.getByRole("status")).toBeDefined()); + expect(bodies).toEqual([ + { + expectedRevision: 9, + credential: { secretValues: { TOKEN: " exact token " } }, + }, + ]); + expect(document.body.textContent).not.toContain("exact token"); + }); + + it("clears secrets and focuses a CAS conflict for review", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + revision: 4, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }), + ) + .mockResolvedValueOnce( + Response.json( + { + error: { + code: "revision_conflict", + message: "Credentials changed elsewhere.", + requestId: "request-conflict", + }, + }, + { status: 409 }, + ), + ); + vi.stubGlobal("fetch", fetcher); + render(McpCredentialEditor, { source: source("mcp_http") }); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + const token = await screen.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "clear-after-conflict" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + expect(screen.queryByLabelText("Bearer token")).toBeNull(); + expect(document.body.textContent).not.toContain("clear-after-conflict"); + expect(document.activeElement).toBe( + document.getElementById("mcp-credential-error-mcp_http-source"), + ); + expect( + screen.getByRole("button", { name: "Save replacement" }).disabled, + ).toBe(true); + }); + + it("does not reuse stdio fields when a later template lookup is invalid", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + revision: 2, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }), + ) + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }), + ) + .mockResolvedValueOnce( + Response.json({ + revision: 3, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }), + ) + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "different", secretFields: ["OTHER"] }] }), + ); + vi.stubGlobal("fetch", fetcher); + render(McpCredentialEditor, { source: source("mcp_stdio") }); + + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + await screen.findByLabelText("TOKEN"); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await fireEvent.click(screen.getByRole("button", { name: "Manage credentials" })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + expect(screen.queryByLabelText("TOKEN")).toBeNull(); + expect(screen.queryByLabelText("OTHER")).toBeNull(); + expect( + screen.getByRole("button", { name: "Save replacement" }).disabled, + ).toBe(true); + }); +}); diff --git a/web/src/lib/McpHttpSourceForm.svelte b/web/src/lib/McpHttpSourceForm.svelte new file mode 100644 index 000000000..e1d09eb31 --- /dev/null +++ b/web/src/lib/McpHttpSourceForm.svelte @@ -0,0 +1,298 @@ + + +
+
+ MCP Streamable HTTP +

+ Connect a remote or local MCP server. Executor negotiates the protocol and imports its tool + catalog. +

+ + + + +

+ This permits loopback and private-network targets. Link-local and cloud metadata targets stay + blocked. +

+
+ Authentication + + {#if credentialDraft.type === "api_key_header"} + + {/if} + {#if credentialDraft.type === "basic"} + + {/if} + {#if credentialDraft.type !== "none"} + + {/if} +

Credentials are encrypted locally and never shown again.

+
+ {#if localOptInMissing} +

+ This looks like a local or private endpoint. Enable private network access to connect it. +

+ {/if} +
+ +
+
+
Sessions
+
Kept in memory and never displayed
+
+
+
Redirects
+
Disabled
+
+
+
Proxies
+
Ignored, Executor connects directly
+
+
+ + {#if error !== null} +
+ {/if} + +
+ + diff --git a/web/src/lib/McpHttpSourceForm.test.ts b/web/src/lib/McpHttpSourceForm.test.ts new file mode 100644 index 000000000..6d56db517 --- /dev/null +++ b/web/src/lib/McpHttpSourceForm.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { ApiError, type ApiResult, type McpHttpSourceInput, type Source } from "./api"; +import McpHttpSourceForm from "./McpHttpSourceForm.svelte"; + +function sourceFixture(): Source { + return { + id: "source-1", + kind: "mcp_http", + slug: "issues", + displayName: "Issue tracker", + description: null, + configuration: { endpoint: "https://mcp.example.test/mcp", allowPrivateNetwork: false }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 4, + tombstonedToolCount: 0, + }; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("MCP HTTP source form", () => { + it("submits the stable source contract and clears the completed draft", async () => { + const inputs: McpHttpSourceInput[] = []; + const create = vi.fn(async (input: McpHttpSourceInput) => { + inputs.push(input); + return { ok: true, value: sourceFixture() } as const; + }); + const created = vi.fn(); + render(McpHttpSourceForm, { create, oncreated: created }); + + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://mcp.example.test/mcp" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Issue tracker" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(created).toHaveBeenCalledOnce()); + expect(inputs).toEqual([ + { + kind: "mcp_http", + displayName: "Issue tracker", + endpoint: "https://mcp.example.test/mcp", + allowPrivateNetwork: false, + }, + ]); + expect(screen.getByLabelText("Endpoint").value).toBe(""); + expect(screen.getByLabelText("Source name").value).toBe(""); + }); + + it("blocks an obvious private endpoint until the operator opts in", async () => { + const create = vi.fn(); + render(McpHttpSourceForm, { create, oncreated: vi.fn() }); + + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "http://127.42.0.1:7331/mcp" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Local tools" }, + }); + + expect(screen.getByRole("button", { name: "Connect source" }).disabled).toBe( + true, + ); + expect(screen.getByText(/Enable private network access/)).toBeDefined(); + expect(create).not.toHaveBeenCalled(); + }); + + it("rejects a completion after unmount and aborts the request", async () => { + const response = deferred>(); + const request = { signal: null as AbortSignal | null }; + const create = vi.fn((_input: McpHttpSourceInput, signal: AbortSignal) => { + request.signal = signal; + return response.promise; + }); + const created = vi.fn(); + const mounted = render(McpHttpSourceForm, { create, oncreated: created }); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://mcp.example.test/mcp" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Issue tracker" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + mounted.unmount(); + expect(request.signal?.aborted).toBe(true); + response.resolve({ ok: true, value: sourceFixture() }); + await response.promise; + await Promise.resolve(); + expect(created).not.toHaveBeenCalled(); + }); + + it("clears the submitted secret and focuses a coordinator failure", async () => { + const busy = vi.fn(); + const create = vi.fn( + async () => + ({ + ok: false, + error: new ApiError({ + code: "source_create_recovery_pending", + displayMessage: "Source recovery is pending.", + requestId: null, + status: 0, + }), + }) as const, + ); + render(McpHttpSourceForm, { create, oncreated: vi.fn(), onbusychange: busy }); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://mcp.example.test/mcp" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Issue tracker" }, + }); + await fireEvent.change(screen.getByLabelText("Method"), { + target: { value: "bearer" }, + }); + const bearer = screen.getByLabelText("Bearer token"); + await fireEvent.input(bearer, { target: { value: "clear-after-attempt" } }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + await waitFor(() => expect(bearer.value).toBe("")); + expect(busy).toHaveBeenLastCalledWith(false); + expect(screen.getByText("Source recovery is pending.")).toBeDefined(); + expect(document.activeElement).toBe(document.getElementById("mcp-http-error")); + }); +}); diff --git a/web/src/lib/McpStdioSourceForm.svelte b/web/src/lib/McpStdioSourceForm.svelte new file mode 100644 index 000000000..e873e2cf8 --- /dev/null +++ b/web/src/lib/McpStdioSourceForm.svelte @@ -0,0 +1,330 @@ + + +
+
+

Trusted local MCP template

+

+ Choose a template configured on this machine. Browser users cannot enter commands, arguments, + working directories, or environment variable names. +

+
+ {#if templates.data !== null && templates.data.length > 0 && !templates.stale} + + {/if} + + {#if templates.stale && templates.error !== null} +
+ Showing the last loaded template list while Executor reconnects. + + +
+ {:else if templates.error !== null} +
+ + +
+ {/if} + + {#if templates.data === null && templates.loading} +

Loading trusted templates...

+ {:else if templates.data?.length === 0} +
+ No trusted local templates are configured. + + Add templates to the machine-admin JSON registry selected by + --mcp-stdio-templates or EXECUTOR_MCP_STDIO_TEMPLATES_FILE, + restart Executor, then try again. + +
+ + {:else if templates.data !== null} +
+
+ Local process source + + + + {#each currentFields as field (field.key)} + + {/each} +
+ + {#if error !== null} +
+ {/if} + +
+ {/if} +
+ + diff --git a/web/src/lib/McpStdioSourceForm.test.ts b/web/src/lib/McpStdioSourceForm.test.ts new file mode 100644 index 000000000..cc4021585 --- /dev/null +++ b/web/src/lib/McpStdioSourceForm.test.ts @@ -0,0 +1,243 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { Effect } from "effect"; +import { ApiError, type ApiResult, type McpStdioSourceInput, type Source } from "./api"; +import McpStdioSourceForm from "./McpStdioSourceForm.svelte"; + +function sourceFixture(): Source { + return { + id: "source-stdio", + kind: "mcp_stdio", + slug: "github-local", + displayName: "Local GitHub", + description: null, + configuration: { templateName: "github" }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 3, + tombstonedToolCount: 0, + }; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("MCP stdio source form", () => { + it("shows only trusted templates and preserves secret bytes in create payloads", async () => { + const inputs: McpStdioSourceInput[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }), + ), + ); + const create = vi.fn(async (input: McpStdioSourceInput) => { + inputs.push(input); + return { ok: true, value: sourceFixture() } as const; + }); + const created = vi.fn(); + render(McpStdioSourceForm, { create, oncreated: created }); + + await screen.findByRole("option", { name: "github" }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Local GitHub" }, + }); + await fireEvent.input(screen.getByLabelText("TOKEN"), { + target: { value: " whitespace-sensitive " }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(created).toHaveBeenCalledOnce()); + expect(fetch).toHaveBeenCalledOnce(); + expect(inputs).toEqual([ + { + kind: "mcp_stdio", + displayName: "Local GitHub", + templateName: "github", + secretValues: { TOKEN: " whitespace-sensitive " }, + }, + ]); + expect(document.body.textContent).not.toContain("whitespace-sensitive"); + }); + + it("clears secrets whenever the trusted template changes", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + templates: [ + { name: "github", secretFields: ["TOKEN"] }, + { name: "linear", secretFields: ["TOKEN"] }, + ], + }), + ), + ); + render(McpStdioSourceForm, { create: vi.fn(), oncreated: vi.fn() }); + + const selector = await screen.findByLabelText("Trusted template"); + const secret = screen.getByLabelText("TOKEN"); + await fireEvent.input(secret, { target: { value: "must-not-cross-templates" } }); + await fireEvent.change(selector, { target: { value: "linear" } }); + + expect(screen.getByLabelText("TOKEN").value).toBe(""); + expect(document.body.textContent).not.toContain("must-not-cross-templates"); + }); + + it("shows an honest empty state instead of a dead connect action", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ templates: [] })), + ); + render(McpStdioSourceForm, { create: vi.fn(), oncreated: vi.fn() }); + + expect(await screen.findByText("No trusted local templates are configured.")).toBeDefined(); + expect(screen.getByText(/machine-admin JSON registry selected by/)).toBeDefined(); + expect(screen.getByText("--mcp-stdio-templates")).toBeDefined(); + expect(screen.getByText("EXECUTOR_MCP_STDIO_TEMPLATES_FILE")).toBeDefined(); + expect(document.body.textContent).not.toContain("local CLI"); + expect(screen.queryByRole("button", { name: "Connect source" })).toBeNull(); + expect(screen.getByRole("button", { name: "Refresh templates" })).toBeDefined(); + }); + + it("retries a failed template refresh before making stale fields writable", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }), + ) + .mockImplementationOnce(() => Effect.runPromise(Effect.fail("offline"))) + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }), + ); + vi.stubGlobal("fetch", fetcher); + render(McpStdioSourceForm, { create: vi.fn(), oncreated: vi.fn() }); + + await screen.findByRole("option", { name: "github" }); + await fireEvent.click(screen.getByRole("button", { name: "Refresh templates" })); + expect(await screen.findByText(/Showing the last loaded template list/)).toBeDefined(); + expect( + screen.getByRole("group", { name: "Local process source" }).disabled, + ).toBe(true); + + await fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await waitFor(() => + expect( + screen.getByRole("group", { name: "Local process source" }).disabled, + ).toBe(false), + ); + }); + + it("clears secrets when a same-name template changes its approved fields", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }), + ) + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "github", secretFields: ["API_KEY"] }] }), + ); + vi.stubGlobal("fetch", fetcher); + render(McpStdioSourceForm, { create: vi.fn(), oncreated: vi.fn() }); + + const oldSecret = await screen.findByLabelText("TOKEN"); + await fireEvent.input(oldSecret, { target: { value: "must-be-forgotten" } }); + await fireEvent.click(screen.getByRole("button", { name: "Refresh templates" })); + + const nextSecret = await screen.findByLabelText("API_KEY"); + expect(nextSecret.value).toBe(""); + expect(screen.queryByLabelText("TOKEN")).toBeNull(); + expect(document.body.textContent).not.toContain("must-be-forgotten"); + }); + + it("reports not busy on unmount and ignores a late create completion", async () => { + const creation = deferred>(); + const request = { signal: null as AbortSignal | null }; + const busy = vi.fn(); + const created = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }), + ), + ), + ); + const create = vi.fn((_input: McpStdioSourceInput, signal: AbortSignal) => { + request.signal = signal; + return creation.promise; + }); + const mounted = render(McpStdioSourceForm, { create, oncreated: created, onbusychange: busy }); + + await screen.findByRole("option", { name: "github" }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Local GitHub" }, + }); + await fireEvent.input(screen.getByLabelText("TOKEN"), { + target: { value: "forget-on-unmount" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + await waitFor(() => expect(busy).toHaveBeenLastCalledWith(true)); + + mounted.unmount(); + expect(request.signal?.aborted).toBe(true); + expect(busy).toHaveBeenLastCalledWith(false); + creation.resolve({ ok: true, value: sourceFixture() }); + await creation.promise; + await Promise.resolve(); + expect(created).not.toHaveBeenCalled(); + }); + + it("clears template secrets and focuses a coordinator failure", async () => { + const busy = vi.fn(); + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json({ templates: [{ name: "github", secretFields: ["TOKEN"] }] }), + ); + vi.stubGlobal("fetch", fetcher); + const create = vi.fn( + async () => + ({ + ok: false, + error: new ApiError({ + code: "source_create_recovery_pending", + displayMessage: "Source recovery is pending.", + requestId: null, + status: 0, + }), + }) as const, + ); + render(McpStdioSourceForm, { create, oncreated: vi.fn(), onbusychange: busy }); + + await screen.findByRole("option", { name: "github" }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Local GitHub" }, + }); + await fireEvent.input(screen.getByLabelText("TOKEN"), { + target: { value: "clear-me" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + expect(screen.getByLabelText("TOKEN").value).toBe(""); + expect(busy).toHaveBeenLastCalledWith(false); + expect(screen.getByText("Source recovery is pending.")).toBeDefined(); + expect(document.activeElement).toBe(document.getElementById("mcp-stdio-error")); + }); +}); diff --git a/web/src/lib/OAuthConnectionPanel.svelte b/web/src/lib/OAuthConnectionPanel.svelte new file mode 100644 index 000000000..780b85247 --- /dev/null +++ b/web/src/lib/OAuthConnectionPanel.svelte @@ -0,0 +1,996 @@ + + +
+
+
+

Managed OAuth

+

{credentialKey}

+
+ {#if summary !== null} + + {oauthStatusLabel(summary.status)} + + {/if} +
+ + {#if loading} +

Loading OAuth configuration...

+ {:else} + {#if summary !== null} +
+
+
Issuer
+
{summary.issuer}
+
+
+
Client
+
{summary.clientAuthMethod === "none" ? "Public" : "Confidential"}
+
+
+
Granted scopes
+
{summary.grantedScopes.length > 0 ? summary.grantedScopes.join(" ") : "None yet"}
+
+
+
Refresh token
+
{summary.hasRefreshToken ? "Stored securely" : "Not stored"}
+
+
+ +
+ +
+ + +
+

Register this exact URL with the OAuth provider.

+

{copyStatus}

+
+ {/if} + +
+ {#if configurationUnavailable} +

+ This source no longer offers this managed OAuth credential. Disconnect or delete the saved + connection, or refresh the source configuration. +

+ {/if} +
+ {summary === null ? "Configure connection" : "Connection settings"} + + + + {#if draft.clientKind === "confidential"} + +
+ Client secret + {#if summary?.hasClientSecret} + + {/if} + + {#if draft.clientSecretAction === "replace"} + + {/if} +
+ {:else if summary?.hasClientSecret} +

+ Saving as a public client clears the stored client secret. +

+ {/if} + +
+ +
+ + +
+ {#if dirty && summary !== null} +

Save your changes before starting the OAuth authorization.

+ {/if} +
+ + {#if summary !== null} +
+ {#if confirmingDisconnect} + handleConfirmationKeydown(event, cancelDisconnectConfirmation)} + > + Disconnect OAuth tokens? +

The saved client configuration will remain available.

+
+ + +
+
+ {:else} + + {/if} + {#if confirmingDelete} + handleConfirmationKeydown(event, cancelDeleteConfirmation)} + > + Delete this OAuth configuration? +

This removes the client configuration and all stored OAuth tokens.

+
+ + +
+
+ {:else} + + {/if} +
+ {/if} + {/if} + + {#if notice !== null} +

{notice}

+ {/if} + {#if error !== null} + + {#if draftConflict && conflictLatestLoaded} + + {:else if draftConflict && conflictRefreshFailed} + + {:else if draftConflict} +

Loading the latest OAuth configuration...

+ {/if} + {/if} +
+ + diff --git a/web/src/lib/OAuthConnectionPanel.test.ts b/web/src/lib/OAuthConnectionPanel.test.ts new file mode 100644 index 000000000..d825a868d --- /dev/null +++ b/web/src/lib/OAuthConnectionPanel.test.ts @@ -0,0 +1,692 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { tick } from "svelte"; +import OAuthConnectionPanel from "./OAuthConnectionPanel.svelte"; +import type { + OAuthConnectionOperations, + OAuthConnectionList, + OAuthConnectionSummary, + OAuthOperationResult, +} from "./oauth-connection-state"; + +function summary(overrides: Partial = {}): OAuthConnectionSummary { + return { + id: "oauth-1", + credentialKey: "provider-oauth", + revision: 4, + status: "connected", + issuer: "https://identity.example.test/", + clientId: "executor-client", + clientAuthMethod: "client_secret_basic", + callbackUrl: "https://executor.example.test/api/v1/oauth/callback/provider-oauth", + requestedScopes: ["read", "write"], + grantedScopes: ["read"], + hasClientSecret: true, + hasRefreshToken: true, + accessExpiresAt: 100, + authorizedAt: 90, + lastRefreshedAt: 95, + errorCode: null, + managedOAuthEligible: true, + ...overrides, + }; +} + +function success(value: Value): OAuthOperationResult { + return { ok: true, value }; +} + +function connectionList(connections: readonly OAuthConnectionSummary[]): OAuthConnectionList { + return { connections, availableCredentials: [] }; +} + +function operations(connection: OAuthConnectionSummary | null = summary()) { + return { + load: vi.fn(async (_sourceId: string, _signal: AbortSignal) => + success(connectionList(connection === null ? [] : [connection])), + ), + save: vi.fn(async (_sourceId, _credentialKey, _input, _signal) => + success(summary({ revision: 5 })), + ), + authorize: vi.fn(async (_sourceId, _credentialKey, _input, _signal) => + success({ authorizationUrl: "https://identity.example.test/authorize" }), + ), + disconnect: vi.fn(async (_sourceId, _credentialKey, _input, _signal) => + success(summary({ revision: 5, status: "ready_to_connect", hasRefreshToken: false })), + ), + remove: vi.fn(async (_sourceId, _credentialKey, _input, _signal) => success(undefined)), + } satisfies OAuthConnectionOperations; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function focusedCancel() { + const focused = document.activeElement ?? document.body; + expect(focused).toBe(screen.getByRole("button", { name: "Cancel" })); + return focused; +} + +afterEach(() => cleanup()); + +describe("managed OAuth connection panel", () => { + it("shows redacted connection metadata and keeps authorization separate from saving", async () => { + const api = operations(); + const navigate = vi.fn(); + render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + navigate, + }); + + expect(await screen.findByText("Connected")).toBeDefined(); + expect(screen.getByDisplayValue(summary().callbackUrl)).toBeDefined(); + expect(screen.getByText("Stored securely")).toBeDefined(); + expect(document.body.textContent).not.toContain("refresh-token"); + expect(document.body.textContent).not.toContain("client-secret"); + + await fireEvent.input(screen.getByLabelText(/Requested scopes/), { + target: { value: "read write profile" }, + }); + expect(screen.getByRole("button", { name: "Connect OAuth" }).disabled).toBe( + true, + ); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + + await waitFor(() => expect(api.save).toHaveBeenCalledOnce()); + expect(api.save.mock.calls[0]?.[2]).toEqual({ + expectedRevision: 4, + discovery: { type: "issuer", issuer: "https://identity.example.test/" }, + client: { + clientId: "executor-client", + authentication: "client_secret_basic", + clientSecret: { action: "preserve" }, + }, + scopes: ["read", "write", "profile"], + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect OAuth" })); + await waitFor(() => + expect(navigate).toHaveBeenCalledWith("https://identity.example.test/authorize"), + ); + expect(api.authorize).toHaveBeenCalledWith( + "source-1", + "provider-oauth", + { expectedRevision: 5 }, + expect.any(AbortSignal), + ); + }); + + it("clears a replacement secret after a failed save without echoing it", async () => { + const api = operations(); + const response = deferred>(); + api.save.mockImplementation(() => response.promise); + const failure = { + ok: false, + error: { + code: "upstream_rejected", + displayMessage: "The OAuth configuration was rejected.", + requestId: "request-safe", + status: 400, + }, + } as const; + render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + }); + + await screen.findByText("Connected"); + await fireEvent.click(screen.getByLabelText("Replace the saved secret")); + const secret = screen.getByLabelText("New client secret"); + await fireEvent.input(secret, { target: { value: " never-render-this " } }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + + await waitFor(() => expect(api.save).toHaveBeenCalledOnce()); + expect(screen.getByLabelText("New client secret").value).toBe(""); + expect(document.body.textContent).not.toContain("never-render-this"); + response.resolve(failure); + await waitFor(() => expect(screen.getByRole("alert")).toBeDefined()); + expect(api.save.mock.calls[0]?.[2].client).toEqual({ + clientId: "executor-client", + authentication: "client_secret_basic", + clientSecret: { action: "replace", value: " never-render-this " }, + }); + }); + + it("makes the public-client transition explicitly clear the stored secret", async () => { + const api = operations(); + render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + }); + + await screen.findByText("Connected"); + await fireEvent.change(screen.getByLabelText("Client type"), { target: { value: "public" } }); + expect(screen.getByText(/clears the stored client secret/)).toBeDefined(); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + await waitFor(() => expect(api.save).toHaveBeenCalledOnce()); + expect(api.save.mock.calls[0]?.[2].client).toEqual({ + clientId: "executor-client", + authentication: "none", + }); + }); + + it("saves MCP discovery with default scopes and no required issuer override", async () => { + const api = operations(null); + render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "default", + defaultRequestedScopes: ["tools.read", "tools.call"], + discoveryType: "mcp", + operations: api, + }); + + expect(await screen.findByDisplayValue("tools.read tools.call")).toBeDefined(); + await fireEvent.input(screen.getByLabelText("Client ID"), { + target: { value: "executor-client" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + + await waitFor(() => expect(api.save).toHaveBeenCalledOnce()); + expect(api.save.mock.calls[0]?.[2]).toEqual({ + expectedRevision: 0, + discovery: { type: "mcp" }, + client: { clientId: "executor-client", authentication: "none" }, + scopes: ["tools.read", "tools.call"], + }); + }); + + it("refetches on an opaque callback key and rejects an older completion", async () => { + const first = deferred>(); + const second = deferred>(); + const api = operations(); + api.load + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: null, + operations: api, + }); + + await mounted.rerender({ + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: "success:oauth-1:provider-secret-data", + operations: api, + }); + second.resolve(success(connectionList([summary({ status: "connected" })]))); + const status = await screen.findByText("Connected"); + expect(status.getAttribute("role")).toBe("status"); + expect(status.getAttribute("aria-live")).toBe("polite"); + first.resolve(success(connectionList([summary({ status: "error" })]))); + await first.promise; + await Promise.resolve(); + expect(screen.queryByText("Connection error")).toBeNull(); + expect(document.body.textContent).not.toContain("provider-secret-data"); + }); + + it("aborts an owned load when the panel unmounts", async () => { + const request = { signal: null as AbortSignal | null }; + const pending = deferred>(); + const api = operations(); + api.load.mockImplementation((_sourceId, currentSignal) => { + request.signal = currentSignal; + return pending.promise; + }); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + }); + + await waitFor(() => expect(request.signal).not.toBeNull()); + mounted.unmount(); + expect(request.signal?.aborted).toBe(true); + }); + + it("reports only OAuth writes as mutations and clears the fence on unmount", async () => { + const response = deferred>(); + const request = { signal: null as AbortSignal | null }; + const api = operations(); + api.save.mockImplementation((_sourceId, _credentialKey, _input, signal) => { + request.signal = signal; + return response.promise; + }); + const onmutationchange = vi.fn(); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + onmutationchange, + }); + + await screen.findByText("Connected"); + expect(onmutationchange).not.toHaveBeenCalled(); + await fireEvent.input(screen.getByLabelText(/Requested scopes/), { + target: { value: "read profile" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + await waitFor(() => expect(onmutationchange).toHaveBeenLastCalledWith(true)); + + mounted.unmount(); + expect(request.signal?.aborted).toBe(true); + expect(onmutationchange).toHaveBeenLastCalledWith(false); + response.resolve(success(summary({ revision: 5 }))); + await response.promise; + }); + + it("aborts and rejects an authorization completion after the panel identity changes", async () => { + const authorization = deferred>(); + const request = { signal: null as AbortSignal | null }; + const api = operations(); + api.authorize.mockImplementation((_sourceId, _credentialKey, _input, signal) => { + request.signal = signal; + return authorization.promise; + }); + const navigate = vi.fn(); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-a", + credentialKey: "provider-oauth", + operations: api, + navigate, + }); + await screen.findByText("Connected"); + await fireEvent.click(screen.getByRole("button", { name: "Connect OAuth" })); + await waitFor(() => expect(request.signal).not.toBeNull()); + + await mounted.rerender({ + sourceId: "source-b", + credentialKey: "provider-oauth", + operations: api, + navigate, + }); + expect(request.signal?.aborted).toBe(true); + authorization.resolve(success({ authorizationUrl: "https://old.example.test/authorize" })); + await authorization.promise; + await Promise.resolve(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("aborts a save when an OAuth callback triggers a status refresh", async () => { + const response = deferred>(); + const request = { signal: null as AbortSignal | null }; + const api = operations(); + api.save.mockImplementation((_sourceId, _credentialKey, _input, signal) => { + request.signal = signal; + return response.promise; + }); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: null, + operations: api, + }); + await screen.findByText("Connected"); + await fireEvent.input(screen.getByLabelText(/Requested scopes/), { + target: { value: "read profile" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + await waitFor(() => expect(request.signal).not.toBeNull()); + + await mounted.rerender({ + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: "success:oauth-1", + operations: api, + }); + expect(request.signal?.aborted).toBe(true); + response.resolve(success(summary({ revision: 99 }))); + await response.promise; + await Promise.resolve(); + expect(screen.queryByText(/OAuth configuration saved/)).toBeNull(); + }); + + it("aborts authorization when the operations implementation changes", async () => { + const authorization = deferred>(); + const request = { signal: null as AbortSignal | null }; + const firstOperations = operations(); + firstOperations.authorize.mockImplementation((_sourceId, _credentialKey, _input, signal) => { + request.signal = signal; + return authorization.promise; + }); + const nextOperations = operations(); + const navigate = vi.fn(); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: firstOperations, + navigate, + }); + await screen.findByText("Connected"); + await fireEvent.click(screen.getByRole("button", { name: "Connect OAuth" })); + await waitFor(() => expect(request.signal).not.toBeNull()); + + await mounted.rerender({ + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: nextOperations, + navigate, + }); + expect(request.signal?.aborted).toBe(true); + authorization.resolve(success({ authorizationUrl: "https://old.example.test/authorize" })); + await authorization.promise; + await Promise.resolve(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("clears a completed mutation notice when the panel identity changes", async () => { + const api = operations(); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-a", + credentialKey: "provider-oauth", + operations: api, + }); + await screen.findByText("Connected"); + await fireEvent.input(screen.getByLabelText(/Requested scopes/), { + target: { value: "read profile" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + expect(await screen.findByText(/OAuth configuration saved/)).toBeDefined(); + + await mounted.rerender({ + sourceId: "source-b", + credentialKey: "provider-oauth", + operations: api, + }); + await waitFor(() => expect(screen.queryByText(/OAuth configuration saved/)).toBeNull()); + }); + + it("blocks a conflicted draft until the operator loads the latest revision", async () => { + const api = operations(); + api.load + .mockResolvedValueOnce(success(connectionList([summary({ revision: 4 })]))) + .mockResolvedValueOnce({ + ok: false, + error: { + code: "network_error", + displayMessage: "Executor could not load the latest configuration.", + requestId: null, + status: 503, + }, + }) + .mockResolvedValueOnce( + success(connectionList([summary({ revision: 5, requestedScopes: ["server-change"] })])), + ); + api.save + .mockResolvedValueOnce({ + ok: false, + error: { + code: "revision_conflict", + displayMessage: "OAuth configuration changed elsewhere.", + requestId: "request-conflict", + status: 409, + }, + }) + .mockResolvedValueOnce(success(summary({ revision: 6 }))); + render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + }); + await screen.findByText("Connected"); + let scopes = screen.getByLabelText(/Requested scopes/); + await fireEvent.input(scopes, { target: { value: "my-draft" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + + const retry = await screen.findByRole("button", { name: "Retry loading latest" }); + expect( + screen.getByRole("button", { name: "Save configuration" }).disabled, + ).toBe(true); + expect(api.save.mock.calls[0]?.[2].expectedRevision).toBe(4); + await fireEvent.click(retry); + const discard = await screen.findByRole("button", { name: "Discard draft and load latest" }); + await fireEvent.click(discard); + await waitFor(() => + expect(screen.getByLabelText(/Requested scopes/).value).toBe( + "server-change", + ), + ); + + scopes = screen.getByLabelText(/Requested scopes/); + await fireEvent.input(scopes, { target: { value: "server-change mine" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save configuration" })); + await waitFor(() => expect(api.save).toHaveBeenCalledTimes(2)); + expect(api.save.mock.calls[1]?.[2].expectedRevision).toBe(5); + }); + + it("allows access-token-only disconnects and closes confirmation when status clears", async () => { + const refreshed = deferred>(); + const api = operations(); + api.load + .mockResolvedValueOnce( + success(connectionList([summary({ status: "connected", hasRefreshToken: false })])), + ) + .mockImplementationOnce(() => refreshed.promise); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: null, + operations: api, + }); + expect(await screen.findByText("Not stored")).toBeDefined(); + const disconnect = screen.getByRole("button", { + name: "Disconnect tokens", + }); + expect(disconnect.disabled).toBe(false); + await fireEvent.click(disconnect); + expect(screen.getByRole("dialog", { name: "Disconnect OAuth tokens?" })).toBeDefined(); + focusedCancel(); + + await mounted.rerender({ + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: "success:oauth-1", + operations: api, + }); + await waitFor(() => expect(api.load).toHaveBeenCalledTimes(2)); + const panelHeading = screen.getByRole("heading", { name: "provider-oauth" }); + expect(document.activeElement).toBe(panelHeading); + refreshed.resolve( + success(connectionList([summary({ status: "ready_to_connect", hasRefreshToken: false })])), + ); + const disabledDisconnect = await screen.findByRole("button", { + name: "Disconnect tokens", + }); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Disconnect OAuth tokens?" })).toBeNull(), + ); + expect(disabledDisconnect.disabled).toBe(true); + expect(document.activeElement).toBe(panelHeading); + }); + + it("restores Cancel after a refresh removes focused disconnect confirmation", async () => { + const refreshed = deferred>(); + const api = operations(); + api.load + .mockResolvedValueOnce(success(connectionList([summary()]))) + .mockImplementationOnce(() => refreshed.promise); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: null, + operations: api, + }); + await screen.findByText("Connected"); + await fireEvent.click(screen.getByRole("button", { name: "Disconnect tokens" })); + const confirm = screen.getByRole("button", { name: "Disconnect tokens" }); + confirm.focus(); + expect(document.activeElement).toBe(confirm); + + await mounted.rerender({ + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: "success:oauth-1", + operations: api, + }); + await waitFor(() => expect(api.load).toHaveBeenCalledTimes(2)); + expect(document.activeElement).toBe(screen.getByRole("heading", { name: "provider-oauth" })); + + refreshed.resolve(success(connectionList([summary()]))); + expect(await screen.findByRole("dialog", { name: "Disconnect OAuth tokens?" })).toBeDefined(); + await waitFor(() => focusedCancel()); + }); + + it("restores Cancel after a refresh removes focused delete confirmation", async () => { + const refreshed = deferred>(); + const api = operations(); + api.load + .mockResolvedValueOnce(success(connectionList([summary()]))) + .mockImplementationOnce(() => refreshed.promise); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: null, + operations: api, + }); + await screen.findByText("Connected"); + await fireEvent.click(screen.getByRole("button", { name: "Delete configuration" })); + const confirm = screen.getByRole("button", { name: "Delete" }); + confirm.focus(); + expect(document.activeElement).toBe(confirm); + + await mounted.rerender({ + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: "success:oauth-1", + operations: api, + }); + await waitFor(() => expect(api.load).toHaveBeenCalledTimes(2)); + expect(document.activeElement).toBe(screen.getByRole("heading", { name: "provider-oauth" })); + + refreshed.resolve(success(connectionList([summary()]))); + expect( + await screen.findByRole("dialog", { name: "Delete this OAuth configuration?" }), + ).toBeDefined(); + await waitFor(() => focusedCancel()); + }); + + it("does not restore confirmation focus after the operator moves elsewhere", async () => { + const refreshed = deferred>(); + const api = operations(); + api.load + .mockResolvedValueOnce(success(connectionList([summary()]))) + .mockImplementationOnce(() => refreshed.promise); + const mounted = render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: null, + operations: api, + }); + await screen.findByText("Connected"); + await fireEvent.click(screen.getByRole("button", { name: "Disconnect tokens" })); + screen.getByRole("button", { name: "Disconnect tokens" }).focus(); + + await mounted.rerender({ + sourceId: "source-1", + credentialKey: "provider-oauth", + callbackRefreshKey: "success:oauth-1", + operations: api, + }); + await waitFor(() => expect(api.load).toHaveBeenCalledTimes(2)); + expect(document.activeElement).toBe(screen.getByRole("heading", { name: "provider-oauth" })); + const elsewhere = document.createElement("button"); + elsewhere.textContent = "Elsewhere"; + mounted.container.append(elsewhere); + elsewhere.focus(); + expect(document.activeElement).toBe(elsewhere); + + refreshed.resolve(success(connectionList([summary()]))); + expect(await screen.findByRole("dialog", { name: "Disconnect OAuth tokens?" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined(); + await tick(); + expect(document.activeElement).toBe(elsewhere); + }); + + it("confirms destructive actions and manages keyboard focus", async () => { + const api = operations(); + render(OAuthConnectionPanel, { + sourceId: "source-1", + credentialKey: "provider-oauth", + operations: api, + }); + await screen.findByText("Connected"); + + const disconnectOpener = screen.getByRole("button", { name: "Disconnect tokens" }); + await fireEvent.click(disconnectOpener); + expect(screen.getByRole("dialog", { name: "Disconnect OAuth tokens?" })).toBeDefined(); + await fireEvent.click(focusedCancel()); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Disconnect tokens" }), + ), + ); + expect(api.disconnect).not.toHaveBeenCalled(); + + await fireEvent.click(screen.getByRole("button", { name: "Disconnect tokens" })); + expect(screen.getByRole("dialog", { name: "Disconnect OAuth tokens?" })).toBeDefined(); + await fireEvent.keyDown(focusedCancel(), { key: "Escape" }); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Disconnect tokens" }), + ), + ); + expect(api.disconnect).not.toHaveBeenCalled(); + + await fireEvent.click(screen.getByRole("button", { name: "Disconnect tokens" })); + expect(screen.getByRole("dialog", { name: "Disconnect OAuth tokens?" })).toBeDefined(); + focusedCancel(); + await fireEvent.click(screen.getByRole("button", { name: "Disconnect tokens" })); + await waitFor(() => expect(api.disconnect).toHaveBeenCalledOnce()); + expect(api.disconnect).toHaveBeenCalledWith( + "source-1", + "provider-oauth", + { expectedRevision: 4 }, + expect.any(AbortSignal), + ); + const disconnectedNotice = screen.getByText(/OAuth tokens disconnected/); + await waitFor(() => expect(document.activeElement).toBe(disconnectedNotice)); + expect( + screen.getByRole("button", { name: "Disconnect tokens" }).disabled, + ).toBe(true); + + const deleteOpener = screen.getByRole("button", { name: "Delete configuration" }); + await fireEvent.click(deleteOpener); + expect(screen.getByText("Delete this OAuth configuration?")).toBeDefined(); + expect(screen.getByRole("dialog", { name: "Delete this OAuth configuration?" })).toBeDefined(); + await fireEvent.click(focusedCancel()); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Delete configuration" }), + ), + ); + expect(api.remove).not.toHaveBeenCalled(); + + await fireEvent.click(screen.getByRole("button", { name: "Delete configuration" })); + expect(screen.getByRole("dialog", { name: "Delete this OAuth configuration?" })).toBeDefined(); + await fireEvent.keyDown(focusedCancel(), { key: "Escape" }); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Delete configuration" }), + ), + ); + expect(api.remove).not.toHaveBeenCalled(); + + await fireEvent.click(screen.getByRole("button", { name: "Delete configuration" })); + expect(screen.getByRole("dialog", { name: "Delete this OAuth configuration?" })).toBeDefined(); + focusedCancel(); + await fireEvent.click(screen.getByRole("button", { name: "Delete" })); + await waitFor(() => expect(api.remove).toHaveBeenCalledOnce()); + expect(screen.queryByDisplayValue(summary().callbackUrl)).toBeNull(); + const deletedNotice = screen.getByText("OAuth configuration deleted."); + await waitFor(() => expect(document.activeElement).toBe(deletedNotice)); + }); +}); diff --git a/web/src/lib/OAuthSourceConnections.svelte b/web/src/lib/OAuthSourceConnections.svelte new file mode 100644 index 000000000..a738b6386 --- /dev/null +++ b/web/src/lib/OAuthSourceConnections.svelte @@ -0,0 +1,322 @@ + + +{#if loading && list === null} +

Loading managed OAuth options...

+{:else if error !== null && list === null} + +{:else if list !== null} +
+ {#if error !== null} + + {:else if loading} +

Refreshing managed OAuth options...

+ {/if} + {#if callbackNotice !== null} +

+ {callbackNotice.message} +

+ {/if} + {#if entries.length > 0} +
+
+

Managed OAuth

+

Provider connections

+
+

+ Executor stores refreshable tokens locally. Manual access tokens remain available under + advanced credential settings. +

+
+ {#each entries as entry (entry.credentialKey)} + setPanelBusy(entry.credentialKey, busy)} + onmutationchange={(busy) => setPanelMutation(entry.credentialKey, busy)} + /> + {/each} + {/if} +
+{/if} + + diff --git a/web/src/lib/OAuthSourceConnections.test.ts b/web/src/lib/OAuthSourceConnections.test.ts new file mode 100644 index 000000000..4f3f00ae1 --- /dev/null +++ b/web/src/lib/OAuthSourceConnections.test.ts @@ -0,0 +1,368 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; +import OAuthSourceConnections from "./OAuthSourceConnections.svelte"; +import type { + OAuthConnectionList, + OAuthConnectionOperations, + OAuthConnectionSummary, + OAuthOperationResult, +} from "./oauth-connection-state"; +import type { Source } from "./api"; + +function source(): Source { + return { + id: "source-1", + kind: "openapi", + slug: "provider", + displayName: "Provider API", + description: null, + configuration: {}, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 1, + updatedAt: 1, + lastRefreshedAt: 1, + toolCount: 2, + tombstonedToolCount: 0, + }; +} + +function connection(overrides: Partial = {}): OAuthConnectionSummary { + return { + id: "connection-1", + credentialKey: "configured-oauth", + revision: 3, + status: "connected", + issuer: "https://identity.example.test/", + clientId: "executor", + clientAuthMethod: "none", + callbackUrl: "https://executor.example.test/api/v1/oauth/callback/connection-1", + requestedScopes: ["read"], + grantedScopes: ["read"], + hasClientSecret: false, + hasRefreshToken: true, + accessExpiresAt: null, + authorizedAt: 1, + lastRefreshedAt: 1, + errorCode: null, + managedOAuthEligible: true, + ...overrides, + }; +} + +function success(value: Value): OAuthOperationResult { + return { ok: true, value }; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function operations() { + const list: OAuthConnectionList = { + connections: [connection()], + availableCredentials: [ + { + credentialKey: "new-oauth", + protocol: "openapi" as const, + requestedScopes: ["read", "write"], + managedOAuthEligible: true as const, + }, + ], + }; + return { + load: vi.fn(async () => success(list)), + save: vi.fn(async () => success(connection())), + authorize: vi.fn(async () => + success({ authorizationUrl: "https://identity.example.test/authorize?state=opaque" }), + ), + disconnect: vi.fn(async () => + success({ ...connection(), status: "ready_to_connect" as const }), + ), + remove: vi.fn(async () => success(undefined)), + } satisfies OAuthConnectionOperations; +} + +afterEach(cleanup); + +describe("OAuth source connections", () => { + it("renders only discovered and configured credential keys without a free-form key", async () => { + const api = operations(); + render(OAuthSourceConnections, { source: source(), operations: api }); + + expect(await screen.findByText("new-oauth")).toBeDefined(); + expect(screen.getByText("configured-oauth")).toBeDefined(); + expect(screen.queryByLabelText(/Credential key/i)).toBeNull(); + expect(screen.getByDisplayValue("read write")).toBeDefined(); + }); + + it("announces and consumes a failed callback only after its connection source refetches", async () => { + const api = operations(); + const checked = vi.fn(); + const busy = vi.fn(); + const mounted = render(OAuthSourceConnections, { + source: source(), + operations: api, + callbackRefreshKey: "failed:connection-1", + oncallbackchecked: checked, + onbusychange: busy, + }); + + await waitFor(() => expect(checked).toHaveBeenCalledWith(true)); + expect(screen.getByRole("alert").textContent).toContain("OAuth authorization did not complete"); + expect(busy).toHaveBeenCalledWith(true); + await waitFor(() => expect(busy).toHaveBeenLastCalledWith(false)); + + await mounted.rerender({ + source: source(), + operations: api, + callbackRefreshKey: "success:connection-1", + oncallbackchecked: checked, + onbusychange: busy, + }); + expect(await screen.findByText(/OAuth authorization completed/)).toBeDefined(); + expect(screen.queryByText(/OAuth authorization did not complete/)).toBeNull(); + }); + + it("cleans a failed callback for a missing connection without claiming a connection result", async () => { + const api = operations(); + api.load.mockResolvedValue( + success({ + connections: [], + availableCredentials: [ + { + credentialKey: "new-oauth", + protocol: "openapi", + requestedScopes: [], + managedOAuthEligible: true, + }, + ], + }), + ); + const checked = vi.fn(); + render(OAuthSourceConnections, { + source: source(), + operations: api, + callbackRefreshKey: "failed:deleted-connection", + oncallbackchecked: checked, + }); + + await waitFor(() => expect(checked).toHaveBeenCalledWith(false)); + expect(screen.queryByText(/OAuth authorization did not complete/)).toBeNull(); + }); + + it("keeps configured but ineligible connections removable while blocking save and connect", async () => { + const api = operations(); + api.load.mockResolvedValue( + success({ + connections: [connection({ managedOAuthEligible: false })], + availableCredentials: [], + }), + ); + render(OAuthSourceConnections, { source: source(), operations: api }); + + expect(await screen.findByText(/no longer offers this managed OAuth credential/)).toBeDefined(); + expect( + screen.getByRole("button", { name: "Save configuration" }).disabled, + ).toBe(true); + expect(screen.getByRole("button", { name: "Connect OAuth" }).disabled).toBe( + true, + ); + expect( + screen.getByRole("button", { name: "Disconnect tokens" }).disabled, + ).toBe(false); + expect( + screen.getByRole("button", { name: "Delete configuration" }).disabled, + ).toBe(false); + }); + + it("keeps the prior list visible after a callback reload fails and consumes it after retry", async () => { + const api = operations(); + const checked = vi.fn(); + api.load + .mockResolvedValueOnce( + success({ + connections: [], + availableCredentials: [], + }), + ) + .mockResolvedValueOnce({ + ok: false, + error: { + code: "network_error", + displayMessage: "Managed OAuth refresh failed.", + requestId: "oauth-reload", + status: 503, + }, + }) + .mockResolvedValue( + success({ + connections: [connection()], + availableCredentials: [], + }), + ); + const mounted = render(OAuthSourceConnections, { + source: source(), + operations: api, + oncallbackchecked: checked, + }); + await waitFor(() => expect(api.load).toHaveBeenCalledOnce()); + + await mounted.rerender({ + source: source(), + operations: api, + callbackRefreshKey: "success:connection-1", + oncallbackchecked: checked, + }); + + const staleError = await screen.findByRole("alert"); + expect(staleError.textContent).toContain("Managed OAuth refresh failed."); + expect(staleError.textContent).toContain("Showing the last loaded managed OAuth options."); + expect(checked).not.toHaveBeenCalled(); + + await fireEvent.click(screen.getByRole("button", { name: "Retry managed OAuth" })); + await waitFor(() => expect(checked).toHaveBeenCalledOnce()); + expect(checked).toHaveBeenCalledWith(true); + expect(screen.queryByText("Managed OAuth refresh failed.")).toBeNull(); + expect(screen.getByText(/OAuth authorization completed/)).toBeDefined(); + }); + + it("ignores a late callback reload failure after a newer load succeeds", async () => { + const api = operations(); + const oldLoad = deferred>(); + const newLoad = deferred>(); + api.load + .mockResolvedValueOnce( + success({ + connections: [], + availableCredentials: [], + }), + ) + .mockImplementationOnce(() => oldLoad.promise) + .mockImplementationOnce(() => newLoad.promise) + .mockResolvedValue( + success({ + connections: [connection()], + availableCredentials: [], + }), + ); + const checked = vi.fn(); + const mounted = render(OAuthSourceConnections, { + source: source(), + operations: api, + oncallbackchecked: checked, + }); + await waitFor(() => expect(api.load).toHaveBeenCalledOnce()); + + await mounted.rerender({ + source: source(), + operations: api, + callbackRefreshKey: "failed:connection-1", + oncallbackchecked: checked, + }); + await waitFor(() => expect(api.load).toHaveBeenCalledTimes(2)); + await mounted.rerender({ + source: source(), + operations: api, + callbackRefreshKey: "success:connection-1", + oncallbackchecked: checked, + }); + await waitFor(() => expect(api.load).toHaveBeenCalledTimes(3)); + newLoad.resolve( + success({ + connections: [connection()], + availableCredentials: [], + }), + ); + await waitFor(() => expect(checked).toHaveBeenCalledOnce()); + + oldLoad.resolve({ + ok: false, + error: { + code: "network_error", + displayMessage: "Late old failure.", + requestId: null, + status: 503, + }, + }); + await oldLoad.promise; + await Promise.resolve(); + expect(screen.queryByText("Late old failure.")).toBeNull(); + expect(screen.getByText(/OAuth authorization completed/)).toBeDefined(); + }); + + it("uses collision-free panel IDs and keeps error focus inside the active credential panel", async () => { + const api = operations(); + api.load.mockResolvedValue( + success({ + connections: [ + connection({ + id: "dot-connection", + credentialKey: "client.id", + callbackUrl: "https://executor.example.test/callback/dot", + clientAuthMethod: "client_secret_basic", + hasClientSecret: true, + }), + connection({ + id: "dash-connection", + credentialKey: "client-id", + callbackUrl: "https://executor.example.test/callback/dash", + clientAuthMethod: "client_secret_basic", + hasClientSecret: true, + }), + ], + availableCredentials: [], + }), + ); + api.save.mockResolvedValue({ + ok: false, + error: { + code: "invalid_oauth_configuration", + displayMessage: "Review this OAuth configuration.", + requestId: null, + status: 400, + }, + }); + render(OAuthSourceConnections, { source: source(), operations: api }); + + const dotHeading = await screen.findByRole("heading", { name: "client.id" }); + const dashHeading = screen.getByRole("heading", { name: "client-id" }); + const dotPanel = dotHeading.closest("section"); + const dashPanel = dashHeading.closest("section"); + expect(dotPanel).not.toBeNull(); + expect(dashPanel).not.toBeNull(); + if (dotPanel === null || dashPanel === null) return; + + expect(dotHeading.id).not.toBe(dashHeading.id); + expect(dotPanel.getAttribute("aria-labelledby")).toBe(dotHeading.id); + expect(dashPanel.getAttribute("aria-labelledby")).toBe(dashHeading.id); + const dotCallback = within(dotPanel).getByLabelText("Exact callback URL"); + const dashCallback = within(dashPanel).getByLabelText("Exact callback URL"); + expect(dotCallback.id).not.toBe(dashCallback.id); + const dotRadio = dotPanel.querySelector('input[type="radio"]'); + const dashRadio = dashPanel.querySelector('input[type="radio"]'); + expect(dotRadio?.name).not.toBe(dashRadio?.name); + + await fireEvent.input(within(dotPanel).getByLabelText(/Requested scopes/), { + target: { value: "dot-change" }, + }); + await fireEvent.click(within(dotPanel).getByRole("button", { name: "Save configuration" })); + await waitFor(() => expect(dotPanel.contains(document.activeElement)).toBe(true)); + + await fireEvent.input(within(dashPanel).getByLabelText(/Requested scopes/), { + target: { value: "dash-change" }, + }); + await fireEvent.click(within(dashPanel).getByRole("button", { name: "Save configuration" })); + await waitFor(() => expect(dashPanel.contains(document.activeElement)).toBe(true)); + expect(dotPanel.querySelector('[role="alert"]')?.id).not.toBe( + dashPanel.querySelector('[role="alert"]')?.id, + ); + }); +}); diff --git a/web/src/lib/PlaceholderPanel.svelte b/web/src/lib/PlaceholderPanel.svelte new file mode 100644 index 000000000..411c0d26f --- /dev/null +++ b/web/src/lib/PlaceholderPanel.svelte @@ -0,0 +1,20 @@ + + +
+
+ {label} +

{title}

+

{detail}

+

Not available yet

+
+ +
diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts new file mode 100644 index 000000000..9b174d9ef --- /dev/null +++ b/web/src/lib/api.test.ts @@ -0,0 +1,1733 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { + ApiError, + authorizeOAuthConnection, + bulkSetToolModes, + createGraphqlSource, + createMcpHttpSource, + createMcpStdioSource, + createOpenApiSource, + createToken, + decideApproval, + deleteOAuthConnection, + deleteOpenApiCredentials, + getApproval, + getOpenApiCredentials, + getSourceCreationResolution, + getSourceCredentials, + getBootstrap, + disconnectOAuthConnection, + listApprovals, + listOAuthConnections, + listMcpStdioTemplates, + listRequestLogs, + listSources, + listTokens, + listTools, + loginAdmin, + previewOpenApiSource, + putOpenApiCredentials, + putMcpHttpCredentials, + putMcpStdioCredentials, + putOAuthConnection, + putGraphqlCredentials, + refreshOpenApiSource, + revokeToken, + sealMissingSourceCreation, + setSourceMode, +} from "./api"; + +function sourceFixture() { + return { + id: "source-1", + kind: "openapi", + slug: "github", + displayName: "GitHub", + description: "Repository API", + configuration: { publicBaseUrl: "https://api.example.test" }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 3, + catalogRevision: 8, + createdAt: 100, + updatedAt: 200, + lastRefreshedAt: 190, + toolCount: 2, + tombstonedToolCount: 1, + }; +} + +function toolFixture() { + return { + id: "tool-1", + sourceId: "source-1", + sourceSlug: "github", + stableKey: "GET /repos", + localName: "list_repos", + callablePath: "tools.github.list_repos", + sandboxPath: "github.list_repos", + displayName: "List repositories", + description: "Lists repositories", + intrinsicMode: "enabled", + modeOverride: null, + effectiveMode: { mode: "enabled", provenance: "intrinsic" }, + present: true, + revision: 4, + createdAt: 100, + updatedAt: 200, + lastSeenAt: 190, + tombstonedAt: null, + }; +} + +function logFixture() { + return { + requestId: "request-1", + actorApiTokenId: "token-id", + surface: "gateway", + sourceId: "source-1", + toolId: "tool-1", + pathSnapshot: "tools.github.list_repos", + outcome: "succeeded", + errorCode: null, + durationMs: 27, + approvalId: null, + createdAt: 200, + }; +} + +function approvalSummaryFixture() { + return { + id: "approval-1", + status: "pending", + revision: 4, + sourceId: "source-1", + toolId: "tool-1", + path: "tools.github.create_issue", + sourceDisplayName: "GitHub", + toolDisplayName: "Create issue", + actorKind: "api_token", + actorId: "token-1", + actorName: "Laptop", + actorLabel: "Laptop", + actorApiTokenId: "token-1", + actorTokenName: "Laptop", + surface: "gateway", + mode: "ask", + provenance: "tool_override", + executionId: "execution-1", + callId: "call-1", + createdAt: 100, + updatedAt: 101, + expiresAt: 700, + decidedAt: null, + startedAt: null, + completedAt: null, + decision: null, + failureCode: null, + }; +} + +function approvalDetailFixture() { + return { + ...approvalSummaryFixture(), + redactedArguments: { title: "Issue title", body: "[REDACTED]" }, + inputSchema: { type: "object" }, + }; +} + +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const validTokenSecret = `exr_${"A".repeat(43)}`; + +describe("dashboard API client", () => { + it("normalizes bootstrap data from the control API", async () => { + const result = await getBootstrap(async () => + Response.json({ setupRequired: true, authenticated: false }), + ); + + expect(result).toEqual({ + ok: true, + value: { setupRequired: true, authenticated: false }, + }); + }); + + it("sends cookies and the double-submit CSRF token for authenticated mutations", async () => { + document.cookie = "executor_csrf=csrf_test_value; Path=/"; + let observedHeaders = new Headers(); + let observedCredentials: RequestCredentials | undefined; + const created = await createToken("Laptop", "a".repeat(64), async (_input, init) => { + observedHeaders = new Headers(init?.headers); + observedCredentials = init?.credentials; + return Response.json( + { + id: "token-id", + name: "Laptop", + token: validTokenSecret, + createdAt: 123, + }, + { status: 201, headers: { "cache-control": "no-store" } }, + ); + }); + + expect(observedHeaders.get("x-executor-csrf")).toBe("csrf_test_value"); + expect(observedHeaders.get("content-type")).toBe("application/json"); + expect(observedHeaders.get("idempotency-key")).toBe("a".repeat(64)); + expect( + [...observedHeaders.keys()].filter((header) => header === "idempotency-key"), + ).toHaveLength(1); + expect(observedCredentials).toBe("same-origin"); + expect(created).toEqual({ + ok: true, + value: { + id: "token-id", + name: "Laptop", + token: validTokenSecret, + createdAt: 123, + }, + replayProvenance: "none", + responseDisposition: "authoritative", + }); + }); + + it("accepts only strict no-store token creation and replay responses", async () => { + const createdToken = { + id: "token-id", + name: "Laptop", + token: validTokenSecret, + createdAt: 123, + }; + const fresh = await createToken("Laptop", "1".repeat(64), async () => + Response.json(createdToken, { + status: 201, + headers: { "cache-control": "private, no-store" }, + }), + ); + const replay = await createToken("Laptop", "1".repeat(64), async () => + Response.json(createdToken, { + status: 201, + headers: { + "cache-control": "no-store", + "idempotency-replayed": "true", + }, + }), + ); + const missingNoStore = await createToken("Laptop", "1".repeat(64), async () => + Response.json(createdToken, { status: 201 }), + ); + const wrongStatus = await createToken("Laptop", "1".repeat(64), async () => + Response.json(createdToken, { + status: 200, + headers: { "cache-control": "no-store" }, + }), + ); + const invalidReplay = await createToken("Laptop", "1".repeat(64), async () => + Response.json(createdToken, { + status: 201, + headers: { + "cache-control": "no-store", + "idempotency-replayed": "false", + }, + }), + ); + + expect(fresh).toMatchObject({ + ok: true, + replayProvenance: "none", + responseDisposition: "authoritative", + }); + expect(replay).toMatchObject({ + ok: true, + replayProvenance: "authoritative", + responseDisposition: "authoritative", + }); + for (const result of [missingNoStore, wrongStatus, invalidReplay]) { + expect(result).toMatchObject({ + ok: false, + error: { code: "invalid_response" }, + responseDisposition: "ambiguous", + }); + } + }); + + it("keeps semantically malformed token successes ambiguous", async () => { + const valid = { + id: "token-id", + name: "Laptop", + token: validTokenSecret, + createdAt: 123, + }; + const malformed = [ + { ...valid, id: "" }, + { ...valid, id: "x".repeat(129) }, + { ...valid, name: "Different agent" }, + { ...valid, token: "exr_not-a-32-byte-secret" }, + { ...valid, token: `exr_${"A".repeat(42)}_` }, + { ...valid, createdAt: -1 }, + { ...valid, createdAt: Number.MAX_SAFE_INTEGER + 1 }, + ]; + + for (const body of malformed) { + const result = await createToken("Laptop", "2".repeat(64), async () => + Response.json(body, { + status: 201, + headers: { "cache-control": "no-store" }, + }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "invalid_response" }, + responseDisposition: "ambiguous", + }); + } + }); + + it("automatically retries one ambiguous token revocation", async () => { + const calls: Array<{ path: string; method: string | undefined }> = []; + const result = await revokeToken("token/one", async (input, init) => { + calls.push({ path: String(input), method: init?.method }); + if (calls.length === 1) return Effect.runPromise(Effect.fail("response lost")); + return new Response(null, { status: 204 }); + }); + + expect(result).toEqual({ ok: true, value: undefined }); + expect(calls).toEqual([ + { path: "/api/v1/tokens/token%2Fone", method: "DELETE" }, + { path: "/api/v1/tokens/token%2Fone", method: "DELETE" }, + ]); + }); + + it("does not attach a stale CSRF token to login", async () => { + document.cookie = "executor_csrf=stale_value; Path=/"; + let observedHeaders = new Headers(); + await loginAdmin({ username: "admin", password: "password" }, async (_input, init) => { + observedHeaders = new Headers(init?.headers); + return Response.json({ username: "admin", csrfToken: "csrf_fresh" }); + }); + + expect(observedHeaders.has("x-executor-csrf")).toBe(false); + }); + + it("sends exactly one opaque idempotency header for every source connector", async () => { + const observations: Array<{ + headers: Headers; + body: unknown; + credentials: RequestCredentials | undefined; + }> = []; + const fetcher = async (_input: RequestInfo | URL, init?: RequestInit) => { + observations.push({ + headers: new Headers(init?.headers), + body: decodeJson(String(init?.body)), + credentials: init?.credentials, + }); + return Response.json(sourceFixture(), { status: 201 }); + }; + + await createOpenApiSource( + { + kind: "openapi", + displayName: "OpenAPI", + spec: { type: "inline", content: "openapi: 3.1.0" }, + }, + "openapi-key", + fetcher, + ); + await createGraphqlSource( + { + kind: "graphql", + displayName: "GraphQL", + endpoint: "https://api.example.test/graphql", + }, + "graphql-key", + fetcher, + ); + await createMcpHttpSource( + { + kind: "mcp_http", + displayName: "MCP HTTP", + endpoint: "https://mcp.example.test/mcp", + }, + "mcp-http-key", + fetcher, + ); + await createMcpStdioSource( + { + kind: "mcp_stdio", + displayName: "MCP stdio", + templateName: "local", + secretValues: {}, + }, + "mcp-stdio-key", + fetcher, + ); + + expect(observations.map(({ headers }) => headers.get("idempotency-key"))).toEqual([ + "openapi-key", + "graphql-key", + "mcp-http-key", + "mcp-stdio-key", + ]); + for (const observation of observations) { + expect( + [...observation.headers.keys()].filter((name) => name === "idempotency-key"), + ).toHaveLength(1); + expect(observation.credentials).toBe("same-origin"); + expect(JSON.stringify(observation.body)).not.toContain("-key"); + } + }); + + it("preserves failed source-create replay metadata and transport ambiguity", async () => { + const input = { + kind: "graphql" as const, + displayName: "GraphQL", + endpoint: "https://api.example.test/graphql", + }; + const replayedFailure = await createGraphqlSource(input, "replayed-key", async () => + Response.json( + { + error: { + code: "internal_error", + message: "The stored source creation failed.", + requestId: "stored-failure", + }, + }, + { + status: 500, + headers: { "cache-control": "no-store", "idempotency-replayed": "true" }, + }, + ), + ); + const firstFailure = await createGraphqlSource(input, "first-key", async () => + Response.json( + { + error: { + code: "internal_error", + message: "Source creation failed.", + requestId: "first-failure", + }, + }, + { status: 500 }, + ), + ); + const duplicateReplayHeaders = new Headers(); + duplicateReplayHeaders.set("Cache-Control", "no-store"); + duplicateReplayHeaders.append("Idempotency-Replayed", "true"); + duplicateReplayHeaders.append("idempotency-replayed", "true"); + const invalidReplayFailure = await createGraphqlSource(input, "invalid-replay-key", async () => + Response.json( + { + error: { + code: "internal_error", + message: "Source creation failed.", + requestId: "invalid-replay-failure", + }, + }, + { status: 500, headers: duplicateReplayHeaders }, + ), + ); + const transportFailure = await createGraphqlSource(input, "transport-key", async () => + Effect.runPromise(Effect.fail("connection lost")), + ); + + expect(replayedFailure).toEqual({ + ok: false, + error: new ApiError({ + code: "internal_error", + displayMessage: "The stored source creation failed.", + requestId: "stored-failure", + status: 500, + }), + replayProvenance: "authoritative", + responseDisposition: "authoritative", + }); + expect(firstFailure).toMatchObject({ + ok: false, + error: { code: "internal_error", status: 500 }, + replayProvenance: "none", + }); + expect(invalidReplayFailure).toMatchObject({ + ok: false, + error: { code: "internal_error", status: 500 }, + replayProvenance: "invalid", + }); + expect(transportFailure).toMatchObject({ + ok: false, + error: { code: "network_error", status: 0 }, + replayProvenance: "none", + }); + }); + + it("keeps malformed replayed failure bodies ambiguous", async () => { + const input = { + kind: "graphql" as const, + displayName: "GraphQL", + endpoint: "https://api.example.test/graphql", + }; + const headers = { + "cache-control": "no-store", + "idempotency-replayed": "true", + }; + const malformedText = await createGraphqlSource( + input, + "text-key", + async () => new Response("gateway failure", { status: 500, headers }), + ); + const malformedJson = await createGraphqlSource( + input, + "json-key", + async () => new Response('{"error":', { status: 500, headers }), + ); + const incompleteError = await createGraphqlSource(input, "incomplete-key", async () => + Response.json( + { error: { code: "internal_error", message: "Stored failure." } }, + { status: 500, headers }, + ), + ); + const missingNoStore = await createGraphqlSource(input, "cache-key", async () => + Response.json( + { + error: { + code: "internal_error", + message: "Stored failure.", + requestId: "stored-failure", + }, + }, + { status: 500, headers: { "idempotency-replayed": "true" } }, + ), + ); + + for (const result of [malformedText, malformedJson, incompleteError, missingNoStore]) { + expect(result.ok).toBe(false); + expect(result.replayProvenance).toBe("invalid"); + } + }); + + it("requires a valid source body and replay contract for authoritative success", async () => { + const input = { + kind: "graphql" as const, + displayName: "GraphQL", + endpoint: "https://api.example.test/graphql", + }; + const replayHeaders = { + "cache-control": "private, No-Store", + "idempotency-replayed": "true", + }; + const authoritative = await createGraphqlSource(input, "success-key", async () => + Response.json(sourceFixture(), { status: 201, headers: replayHeaders }), + ); + const incompleteBody = await createGraphqlSource(input, "incomplete-key", async () => + Response.json({ ...sourceFixture(), id: undefined }, { status: 201, headers: replayHeaders }), + ); + const missingNoStore = await createGraphqlSource(input, "cache-key", async () => + Response.json(sourceFixture(), { + status: 201, + headers: { "idempotency-replayed": "true" }, + }), + ); + + expect(authoritative).toMatchObject({ ok: true, replayProvenance: "authoritative" }); + expect(incompleteBody).toMatchObject({ + ok: false, + error: { code: "invalid_response" }, + replayProvenance: "invalid", + }); + expect(missingNoStore).toMatchObject({ ok: true, replayProvenance: "invalid" }); + }); + + it("looks up source creation using only a no-store authenticated header", async () => { + const result = await getSourceCreationResolution("lookup-key", async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/idempotency"); + expect(init?.method).toBe("GET"); + expect(init?.body).toBeUndefined(); + expect(init?.credentials).toBe("same-origin"); + expect(init?.cache).toBe("no-store"); + const headers = new Headers(init?.headers); + expect(headers.get("idempotency-key")).toBe("lookup-key"); + expect(headers.has("content-type")).toBe(false); + return Response.json({ status: "missing" }, { headers: { "cache-control": "no-store" } }); + }); + + expect(result).toEqual({ ok: true, value: { kind: "status", status: "missing" } }); + }); + + it("accepts a case-insensitive no-store directive from combined Fetch headers", async () => { + const headers = new Headers(); + headers.append("Cache-Control", "private"); + headers.append("cache-control", "No-Store"); + expect(headers.get("cache-control")).toBe("private, No-Store"); + + const result = await getSourceCreationResolution("lookup-key", async () => + Response.json({ status: "missing" }, { headers }), + ); + + expect(result).toEqual({ ok: true, value: { kind: "status", status: "missing" } }); + }); + + it("strictly distinguishes successful and failed stored replays", async () => { + const completed = await getSourceCreationResolution("completed-key", async () => + Response.json(sourceFixture(), { + status: 201, + headers: { + "cache-control": "no-store", + "idempotency-replayed": "true", + }, + }), + ); + const failed = await getSourceCreationResolution("failed-key", async () => + Response.json( + { + error: { + code: "invalid_source", + message: "The source is invalid.", + requestId: "failed-request", + }, + }, + { + status: 422, + headers: { + "cache-control": "no-store", + "idempotency-replayed": "true", + }, + }, + ), + ); + + expect(completed.ok && completed.value.kind).toBe("replay"); + expect(completed.ok && completed.value.kind === "replay" && completed.value.result.ok).toBe( + true, + ); + expect(failed).toEqual({ + ok: true, + value: { + kind: "replay", + result: { + ok: false, + error: new ApiError({ + code: "invalid_source", + displayMessage: "The source is invalid.", + requestId: "failed-request", + status: 422, + }), + }, + }, + }); + }); + + it("accepts failed replays only for HTTP error statuses across every source path", async () => { + const input = { + kind: "graphql" as const, + displayName: "GraphQL", + endpoint: "https://api.example.test/graphql", + }; + const invalidStatuses = [199, 200, 204, 302, 399, 600] as const; + const authoritativeStatuses = [400, 599] as const; + + function failedReplayResponse(status: number) { + const nativeStatus = status < 200 ? 200 : status > 599 ? 599 : status; + const init = { + status: nativeStatus, + headers: { + "cache-control": "no-store", + "idempotency-replayed": "true", + }, + }; + const response = + nativeStatus === 204 + ? new Response(null, init) + : Response.json( + { + error: { + code: "internal_error", + message: "The stored source creation failed.", + requestId: `failed-${status}`, + }, + }, + init, + ); + if (nativeStatus !== status) Object.defineProperty(response, "status", { value: status }); + return response; + } + + for (const status of invalidStatuses) { + const direct = await createGraphqlSource(input, `direct-${status}`, async () => + failedReplayResponse(status), + ); + const lookup = await getSourceCreationResolution(`lookup-${status}`, async () => + failedReplayResponse(status), + ); + const seal = await sealMissingSourceCreation(`seal-${status}`, async () => + failedReplayResponse(status), + ); + + expect(direct.ok).toBe(false); + expect(direct.replayProvenance).toBe("invalid"); + for (const result of [lookup, seal]) { + expect(result).toMatchObject({ + ok: false, + error: { code: "invalid_response" }, + }); + } + } + + for (const status of authoritativeStatuses) { + const direct = await createGraphqlSource(input, `direct-${status}`, async () => + failedReplayResponse(status), + ); + const lookup = await getSourceCreationResolution(`lookup-${status}`, async () => + failedReplayResponse(status), + ); + const seal = await sealMissingSourceCreation(`seal-${status}`, async () => + failedReplayResponse(status), + ); + + expect(direct.ok).toBe(false); + expect(direct.replayProvenance).toBe("authoritative"); + for (const result of [lookup, seal]) { + expect(result).toMatchObject({ + ok: true, + value: { + kind: "replay", + result: { ok: false, error: { code: "internal_error", status } }, + }, + }); + } + } + }); + + it("seals with no body and validates the in-progress retry contract", async () => { + document.cookie = "executor_csrf=seal_csrf; Path=/"; + const result = await sealMissingSourceCreation("seal-key", async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/idempotency/seal"); + expect(init?.method).toBe("POST"); + expect(init?.body).toBeUndefined(); + expect(init?.credentials).toBe("same-origin"); + expect(init?.cache).toBe("no-store"); + const headers = new Headers(init?.headers); + expect(headers.get("idempotency-key")).toBe("seal-key"); + expect(headers.get("x-executor-csrf")).toBe("seal_csrf"); + expect(headers.has("content-type")).toBe(false); + return Response.json( + { + error: { + code: "idempotency_in_progress", + message: "Source creation is still in progress.", + requestId: "seal-request", + }, + }, + { + status: 409, + headers: { "cache-control": "no-store", "retry-after": "1" }, + }, + ); + }); + + expect(result).toEqual({ ok: true, value: { kind: "status", status: "in_progress" } }); + }); + + it("normalizes terminal seal races without treating them as transport failures", async () => { + const result = await sealMissingSourceCreation("expired-key", async () => + Response.json( + { + error: { + code: "idempotency_expired_unknown", + message: "The record expired.", + requestId: "expired-request", + }, + }, + { status: 410, headers: { "cache-control": "no-store" } }, + ), + ); + + expect(result).toEqual({ + ok: true, + value: { kind: "status", status: "expired_unknown" }, + }); + }); + + it("rejects malformed source creation status metadata", async () => { + const missingNoStore = await getSourceCreationResolution("key", async () => + Response.json({ status: "missing" }), + ); + const unknownStatus = await getSourceCreationResolution("key", async () => + Response.json({ status: "completed" }, { headers: { "cache-control": "no-store" } }), + ); + const invalidReplayHeader = await getSourceCreationResolution("key", async () => + Response.json(sourceFixture(), { + status: 201, + headers: { + "cache-control": "no-store", + "idempotency-replayed": "false", + }, + }), + ); + const invalidReplayStatus = await getSourceCreationResolution("key", async () => + Response.json(sourceFixture(), { + status: 200, + headers: { + "cache-control": "no-store", + "idempotency-replayed": "true", + }, + }), + ); + const missingRetryAfter = await sealMissingSourceCreation("key", async () => + Response.json( + { + error: { + code: "idempotency_in_progress", + message: "Still running.", + requestId: "request", + }, + }, + { status: 409, headers: { "cache-control": "no-store" } }, + ), + ); + + for (const result of [ + missingNoStore, + unknownStatus, + invalidReplayHeader, + invalidReplayStatus, + missingRetryAfter, + ]) { + expect(result.ok).toBe(false); + expect(!result.ok && result.error.code).toBe("invalid_response"); + } + }); + + it("rejects excess properties in lookup and seal status responses", async () => { + const lookup = await getSourceCreationResolution("key", async () => + Response.json( + { status: "missing", outcome: "completed" }, + { headers: { "cache-control": "no-store" } }, + ), + ); + const seal = await sealMissingSourceCreation("key", async () => + Response.json( + { status: "missing", outcome: "completed" }, + { headers: { "cache-control": "no-store" } }, + ), + ); + + for (const result of [lookup, seal]) { + expect(result.ok).toBe(false); + expect(!result.ok && result.error.code).toBe("invalid_response"); + } + }); + + it("keeps token list responses masked", async () => { + const tokens = await listTokens(async () => + Response.json({ + tokens: [ + { + id: "token-id", + name: "Laptop", + maskedToken: "exr_abcd...wxyz", + createdAt: 123, + lastUsedAt: null, + revokedAt: null, + }, + ], + }), + ); + + expect(tokens).toEqual({ + ok: true, + value: [ + { + id: "token-id", + name: "Laptop", + maskedToken: "exr_abcd...wxyz", + createdAt: 123, + lastUsedAt: null, + revokedAt: null, + }, + ], + }); + }); + + it("preserves the stable server error envelope", async () => { + const failure = await getBootstrap(async () => + Response.json( + { + error: { + code: "unauthorized", + message: "An administrator session is required.", + requestId: "request-body", + }, + }, + { status: 401, headers: { "x-request-id": "request-header" } }, + ), + ); + + expect(failure).toEqual({ + ok: false, + error: new ApiError({ + code: "unauthorized", + displayMessage: "An administrator session is required.", + requestId: "request-body", + status: 401, + }), + }); + }); + + it("uses a response request ID when an error body is malformed", async () => { + const failure = await getBootstrap( + async () => + new Response("upstream exploded", { + status: 502, + headers: { "x-request-id": "request-header" }, + }), + ); + + expect(failure).toEqual({ + ok: false, + error: new ApiError({ + code: "http_502", + displayMessage: "Executor could not complete the request.", + requestId: "request-header", + status: 502, + }), + }); + }); + + it("reports successful payloads that drift from the Rust DTO", async () => { + const failure = await getBootstrap(async () => + Response.json( + { setupRequired: "yes", authenticated: false }, + { headers: { "x-request-id": "request-invalid" } }, + ), + ); + + expect(failure).toEqual({ + ok: false, + error: new ApiError({ + code: "invalid_response", + displayMessage: "Executor returned a response the dashboard could not understand.", + requestId: "request-invalid", + status: 502, + }), + }); + }); + + it("strictly decodes source and tool catalog snapshots", async () => { + const sources = await listSources(async () => + Response.json({ sources: [sourceFixture()], catalogRevision: 12 }), + ); + const tools = await listTools( + { + query: "repos & teams", + sourceId: "source/one", + mode: "ask", + includeTombstoned: true, + limit: 50, + offset: 100, + }, + async (input) => { + expect(String(input)).toBe( + "/api/v1/tools?query=repos+%26+teams&sourceId=source%2Fone&mode=ask&includeTombstoned=true&limit=50&offset=100", + ); + return Response.json({ + items: [toolFixture()], + total: 1, + hasMore: false, + nextOffset: null, + catalogRevision: 12, + }); + }, + ); + + expect(sources.ok && sources.value.sources[0]?.kind).toBe("openapi"); + expect(tools.ok && tools.value.items[0]?.effectiveMode.provenance).toBe("intrinsic"); + }); + + it("rejects catalog enum drift instead of trusting response JSON", async () => { + const source = sourceFixture(); + const result = await listSources(async () => + Response.json( + { sources: [{ ...source, healthStatus: "mostly_fine" }], catalogRevision: 12 }, + { headers: { "x-request-id": "request-invalid-catalog" } }, + ), + ); + + expect(result).toEqual({ + ok: false, + error: new ApiError({ + code: "invalid_response", + displayMessage: "Executor returned a response the dashboard could not understand.", + requestId: "request-invalid-catalog", + status: 502, + }), + }); + }); + + it("strips nested private source configuration before it enters browser state", async () => { + const secret = "nested-source-secret"; + const result = await listSources(async () => + Response.json({ + sources: [ + { + ...sourceFixture(), + kind: "mcp_http", + configuration: { + endpoint: "https://mcp.example.test/mcp", + allowPrivateNetwork: false, + sessionId: secret, + query: `token=${secret}`, + headers: { authorization: secret }, + env: { TOKEN: secret }, + stderr: secret, + command: secret, + }, + }, + ], + catalogRevision: 12, + }), + ); + + expect(result.ok && result.value.sources[0]?.configuration).toEqual({ + endpoint: "https://mcp.example.test", + allowPrivateNetwork: false, + }); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("sends source and current-page bulk revisions exactly once", async () => { + const bodies: unknown[] = []; + await setSourceMode("source/1", "ask", 7, async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/mode"); + bodies.push(decodeJson(String(init?.body))); + return Response.json({ ...sourceFixture(), modeOverride: "ask", revision: 8 }); + }); + await bulkSetToolModes(["tool-1", "tool-2"], "disabled", 19, async (_input, init) => { + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + updatedCount: 2, + catalogRevision: 20, + sourceRevisions: { "source-1": 9 }, + }); + }); + + expect(bodies).toEqual([ + { mode: "ask", expectedRevision: 7 }, + { + selection: { + type: "tool_ids", + toolIds: ["tool-1", "tool-2"], + expectedCatalogRevision: 19, + }, + mode: "disabled", + }, + ]); + }); + + it("preserves revision conflicts for review instead of retrying", async () => { + const result = await setSourceMode("source-1", "enabled", 2, async () => + Response.json( + { + error: { + code: "revision_conflict", + message: "The source changed.", + requestId: "request-conflict", + }, + }, + { status: 409 }, + ), + ); + + expect(result).toEqual({ + ok: false, + error: new ApiError({ + code: "revision_conflict", + displayMessage: "The source changed.", + requestId: "request-conflict", + status: 409, + }), + }); + }); + + it("keeps request-log state metadata-only even if a server adds secret fields", async () => { + const secret = "secret-sentinel-never-render"; + const result = await listRequestLogs(null, async (input) => { + expect(String(input)).toBe("/api/v1/request-logs?limit=50"); + return Response.json({ + items: [ + { + ...logFixture(), + requestBody: secret, + responseBody: secret, + headers: { authorization: secret }, + credential: secret, + token: secret, + }, + ], + nextCursor: "older", + }); + }); + + expect(result.ok).toBe(true); + expect(JSON.stringify(result)).not.toContain(secret); + expect(result.ok && Object.keys(result.value.items[0] ?? {})).toEqual([ + "requestId", + "actorApiTokenId", + "surface", + "sourceId", + "toolId", + "pathSnapshot", + "outcome", + "errorCode", + "durationMs", + "approvalId", + "createdAt", + ]); + }); + + it("previews an OpenAPI URL with strict tool metadata", async () => { + let body: unknown; + const result = await previewOpenApiSource( + { type: "url", url: "https://api.example.test/openapi.json" }, + false, + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/openapi/preview"); + body = decodeJson(String(init?.body)); + return Response.json({ + title: "Example API", + description: null, + toolCount: 1, + tools: [ + { + preferredName: "list_widgets", + displayName: "List widgets", + description: "Lists widgets", + intrinsicMode: "enabled", + security: [["bearerAuth"]], + }, + ], + securitySchemes: [ + { + name: "bearerAuth", + credentialType: "bearer", + placement: "header", + supported: true, + oauthFlows: null, + }, + ], + }); + }, + ); + + expect(body).toEqual({ + spec: { type: "url", url: "https://api.example.test/openapi.json" }, + allowPrivateNetwork: false, + }); + expect(result.ok && result.value.tools[0]?.intrinsicMode).toBe("enabled"); + expect(result.ok && result.value.securitySchemes[0]?.name).toBe("bearerAuth"); + }); + + it("imports credentials without accepting secret echoes in the source response", async () => { + const secret = "source-secret-sentinel"; + let body: unknown; + const result = await createOpenApiSource( + { + kind: "openapi", + displayName: "Example API", + spec: { type: "inline", content: "openapi: 3.1.0" }, + credential: { + schemes: { bearerAuth: { type: "bearer", token: secret } }, + }, + }, + "openapi-import-key", + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources"); + expect(init?.method).toBe("POST"); + body = decodeJson(String(init?.body)); + return Response.json({ ...sourceFixture(), credentials: secret }, { status: 201 }); + }, + ); + + expect(JSON.stringify(body)).toContain(secret); + expect(result.ok).toBe(true); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("creates an MCP HTTP source and strips unrecognized connection secrets", async () => { + let body: unknown; + const result = await createMcpHttpSource( + { + kind: "mcp_http", + displayName: "Issue tracker", + description: "Local issue tools", + endpoint: "https://mcp.example.test/rpc?tenant=private", + allowPrivateNetwork: false, + }, + "mcp-http-create-key", + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources"); + body = decodeJson(String(init?.body)); + return Response.json( + { + ...sourceFixture(), + kind: "mcp_http", + displayName: "Issue tracker", + configuration: { + endpoint: "https://mcp.example.test/rpc", + allowPrivateNetwork: false, + }, + sessionId: "upstream-session-secret", + authorization: "Bearer source-secret", + }, + { status: 201 }, + ); + }, + ); + + expect(body).toEqual({ + kind: "mcp_http", + displayName: "Issue tracker", + description: "Local issue tools", + endpoint: "https://mcp.example.test/rpc?tenant=private", + allowPrivateNetwork: false, + }); + expect(result.ok && result.value.kind).toBe("mcp_http"); + expect(JSON.stringify(result)).not.toContain("upstream-session-secret"); + expect(JSON.stringify(result)).not.toContain("source-secret"); + }); + + it("sends every supported initial MCP HTTP credential directly on source create", async () => { + const credentials = [ + { type: "bearer", token: "bearer-secret" }, + { type: "basic", username: "admin", password: "password-secret" }, + { type: "api_key_header", name: "X-Service-Key", value: "header-secret" }, + { type: "oauth_access_token", accessToken: "oauth-secret" }, + ] as const; + const bodies: unknown[] = []; + + for (const credential of credentials) { + await createMcpHttpSource( + { + kind: "mcp_http", + displayName: "Authenticated MCP", + endpoint: "https://mcp.example.test/mcp", + credential, + }, + `mcp-http-credential-${credential.type}`, + async (_input, init) => { + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + ...sourceFixture(), + kind: "mcp_http", + configuration: { + endpoint: "https://mcp.example.test/mcp", + allowPrivateNetwork: false, + }, + }); + }, + ); + } + + expect(bodies).toEqual( + credentials.map((credential) => ({ + kind: "mcp_http", + displayName: "Authenticated MCP", + endpoint: "https://mcp.example.test/mcp", + credential, + })), + ); + }); + + it("creates GraphQL sources with the flattened credential contract and redacted public endpoint", async () => { + const secret = "graphql-query-secret"; + let body: unknown; + const result = await createGraphqlSource( + { + kind: "graphql", + displayName: "Product API", + preferredSlug: "product", + endpoint: `https://api.example.test/graphql?token=${secret}`, + allowPrivateNetwork: false, + credential: { type: "bearer", token: "bearer-secret" }, + }, + "graphql-create-key", + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources"); + body = decodeJson(String(init?.body)); + return Response.json( + { + ...sourceFixture(), + kind: "graphql", + slug: "product", + displayName: "Product API", + configuration: { + endpoint: `https://api.example.test/graphql?token=${secret}`, + allowPrivateNetwork: false, + encryptedEndpoint: secret, + }, + }, + { status: 201 }, + ); + }, + ); + + expect(body).toEqual({ + kind: "graphql", + displayName: "Product API", + preferredSlug: "product", + endpoint: `https://api.example.test/graphql?token=${secret}`, + allowPrivateNetwork: false, + credential: { type: "bearer", token: "bearer-secret" }, + }); + expect(result.ok && result.value.configuration).toEqual({ + endpoint: "https://api.example.test", + allowPrivateNetwork: false, + }); + expect(JSON.stringify(result)).not.toContain(secret); + expect(JSON.stringify(result)).not.toContain("bearer-secret"); + }); + + it("strips path credentials from source endpoints before browser state", async () => { + const pathSecret = "path-secret-never-render"; + const result = await listSources(async () => + Response.json({ + sources: [ + { + ...sourceFixture(), + kind: "graphql", + configuration: { + endpoint: `https://api.example.test/graphql/${pathSecret}`, + allowPrivateNetwork: false, + }, + }, + ], + catalogRevision: 12, + }), + ); + + expect(result.ok && result.value.sources[0]?.configuration.endpoint).toBe( + "https://api.example.test", + ); + expect(JSON.stringify(result)).not.toContain(pathSecret); + }); + + it("lists trusted stdio templates without decoding raw process configuration", async () => { + const secret = "raw-process-secret"; + const result = await listMcpStdioTemplates(async (input) => { + expect(String(input)).toBe("/api/v1/mcp/stdio/templates"); + return Response.json({ + templates: [ + { + name: "github-local", + secretFields: ["GITHUB_TOKEN"], + command: "/usr/local/bin/private-server", + args: ["--token", secret], + env: { TOKEN: secret }, + }, + ], + }); + }); + + expect(result).toEqual({ + ok: true, + value: { templates: [{ name: "github-local", secretFields: ["GITHUB_TOKEN"] }] }, + }); + expect(JSON.stringify(result)).not.toContain(secret); + expect(JSON.stringify(result)).not.toContain("private-server"); + }); + + it("creates a trusted stdio source without accepting a secret echo", async () => { + const secret = " whitespace-sensitive-secret "; + let body: unknown; + const result = await createMcpStdioSource( + { + kind: "mcp_stdio", + displayName: "Local GitHub", + templateName: "github-local", + secretValues: { GITHUB_TOKEN: secret }, + }, + "mcp-stdio-create-key", + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources"); + expect(init?.method).toBe("POST"); + body = decodeJson(String(init?.body)); + return Response.json({ + ...sourceFixture(), + kind: "mcp_stdio", + configuration: { templateName: "github-local" }, + secretValues: { GITHUB_TOKEN: secret }, + }); + }, + ); + + expect(body).toEqual({ + kind: "mcp_stdio", + displayName: "Local GitHub", + templateName: "github-local", + secretValues: { GITHUB_TOKEN: secret }, + }); + expect(result.ok && result.value.configuration).toEqual({ templateName: "github-local" }); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("reads MCP credential metadata and sends protocol-specific CAS replacements", async () => { + const reads = await getSourceCredentials("mcp/source", async (input) => { + expect(String(input)).toBe("/api/v1/sources/mcp%2Fsource/credentials"); + return Response.json({ + revision: 7, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }); + }); + const bodies: unknown[] = []; + await putMcpHttpCredentials( + "http-source", + 4, + { + credential: { type: "api_key_header", name: "X-Service-Key", value: "secret" }, + }, + async (_input, init) => { + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + revision: 5, + configuredSchemes: [{ name: "authorization", credentialType: "api_key_header" }], + }); + }, + ); + await putMcpStdioCredentials( + "stdio-source", + 7, + { secretValues: { TOKEN: " exact secret " } }, + async (_input, init) => { + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + revision: 8, + configuredSchemes: [{ name: "TOKEN", credentialType: "secret_env" }], + }); + }, + ); + + expect(reads.ok && reads.value.revision).toBe(7); + expect(bodies).toEqual([ + { + expectedRevision: 4, + credential: { + credential: { type: "api_key_header", name: "X-Service-Key", value: "secret" }, + }, + }, + { + expectedRevision: 7, + credential: { secretValues: { TOKEN: " exact secret " } }, + }, + ]); + }); + + it("sends GraphQL replacement and clear credentials without an extra envelope", async () => { + const bodies: unknown[] = []; + await putGraphqlCredentials( + "graphql/source", + 7, + { type: "api_key_header", name: "X-Service-Key", value: " exact secret " }, + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/graphql%2Fsource/credentials"); + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + revision: 8, + configuredSchemes: [{ name: "default", credentialType: "api_key_header" }], + }); + }, + ); + await putGraphqlCredentials("graphql/source", 8, null, async (_input, init) => { + bodies.push(decodeJson(String(init?.body))); + return Response.json({ revision: 9, configuredSchemes: [] }); + }); + + expect(bodies).toEqual([ + { + expectedRevision: 7, + credential: { + type: "api_key_header", + name: "X-Service-Key", + value: " exact secret ", + }, + }, + { expectedRevision: 8, credential: null }, + ]); + }); + + it("strictly decodes managed OAuth metadata and sends credential-keyed CAS operations", async () => { + const secret = "oauth-secret-never-decode"; + const connection = { + id: "connection-1", + credentialKey: "oauth/scheme", + revision: 4, + status: "connected", + issuer: "https://identity.example.test/", + clientId: "executor-client", + clientAuthMethod: "client_secret_basic", + callbackUrl: "https://executor.example.test/api/v1/oauth/callback/connection-1", + requestedScopes: ["read"], + grantedScopes: ["read"], + hasClientSecret: true, + hasRefreshToken: true, + accessExpiresAt: 900, + authorizedAt: 100, + lastRefreshedAt: 200, + errorCode: null, + managedOAuthEligible: true, + } as const; + const listed = await listOAuthConnections("source/1", async (input) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/oauth"); + return Response.json({ + connections: [ + { + ...connection, + managedOAuthEligible: false, + accessToken: secret, + clientSecret: secret, + }, + ], + availableCredentials: [ + { + credentialKey: "oauth/scheme", + protocol: "openapi", + requestedScopes: ["read"], + managedOAuthEligible: true, + providerMetadata: secret, + }, + ], + tokenResponse: secret, + }); + }); + expect(listed.ok).toBe(true); + expect(listed.ok && listed.value.connections[0]?.managedOAuthEligible).toBe(false); + expect(JSON.stringify(listed)).not.toContain(secret); + + const bodies: unknown[] = []; + const saveInput = { + expectedRevision: 4, + discovery: { type: "issuer" as const, issuer: "https://identity.example.test/" }, + client: { + clientId: "executor-client", + authentication: "client_secret_basic" as const, + clientSecret: { action: "preserve" as const }, + }, + scopes: ["read"], + }; + await putOAuthConnection("source/1", "oauth/scheme", saveInput, async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/oauth/oauth%2Fscheme"); + bodies.push(decodeJson(String(init?.body))); + return Response.json({ ...connection, revision: 5 }); + }); + await authorizeOAuthConnection( + "source/1", + "oauth/scheme", + { expectedRevision: 5 }, + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/oauth/oauth%2Fscheme/authorize"); + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + authorizationUrl: "https://identity.example.test/authorize?state=opaque", + state: secret, + }); + }, + ); + const disconnected = await disconnectOAuthConnection( + "source/1", + "oauth/scheme", + { expectedRevision: 5 }, + async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/oauth/oauth%2Fscheme/disconnect"); + bodies.push(decodeJson(String(init?.body))); + return Response.json({ + ...connection, + revision: 6, + status: "ready_to_connect", + managedOAuthEligible: false, + }); + }, + ); + expect(disconnected.ok && disconnected.value.managedOAuthEligible).toBe(false); + await deleteOAuthConnection( + "source/1", + "oauth/scheme", + { expectedRevision: 6 }, + async (input, init) => { + expect(String(input)).toBe( + "/api/v1/sources/source%2F1/oauth/oauth%2Fscheme?expectedRevision=6", + ); + expect(init?.method).toBe("DELETE"); + return new Response(null, { status: 204 }); + }, + ); + expect(bodies).toEqual([saveInput, { expectedRevision: 5 }, { expectedRevision: 5 }]); + }); + + it("decodes OpenAPI refresh counts", async () => { + const result = await refreshOpenApiSource("source/1", async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/refresh"); + expect(init?.body).toBe("{}"); + return Response.json({ + sourceId: "source/1", + sourceRevision: 4, + catalogRevision: 9, + globalRevision: 13, + activeToolCount: 6, + tombstonedToolCount: 2, + }); + }); + + expect(result.ok && result.value.activeToolCount).toBe(6); + }); + + it("reads and replaces only credential metadata and sends CAS revisions", async () => { + const metadata = { + revision: 4, + configuredSchemes: [{ name: "bearerAuth", credentialType: "bearer" }], + }; + const read = await getOpenApiCredentials("source-1", async () => Response.json(metadata)); + let putBody: unknown; + const replaced = await putOpenApiCredentials( + "source-1", + 4, + { oauth: { type: "oauth_access_token", access_token: "secret-token" } }, + async (_input, init) => { + putBody = decodeJson(String(init?.body)); + return Response.json({ + revision: 5, + configuredSchemes: [{ name: "oauth", credentialType: "manual_oauth_access_token" }], + }); + }, + ); + + expect(read).toEqual({ ok: true, value: metadata }); + expect(putBody).toEqual({ + expectedRevision: 4, + credential: { + schemes: { oauth: { type: "oauth_access_token", access_token: "secret-token" } }, + }, + }); + expect(replaced.ok && replaced.value.revision).toBe(5); + }); + + it("clears credentials with an encoded CAS query", async () => { + const result = await deleteOpenApiCredentials("source/1", 5, async (input, init) => { + expect(String(input)).toBe("/api/v1/sources/source%2F1/credentials?expectedRevision=5"); + expect(init?.method).toBe("DELETE"); + return Response.json({ revision: 6, configuredSchemes: [] }); + }); + + expect(result.ok && result.value.configuredSchemes).toEqual([]); + }); + + it("lists and reads strict approval DTOs without retaining secret extras", async () => { + const secret = "approval-secret-sentinel"; + const list = await listApprovals( + { status: "pending", cursor: "older/page", limit: 50 }, + async (input) => { + expect(String(input)).toBe("/api/v1/approvals?limit=50&status=pending&cursor=older%2Fpage"); + return Response.json({ + items: [ + { + ...approvalSummaryFixture(), + sourceDisplayName: null, + toolDisplayName: null, + actorKind: "system", + actorId: "local_cli", + actorName: null, + actorLabel: "Local CLI", + actorApiTokenId: null, + actorTokenName: null, + rawArguments: { password: secret }, + encryptedArguments: secret, + credential: secret, + }, + ], + nextCursor: "next", + internalKey: secret, + }); + }, + ); + const detail = await getApproval("approval/1", async (input) => { + expect(String(input)).toBe("/api/v1/approvals/approval%2F1"); + return Response.json({ + ...approvalDetailFixture(), + rawArguments: { password: secret }, + encryptedArguments: secret, + result: secret, + }); + }); + + expect(list.ok).toBe(true); + expect(detail.ok).toBe(true); + expect(JSON.stringify(list)).not.toContain(secret); + expect(JSON.stringify(detail)).not.toContain(secret); + expect(list.ok && list.value.items[0]?.sourceDisplayName).toBeNull(); + expect(list.ok && list.value.items[0]?.toolDisplayName).toBeNull(); + expect(list.ok && list.value.items[0]?.actorTokenName).toBeNull(); + expect(list.ok && list.value.items[0]?.actorKind).toBe("system"); + expect(list.ok && list.value.items[0]?.actorLabel).toBe("Local CLI"); + expect(list.ok && list.value.items[0]?.actorApiTokenId).toBeNull(); + expect(detail.ok && detail.value.redactedArguments).toEqual({ + title: "Issue title", + body: "[REDACTED]", + }); + }); + + it("omits the approval status parameter for an all-status list", async () => { + await listApprovals({ status: null, cursor: null, limit: 50 }, async (input) => { + expect(String(input)).toBe("/api/v1/approvals?limit=50"); + return Response.json({ items: [], nextCursor: null }); + }); + }); + + it("sends approval decisions once with the viewed CAS revision", async () => { + document.cookie = "executor_csrf=approval_csrf; Path=/"; + let calls = 0; + let body: unknown; + let csrf: string | null = null; + const result = await decideApproval("approval/1", "approve", 4, async (input, init) => { + calls += 1; + expect(String(input)).toBe("/api/v1/approvals/approval%2F1/decision"); + expect(init?.method).toBe("POST"); + body = decodeJson(String(init?.body)); + csrf = new Headers(init?.headers).get("x-executor-csrf"); + return Response.json({ + ...approvalDetailFixture(), + status: "approved", + revision: 5, + decidedAt: 150, + }); + }); + + expect(calls).toBe(1); + expect(body).toEqual({ decision: "approve", expectedRevision: 4 }); + expect(csrf).toBe("approval_csrf"); + expect(result.ok && result.value.status).toBe("approved"); + }); + + it("preserves an approval conflict for refetch and review without retrying", async () => { + let calls = 0; + const result = await decideApproval("approval-1", "deny", 4, async () => { + calls += 1; + return Response.json( + { + error: { + code: "revision_conflict", + message: "The approval changed.", + requestId: "request-conflict", + }, + }, + { status: 409 }, + ); + }); + + expect(calls).toBe(1); + expect(result).toEqual({ + ok: false, + error: new ApiError({ + code: "revision_conflict", + displayMessage: "The approval changed.", + requestId: "request-conflict", + status: 409, + }), + }); + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 000000000..e6827b14d --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,1676 @@ +import { Option, Schema } from "effect"; +import type { GraphqlCredential, GraphqlSourceInput } from "$lib/graphql-source-state"; +import type { + OAuthAuthorization, + OAuthConnectionInput, + OAuthConnectionList, + OAuthConnectionSummary, +} from "$lib/oauth-connection-state"; + +export type { GraphqlCredential, GraphqlSourceInput } from "$lib/graphql-source-state"; +export type { + OAuthAuthorization, + OAuthAvailableCredential, + OAuthConnectionInput, + OAuthConnectionList, + OAuthConnectionSummary, +} from "$lib/oauth-connection-state"; + +const BootstrapSchema = Schema.Struct({ + setupRequired: Schema.Boolean, + authenticated: Schema.Boolean, +}); + +const SessionSchema = Schema.Struct({ + username: Schema.String, + csrfToken: Schema.NullOr(Schema.String), +}); + +const TokenMetadataSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + maskedToken: Schema.String, + createdAt: Schema.Number, + lastUsedAt: Schema.NullOr(Schema.Number), + revokedAt: Schema.NullOr(Schema.Number), +}); + +const CreatedTokenSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + token: Schema.String, + createdAt: Schema.Number, +}); + +const TokenListSchema = Schema.Struct({ + tokens: Schema.Array(TokenMetadataSchema), +}); + +const ToolModeSchema = Schema.Literals(["enabled", "ask", "disabled"]); +const ModeProvenanceSchema = Schema.Literals(["tool_override", "source_override", "intrinsic"]); +const SourcePublicConfigurationSchema = Schema.Struct({ + endpoint: Schema.optional(Schema.String), + allowPrivateNetwork: Schema.optional(Schema.Boolean), + templateName: Schema.optional(Schema.String), + negotiatedProtocolVersion: Schema.optional(Schema.String), +}); + +const SourceSchema = Schema.Struct({ + id: Schema.String, + kind: Schema.Literals(["openapi", "graphql", "mcp_http", "mcp_stdio"]), + slug: Schema.String, + displayName: Schema.String, + description: Schema.NullOr(Schema.String), + configuration: SourcePublicConfigurationSchema, + modeOverride: Schema.NullOr(ToolModeSchema), + healthStatus: Schema.Literals(["unknown", "healthy", "error"]), + healthErrorCode: Schema.NullOr(Schema.String), + revision: Schema.Number, + catalogRevision: Schema.Number, + createdAt: Schema.Number, + updatedAt: Schema.Number, + lastRefreshedAt: Schema.NullOr(Schema.Number), + toolCount: Schema.Number, + tombstonedToolCount: Schema.Number, +}); + +const SourceListSchema = Schema.Struct({ + sources: Schema.Array(SourceSchema), + catalogRevision: Schema.Number, +}); + +const SourceCreationStatusSchema = Schema.Struct({ + status: Schema.Literals([ + "missing", + "in_progress", + "abandoned", + "interrupted", + "expired_unknown", + ]), +}); + +const OAuthFlowSchema = Schema.Struct({ + authorizationUrl: Schema.NullOr(Schema.String), + tokenUrl: Schema.NullOr(Schema.String), + refreshUrl: Schema.NullOr(Schema.String), + scopes: Schema.Record(Schema.String, Schema.String), +}); + +const OAuthFlowsSchema = Schema.Struct({ + implicit: Schema.NullOr(OAuthFlowSchema), + password: Schema.NullOr(OAuthFlowSchema), + clientCredentials: Schema.NullOr(OAuthFlowSchema), + authorizationCode: Schema.NullOr(OAuthFlowSchema), +}); + +const OpenApiCredentialTypeSchema = Schema.Literals([ + "api_key", + "bearer", + "basic", + "manual_oauth_access_token", + "http", + "mutual_tls", +]); + +const OpenApiPreviewSchema = Schema.Struct({ + title: Schema.String, + description: Schema.NullOr(Schema.String), + toolCount: Schema.Number, + tools: Schema.Array( + Schema.Struct({ + preferredName: Schema.String, + displayName: Schema.String, + description: Schema.NullOr(Schema.String), + intrinsicMode: ToolModeSchema, + security: Schema.Array(Schema.Array(Schema.String)), + }), + ), + securitySchemes: Schema.Array( + Schema.Struct({ + name: Schema.String, + credentialType: OpenApiCredentialTypeSchema, + placement: Schema.NullOr(Schema.Literals(["header", "query", "cookie", "path"])), + supported: Schema.Boolean, + oauthFlows: Schema.NullOr(OAuthFlowsSchema), + }), + ), +}); + +const CredentialMetadataSchema = Schema.Struct({ + revision: Schema.Number, + configuredSchemes: Schema.Array( + Schema.Struct({ + name: Schema.String, + credentialType: Schema.Literals([ + "api_key", + "bearer", + "basic", + "manual_oauth_access_token", + "secret_env", + "header", + "api_key_header", + "oauth_access_token", + ]), + }), + ), +}); + +const McpStdioTemplateListSchema = Schema.Struct({ + templates: Schema.Array( + Schema.Struct({ + name: Schema.String, + secretFields: Schema.Array(Schema.String), + }), + ), +}); + +const OAuthConnectionStatusSchema = Schema.Literals([ + "not_configured", + "ready_to_connect", + "connecting", + "connected", + "reauthorization_required", + "error", +]); + +const OAuthConnectionSummarySchema = Schema.Struct({ + id: Schema.String, + credentialKey: Schema.String, + revision: Schema.Number, + status: OAuthConnectionStatusSchema, + issuer: Schema.String, + clientId: Schema.String, + clientAuthMethod: Schema.Literals(["none", "client_secret_basic", "client_secret_post"]), + callbackUrl: Schema.String, + requestedScopes: Schema.Array(Schema.String), + grantedScopes: Schema.Array(Schema.String), + hasClientSecret: Schema.Boolean, + hasRefreshToken: Schema.Boolean, + accessExpiresAt: Schema.NullOr(Schema.Number), + authorizedAt: Schema.NullOr(Schema.Number), + lastRefreshedAt: Schema.NullOr(Schema.Number), + errorCode: Schema.NullOr(Schema.String), + managedOAuthEligible: Schema.Boolean, +}); + +const OAuthAvailableCredentialSchema = Schema.Struct({ + credentialKey: Schema.String, + protocol: Schema.Literals(["openapi", "graphql", "mcp_http"]), + requestedScopes: Schema.Array(Schema.String), + managedOAuthEligible: Schema.Literal(true), +}); + +const OAuthConnectionListSchema = Schema.Struct({ + connections: Schema.Array(OAuthConnectionSummarySchema), + availableCredentials: Schema.Array(OAuthAvailableCredentialSchema), +}); + +const OAuthAuthorizationSchema = Schema.Struct({ authorizationUrl: Schema.String }); + +const CatalogSyncResultSchema = Schema.Struct({ + sourceId: Schema.String, + sourceRevision: Schema.Number, + catalogRevision: Schema.Number, + globalRevision: Schema.Number, + activeToolCount: Schema.Number, + tombstonedToolCount: Schema.Number, +}); + +const EffectiveModeSchema = Schema.Struct({ + mode: ToolModeSchema, + provenance: ModeProvenanceSchema, +}); + +const ToolSummarySchema = Schema.Struct({ + id: Schema.String, + sourceId: Schema.String, + sourceSlug: Schema.String, + stableKey: Schema.String, + localName: Schema.String, + callablePath: Schema.String, + sandboxPath: Schema.String, + displayName: Schema.String, + description: Schema.NullOr(Schema.String), + intrinsicMode: ToolModeSchema, + modeOverride: Schema.NullOr(ToolModeSchema), + effectiveMode: EffectiveModeSchema, + present: Schema.Boolean, + revision: Schema.Number, + createdAt: Schema.Number, + updatedAt: Schema.Number, + lastSeenAt: Schema.Number, + tombstonedAt: Schema.NullOr(Schema.Number), +}); + +const ToolRecordSchema = Schema.Struct({ + ...ToolSummarySchema.fields, + inputSchema: Schema.Unknown, + outputSchema: Schema.NullOr(Schema.Unknown), + inputTypescript: Schema.NullOr(Schema.String), + outputTypescript: Schema.NullOr(Schema.String), + typescriptDefinitions: Schema.Record(Schema.String, Schema.String), +}); + +const ToolPageSchema = Schema.Struct({ + items: Schema.Array(ToolSummarySchema), + total: Schema.Number, + hasMore: Schema.Boolean, + nextOffset: Schema.NullOr(Schema.Number), + catalogRevision: Schema.Number, +}); + +const BulkToolModeResultSchema = Schema.Struct({ + updatedCount: Schema.Number, + catalogRevision: Schema.Number, + sourceRevisions: Schema.Record(Schema.String, Schema.Number), +}); + +const RequestLogSchema = Schema.Struct({ + requestId: Schema.String, + actorApiTokenId: Schema.NullOr(Schema.String), + surface: Schema.Literals(["admin", "gateway", "cli", "mcp"]), + sourceId: Schema.NullOr(Schema.String), + toolId: Schema.NullOr(Schema.String), + pathSnapshot: Schema.NullOr(Schema.String), + outcome: Schema.Literals(["succeeded", "failed", "pending_approval", "denied"]), + errorCode: Schema.NullOr(Schema.String), + durationMs: Schema.Number, + approvalId: Schema.NullOr(Schema.String), + createdAt: Schema.Number, +}); + +const RequestLogPageSchema = Schema.Struct({ + items: Schema.Array(RequestLogSchema), + nextCursor: Schema.NullOr(Schema.String), +}); + +const ApprovalStatusSchema = Schema.Literals([ + "pending", + "approved", + "executing", + "succeeded", + "failed", + "denied", + "canceled", + "expired", + "stale", + "interrupted", +]); + +const ApprovalSummarySchema = Schema.Struct({ + id: Schema.String, + status: ApprovalStatusSchema, + revision: Schema.Number, + sourceId: Schema.String, + toolId: Schema.String, + path: Schema.String, + sourceDisplayName: Schema.NullOr(Schema.String), + toolDisplayName: Schema.NullOr(Schema.String), + actorKind: Schema.Literals(["api_token", "admin", "system"]), + actorId: Schema.String, + actorName: Schema.NullOr(Schema.String), + actorLabel: Schema.String, + actorApiTokenId: Schema.NullOr(Schema.String), + actorTokenName: Schema.NullOr(Schema.String), + surface: Schema.Literals(["gateway", "cli", "mcp"]), + mode: Schema.Literal("ask"), + provenance: ModeProvenanceSchema, + executionId: Schema.String, + callId: Schema.String, + createdAt: Schema.Number, + updatedAt: Schema.Number, + expiresAt: Schema.Number, + decidedAt: Schema.NullOr(Schema.Number), + startedAt: Schema.NullOr(Schema.Number), + completedAt: Schema.NullOr(Schema.Number), + decision: Schema.NullOr(Schema.Literals(["approve", "deny"])), + failureCode: Schema.NullOr(Schema.String), +}); + +const ApprovalDetailSchema = Schema.Struct({ + ...ApprovalSummarySchema.fields, + redactedArguments: Schema.Unknown, + inputSchema: Schema.Unknown, +}); + +const ApprovalPageSchema = Schema.Struct({ + items: Schema.Array(ApprovalSummarySchema), + nextCursor: Schema.NullOr(Schema.String), +}); + +const ErrorEnvelopeSchema = Schema.Struct({ + error: Schema.Struct({ + code: Schema.String, + message: Schema.String, + requestId: Schema.String, + }), +}); + +export type Bootstrap = typeof BootstrapSchema.Type; +export type Session = typeof SessionSchema.Type; +export type TokenMetadata = typeof TokenMetadataSchema.Type; +export type CreatedToken = typeof CreatedTokenSchema.Type; +export type ToolMode = typeof ToolModeSchema.Type; +export type Source = typeof SourceSchema.Type; +export type SourceList = typeof SourceListSchema.Type; +export type SourceCreationStatus = (typeof SourceCreationStatusSchema.Type)["status"]; +export type SourceCreationResolution = + | { readonly kind: "status"; readonly status: SourceCreationStatus } + | { readonly kind: "replay"; readonly result: ApiResult }; +export type OpenApiPreview = typeof OpenApiPreviewSchema.Type; +export type CatalogSyncResult = typeof CatalogSyncResultSchema.Type; +export type OpenApiCredentialMetadata = typeof CredentialMetadataSchema.Type; +export type McpStdioTemplate = (typeof McpStdioTemplateListSchema.Type)["templates"][number]; +export type ToolSummary = typeof ToolSummarySchema.Type; +export type ToolRecord = typeof ToolRecordSchema.Type; +export type ToolPage = typeof ToolPageSchema.Type; +export type BulkToolModeResult = typeof BulkToolModeResultSchema.Type; +export type RequestLog = typeof RequestLogSchema.Type; +export type RequestLogPage = typeof RequestLogPageSchema.Type; +export type ApprovalStatus = typeof ApprovalStatusSchema.Type; +export type ApprovalSummary = typeof ApprovalSummarySchema.Type; +export type ApprovalDetail = typeof ApprovalDetailSchema.Type; +export type ApprovalPage = typeof ApprovalPageSchema.Type; +export type ApprovalDecision = "approve" | "deny"; + +export class ApiError extends Schema.TaggedErrorClass()("ApiError", { + code: Schema.String, + displayMessage: Schema.String, + requestId: Schema.NullOr(Schema.String), + status: Schema.Number, +}) {} + +export type ApiResult = + | { readonly ok: true; readonly value: Value } + | { readonly ok: false; readonly error: ApiError }; + +export type TokenCreateApiResult = + | { + readonly ok: true; + readonly value: CreatedToken; + readonly replayProvenance: "none" | "authoritative" | "invalid"; + readonly responseDisposition: "authoritative" | "ambiguous"; + } + | { + readonly ok: false; + readonly error: ApiError; + readonly replayProvenance: "none" | "authoritative" | "invalid"; + readonly responseDisposition: "authoritative" | "ambiguous"; + }; + +export type SourceCreateApiResult = + | { + readonly ok: true; + readonly value: Source; + readonly replayProvenance: "none" | "authoritative" | "invalid"; + readonly responseDisposition: "authoritative" | "ambiguous"; + } + | { + readonly ok: false; + readonly error: ApiError; + readonly replayProvenance: "none" | "authoritative" | "invalid"; + readonly responseDisposition: "authoritative" | "ambiguous"; + }; + +type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise; +type ResponsePayload = { text: string; requestId: string | null }; + +const decodeBootstrap = Schema.decodeUnknownOption(Schema.fromJsonString(BootstrapSchema)); +const decodeSession = Schema.decodeUnknownOption(Schema.fromJsonString(SessionSchema)); +const decodeCreatedToken = Schema.decodeUnknownOption(Schema.fromJsonString(CreatedTokenSchema)); +const decodeTokenList = Schema.decodeUnknownOption(Schema.fromJsonString(TokenListSchema)); +const decodeRawSource = Schema.decodeUnknownOption(Schema.fromJsonString(SourceSchema)); +const decodeRawSourceList = Schema.decodeUnknownOption(Schema.fromJsonString(SourceListSchema)); +const decodeSourceCreationStatusJson = Schema.decodeUnknownOption( + Schema.fromJsonString(SourceCreationStatusSchema), +); +const decodeSourceCreationStatus = (text: string) => + decodeSourceCreationStatusJson(text, { onExcessProperty: "error" }); +const decodeSource = (text: string) => sanitizeDecodedSource(decodeRawSource(text)); +const decodeSourceList = (text: string) => { + const decoded = decodeRawSourceList(text); + if (Option.isNone(decoded)) return decoded; + return Option.some({ + ...decoded.value, + sources: decoded.value.sources.map(sanitizeSource), + }); +}; +const decodeOpenApiPreview = Schema.decodeUnknownOption( + Schema.fromJsonString(OpenApiPreviewSchema), +); +const decodeCatalogSyncResult = Schema.decodeUnknownOption( + Schema.fromJsonString(CatalogSyncResultSchema), +); +const decodeCredentialMetadata = Schema.decodeUnknownOption( + Schema.fromJsonString(CredentialMetadataSchema), +); +const decodeMcpStdioTemplateList = Schema.decodeUnknownOption( + Schema.fromJsonString(McpStdioTemplateListSchema), +); +const decodeOAuthConnection = Schema.decodeUnknownOption( + Schema.fromJsonString(OAuthConnectionSummarySchema), +); +const decodeOAuthConnectionList = Schema.decodeUnknownOption( + Schema.fromJsonString(OAuthConnectionListSchema), +); +const decodeOAuthAuthorization = Schema.decodeUnknownOption( + Schema.fromJsonString(OAuthAuthorizationSchema), +); +const decodeToolRecord = Schema.decodeUnknownOption(Schema.fromJsonString(ToolRecordSchema)); +const decodeToolPage = Schema.decodeUnknownOption(Schema.fromJsonString(ToolPageSchema)); +const decodeBulkToolModeResult = Schema.decodeUnknownOption( + Schema.fromJsonString(BulkToolModeResultSchema), +); +const decodeRequestLog = Schema.decodeUnknownOption(Schema.fromJsonString(RequestLogSchema)); +const decodeRequestLogPage = Schema.decodeUnknownOption( + Schema.fromJsonString(RequestLogPageSchema), +); +const decodeApprovalDetail = Schema.decodeUnknownOption( + Schema.fromJsonString(ApprovalDetailSchema), +); +const decodeApprovalPage = Schema.decodeUnknownOption(Schema.fromJsonString(ApprovalPageSchema)); +const decodeErrorEnvelope = Schema.decodeUnknownOption(Schema.fromJsonString(ErrorEnvelopeSchema)); + +export async function getBootstrap(fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request("/api/v1/bootstrap", { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeBootstrap); +} + +export async function setupAdmin( + input: { setupToken: string; username: string; password: string }, + fetcher: Fetcher = fetch, +) { + const response = await request( + "/api/v1/setup", + { method: "POST", body: JSON.stringify(input) }, + fetcher, + false, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSession); +} + +export async function loginAdmin( + input: { username: string; password: string }, + fetcher: Fetcher = fetch, +) { + const response = await request( + "/api/v1/session", + { method: "POST", body: JSON.stringify(input) }, + fetcher, + false, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSession); +} + +export async function getSession(fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request("/api/v1/session", { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSession); +} + +export async function logoutAdmin(fetcher: Fetcher = fetch) { + const response = await request("/api/v1/session", { method: "DELETE" }, fetcher); + if (!response.ok) return response; + return { ok: true, value: undefined } as const; +} + +export async function listTokens(fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request("/api/v1/tokens", { signal }, fetcher); + if (!response.ok) return response; + + const decoded = decodeResponse(response.value, decodeTokenList); + if (!decoded.ok) return decoded; + return { ok: true, value: [...decoded.value.tokens] } as const; +} + +export async function createToken( + name: string, + idempotencyKey: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + return tokenCreateRequest(name, idempotencyKey, fetcher, signal); +} + +export async function revokeToken(tokenId: string, fetcher: Fetcher = fetch, signal?: AbortSignal) { + const revoke = () => + request(`/api/v1/tokens/${encodeURIComponent(tokenId)}`, { method: "DELETE", signal }, fetcher); + const first = await revoke(); + if (!isAmbiguousRevokeResult(first) || signal?.aborted) { + return first.ok ? ({ ok: true, value: undefined } as const) : first; + } + + const second = await revoke(); + return second.ok ? ({ ok: true, value: undefined } as const) : second; +} + +export async function listSources(fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request("/api/v1/sources", { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSourceList); +} + +export async function setSourceMode( + sourceId: string, + mode: ToolMode | null, + expectedRevision: number, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/mode`, + { + method: "PATCH", + body: JSON.stringify({ mode, expectedRevision }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeSource); +} + +export async function deleteSource( + sourceId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}`, + { method: "DELETE", signal }, + fetcher, + ); + if (!response.ok) return response; + return { ok: true, value: undefined } as const; +} + +export type OpenApiSpecInput = + | { readonly type: "inline"; readonly content: string } + | { readonly type: "url"; readonly url: string }; + +export type OpenApiStaticCredential = + | { readonly type: "api_key"; readonly value: string } + | { readonly type: "bearer"; readonly token: string } + | { readonly type: "basic"; readonly username: string; readonly password: string } + | { readonly type: "oauth_access_token"; readonly access_token: string }; + +export type OpenApiSourceInput = { + readonly kind: "openapi"; + readonly displayName: string; + readonly preferredSlug?: string; + readonly description?: string; + readonly spec: OpenApiSpecInput; + readonly allowPrivateNetwork?: boolean; + readonly credential?: { + readonly schemes: Readonly>; + }; +}; + +export type McpHttpSourceInput = { + readonly kind: "mcp_http"; + readonly displayName: string; + readonly description?: string; + readonly endpoint: string; + readonly allowPrivateNetwork?: boolean; + readonly credential?: Exclude; +}; + +export type McpStdioSourceInput = { + readonly kind: "mcp_stdio"; + readonly displayName: string; + readonly description?: string; + readonly templateName: string; + readonly secretValues: Readonly>; +}; + +export type McpHttpCredential = + | { readonly credential: null } + | { readonly credential: { readonly type: "bearer"; readonly token: string } } + | { + readonly credential: { + readonly type: "basic"; + readonly username: string; + readonly password: string; + }; + } + | { + readonly credential: { + readonly type: "api_key_header"; + readonly name: string; + readonly value: string; + }; + } + | { + readonly credential: { + readonly type: "oauth_access_token"; + readonly accessToken: string; + }; + }; + +export type McpStdioCredential = { + readonly secretValues: Readonly>; +}; + +export async function previewOpenApiSource( + spec: OpenApiSpecInput, + allowPrivateNetwork: boolean, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + "/api/v1/sources/openapi/preview", + { method: "POST", body: JSON.stringify({ spec, allowPrivateNetwork }), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeOpenApiPreview); +} + +export async function createOpenApiSource( + input: OpenApiSourceInput, + idempotencyKey: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + return sourceCreateRequest(input, idempotencyKey, fetcher, signal); +} + +export async function createMcpHttpSource( + input: McpHttpSourceInput, + idempotencyKey: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + return sourceCreateRequest(input, idempotencyKey, fetcher, signal); +} + +export async function createGraphqlSource( + input: GraphqlSourceInput, + idempotencyKey: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + return sourceCreateRequest(input, idempotencyKey, fetcher, signal); +} + +export async function listMcpStdioTemplates(fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request("/api/v1/mcp/stdio/templates", { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeMcpStdioTemplateList); +} + +export async function createMcpStdioSource( + input: McpStdioSourceInput, + idempotencyKey: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + return sourceCreateRequest(input, idempotencyKey, fetcher, signal); +} + +export async function getSourceCreationResolution( + idempotencyKey: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + return sourceCreationResolutionRequest( + "/api/v1/sources/idempotency", + "GET", + idempotencyKey, + fetcher, + signal, + ); +} + +export async function sealMissingSourceCreation( + idempotencyKey: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + return sourceCreationResolutionRequest( + "/api/v1/sources/idempotency/seal", + "POST", + idempotencyKey, + fetcher, + signal, + ); +} + +export async function refreshOpenApiSource( + sourceId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/refresh`, + { method: "POST", body: "{}", signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCatalogSyncResult); +} + +export async function refreshSourceCatalog( + sourceId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/refresh`, + { method: "POST", body: "{}", signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCatalogSyncResult); +} + +export async function getOpenApiCredentials( + sourceId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/credentials`, + { signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCredentialMetadata); +} + +export async function getSourceCredentials( + sourceId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/credentials`, + { signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCredentialMetadata); +} + +export async function putMcpHttpCredentials( + sourceId: string, + expectedRevision: number, + credential: McpHttpCredential, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + return putProtocolCredentials(sourceId, expectedRevision, credential, fetcher, signal); +} + +export async function putMcpStdioCredentials( + sourceId: string, + expectedRevision: number, + credential: McpStdioCredential, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + return putProtocolCredentials(sourceId, expectedRevision, credential, fetcher, signal); +} + +export async function putGraphqlCredentials( + sourceId: string, + expectedRevision: number, + credential: GraphqlCredential, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/credentials`, + { + method: "PUT", + body: JSON.stringify({ expectedRevision, credential }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCredentialMetadata); +} + +export async function listOAuthConnections( + sourceId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +): Promise> { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/oauth`, + { signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeOAuthConnectionList); +} + +export async function putOAuthConnection( + sourceId: string, + credentialKey: string, + input: OAuthConnectionInput, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +): Promise> { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/oauth/${encodeURIComponent(credentialKey)}`, + { method: "PUT", body: JSON.stringify(input), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeOAuthConnection); +} + +export async function authorizeOAuthConnection( + sourceId: string, + credentialKey: string, + input: { readonly expectedRevision: number }, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +): Promise> { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/oauth/${encodeURIComponent(credentialKey)}/authorize`, + { method: "POST", body: JSON.stringify(input), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeOAuthAuthorization); +} + +export async function disconnectOAuthConnection( + sourceId: string, + credentialKey: string, + input: { readonly expectedRevision: number }, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +): Promise> { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/oauth/${encodeURIComponent(credentialKey)}/disconnect`, + { method: "POST", body: JSON.stringify(input), signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeOAuthConnection); +} + +export async function deleteOAuthConnection( + sourceId: string, + credentialKey: string, + input: { readonly expectedRevision: number }, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +): Promise> { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/oauth/${encodeURIComponent(credentialKey)}?expectedRevision=${encodeURIComponent(String(input.expectedRevision))}`, + { method: "DELETE", signal }, + fetcher, + ); + if (!response.ok) return response; + return { ok: true, value: undefined }; +} + +export async function putOpenApiCredentials( + sourceId: string, + expectedRevision: number, + schemes: Readonly>, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/credentials`, + { + method: "PUT", + body: JSON.stringify({ expectedRevision, credential: { schemes } }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCredentialMetadata); +} + +export async function deleteOpenApiCredentials( + sourceId: string, + expectedRevision: number, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/credentials?expectedRevision=${encodeURIComponent(String(expectedRevision))}`, + { method: "DELETE", signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCredentialMetadata); +} + +async function putProtocolCredentials( + sourceId: string, + expectedRevision: number, + credential: McpHttpCredential | McpStdioCredential, + fetcher: Fetcher, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/sources/${encodeURIComponent(sourceId)}/credentials`, + { + method: "PUT", + body: JSON.stringify({ expectedRevision, credential }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeCredentialMetadata); +} + +export type ToolListFilters = { + query?: string; + sourceId?: string; + mode?: ToolMode; + includeTombstoned?: boolean; + limit?: number; + offset?: number; +}; + +export async function listTools( + filters: ToolListFilters, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const parameters = new URLSearchParams(); + if (filters.query) parameters.set("query", filters.query); + if (filters.sourceId) parameters.set("sourceId", filters.sourceId); + if (filters.mode) parameters.set("mode", filters.mode); + if (filters.includeTombstoned) parameters.set("includeTombstoned", "true"); + if (filters.limit !== undefined) parameters.set("limit", String(filters.limit)); + if (filters.offset !== undefined) parameters.set("offset", String(filters.offset)); + const response = await request(`/api/v1/tools?${parameters}`, { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeToolPage); +} + +export async function getTool(toolId: string, fetcher: Fetcher = fetch, signal?: AbortSignal) { + const response = await request( + `/api/v1/tools/${encodeURIComponent(toolId)}`, + { signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeToolRecord); +} + +export async function setToolMode( + toolId: string, + mode: ToolMode | null, + expectedRevision: number, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/tools/${encodeURIComponent(toolId)}/mode`, + { + method: "PATCH", + body: JSON.stringify({ mode, expectedRevision }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeToolRecord); +} + +export async function bulkSetToolModes( + toolIds: readonly string[], + mode: ToolMode | null, + expectedCatalogRevision: number, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + "/api/v1/tools/modes", + { + method: "PATCH", + body: JSON.stringify({ + selection: { type: "tool_ids", toolIds, expectedCatalogRevision }, + mode, + }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeBulkToolModeResult); +} + +export async function listRequestLogs( + cursor: string | null, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const parameters = new URLSearchParams({ limit: "50" }); + if (cursor) parameters.set("cursor", cursor); + const response = await request(`/api/v1/request-logs?${parameters}`, { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeRequestLogPage); +} + +export async function getRequestLog( + requestId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/request-logs/${encodeURIComponent(requestId)}`, + { signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeRequestLog); +} + +export async function listApprovals( + filters: { + readonly status: ApprovalStatus | null; + readonly cursor: string | null; + readonly limit: number; + }, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const parameters = new URLSearchParams({ limit: String(filters.limit) }); + if (filters.status !== null) parameters.set("status", filters.status); + if (filters.cursor !== null) parameters.set("cursor", filters.cursor); + const response = await request(`/api/v1/approvals?${parameters}`, { signal }, fetcher); + if (!response.ok) return response; + return decodeResponse(response.value, decodeApprovalPage); +} + +export async function getApproval( + approvalId: string, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/approvals/${encodeURIComponent(approvalId)}`, + { signal }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeApprovalDetail); +} + +export async function decideApproval( + approvalId: string, + decision: ApprovalDecision, + expectedRevision: number, + fetcher: Fetcher = fetch, + signal?: AbortSignal, +) { + const response = await request( + `/api/v1/approvals/${encodeURIComponent(approvalId)}/decision`, + { + method: "POST", + body: JSON.stringify({ decision, expectedRevision }), + signal, + }, + fetcher, + ); + if (!response.ok) return response; + return decodeResponse(response.value, decodeApprovalDetail); +} + +async function tokenCreateRequest( + name: string, + idempotencyKey: string, + fetcher: Fetcher, + signal?: AbortSignal, +): Promise { + const headers = new Headers({ + accept: "application/json", + "content-type": "application/json", + "Idempotency-Key": idempotencyKey, + }); + const csrfToken = readCookie("executor_csrf"); + if (csrfToken !== null) headers.set("x-executor-csrf", csrfToken); + + const fetched = await fetcher("/api/v1/tokens", { + method: "POST", + body: JSON.stringify({ name }), + headers, + credentials: "same-origin", + cache: "no-store", + signal, + }).then( + (response) => ({ ok: true, response }) as const, + () => ({ ok: false }) as const, + ); + if (!fetched.ok) { + return tokenCreateResult( + failure( + signal?.aborted ? "request_cancelled" : "network_error", + signal?.aborted + ? "The request was cancelled." + : "Executor could not be reached. Check that the local server is running.", + null, + 0, + ), + { replayProvenance: "none", responseDisposition: "ambiguous" }, + ); + } + + const { response } = fetched; + const requestId = response.headers.get("x-request-id"); + const body = await response.text().then( + (text) => ({ ok: true, text }) as const, + () => ({ ok: false }) as const, + ); + if (!body.ok) { + return tokenCreateResult( + invalidTokenCreateResponse(requestId), + tokenCreateResponseMetadata(response, "failure", false), + ); + } + + if (response.ok) { + const decoded = decodeResponse({ text: body.text, requestId }, decodeCreatedToken); + const bodyValid = decoded.ok && validCreatedToken(decoded.value, name); + const metadata = tokenCreateResponseMetadata(response, "success", bodyValid); + return bodyValid && metadata.responseDisposition === "authoritative" + ? tokenCreateResult(decoded, metadata) + : tokenCreateResult(invalidTokenCreateResponse(requestId), metadata); + } + + const decodedEnvelope = decodeErrorEnvelope(body.text); + const metadata = tokenCreateResponseMetadata(response, "failure", Option.isSome(decodedEnvelope)); + return tokenCreateContractValid(response, "failure", Option.isSome(decodedEnvelope)) + ? tokenCreateResult(decodeError(body.text, requestId, response.status), metadata) + : tokenCreateResult(invalidTokenCreateResponse(requestId), metadata); +} + +function tokenCreateResult( + result: ApiResult, + metadata: Pick, +): TokenCreateApiResult { + return result.ok ? { ...result, ...metadata } : { ...result, ...metadata }; +} + +function tokenCreateResponseMetadata( + response: Response, + outcome: "success" | "failure", + bodyValid: boolean, +): Pick { + const replayed = response.headers.get("idempotency-replayed"); + const replayProvenance = + replayed === null ? "none" : replayed === "true" ? "authoritative" : "invalid"; + const responseDisposition = + tokenCreateContractValid(response, outcome, bodyValid) && + (outcome === "success" || replayProvenance === "authoritative" || response.status < 500) + ? "authoritative" + : "ambiguous"; + return { replayProvenance, responseDisposition }; +} + +function tokenCreateContractValid( + response: Response, + outcome: "success" | "failure", + bodyValid: boolean, +) { + const replayed = response.headers.get("idempotency-replayed"); + const statusValid = + outcome === "success" + ? response.status === 201 + : response.status >= 400 && response.status <= 599; + return ( + bodyValid && + statusValid && + hasJsonContentType(response.headers.get("content-type")) && + hasNoStoreDirective(response.headers.get("cache-control")) && + (replayed === null || replayed === "true") + ); +} + +function invalidTokenCreateResponse(requestId: string | null) { + return failure( + "invalid_response", + "Executor returned a token creation response the dashboard could not safely use.", + requestId, + 502, + ); +} + +function hasJsonContentType(value: string | null) { + return value?.split(";", 1)[0]?.trim().toLowerCase() === "application/json"; +} + +function validCreatedToken(token: CreatedToken, expectedName: string) { + return ( + token.id === token.id.trim() && + token.id.length > 0 && + token.id.length <= 128 && + token.name === expectedName && + /^exr_[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(token.token) && + Number.isSafeInteger(token.createdAt) && + token.createdAt >= 0 + ); +} + +function isAmbiguousRevokeResult(result: ApiResult) { + if (result.ok) return false; + if ( + ["network_error", "invalid_response", "unexpected_client_error"].includes(result.error.code) + ) { + return true; + } + return result.error.status >= 500; +} + +async function sourceCreateRequest( + input: OpenApiSourceInput | GraphqlSourceInput | McpHttpSourceInput | McpStdioSourceInput, + idempotencyKey: string, + fetcher: Fetcher, + signal?: AbortSignal, +) { + const headers = new Headers({ + accept: "application/json", + "content-type": "application/json", + "Idempotency-Key": idempotencyKey, + }); + const csrfToken = readCookie("executor_csrf"); + if (csrfToken !== null) headers.set("x-executor-csrf", csrfToken); + + const fetched = await fetcher("/api/v1/sources", { + method: "POST", + body: JSON.stringify(input), + headers, + credentials: "same-origin", + signal, + }).then( + (response) => ({ ok: true, response }) as const, + () => ({ ok: false }) as const, + ); + if (!fetched.ok) { + return sourceCreateResult( + failure( + signal?.aborted ? "request_cancelled" : "network_error", + signal?.aborted + ? "The request was cancelled." + : "Executor could not be reached. Check that the local server is running.", + null, + 0, + ), + { replayProvenance: "none", responseDisposition: "ambiguous" }, + ); + } + + const { response } = fetched; + const requestId = response.headers.get("x-request-id"); + const body = await response.text().then( + (text) => ({ ok: true, text }) as const, + () => ({ ok: false }) as const, + ); + if (!body.ok) { + return sourceCreateResult( + failure( + "invalid_response", + "Executor returned a response body the dashboard could not read.", + requestId, + 502, + ), + sourceCreateResponseMetadata(response, "failure", false), + ); + } + if (response.ok) { + const decoded = decodeResponse({ text: body.text, requestId }, decodeSource); + return sourceCreateResult( + decoded, + sourceCreateResponseMetadata(response, "success", decoded.ok), + ); + } + const decodedError = decodeErrorEnvelope(body.text); + return sourceCreateResult( + decodeError(body.text, requestId, response.status), + sourceCreateResponseMetadata(response, "failure", Option.isSome(decodedError)), + ); +} + +function sourceCreateResult( + result: ApiResult, + metadata: Pick, +): SourceCreateApiResult { + return result.ok ? { ...result, ...metadata } : { ...result, ...metadata }; +} + +function sourceCreateResponseMetadata( + response: Response, + outcome: "success" | "failure", + bodyValid: boolean, +) { + const replayed = response.headers.get("idempotency-replayed"); + if (replayed === null) { + return isAuthoritativeSourceCreationResponse(response, "fresh", outcome, bodyValid) + ? ({ replayProvenance: "none", responseDisposition: "authoritative" } as const) + : ({ replayProvenance: "none", responseDisposition: "ambiguous" } as const); + } + return isAuthoritativeSourceCreationResponse(response, "replay", outcome, bodyValid) + ? ({ replayProvenance: "authoritative", responseDisposition: "authoritative" } as const) + : ({ replayProvenance: "invalid", responseDisposition: "ambiguous" } as const); +} + +function isAuthoritativeSourceCreationResponse( + response: Pick, + origin: "fresh" | "replay", + outcome: "success" | "failure", + bodyValid: boolean, +) { + const statusValid = + outcome === "success" + ? response.status === 201 + : response.status >= 400 && response.status <= (origin === "fresh" ? 499 : 599); + const replayHeader = response.headers.get("idempotency-replayed"); + return ( + bodyValid && + statusValid && + (origin === "fresh" ? replayHeader === null : replayHeader === "true") && + hasNoStoreDirective(response.headers.get("cache-control")) + ); +} + +async function sourceCreationResolutionRequest( + path: string, + method: "GET" | "POST", + idempotencyKey: string, + fetcher: Fetcher, + signal?: AbortSignal, +) { + const headers = new Headers({ + accept: "application/json", + "Idempotency-Key": idempotencyKey, + }); + if (method === "POST") { + const csrfToken = readCookie("executor_csrf"); + if (csrfToken !== null) headers.set("x-executor-csrf", csrfToken); + } + const fetched = await fetcher(path, { + method, + headers, + credentials: "same-origin", + cache: "no-store", + signal, + }).then( + (response) => ({ ok: true, response }) as const, + () => ({ ok: false }) as const, + ); + if (!fetched.ok) { + return failure( + signal?.aborted ? "request_cancelled" : "network_error", + signal?.aborted + ? "The request was cancelled." + : "Executor could not be reached. Check that the local server is running.", + null, + 0, + ); + } + + const { response } = fetched; + const requestId = response.headers.get("x-request-id"); + const body = await response.text().then( + (text) => ({ ok: true, text }) as const, + () => ({ ok: false }) as const, + ); + if (!body.ok) return invalidSourceCreationResolution(requestId); + if (!hasNoStoreDirective(response.headers.get("cache-control"))) { + return invalidSourceCreationResolution(requestId); + } + + const replayed = response.headers.get("idempotency-replayed"); + if (replayed !== null && replayed !== "true") { + return invalidSourceCreationResolution(requestId); + } + if (replayed === "true") { + if (response.status === 201) { + const decoded = decodeResponse({ text: body.text, requestId }, decodeSource); + if ( + !decoded.ok || + !isAuthoritativeSourceCreationResponse(response, "replay", "success", true) + ) { + return invalidSourceCreationResolution(requestId); + } + return { + ok: true, + value: { kind: "replay", result: decoded } as const, + } as const; + } + const decoded = decodeErrorEnvelope(body.text); + if ( + Option.isNone(decoded) || + !isAuthoritativeSourceCreationResponse(response, "replay", "failure", true) + ) { + return invalidSourceCreationResolution(requestId); + } + return { + ok: true, + value: { + kind: "replay", + result: failure( + decoded.value.error.code, + decoded.value.error.message, + decoded.value.error.requestId || requestId, + response.status, + ), + } as const, + } as const; + } + + if (response.ok) { + if (response.status !== 200) return invalidSourceCreationResolution(requestId); + const decoded = decodeResponse({ text: body.text, requestId }, decodeSourceCreationStatus); + if (!decoded.ok) return decoded; + return { + ok: true, + value: { kind: "status", status: decoded.value.status } as const, + } as const; + } + + const decoded = decodeErrorEnvelope(body.text); + if (Option.isNone(decoded)) return invalidSourceCreationResolution(requestId); + const error = failure( + decoded.value.error.code, + decoded.value.error.message, + decoded.value.error.requestId || requestId, + response.status, + ); + if ( + method === "POST" && + response.status === 409 && + decoded.value.error.code === "idempotency_in_progress" + ) { + const retryAfter = response.headers.get("retry-after"); + if (retryAfter === null || !/^\d+$/.test(retryAfter) || Number(retryAfter) < 1) { + return invalidSourceCreationResolution(requestId); + } + return { + ok: true, + value: { kind: "status", status: "in_progress" } as const, + } as const; + } + if (method === "POST") { + const status = sourceCreationTerminalStatus(decoded.value.error.code); + const expectedStatus = status === "expired_unknown" ? 410 : 409; + if (status !== null && response.status === expectedStatus) { + return { ok: true, value: { kind: "status", status } as const } as const; + } + } + return error; +} + +function sourceCreationTerminalStatus(code: string): SourceCreationStatus | null { + if (code === "idempotency_abandoned") return "abandoned"; + if (code === "idempotency_interrupted") return "interrupted"; + if (code === "idempotency_expired_unknown") return "expired_unknown"; + return null; +} + +function invalidSourceCreationResolution(requestId: string | null) { + return failure( + "invalid_response", + "Executor returned an idempotency status the dashboard could not understand.", + requestId, + 502, + ); +} + +function hasNoStoreDirective(value: string | null) { + return ( + value?.split(",").some((directive) => directive.trim().toLowerCase() === "no-store") ?? false + ); +} + +async function request(path: string, init: RequestInit, fetcher: Fetcher, includeCsrf = true) { + if (!path.startsWith("/api/")) { + return failure( + "invalid_request_path", + "The dashboard only sends requests to this Executor instance.", + null, + 0, + ); + } + + const headers = new Headers(init.headers); + headers.set("accept", "application/json"); + if (init.body !== undefined) headers.set("content-type", "application/json"); + if (includeCsrf && isUnsafeMethod(init.method)) { + const csrfToken = readCookie("executor_csrf"); + if (csrfToken !== null) headers.set("x-executor-csrf", csrfToken); + } + + const fetched = await fetcher(path, { + ...init, + headers, + credentials: "same-origin", + }).then( + (response) => ({ ok: true, response }) as const, + () => ({ ok: false }) as const, + ); + + if (!fetched.ok) { + return failure( + init.signal?.aborted ? "request_cancelled" : "network_error", + init.signal?.aborted + ? "The request was cancelled." + : "Executor could not be reached. Check that the local server is running.", + null, + 0, + ); + } + + const { response } = fetched; + const requestId = response.headers.get("x-request-id"); + const body = await response.text().then( + (text) => ({ ok: true, text }) as const, + () => ({ ok: false }) as const, + ); + if (!body.ok) { + return failure( + "invalid_response", + "Executor returned a response body the dashboard could not read.", + requestId, + 502, + ); + } + + if (!response.ok) return decodeError(body.text, requestId, response.status); + return { ok: true, value: { text: body.text, requestId } } as const; +} + +function decodeResponse( + response: ResponsePayload, + decoder: (text: string) => Option.Option, +): ApiResult { + const decoded = decoder(response.text); + if (Option.isNone(decoded)) { + return failure( + "invalid_response", + "Executor returned a response the dashboard could not understand.", + response.requestId, + 502, + ); + } + return { ok: true, value: decoded.value }; +} + +function decodeError(text: string, headerRequestId: string | null, status: number) { + const decoded = decodeErrorEnvelope(text); + if (Option.isNone(decoded)) { + return failure(`http_${status}`, fallbackStatusMessage(status), headerRequestId, status); + } + + return failure( + decoded.value.error.code, + decoded.value.error.message, + decoded.value.error.requestId || headerRequestId, + status, + ); +} + +function failure(code: string, displayMessage: string, requestId: string | null, status: number) { + return { + ok: false, + error: new ApiError({ code, displayMessage, requestId, status }), + } as const; +} + +function fallbackStatusMessage(status: number) { + if (status === 401) return "Your administrator session is no longer valid."; + if (status === 403) return "Executor rejected this request."; + if (status === 404) return "The requested Executor resource does not exist."; + if (status >= 500) return "Executor could not complete the request."; + return `Executor rejected the request with status ${status}.`; +} + +function isUnsafeMethod(method: string | undefined) { + return method !== undefined && !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase()); +} + +function readCookie(name: string) { + if (typeof document === "undefined") return null; + for (const part of document.cookie.split(";")) { + const [cookieName, ...valueParts] = part.trim().split("="); + if (cookieName === name) return valueParts.join("="); + } + return null; +} + +function sanitizeDecodedSource(decoded: Option.Option) { + return Option.isNone(decoded) ? decoded : Option.some(sanitizeSource(decoded.value)); +} + +function sanitizeSource(source: Source): Source { + const endpoint = source.configuration.endpoint; + return { + ...source, + configuration: { + ...(endpoint === undefined ? {} : publicEndpoint(endpoint)), + ...(source.configuration.allowPrivateNetwork === undefined + ? {} + : { allowPrivateNetwork: source.configuration.allowPrivateNetwork }), + ...(source.configuration.templateName === undefined + ? {} + : { templateName: source.configuration.templateName }), + ...(source.configuration.negotiatedProtocolVersion === undefined + ? {} + : { negotiatedProtocolVersion: source.configuration.negotiatedProtocolVersion }), + }, + }; +} + +function publicEndpoint(endpoint: string) { + if (!URL.canParse(endpoint)) return {}; + const url = new URL(endpoint); + if (url.protocol !== "http:" && url.protocol !== "https:") return {}; + return { endpoint: url.origin }; +} diff --git a/web/src/lib/approval-state.test.ts b/web/src/lib/approval-state.test.ts new file mode 100644 index 000000000..5d5043674 --- /dev/null +++ b/web/src/lib/approval-state.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { ApprovalDetail } from "$lib/api"; +import { + approvalCountdown, + approvalDecisionCopy, + approvalStatusLabel, + canDecideApproval, + createApprovalPoller, + decisionResultScope, + focusTargetAfterDecision, +} from "./approval-state"; + +function fixture(overrides: Partial = {}): ApprovalDetail { + return { + id: "approval-1", + status: "pending", + revision: 4, + createdAt: 100, + updatedAt: 101, + expiresAt: 200, + decidedAt: null, + completedAt: null, + sourceId: "source-1", + toolId: "tool-1", + path: "tools.github.create_issue", + sourceDisplayName: "GitHub", + toolDisplayName: "Create issue", + actorKind: "api_token", + actorId: "token-1", + actorName: "Laptop", + actorLabel: "Laptop", + actorApiTokenId: "token-1", + actorTokenName: "Laptop", + surface: "gateway", + mode: "ask", + provenance: "tool_override", + executionId: "execution-1", + callId: "call-1", + redactedArguments: { title: "Safe title", token: "[REDACTED]" }, + inputSchema: { type: "object" }, + failureCode: null, + startedAt: null, + decision: null, + ...overrides, + }; +} + +describe("approval UI state", () => { + it("labels every terminal and uncertain state honestly", () => { + expect(approvalStatusLabel("stale")).toBe("Stale"); + expect(approvalStatusLabel("interrupted")).toBe("Interrupted"); + expect(approvalStatusLabel("canceled")).toBe("Canceled"); + }); + + it("only permits decisions while the approval is pending and unexpired", () => { + expect(canDecideApproval(fixture(), 150_000)).toBe(true); + expect(canDecideApproval(fixture({ status: "approved" }), 150_000)).toBe(false); + expect(canDecideApproval(fixture({ status: "stale" }), 150_000)).toBe(false); + expect(canDecideApproval(fixture(), 200_000)).toBe(false); + }); + + it("computes countdown text with an injected clock", () => { + expect(approvalCountdown(200, 150_000)).toBe("50s remaining"); + expect(approvalCountdown(250, 150_000)).toBe("2m remaining"); + expect(approvalCountdown(150, 150_000)).toBe("Expired"); + }); + + it("restates the tool and side-effect boundary in confirmations", () => { + expect(approvalDecisionCopy(fixture(), "approve")).toContain( + "stored original arguments, including values hidden from this structural preview", + ); + expect(approvalDecisionCopy(fixture(), "approve")).toContain( + "This may cause side effects in GitHub.", + ); + expect(approvalDecisionCopy(fixture(), "deny")).toContain("waiting caller will not run"); + }); + + it("uses safe confirmation fallbacks when snapshot names are unavailable", () => { + const approval = fixture({ + sourceDisplayName: null, + toolDisplayName: null, + }); + expect(approvalDecisionCopy(approval, "approve")).toContain( + "Approve one execution of tools.github.create_issue?", + ); + expect(approvalDecisionCopy(approval, "approve")).toContain("the connected source"); + }); + + it("chooses a stable focus target after a decision removes a pending row", () => { + const first = fixture(); + const second = fixture({ id: "approval-2" }); + const third = fixture({ id: "approval-3" }); + expect(focusTargetAfterDecision([first, second, third], second.id)).toBe( + "inspect-approval-approval-3", + ); + expect(focusTargetAfterDecision([first], first.id)).toBe("approvals-heading"); + }); + + it("rejects a late decision result for a newer detail or list identity", () => { + expect( + decisionResultScope({ + submittedApprovalId: "approval-a", + submittedListKey: "pending:first", + currentApprovalId: "approval-b", + currentListKey: "pending:first", + }), + ).toEqual({ sameDetail: false, sameList: true }); + expect( + decisionResultScope({ + submittedApprovalId: "approval-a", + submittedListKey: "pending:first", + currentApprovalId: "approval-a", + currentListKey: "denied:first", + }), + ).toEqual({ sameDetail: true, sameList: false }); + }); + + it("polls only completed resources and cancels the scheduled retry on disposal", () => { + let listLoading = true; + let detailLoading = true; + let listRefreshes = 0; + let detailRefreshes = 0; + let nextHandle = 0; + const scheduled = new Map void>(); + const canceled: number[] = []; + const dispose = createApprovalPoller({ + schedule: (callback) => { + const handle = ++nextHandle; + scheduled.set(handle, callback); + return handle; + }, + cancel: (handle) => canceled.push(handle), + isVisible: () => true, + isListLoading: () => listLoading, + hasDetail: () => true, + isDetailLoading: () => detailLoading, + refreshList: () => (listRefreshes += 1), + refreshDetail: () => (detailRefreshes += 1), + }); + + scheduled.get(1)?.(); + expect([listRefreshes, detailRefreshes]).toEqual([0, 0]); + listLoading = false; + detailLoading = false; + scheduled.get(2)?.(); + expect([listRefreshes, detailRefreshes]).toEqual([1, 1]); + dispose(); + expect(canceled).toEqual([3]); + scheduled.get(3)?.(); + expect([listRefreshes, detailRefreshes]).toEqual([1, 1]); + }); +}); diff --git a/web/src/lib/approval-state.ts b/web/src/lib/approval-state.ts new file mode 100644 index 000000000..95295ea70 --- /dev/null +++ b/web/src/lib/approval-state.ts @@ -0,0 +1,89 @@ +import type { ApprovalDecision, ApprovalDetail, ApprovalStatus, ApprovalSummary } from "$lib/api"; + +export const approvalStatuses = [ + "pending", + "approved", + "executing", + "succeeded", + "failed", + "denied", + "expired", + "canceled", + "stale", + "interrupted", +] as const satisfies readonly ApprovalStatus[]; + +export function approvalStatusLabel(status: ApprovalStatus) { + return `${status[0].toUpperCase()}${status.slice(1)}`; +} + +export function canDecideApproval(approval: ApprovalSummary, now: number) { + return approval.status === "pending" && approval.expiresAt > Math.floor(now / 1000); +} + +export function approvalCountdown(expiresAt: number, now: number) { + const remaining = expiresAt - Math.floor(now / 1000); + if (remaining <= 0) return "Expired"; + if (remaining < 60) return `${remaining}s remaining`; + const minutes = Math.ceil(remaining / 60); + return `${minutes}m remaining`; +} + +export function approvalDecisionCopy(approval: ApprovalDetail, decision: ApprovalDecision) { + const tool = approval.toolDisplayName ?? approval.path; + const source = approval.sourceDisplayName ?? "the connected source"; + if (decision === "approve") { + return `Approve one execution of ${tool}? Executor will use the stored original arguments, including values hidden from this structural preview. This may cause side effects in ${source}.`; + } + return `Deny this execution of ${tool}? The waiting caller will not run this request.`; +} + +export function focusTargetAfterDecision(items: readonly ApprovalSummary[], approvalId: string) { + const index = items.findIndex((approval) => approval.id === approvalId); + const next = items[index + 1] ?? items[index - 1]; + return next === undefined ? "approvals-heading" : `inspect-approval-${next.id}`; +} + +export function decisionResultScope(input: { + readonly submittedApprovalId: string; + readonly submittedListKey: string; + readonly currentApprovalId: string | null; + readonly currentListKey: string; +}) { + return { + sameDetail: input.currentApprovalId === input.submittedApprovalId, + sameList: input.currentListKey === input.submittedListKey, + }; +} + +export function createApprovalPoller( + options: { + readonly schedule: (callback: () => void, delay: number) => number; + readonly cancel: (handle: number) => void; + readonly isVisible: () => boolean; + readonly isListLoading: () => boolean; + readonly hasDetail: () => boolean; + readonly isDetailLoading: () => boolean; + readonly refreshList: () => void; + readonly refreshDetail: () => void; + }, + interval = 5_000, +) { + let disposed = false; + let handle = 0; + + function poll() { + if (disposed) return; + if (options.isVisible()) { + if (!options.isListLoading()) options.refreshList(); + if (options.hasDetail() && !options.isDetailLoading()) options.refreshDetail(); + } + handle = options.schedule(poll, interval); + } + + handle = options.schedule(poll, interval); + return () => { + disposed = true; + options.cancel(handle); + }; +} diff --git a/web/src/lib/approval-url.test.ts b/web/src/lib/approval-url.test.ts new file mode 100644 index 000000000..8ef839fa8 --- /dev/null +++ b/web/src/lib/approval-url.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@effect/vitest"; +import { approvalsListKey, approvalsUrl, parseApprovalsUrl } from "./approval-url"; + +describe("approval URL state", () => { + it("defaults invalid and missing status filters to pending", () => { + expect(parseApprovalsUrl(new URLSearchParams())).toEqual({ + status: "pending", + cursor: null, + approval: null, + }); + expect(parseApprovalsUrl(new URLSearchParams("status=surprise&cursor=&approval="))).toEqual({ + status: "pending", + cursor: null, + approval: null, + }); + expect(parseApprovalsUrl(new URLSearchParams("status=active"))).toEqual({ + status: "pending", + cursor: null, + approval: null, + }); + }); + + it("keeps status, pagination, and detail selection bookmarkable", () => { + const state = parseApprovalsUrl( + new URLSearchParams("status=denied&cursor=older-page&approval=approval-1"), + ); + + expect(state).toEqual({ + status: "denied", + cursor: "older-page", + approval: "approval-1", + }); + expect(approvalsUrl(state, { approval: null })).toBe( + "/approvals?status=denied&cursor=older-page", + ); + expect(approvalsUrl(state, { status: "pending", cursor: null })).toBe( + "/approvals?approval=approval-1", + ); + }); + + it("excludes detail selection from the list request identity", () => { + const first = parseApprovalsUrl( + new URLSearchParams("status=failed&cursor=older&approval=approval-a"), + ); + const second = parseApprovalsUrl( + new URLSearchParams("status=failed&cursor=older&approval=approval-b"), + ); + + expect(approvalsListKey(first)).toBe(approvalsListKey(second)); + expect(approvalsListKey(first)).not.toBe( + approvalsListKey({ status: "pending", cursor: "older", approval: "approval-a" }), + ); + }); +}); diff --git a/web/src/lib/approval-url.ts b/web/src/lib/approval-url.ts new file mode 100644 index 000000000..2aa6c080c --- /dev/null +++ b/web/src/lib/approval-url.ts @@ -0,0 +1,53 @@ +import type { ApprovalStatus } from "$lib/api"; + +const statuses = new Set([ + "pending", + "approved", + "executing", + "succeeded", + "failed", + "denied", + "expired", + "canceled", + "stale", + "interrupted", +]); + +export type ApprovalStatusFilter = ApprovalStatus | "all"; + +export type ApprovalsUrlState = { + readonly status: ApprovalStatusFilter; + readonly cursor: string | null; + readonly approval: string | null; +}; + +export function parseApprovalsUrl(parameters: URLSearchParams): ApprovalsUrlState { + const rawStatus = parameters.get("status"); + return { + status: rawStatus === "all" || isApprovalStatus(rawStatus) ? rawStatus : "pending", + cursor: nonEmpty(parameters.get("cursor")), + approval: nonEmpty(parameters.get("approval")), + }; +} + +export function approvalsUrl(state: ApprovalsUrlState, updates: Partial = {}) { + const next = { ...state, ...updates }; + const parameters = new URLSearchParams(); + if (next.status !== "pending") parameters.set("status", next.status); + if (next.cursor) parameters.set("cursor", next.cursor); + if (next.approval) parameters.set("approval", next.approval); + const search = parameters.toString(); + return search ? `/approvals?${search}` : "/approvals"; +} + +export function approvalsListKey(state: ApprovalsUrlState) { + return JSON.stringify([state.status, state.cursor]); +} + +function isApprovalStatus(value: string | null): value is ApprovalStatus { + return value !== null && statuses.has(value as ApprovalStatus); +} + +function nonEmpty(value: string | null) { + return value === null || value === "" ? null : value; +} diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg new file mode 100644 index 000000000..7a5796457 --- /dev/null +++ b/web/src/lib/assets/favicon.svg @@ -0,0 +1,6 @@ + + Executor + + + + diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts new file mode 100644 index 000000000..005ae4abd --- /dev/null +++ b/web/src/lib/auth.svelte.ts @@ -0,0 +1,196 @@ +import { getContext, setContext } from "svelte"; +import { + getBootstrap, + getSession, + loginAdmin, + logoutAdmin, + setupAdmin, + type ApiError, +} from "$lib/api"; + +const authContext = Symbol("executor-auth"); + +export function createAuthState() { + let phase = $state<"loading" | "ready" | "error">("loading"); + let setupRequired = $state(false); + let authenticated = $state(false); + let username = $state(null); + let error = $state(null); + let notice = $state(null); + let refreshGeneration = 0; + let mutationGeneration = 0; + + async function refresh(signal?: AbortSignal) { + const mine = ++refreshGeneration; + phase = "loading"; + error = null; + + const bootstrap = await getBootstrap(undefined, signal); + if (mine !== refreshGeneration || signal?.aborted) return; + if (!bootstrap.ok) { + if (recoverFromApiError(bootstrap.error)) return; + error = bootstrap.error; + phase = "error"; + return; + } + + let nextUsername: string | null = null; + + if (bootstrap.value.authenticated) { + const session = await getSession(undefined, signal); + if (mine !== refreshGeneration || signal?.aborted) return; + if (!session.ok) { + if (recoverFromApiError(session.error)) return; + error = session.error; + phase = "error"; + return; + } + nextUsername = session.value.username; + } + + if (mine !== refreshGeneration || signal?.aborted) return; + setupRequired = bootstrap.value.setupRequired; + authenticated = bootstrap.value.authenticated; + username = nextUsername; + phase = "ready"; + } + + async function signIn(input: { username: string; password: string }) { + const mine = beginMutation(); + const session = await loginAdmin(input); + settleMutation(); + if (mine !== mutationGeneration) return session; + if (!session.ok) return session; + setupRequired = false; + authenticated = true; + username = session.value.username; + notice = null; + error = null; + phase = "ready"; + return session; + } + + async function completeSetup(input: { setupToken: string; username: string; password: string }) { + const mine = beginMutation(); + const setup = await setupAdmin(input); + if (mine !== mutationGeneration) { + settleMutation(); + return setup; + } + if (!setup.ok) { + settleMutation(); + return setup; + } + + const login = await loginAdmin({ username: input.username, password: input.password }); + settleMutation(); + if (mine !== mutationGeneration) return login; + if (!login.ok) { + setupRequired = false; + authenticated = false; + username = null; + notice = + "Your administrator was created, but automatic sign-in failed. Sign in with the credentials you just chose."; + error = null; + phase = "ready"; + return login; + } + + setupRequired = false; + authenticated = true; + username = login.value.username; + notice = null; + error = null; + phase = "ready"; + return login; + } + + async function signOut() { + const mine = beginMutation(); + const logout = await logoutAdmin(); + settleMutation(); + if (mine !== mutationGeneration) return logout; + if (!logout.ok) { + recoverFromApiError(logout.error); + return logout; + } + authenticated = false; + username = null; + notice = null; + return logout; + } + + function recoverFromApiError(apiError: ApiError) { + if (apiError.status !== 401 && apiError.code !== "invalid_csrf") return false; + + mutationGeneration += 1; + refreshGeneration += 1; + authenticated = false; + username = null; + error = null; + notice = + apiError.code === "invalid_csrf" + ? "Your security token is no longer valid. Sign in again to continue." + : "Your session expired. Sign in again to continue."; + phase = "ready"; + return true; + } + + function beginMutation() { + refreshGeneration += 1; + mutationGeneration += 1; + return mutationGeneration; + } + + function settleMutation() { + refreshGeneration += 1; + phase = "ready"; + } + + function clearNotice() { + notice = null; + } + + function dispose() { + refreshGeneration += 1; + mutationGeneration += 1; + } + + return { + get phase() { + return phase; + }, + get setupRequired() { + return setupRequired; + }, + get authenticated() { + return authenticated; + }, + get username() { + return username; + }, + get error() { + return error; + }, + get notice() { + return notice; + }, + refresh, + signIn, + completeSetup, + signOut, + recoverFromApiError, + clearNotice, + dispose, + }; +} + +export type AuthState = ReturnType; + +export function provideAuthState(auth: AuthState) { + setContext(authContext, auth); +} + +export function useAuthState() { + return getContext(authContext); +} diff --git a/web/src/lib/auth.test.ts b/web/src/lib/auth.test.ts new file mode 100644 index 000000000..67d2a4b88 --- /dev/null +++ b/web/src/lib/auth.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { ApiError } from "./api"; +import { createAuthState } from "./auth.svelte"; + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("dashboard auth state", () => { + it("hydrates an authenticated administrator session", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ setupRequired: false, authenticated: true })) + .mockResolvedValueOnce(Response.json({ username: "admin", csrfToken: null })); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + await auth.refresh(); + + expect(auth.phase).toBe("ready"); + expect(auth.setupRequired).toBe(false); + expect(auth.authenticated).toBe(true); + expect(auth.username).toBe("admin"); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("claims first boot and immediately establishes the session", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ username: "admin", csrfToken: null }, { status: 201 })) + .mockResolvedValueOnce(Response.json({ username: "admin", csrfToken: "csrf_fresh" })); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + const result = await auth.completeSetup({ + setupToken: "set_secret", + username: "admin", + password: "long-enough-password", + }); + + expect(result.ok).toBe(true); + expect(auth.setupRequired).toBe(false); + expect(auth.authenticated).toBe(true); + expect(auth.username).toBe("admin"); + expect(auth.notice).toBeNull(); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("does not let an older refresh overwrite a successful sign-in", async () => { + const bootstrap = deferred(); + const fetcher = vi.fn((input, init) => { + if (String(input) === "/api/v1/session" && init?.method === "POST") { + return Promise.resolve(Response.json({ username: "admin", csrfToken: "csrf_fresh" })); + } + return bootstrap.promise; + }); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + const refreshing = auth.refresh(); + const signedIn = await auth.signIn({ username: "admin", password: "password" }); + bootstrap.resolve(Response.json({ setupRequired: false, authenticated: false })); + await refreshing; + + expect(signedIn.ok).toBe(true); + expect(auth.phase).toBe("ready"); + expect(auth.authenticated).toBe(true); + expect(auth.username).toBe("admin"); + }); + + it("does not let an older refresh overwrite a completed first boot", async () => { + const bootstrap = deferred(); + const fetcher = vi.fn((input, init) => { + if (String(input) === "/api/v1/setup") { + return Promise.resolve( + Response.json({ username: "admin", csrfToken: null }, { status: 201 }), + ); + } + if (String(input) === "/api/v1/session" && init?.method === "POST") { + return Promise.resolve(Response.json({ username: "admin", csrfToken: "csrf_fresh" })); + } + return bootstrap.promise; + }); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + const refreshing = auth.refresh(); + const setup = await auth.completeSetup({ + setupToken: "set_secret", + username: "admin", + password: "long-enough-password", + }); + bootstrap.resolve(Response.json({ setupRequired: true, authenticated: false })); + await refreshing; + + expect(setup.ok).toBe(true); + expect(auth.setupRequired).toBe(false); + expect(auth.authenticated).toBe(true); + }); + + it("does not let an older refresh restore a completed sign-out", async () => { + const bootstrap = deferred(); + const fetcher = vi.fn((input, init) => { + if (String(input) === "/api/v1/session" && init?.method === "POST") { + return Promise.resolve(Response.json({ username: "admin", csrfToken: "csrf_fresh" })); + } + if (String(input) === "/api/v1/session" && init?.method === "DELETE") { + return Promise.resolve(new Response(null, { status: 204 })); + } + return bootstrap.promise; + }); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + await auth.signIn({ username: "admin", password: "password" }); + const refreshing = auth.refresh(); + const signedOut = await auth.signOut(); + bootstrap.resolve(Response.json({ setupRequired: false, authenticated: true })); + await refreshing; + + expect(signedOut.ok).toBe(true); + expect(auth.phase).toBe("ready"); + expect(auth.authenticated).toBe(false); + expect(auth.username).toBeNull(); + }); + + it("transitions to sign-in when an authenticated API reports an invalid CSRF token", async () => { + const fetcher = vi + .fn() + .mockResolvedValue(Response.json({ username: "admin", csrfToken: "csrf_fresh" })); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + await auth.signIn({ username: "admin", password: "password" }); + const recovered = auth.recoverFromApiError( + new ApiError({ + code: "invalid_csrf", + displayMessage: "A valid CSRF token is required.", + requestId: "request-1", + status: 403, + }), + ); + + expect(recovered).toBe(true); + expect(auth.authenticated).toBe(false); + expect(auth.phase).toBe("ready"); + expect(auth.notice).toContain("Sign in again"); + }); + + it("recovers from a session that expires between bootstrap and session lookup", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({ setupRequired: false, authenticated: true })) + .mockResolvedValueOnce( + Response.json( + { + error: { + code: "unauthorized", + message: "An administrator session is required.", + requestId: "request-expired", + }, + }, + { status: 401 }, + ), + ); + vi.stubGlobal("fetch", fetcher); + + const auth = createAuthState(); + await auth.refresh(); + + expect(auth.phase).toBe("ready"); + expect(auth.authenticated).toBe(false); + expect(auth.notice).toContain("session expired"); + }); +}); diff --git a/web/src/lib/catalog-state.test.ts b/web/src/lib/catalog-state.test.ts new file mode 100644 index 000000000..76b8fde51 --- /dev/null +++ b/web/src/lib/catalog-state.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ApiError } from "$lib/api"; +import { + beginIdentityResourceLoad, + beginResourceLoad, + createLatestRequest, + emptyResource, + settleResourceLoad, +} from "./catalog-state"; + +describe("catalog resource state", () => { + it("distinguishes loading, empty, error, and stale data", () => { + const loading = emptyResource(); + const empty = settleResourceLoad(loading, { ok: true, value: [] }); + const refreshing = beginResourceLoad(empty); + const error = new ApiError({ + code: "offline", + displayMessage: "offline", + requestId: null, + status: 0, + }); + const failedEmpty = settleResourceLoad(loading, { ok: false, error }); + const loaded = settleResourceLoad(loading, { ok: true, value: ["tool"] }); + const stale = settleResourceLoad(loaded, { ok: false, error }); + + expect(loading).toMatchObject({ loading: true, data: null, stale: false }); + expect(empty).toMatchObject({ loading: false, data: [], stale: false }); + expect(refreshing).toMatchObject({ loading: true, data: [], stale: false }); + expect(failedEmpty).toMatchObject({ loading: false, data: null, stale: false }); + expect(stale).toMatchObject({ loading: false, data: ["tool"], stale: true, error }); + }); + + it("can discard retained detail data when a same-identity reload fails", () => { + const loaded = settleResourceLoad(emptyResource(), { ok: true, value: "tool-a" }); + const refreshing = beginIdentityResourceLoad(loaded, "a", "a").state; + const error = new ApiError({ + code: "network_error", + displayMessage: "offline", + requestId: null, + status: 0, + }); + const failed = settleResourceLoad( + refreshing, + { ok: false, error }, + { + retainDataOnError: false, + }, + ); + + expect(failed).toEqual({ data: null, loading: false, stale: false, error }); + }); + + it("rejects an older completion after a newer request wins", async () => { + const latest = createLatestRequest(); + const commits: string[] = []; + let resolveA = (_value: string) => {}; + let resolveB = (_value: string) => {}; + const a = new Promise((resolve) => (resolveA = resolve)); + const b = new Promise((resolve) => (resolveB = resolve)); + + latest.start( + () => a, + (value) => commits.push(value), + () => {}, + ); + latest.start( + () => b, + (value) => commits.push(value), + () => {}, + ); + resolveB("b"); + await b; + resolveA("a"); + await a; + await Promise.resolve(); + + expect(commits).toEqual(["b"]); + }); + + it("reports an unexpected rejection through the current request contract", async () => { + const latest = createLatestRequest(); + const commits: string[] = []; + const rejections: unknown[] = []; + let reject = (_error: unknown) => {}; + const pending = new Promise((_resolve, fail) => (reject = fail)); + const failure = { _tag: "UnexpectedTestRejection" } as const; + + latest.start( + () => pending, + (value) => commits.push(value), + (error) => rejections.push(error), + ); + await Promise.resolve(); + reject(failure); + await Promise.resolve(); + await Promise.resolve(); + + expect(commits).toEqual([]); + expect(rejections).toEqual([failure]); + }); + + it("ignores an older rejection after a newer request wins", async () => { + const latest = createLatestRequest(); + const commits: string[] = []; + const rejections: unknown[] = []; + let rejectA = (_error: unknown) => {}; + let resolveB = (_value: string) => {}; + const a = new Promise((_resolve, reject) => (rejectA = reject)); + const b = new Promise((resolve) => (resolveB = resolve)); + + latest.start( + () => a, + (value) => commits.push(value), + (error) => rejections.push(error), + ); + latest.start( + () => b, + (value) => commits.push(value), + (error) => rejections.push(error), + ); + await Promise.resolve(); + resolveB("b"); + await b; + rejectA({ _tag: "LateTestRejection" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(commits).toEqual(["b"]); + expect(rejections).toEqual([]); + }); + + it("retains data for a refresh but clears it across detail identities", () => { + const loaded = settleResourceLoad(emptyResource(), { ok: true, value: "tool-a" }); + + expect(beginIdentityResourceLoad(loaded, "a", "a")).toEqual({ + identity: "a", + state: { data: "tool-a", loading: true, error: null, stale: false }, + }); + expect(beginIdentityResourceLoad(loaded, "a", "b")).toEqual({ + identity: "b", + state: { data: null, loading: true, error: null, stale: false }, + }); + }); + + it("never leaves a previous tool list writable after a new list identity fails", () => { + const loaded = settleResourceLoad(emptyResource(), { + ok: true, + value: ["old-tool"], + }); + const changed = beginIdentityResourceLoad(loaded, "source-a", "source-b").state; + const error = new ApiError({ + code: "network_error", + displayMessage: "offline", + requestId: null, + status: 0, + }); + const failed = settleResourceLoad(changed, { ok: false, error }); + + expect(changed.data).toBeNull(); + expect(failed).toMatchObject({ data: null, loading: false, stale: false, error }); + }); + + it("rejects completion after the owner is disposed", async () => { + const latest = createLatestRequest(); + const commits: string[] = []; + let resolve = (_value: string) => {}; + const pending = new Promise((done) => (resolve = done)); + const dispose = latest.start( + () => pending, + (value) => commits.push(value), + () => {}, + ); + + dispose(); + resolve("late"); + await pending; + await Promise.resolve(); + + expect(commits).toEqual([]); + }); + + it("ignores a rejected task after the owner is disposed", async () => { + const latest = createLatestRequest(); + const rejections: unknown[] = []; + let reject = (_error: unknown) => {}; + const pending = new Promise((_resolve, fail) => (reject = fail)); + const dispose = latest.start( + () => pending, + () => {}, + (error) => rejections.push(error), + ); + + await Promise.resolve(); + dispose(); + reject({ _tag: "LateDisposedTestRejection" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(rejections).toEqual([]); + }); +}); diff --git a/web/src/lib/catalog-state.ts b/web/src/lib/catalog-state.ts new file mode 100644 index 000000000..8bbb430ce --- /dev/null +++ b/web/src/lib/catalog-state.ts @@ -0,0 +1,101 @@ +import { ApiError, type ApiResult, type ToolMode } from "$lib/api"; + +export type ResourceState = { + readonly data: Value | null; + readonly loading: boolean; + readonly error: ApiError | null; + readonly stale: boolean; +}; + +export function emptyResource(): ResourceState { + return { data: null, loading: true, error: null, stale: false }; +} + +export function beginResourceLoad(state: ResourceState) { + return { ...state, loading: true, error: null }; +} + +export function beginIdentityResourceLoad( + state: ResourceState, + currentIdentity: string | null, + nextIdentity: string, +) { + return { + identity: nextIdentity, + state: currentIdentity === nextIdentity ? beginResourceLoad(state) : emptyResource(), + }; +} + +export function settleResourceLoad( + state: ResourceState, + result: ApiResult, + options: { readonly retainDataOnError?: boolean } = {}, +) { + if (result.ok) { + return { data: result.value, loading: false, error: null, stale: false }; + } + const data = options.retainDataOnError === false ? null : state.data; + return { + data, + loading: false, + error: result.error, + stale: data !== null, + }; +} + +export function unexpectedRequestError() { + return new ApiError({ + code: "unexpected_client_error", + displayMessage: "The request ended unexpectedly. Try again.", + requestId: null, + status: 0, + }); +} + +export function createLatestRequest() { + let generation = 0; + let activeController: AbortController | null = null; + + function start( + task: (signal: AbortSignal) => Promise, + commit: (value: Value) => void, + reject: (error: unknown) => void, + ) { + activeController?.abort(); + const mine = ++generation; + const controller = new AbortController(); + activeController = controller; + void task(controller.signal).then( + (value) => { + if (!controller.signal.aborted && mine === generation) commit(value); + }, + (error: unknown) => { + if (!controller.signal.aborted && mine === generation) reject(error); + }, + ); + + return () => { + controller.abort(); + if (mine === generation) { + generation += 1; + activeController = null; + } + }; + } + + return { start }; +} + +export const toolModes = ["enabled", "ask", "disabled"] as const satisfies readonly ToolMode[]; + +export function modeLabel(mode: ToolMode) { + if (mode === "enabled") return "Enabled"; + if (mode === "ask") return "Ask"; + return "Disabled"; +} + +export function provenanceLabel(provenance: "tool_override" | "source_override" | "intrinsic") { + if (provenance === "tool_override") return "Tool override"; + if (provenance === "source_override") return "Source default"; + return "Tool default"; +} diff --git a/web/src/lib/catalog-url.test.ts b/web/src/lib/catalog-url.test.ts new file mode 100644 index 000000000..8c5017617 --- /dev/null +++ b/web/src/lib/catalog-url.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + logsListKey, + logsUrl, + parseLogsUrl, + parseToolsUrl, + toolsListKey, + toolsUrl, +} from "./catalog-url"; + +describe("catalog URL state", () => { + it("normalizes invalid tool filters without hiding valid choices", () => { + const state = parseToolsUrl( + new URLSearchParams( + "q=deploy&source=github&mode=surprise&removed=true&offset=-4&tool=tool-1", + ), + ); + + expect(state).toEqual({ + q: "deploy", + source: "github", + mode: null, + removed: false, + offset: 0, + tool: "tool-1", + }); + }); + + it("creates bookmarkable tool URLs and resets offsets on filter changes", () => { + const state = parseToolsUrl(new URLSearchParams("source=github&offset=100&removed=1")); + expect(toolsUrl(state, { mode: "ask", offset: 0 })).toBe( + "/tools?source=github&mode=ask&removed=1", + ); + }); + + it("keeps log cursors and inline request selection independently addressable", () => { + const state = parseLogsUrl(new URLSearchParams("cursor=older-page&request=req-1")); + expect(state).toEqual({ cursor: "older-page", request: "req-1" }); + expect(logsUrl(state, { request: null })).toBe("/logs?cursor=older-page"); + expect(logsUrl(state, { cursor: null })).toBe("/logs?request=req-1"); + }); + + it("excludes detail-only selections from list request identities", () => { + const toolA = parseToolsUrl(new URLSearchParams("source=github&tool=a")); + const toolB = parseToolsUrl(new URLSearchParams("source=github&tool=b")); + const logA = parseLogsUrl(new URLSearchParams("cursor=older&request=a")); + const logB = parseLogsUrl(new URLSearchParams("cursor=older&request=b")); + + expect(toolsListKey(toolA)).toBe(toolsListKey(toolB)); + expect(logsListKey(logA)).toBe(logsListKey(logB)); + }); + + it("changes list identity for filters, offsets, and cursors", () => { + const first = parseToolsUrl(new URLSearchParams("source=github&offset=0")); + const second = parseToolsUrl(new URLSearchParams("source=github&offset=50")); + expect(toolsListKey(first)).not.toBe(toolsListKey(second)); + expect(logsListKey({ cursor: "a", request: null })).not.toBe( + logsListKey({ cursor: "b", request: null }), + ); + }); +}); diff --git a/web/src/lib/catalog-url.ts b/web/src/lib/catalog-url.ts new file mode 100644 index 000000000..7431716d4 --- /dev/null +++ b/web/src/lib/catalog-url.ts @@ -0,0 +1,82 @@ +import type { ToolMode } from "$lib/api"; + +const modes = new Set(["enabled", "ask", "disabled"]); +const maxSearchLength = 256; +const maxOffset = 1_000_000_000; + +export type ToolsUrlState = { + q: string; + source: string | null; + mode: ToolMode | null; + removed: boolean; + offset: number; + tool: string | null; +}; + +export function parseToolsUrl(parameters: URLSearchParams): ToolsUrlState { + const rawMode = parameters.get("mode"); + return { + q: (parameters.get("q") ?? "").slice(0, maxSearchLength), + source: nonEmpty(parameters.get("source")), + mode: isToolMode(rawMode) ? rawMode : null, + removed: parameters.get("removed") === "1", + offset: parseOffset(parameters.get("offset")), + tool: nonEmpty(parameters.get("tool")), + }; +} + +export function toolsUrl(state: ToolsUrlState, updates: Partial = {}) { + const next = { ...state, ...updates }; + const parameters = new URLSearchParams(); + if (next.q) parameters.set("q", next.q); + if (next.source) parameters.set("source", next.source); + if (next.mode) parameters.set("mode", next.mode); + if (next.removed) parameters.set("removed", "1"); + if (next.offset > 0) parameters.set("offset", String(next.offset)); + if (next.tool) parameters.set("tool", next.tool); + const search = parameters.toString(); + return search ? `/tools?${search}` : "/tools"; +} + +export function toolsListKey(state: ToolsUrlState) { + return JSON.stringify([state.q, state.source, state.mode, state.removed, state.offset]); +} + +export type LogsUrlState = { + cursor: string | null; + request: string | null; +}; + +export function parseLogsUrl(parameters: URLSearchParams): LogsUrlState { + return { + cursor: nonEmpty(parameters.get("cursor")), + request: nonEmpty(parameters.get("request")), + }; +} + +export function logsUrl(state: LogsUrlState, updates: Partial = {}) { + const next = { ...state, ...updates }; + const parameters = new URLSearchParams(); + if (next.cursor) parameters.set("cursor", next.cursor); + if (next.request) parameters.set("request", next.request); + const search = parameters.toString(); + return search ? `/logs?${search}` : "/logs"; +} + +export function logsListKey(state: LogsUrlState) { + return state.cursor ?? ""; +} + +function isToolMode(value: string | null): value is ToolMode { + return value !== null && modes.has(value as ToolMode); +} + +function nonEmpty(value: string | null) { + return value === null || value === "" ? null : value; +} + +function parseOffset(value: string | null) { + if (value === null || !/^\d+$/.test(value)) return 0; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? Math.min(parsed, maxOffset) : 0; +} diff --git a/web/src/lib/catalog-ux.test.ts b/web/src/lib/catalog-ux.test.ts new file mode 100644 index 000000000..e9828eebc --- /dev/null +++ b/web/src/lib/catalog-ux.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + broadConfirmationText, + bulkActionLabel, + inheritLabel, + previewToolKey, + requiresBroadConfirmation, + sourceModeImpact, +} from "./catalog-ux"; + +describe("catalog mode UX", () => { + it("requires confirmation for broad enable and disable changes", () => { + expect(requiresBroadConfirmation("enabled")).toBe(true); + expect(requiresBroadConfirmation("disabled")).toBe(true); + expect(requiresBroadConfirmation("ask")).toBe(false); + expect(requiresBroadConfirmation(null)).toBe(false); + }); + + it("names bulk actions with their target and selected count", () => { + expect(bulkActionLabel("ask", 3)).toBe("Apply Ask to 3 selected"); + expect(bulkActionLabel(null, 1)).toBe("Apply Inherit to 1 selected"); + expect(broadConfirmationText("disabled", 2)).toBe( + "Disabled 2 selected tools? This sets an explicit mode override on every selected tool.", + ); + }); + + it("explains inherited state and conservative source impact", () => { + expect(inheritLabel("enabled")).toBe("Inherit (currently Enabled)"); + expect(sourceModeImpact(1)).toContain("Up to 1 active tool"); + expect(sourceModeImpact(12)).toContain("Up to 12 active tools"); + expect(sourceModeImpact(12)).toContain("Tool overrides stay unchanged"); + }); + + it("keeps legal duplicate preview names collision-safe", () => { + expect(previewToolKey("duplicate", 0)).not.toBe(previewToolKey("duplicate", 1)); + }); +}); diff --git a/web/src/lib/catalog-ux.ts b/web/src/lib/catalog-ux.ts new file mode 100644 index 000000000..dd8d6f35c --- /dev/null +++ b/web/src/lib/catalog-ux.ts @@ -0,0 +1,33 @@ +import type { ToolMode } from "$lib/api"; + +export function requiresBroadConfirmation(mode: ToolMode | null) { + return mode === "enabled" || mode === "disabled"; +} + +export function sourceModeImpact(toolCount: number) { + const noun = toolCount === 1 ? "tool" : "tools"; + return `Up to ${toolCount} active ${noun} may inherit this source default. Tool overrides stay unchanged.`; +} + +export function bulkActionLabel(mode: ToolMode | null, count: number) { + const target = mode === null ? "Inherit" : modeName(mode); + return `Apply ${target} to ${count} selected`; +} + +export function broadConfirmationText(mode: ToolMode, count: number) { + return `${modeName(mode)} ${count} selected ${count === 1 ? "tool" : "tools"}? This sets an explicit mode override on every selected tool.`; +} + +export function modeName(mode: ToolMode) { + if (mode === "enabled") return "Enabled"; + if (mode === "ask") return "Ask"; + return "Disabled"; +} + +export function inheritLabel(mode: ToolMode) { + return `Inherit (currently ${modeName(mode)})`; +} + +export function previewToolKey(preferredName: string, index: number) { + return `${index}:${preferredName}`; +} diff --git a/web/src/lib/clipboard.test.ts b/web/src/lib/clipboard.test.ts new file mode 100644 index 000000000..323f71185 --- /dev/null +++ b/web/src/lib/clipboard.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, expect, it, vi } from "@effect/vitest"; +import { copyText } from "./clipboard"; + +let clipboardDescriptor: PropertyDescriptor | undefined; +let execCommandDescriptor: PropertyDescriptor | undefined; + +beforeEach(() => { + clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + execCommandDescriptor = Object.getOwnPropertyDescriptor(document, "execCommand"); +}); + +afterEach(() => { + if (clipboardDescriptor === undefined) { + Reflect.deleteProperty(navigator, "clipboard"); + } else { + Object.defineProperty(navigator, "clipboard", clipboardDescriptor); + } + + if (execCommandDescriptor === undefined) { + Reflect.deleteProperty(document, "execCommand"); + } else { + Object.defineProperty(document, "execCommand", execCommandDescriptor); + } + document.body.replaceChildren(); +}); + +it("uses the visible readonly field as a fallback and restores focus", async () => { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }); + const execCommand = vi.fn(() => true); + Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand }); + + const copyButton = document.createElement("button"); + const field = document.createElement("input"); + field.readOnly = true; + document.body.append(copyButton, field); + copyButton.focus(); + + const copied = await copyText("exr_secret", field); + + expect(copied).toBe(true); + expect(field.value).toBe("exr_secret"); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(document.activeElement).toBe(copyButton); +}); diff --git a/web/src/lib/clipboard.ts b/web/src/lib/clipboard.ts new file mode 100644 index 000000000..84e64cd2a --- /dev/null +++ b/web/src/lib/clipboard.ts @@ -0,0 +1,24 @@ +export async function copyText(text: string, fallbackField: HTMLInputElement) { + if (navigator.clipboard?.writeText) { + const copied = await navigator.clipboard.writeText(text).then( + () => true, + () => false, + ); + if (copied) return true; + } + + const previousFocus = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + fallbackField.value = text; + fallbackField.focus(); + fallbackField.select(); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: legacy clipboard fallback must report failure and restore focus + try { + return document.execCommand("copy"); + } catch { + return false; + } finally { + previousFocus?.focus({ preventScroll: true }); + } +} diff --git a/web/src/lib/graphql-source-state.test.ts b/web/src/lib/graphql-source-state.test.ts new file mode 100644 index 000000000..422148313 --- /dev/null +++ b/web/src/lib/graphql-source-state.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + buildGraphqlCredential, + graphqlDraftFromCredentialType, + normalizeGraphqlEndpoint, + requiresGraphqlPrivateNetworkOptIn, + safeGraphqlSourceDetails, +} from "./graphql-source-state"; + +describe("GraphQL source state", () => { + it("preserves secret bytes while trimming public credential fields", () => { + expect( + buildGraphqlCredential({ + type: "api_key_header", + headerName: " X-Service-Key ", + username: "", + secret: " exact secret ", + }), + ).toEqual({ type: "api_key_header", name: "X-Service-Key", value: " exact secret " }); + expect( + buildGraphqlCredential({ + type: "basic", + headerName: "", + username: " operator ", + secret: " exact password ", + }), + ).toEqual({ type: "basic", username: "operator", password: " exact password " }); + }); + + it("requires complete credentials and normalizes stored OAuth metadata", () => { + expect( + buildGraphqlCredential({ + type: "bearer", + headerName: "", + username: "", + secret: " ", + }), + ).toBeUndefined(); + expect(graphqlDraftFromCredentialType("manual_oauth_access_token").type).toBe( + "oauth_access_token", + ); + }); + + it("detects obvious private endpoints", () => { + expect(requiresGraphqlPrivateNetworkOptIn("http://127.10.0.1/graphql")).toBe(true); + expect(requiresGraphqlPrivateNetworkOptIn("http://192.168.2.4/graphql")).toBe(true); + expect(requiresGraphqlPrivateNetworkOptIn("http://[::ffff:7f00:1]/graphql")).toBe(true); + expect(requiresGraphqlPrivateNetworkOptIn("https://api.example.test/graphql")).toBe(false); + }); + + it("never exposes endpoint credentials, query parameters, or fragments", () => { + expect( + safeGraphqlSourceDetails({ + endpoint: "https://user:pass@api.example.test/graphql?token=secret#private", + allowPrivateNetwork: false, + }), + ).toEqual({ endpoint: "https://api.example.test", allowPrivateNetwork: false }); + expect( + safeGraphqlSourceDetails({ + endpoint: "https://api.example.test/graphql/path-secret", + allowPrivateNetwork: false, + }), + ).toEqual({ endpoint: "https://api.example.test", allowPrivateNetwork: false }); + expect(normalizeGraphqlEndpoint("https://api.example.test/graphql")).toBe( + "https://api.example.test/graphql", + ); + expect(normalizeGraphqlEndpoint("https://api.example.test/graphql?token=secret")).toBeNull(); + expect(normalizeGraphqlEndpoint("https://user:pass@api.example.test/graphql")).toBeNull(); + expect(normalizeGraphqlEndpoint("https://api.example.test/graphql#secret")).toBeNull(); + expect(normalizeGraphqlEndpoint("http://api.example.test/graphql")).toBeNull(); + expect(normalizeGraphqlEndpoint("http://127.0.0.1:4000/graphql")).toBe( + "http://127.0.0.1:4000/graphql", + ); + }); +}); diff --git a/web/src/lib/graphql-source-state.ts b/web/src/lib/graphql-source-state.ts new file mode 100644 index 000000000..915d96316 --- /dev/null +++ b/web/src/lib/graphql-source-state.ts @@ -0,0 +1,137 @@ +export type GraphqlAuthDraft = { + readonly type: "none" | "bearer" | "basic" | "api_key_header" | "oauth_access_token"; + readonly headerName: string; + readonly username: string; + readonly secret: string; +}; + +export type GraphqlCredential = + | null + | { readonly type: "bearer"; readonly token: string } + | { readonly type: "basic"; readonly username: string; readonly password: string } + | { readonly type: "api_key_header"; readonly name: string; readonly value: string } + | { readonly type: "oauth_access_token"; readonly accessToken: string }; + +export type GraphqlSourceInput = { + readonly kind: "graphql"; + readonly displayName: string; + readonly preferredSlug?: string; + readonly description?: string; + readonly endpoint: string; + readonly allowPrivateNetwork?: boolean; + readonly credential?: Exclude; +}; + +export function emptyGraphqlAuthDraft(): GraphqlAuthDraft { + return { type: "none", headerName: "", username: "", secret: "" }; +} + +export function clearGraphqlSecret(draft: GraphqlAuthDraft): GraphqlAuthDraft { + return { ...draft, headerName: "", username: "", secret: "" }; +} + +export function buildGraphqlCredential(draft: GraphqlAuthDraft): GraphqlCredential | undefined { + if (draft.type === "none") return null; + if (draft.secret.trim() === "") return undefined; + if (draft.type === "bearer") return { type: "bearer", token: draft.secret }; + if (draft.type === "basic") { + const username = draft.username.trim(); + return username === "" ? undefined : { type: "basic", username, password: draft.secret }; + } + if (draft.type === "oauth_access_token") { + return { type: "oauth_access_token", accessToken: draft.secret }; + } + const name = draft.headerName.trim(); + return name === "" ? undefined : { type: "api_key_header", name, value: draft.secret }; +} + +export function graphqlDraftFromCredentialType(type: string | undefined): GraphqlAuthDraft { + const normalized = type === "manual_oauth_access_token" ? "oauth_access_token" : type; + if ( + normalized === "bearer" || + normalized === "basic" || + normalized === "api_key_header" || + normalized === "oauth_access_token" + ) { + return { type: normalized, headerName: "", username: "", secret: "" }; + } + return emptyGraphqlAuthDraft(); +} + +export function requiresGraphqlPrivateNetworkOptIn(endpoint: string) { + const url = parseHttpUrl(endpoint); + if (url === null) return false; + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true; + if (host === "::1" || host.startsWith("fc") || host.startsWith("fd")) return true; + if (host.startsWith("::ffff:")) { + const groups = host.split(":"); + const high = Number.parseInt(groups.at(-2) ?? "", 16); + const low = Number.parseInt(groups.at(-1) ?? "", 16); + if (Number.isInteger(high) && Number.isInteger(low)) { + return isPrivateIpv4([high >> 8, high & 0xff, low >> 8, low & 0xff]); + } + } + const firstIpv6Group = Number.parseInt(host.split(":", 1)[0] ?? "", 16); + if (Number.isInteger(firstIpv6Group) && firstIpv6Group >= 0xfe80 && firstIpv6Group <= 0xfebf) { + return true; + } + const octets = host.split(".").map(Number); + if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) return false; + return isPrivateIpv4(octets); +} + +export function normalizeGraphqlEndpoint(endpoint: string) { + const normalized = endpoint.trim(); + const url = parseHttpUrl(normalized); + if ( + url === null || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" || + (url.protocol === "http:" && !isLoopbackHost(url.hostname)) + ) { + return null; + } + return normalized; +} + +function isLoopbackHost(hostname: string) { + const host = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (host === "localhost" || host.endsWith(".localhost") || host === "::1") return true; + const octets = host.split(".").map(Number); + return ( + octets.length === 4 && + octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) && + octets[0] === 127 + ); +} + +function isPrivateIpv4(octets: readonly number[]) { + if (octets[0] === 10 || octets[0] === 127) return true; + if (octets[0] === 169 && octets[1] === 254) return true; + if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true; + return octets[0] === 192 && octets[1] === 168; +} + +export function safeGraphqlSourceDetails(configuration: Readonly>) { + const endpoint = + typeof configuration.endpoint === "string" ? redactedEndpoint(configuration.endpoint) : null; + return { + endpoint, + allowPrivateNetwork: configuration.allowPrivateNetwork === true, + }; +} + +function redactedEndpoint(endpoint: string) { + const url = parseHttpUrl(endpoint); + return url === null ? null : url.origin; +} + +function parseHttpUrl(value: string) { + const normalized = value.trim(); + if (!URL.canParse(normalized)) return null; + const url = new URL(normalized); + return url.protocol === "http:" || url.protocol === "https:" ? url : null; +} diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts new file mode 100644 index 000000000..856f2b6c3 --- /dev/null +++ b/web/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/web/src/lib/mcp-source-state.test.ts b/web/src/lib/mcp-source-state.test.ts new file mode 100644 index 000000000..c2e5181e8 --- /dev/null +++ b/web/src/lib/mcp-source-state.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + MCP_TOKEN_PLACEHOLDER, + buildMcpHttpCredential, + downstreamMcpSnippet, + mcpHttpFingerprint, + redactedEndpointLabel, + reconcileTemplateSelection, + requiresPrivateNetworkOptIn, + safeMcpSourceDetails, + templateDescriptorFingerprint, + templateSecretFields, + validateTemplateCatalog, + validateTemplateDraft, + type McpTemplateField, +} from "./mcp-source-state"; + +const templateFields = [ + { + key: "access_token", + label: "Access token", + description: "Token issued by the local service.", + required: true, + secret: true, + }, + { + key: "workspace", + label: "Workspace", + description: "Optional workspace name.", + required: false, + secret: false, + }, +] as const satisfies readonly McpTemplateField[]; + +describe("MCP source state", () => { + it("requires explicit opt-in for obvious local and private endpoints", () => { + expect(requiresPrivateNetworkOptIn("http://localhost:7331/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("http://10.200.1.8/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("http://127.42.0.1/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("https://192.168.1.8/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("https://172.31.4.2/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("http://[fe9f::1]/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("http://[::ffff:10.200.1.8]/mcp")).toBe(true); + expect(requiresPrivateNetworkOptIn("https://api.example.com/mcp")).toBe(false); + expect(requiresPrivateNetworkOptIn("not a URL")).toBe(false); + }); + + it("includes the private-network choice in preview identity", () => { + expect(mcpHttpFingerprint(" https://example.com/mcp ", false)).toBe( + "public:https://example.com/mcp", + ); + expect(mcpHttpFingerprint("https://example.com/mcp", true)).toBe( + "private:https://example.com/mcp", + ); + }); + + it("removes query credentials and fragments from endpoint labels", () => { + expect(redactedEndpointLabel("https://example.com/mcp?token=secret#debug")).toBe( + "https://example.com/mcp", + ); + expect(redactedEndpointLabel("file:///tmp/server")).toBeNull(); + }); + + it("accepts only template-approved fields and enforces required values", () => { + expect( + validateTemplateDraft(templateFields, { access_token: " secret ", workspace: "" }), + ).toEqual({ access_token: " secret " }); + expect(validateTemplateDraft(templateFields, { access_token: "" })).toBeNull(); + expect( + validateTemplateDraft(templateFields, { access_token: "secret", raw_command: "rm -rf" }), + ).toBeNull(); + }); + + it("builds supported HTTP auth without trimming secret bytes", () => { + expect( + buildMcpHttpCredential({ type: "none", headerName: "", username: "", secret: "" }), + ).toEqual({ + credential: null, + }); + expect( + buildMcpHttpCredential({ + type: "bearer", + headerName: "", + username: "", + secret: " exact token ", + }), + ).toEqual({ credential: { type: "bearer", token: " exact token " } }); + expect( + buildMcpHttpCredential({ + type: "api_key_header", + headerName: " X-Service-Key ", + username: "", + secret: " exact key ", + }), + ).toEqual({ + credential: { type: "api_key_header", name: "X-Service-Key", value: " exact key " }, + }); + expect( + buildMcpHttpCredential({ + type: "api_key_header", + headerName: "", + username: "", + secret: "key", + }), + ).toBeNull(); + expect( + buildMcpHttpCredential({ + type: "basic", + headerName: "", + username: " admin ", + secret: " exact password ", + }), + ).toEqual({ + credential: { type: "basic", username: "admin", password: " exact password " }, + }); + expect( + buildMcpHttpCredential({ + type: "oauth_access_token", + headerName: "", + username: "", + secret: " exact oauth token ", + }), + ).toEqual({ + credential: { type: "oauth_access_token", accessToken: " exact oauth token " }, + }); + }); + + it("rejects ambiguous template catalogs and preserves only valid selections", () => { + expect( + validateTemplateCatalog([ + { name: "github", secretFields: ["TOKEN"] }, + { name: "github", secretFields: ["OTHER"] }, + ]), + ).toBeNull(); + expect( + validateTemplateCatalog([{ name: "github", secretFields: ["TOKEN", "TOKEN"] }]), + ).toBeNull(); + const templates = [ + { name: "github", secretFields: ["TOKEN"] }, + { name: "linear", secretFields: ["API_KEY"] }, + ]; + expect(reconcileTemplateSelection("linear", templates)).toBe("linear"); + expect(reconcileTemplateSelection("missing", templates)).toBe("github"); + expect(reconcileTemplateSelection(null, [])).toBeNull(); + expect(templateSecretFields(templates[0]!)).toEqual([ + { + key: "TOKEN", + label: "TOKEN", + description: "Secret required by the github template.", + required: true, + secret: true, + }, + ]); + expect(templateDescriptorFingerprint(templates[0])).toBe('github:["TOKEN"]'); + expect(templateDescriptorFingerprint(null)).toBeNull(); + }); + + it("whitelists card metadata without returning sessions, commands, stderr, env, or secrets", () => { + const details = safeMcpSourceDetails("mcp_http", { + endpoint: "https://example.com/mcp?api_key=secret", + negotiatedProtocolVersion: "2025-06-18", + sessionId: "private-session", + command: "node", + stderr: "private output", + env: { TOKEN: "secret" }, + }); + + expect(details).toEqual({ + endpointLabel: "https://example.com/mcp", + templateName: null, + negotiatedProtocolVersion: "2025-06-18", + allowPrivateNetwork: false, + }); + expect(JSON.stringify(details)).not.toContain("secret"); + expect(JSON.stringify(details)).not.toContain("private-session"); + expect(JSON.stringify(details)).not.toContain("node"); + }); + + it("uses a placeholder token in downstream client configuration", () => { + const snippet = downstreamMcpSnippet("https://executor.example.test/admin"); + expect(snippet).toContain("https://executor.example.test/mcp"); + expect(snippet).toContain(MCP_TOKEN_PLACEHOLDER); + expect(snippet).not.toContain("sk_live"); + }); +}); diff --git a/web/src/lib/mcp-source-state.ts b/web/src/lib/mcp-source-state.ts new file mode 100644 index 000000000..e1938c274 --- /dev/null +++ b/web/src/lib/mcp-source-state.ts @@ -0,0 +1,201 @@ +export type McpSourceKind = "mcp_http" | "mcp_stdio"; + +export type McpTemplateField = { + readonly key: string; + readonly label: string; + readonly description: string; + readonly required: boolean; + readonly secret: boolean; +}; + +export type McpTemplateDraft = Readonly>; + +export type McpHttpAuthDraft = { + readonly type: "none" | "bearer" | "basic" | "api_key_header" | "oauth_access_token"; + readonly headerName: string; + readonly username: string; + readonly secret: string; +}; + +export type McpTemplateSummary = { + readonly name: string; + readonly secretFields: readonly string[]; +}; + +export type SafeMcpSourceDetails = { + readonly endpointLabel: string | null; + readonly templateName: string | null; + readonly negotiatedProtocolVersion: string | null; + readonly allowPrivateNetwork: boolean; +}; + +export const MCP_TOKEN_PLACEHOLDER = ""; + +export function mcpHttpFingerprint(endpoint: string, allowPrivateNetwork: boolean) { + return `${allowPrivateNetwork ? "private" : "public"}:${endpoint.trim()}`; +} + +export function requiresPrivateNetworkOptIn(endpoint: string) { + const url = parseHttpUrl(endpoint); + if (url === null) return false; + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true; + if (host === "::1" || isPrivateIpv6(host)) { + return true; + } + const octets = host.split(".").map(Number); + if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) return false; + return isPrivateIpv4(octets); +} + +function isPrivateIpv4(octets: readonly number[]) { + if (octets[0] === 10 || octets[0] === 127) return true; + if (octets[0] === 169 && octets[1] === 254) return true; + if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true; + return octets[0] === 192 && octets[1] === 168; +} + +export function redactedEndpointLabel(endpoint: string) { + const url = parseHttpUrl(endpoint); + if (url === null) return null; + return `${url.origin}${url.pathname}`; +} + +export function validateTemplateDraft( + fields: readonly McpTemplateField[], + draft: McpTemplateDraft, +) { + const allowedKeys = new Set(fields.map((field) => field.key)); + const hasUnknownField = Object.keys(draft).some((key) => !allowedKeys.has(key)); + if (hasUnknownField) return null; + + const values: Record = {}; + for (const field of fields) { + const rawValue = draft[field.key] ?? ""; + if (field.required && rawValue.trim() === "") return null; + if (rawValue !== "") values[field.key] = field.secret ? rawValue : rawValue.trim(); + } + return values; +} + +export function buildMcpHttpCredential(draft: McpHttpAuthDraft) { + if (draft.type === "none") return { credential: null } as const; + if (draft.secret.trim() === "") return null; + if (draft.type === "bearer") { + return { credential: { type: "bearer", token: draft.secret } } as const; + } + if (draft.type === "basic") { + const username = draft.username.trim(); + if (username === "") return null; + return { credential: { type: "basic", username, password: draft.secret } } as const; + } + if (draft.type === "oauth_access_token") { + return { + credential: { type: "oauth_access_token", accessToken: draft.secret }, + } as const; + } + const name = draft.headerName.trim(); + if (name === "") return null; + return { credential: { type: "api_key_header", name, value: draft.secret } } as const; +} + +export function validateTemplateCatalog(templates: readonly McpTemplateSummary[]) { + const names = new Set(); + const validated: McpTemplateSummary[] = []; + for (const template of templates) { + if (template.name.trim() === "" || names.has(template.name)) return null; + names.add(template.name); + const fields = new Set(); + for (const field of template.secretFields) { + if (field.trim() === "" || fields.has(field)) return null; + fields.add(field); + } + validated.push({ name: template.name, secretFields: [...template.secretFields] }); + } + return validated; +} + +export function reconcileTemplateSelection( + selected: string | null, + templates: readonly McpTemplateSummary[], +) { + if (selected !== null && templates.some((template) => template.name === selected)) { + return selected; + } + return templates[0]?.name ?? null; +} + +export function templateDescriptorFingerprint(template: McpTemplateSummary | null | undefined) { + return template === null || template === undefined + ? null + : `${template.name}:${JSON.stringify(template.secretFields)}`; +} + +export function templateSecretFields(template: McpTemplateSummary): readonly McpTemplateField[] { + return template.secretFields.map((key) => ({ + key, + label: key, + description: `Secret required by the ${template.name} template.`, + required: true, + secret: true, + })); +} + +export function safeMcpSourceDetails( + kind: McpSourceKind, + configuration: Readonly>, +): SafeMcpSourceDetails { + return { + endpointLabel: + kind === "mcp_http" && typeof configuration.endpoint === "string" + ? redactedEndpointLabel(configuration.endpoint) + : null, + templateName: + kind === "mcp_stdio" && typeof configuration.templateName === "string" + ? configuration.templateName + : null, + negotiatedProtocolVersion: + typeof configuration.negotiatedProtocolVersion === "string" + ? configuration.negotiatedProtocolVersion + : null, + allowPrivateNetwork: configuration.allowPrivateNetwork === true, + }; +} + +export function downstreamMcpSnippet(origin: string) { + const endpoint = new URL("/mcp", origin).toString(); + return JSON.stringify( + { + mcpServers: { + executor: { + type: "http", + url: endpoint, + headers: { Authorization: `Bearer ${MCP_TOKEN_PLACEHOLDER}` }, + }, + }, + }, + null, + 2, + ); +} + +function parseHttpUrl(value: string) { + const normalized = value.trim(); + if (!URL.canParse(normalized)) return null; + const url = new URL(normalized); + return url.protocol === "http:" || url.protocol === "https:" ? url : null; +} + +function isPrivateIpv6(host: string) { + if (host.startsWith("fc") || host.startsWith("fd")) return true; + if (host.startsWith("::ffff:")) { + const groups = host.split(":"); + const high = Number.parseInt(groups.at(-2) ?? "", 16); + const low = Number.parseInt(groups.at(-1) ?? "", 16); + if (Number.isInteger(high) && Number.isInteger(low)) { + return isPrivateIpv4([high >> 8, high & 0xff, low >> 8, low & 0xff]); + } + } + const firstGroup = Number.parseInt(host.split(":", 1)[0] ?? "", 16); + return Number.isInteger(firstGroup) && firstGroup >= 0xfe80 && firstGroup <= 0xfebf; +} diff --git a/web/src/lib/navigation.test.ts b/web/src/lib/navigation.test.ts new file mode 100644 index 000000000..bf001f923 --- /dev/null +++ b/web/src/lib/navigation.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "@effect/vitest"; +import { consumeSetupToken, routeDestination, safeReturnTo } from "./navigation"; + +describe("dashboard navigation helpers", () => { + it("consumes a setup token without putting it in the replacement URL", () => { + let replacement = ""; + const token = consumeSetupToken( + new URL("http://executor.local/setup?from=terminal#token=set_secret"), + (nextUrl) => { + replacement = nextUrl; + }, + ); + + expect(token).toBe("set_secret"); + expect(replacement).toBe("/setup?from=terminal"); + expect(replacement).not.toContain("set_secret"); + }); + + it("accepts only protected same-origin return paths", () => { + expect(safeReturnTo("/tokens?created=true", "http://executor.local")).toBe( + "/tokens?created=true", + ); + expect(safeReturnTo("/tokens#active", "http://executor.local")).toBe("/tokens#active"); + expect(safeReturnTo("/tokens#token=set_secret", "http://executor.local")).toBe("/tokens"); + expect(safeReturnTo("//attacker.example/tokens", "http://executor.local")).toBeNull(); + expect(safeReturnTo("https://attacker.example/tokens", "http://executor.local")).toBeNull(); + expect(safeReturnTo("/login", "http://executor.local")).toBeNull(); + }); + + it("retains an internal destination across login", () => { + const destination = routeDestination({ + pathname: "/tokens", + search: "?filter=active", + hash: "#latest", + origin: "http://executor.local", + setupRequired: false, + authenticated: false, + }); + + expect(destination).toBe("/login?returnTo=%2Ftokens%3Ffilter%3Dactive%23latest"); + }); + + it("omits a setup token fragment from the login return path", () => { + const destination = routeDestination({ + pathname: "/tokens", + search: "", + hash: "#token=set_secret", + origin: "http://executor.local", + setupRequired: false, + authenticated: false, + }); + + expect(destination).toBe("/login?returnTo=%2Ftokens"); + }); +}); diff --git a/web/src/lib/navigation.ts b/web/src/lib/navigation.ts new file mode 100644 index 000000000..902569978 --- /dev/null +++ b/web/src/lib/navigation.ts @@ -0,0 +1,58 @@ +const protectedRoutes = ["/sources", "/tools", "/approvals", "/logs", "/tokens"]; + +export function isProtectedPath(pathname: string) { + return protectedRoutes.some((route) => pathname === route || pathname.startsWith(`${route}/`)); +} + +export function safeReturnTo(value: string | null, origin: string) { + if (value === null || !value.startsWith("/") || value.startsWith("//")) return null; + + if (!URL.canParse(value, origin)) return null; + const url = new URL(value, origin); + + if (url.origin !== origin || !isProtectedPath(url.pathname)) return null; + return `${url.pathname}${url.search}${safeReturnHash(url.hash)}`; +} + +export function safeReturnHash(hash: string) { + if (hash === "") return ""; + return new URLSearchParams(hash.slice(1)).has("token") ? "" : hash; +} + +export function consumeSetupToken(url: URL, replace: (nextUrl: string) => void) { + if (url.hash === "") return null; + + const token = new URLSearchParams(url.hash.slice(1)).get("token"); + replace(`${url.pathname}${url.search}`); + return token === null || token === "" ? null : token; +} + +export function routeDestination(input: { + pathname: string; + search: string; + hash: string; + origin: string; + setupRequired: boolean; + authenticated: boolean; +}) { + const { pathname, search, hash, origin, setupRequired, authenticated } = input; + + if (setupRequired) return pathname === "/setup" ? null : "/setup"; + + if (authenticated) { + if (pathname === "/login") { + const returnTo = new URLSearchParams(search).get("returnTo"); + return safeReturnTo(returnTo, origin) ?? "/sources"; + } + if (pathname === "/" || pathname === "/setup") return "/sources"; + if (!isProtectedPath(pathname)) return "/sources"; + return null; + } + + if (pathname === "/login") return null; + if (isProtectedPath(pathname)) { + const returnTo = `${pathname}${search}${safeReturnHash(hash)}`; + return `/login?returnTo=${encodeURIComponent(returnTo)}`; + } + return "/login"; +} diff --git a/web/src/lib/oauth-connection-state.test.ts b/web/src/lib/oauth-connection-state.test.ts new file mode 100644 index 000000000..418607fde --- /dev/null +++ b/web/src/lib/oauth-connection-state.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + buildOAuthConnectionInput, + canDisconnectOAuth, + draftFromOAuthSummary, + normalizeOAuthScopes, + oauthCallbackConnectionId, + oauthCallbackOutcomeNotice, + oauthCallbackNoticeWithoutEligibleSources, + oauthCallbackRefreshKey, + safeAuthorizationUrl, + withoutOAuthCallbackParameters, + type OAuthConnectionSummary, +} from "./oauth-connection-state"; + +const summary: OAuthConnectionSummary = { + id: "oauth-1", + credentialKey: "oauth", + revision: 4, + status: "connected", + issuer: "https://identity.example.test", + clientId: "executor", + clientAuthMethod: "client_secret_basic", + callbackUrl: "https://executor.example.test/api/v1/oauth/callback", + requestedScopes: ["read", "write"], + grantedScopes: ["read"], + hasClientSecret: true, + hasRefreshToken: true, + accessExpiresAt: 100, + authorizedAt: 90, + lastRefreshedAt: 95, + errorCode: null, + managedOAuthEligible: true, +}; + +describe("OAuth connection state", () => { + it("builds a confidential-client update with explicit secret preservation", () => { + const draft = draftFromOAuthSummary(summary); + expect(buildOAuthConnectionInput(draft, summary.revision)).toEqual({ + expectedRevision: 4, + discovery: { type: "issuer", issuer: "https://identity.example.test" }, + client: { + clientId: "executor", + authentication: "client_secret_basic", + clientSecret: { action: "preserve" }, + }, + scopes: ["read", "write"], + }); + }); + + it("makes changing to a public client an explicit secret-clearing operation", () => { + const draft = { ...draftFromOAuthSummary(summary), clientKind: "public" as const }; + expect(buildOAuthConnectionInput(draft, 4)).toEqual({ + expectedRevision: 4, + discovery: { type: "issuer", issuer: "https://identity.example.test" }, + client: { clientId: "executor", authentication: "none" }, + scopes: ["read", "write"], + }); + }); + + it("requires a replacement value and never trims secret bytes", () => { + const draft = { + ...draftFromOAuthSummary(summary), + clientSecretAction: "replace" as const, + clientSecret: " exact secret ", + }; + expect(buildOAuthConnectionInput(draft, 4)?.client).toEqual({ + clientId: "executor", + authentication: "client_secret_basic", + clientSecret: { action: "replace", value: " exact secret " }, + }); + expect(buildOAuthConnectionInput({ ...draft, clientSecret: "" }, 4)).toBeNull(); + }); + + it("builds MCP protected-resource discovery with an optional server override", () => { + const draft = { + ...draftFromOAuthSummary(summary), + issuer: "", + clientKind: "public" as const, + }; + expect(buildOAuthConnectionInput(draft, 0, "mcp")?.discovery).toEqual({ type: "mcp" }); + expect( + buildOAuthConnectionInput( + { ...draft, issuer: "https://identity.example.test/issuer" }, + 0, + "mcp", + )?.discovery, + ).toEqual({ + type: "mcp", + authorizationServer: "https://identity.example.test/issuer", + }); + }); + + it("preserves the exact root issuer path after validating the URL", () => { + const draft = { + ...draftFromOAuthSummary(summary), + clientKind: "public" as const, + }; + expect( + buildOAuthConnectionInput({ ...draft, issuer: "https://identity.example.test" }, 4), + ).toMatchObject({ + discovery: { type: "issuer", issuer: "https://identity.example.test" }, + }); + expect( + buildOAuthConnectionInput({ ...draft, issuer: "https://identity.example.test/" }, 4), + ).toMatchObject({ + discovery: { type: "issuer", issuer: "https://identity.example.test/" }, + }); + }); + + it("normalizes and de-duplicates requested scopes", () => { + expect(normalizeOAuthScopes("read, write\nread profile")).toEqual(["read", "write", "profile"]); + }); + + it("uses only allowlisted callback fields as an opaque refetch key", () => { + const parameters = new URLSearchParams({ + result: "failed", + oauth: "connection-1", + error_description: "provider secret detail", + code: "authorization-code", + }); + expect(oauthCallbackRefreshKey(parameters)).toBe("failed:connection-1"); + expect(oauthCallbackConnectionId("failed:connection-1")).toBe("connection-1"); + parameters.set("result", "success_refresh_failed"); + expect(oauthCallbackRefreshKey(parameters)).toBe("success_refresh_failed:connection-1"); + parameters.set("result", "provider-specific-value"); + expect(oauthCallbackRefreshKey(parameters)).toBeNull(); + }); + + it("cleans only the fixed callback fields after refetch", () => { + const cleaned = withoutOAuthCallbackParameters( + new URL("https://executor.test/sources?oauth=connection-1&result=success&filter=active#list"), + ); + expect(cleaned.toString()).toBe("https://executor.test/sources?filter=active#list"); + }); + + it("returns fixed callback notices without provider-controlled detail", () => { + expect(oauthCallbackOutcomeNotice("failed:connection-1", true).message).toContain( + "did not complete", + ); + expect(oauthCallbackOutcomeNotice("success:connection-1", true).message).toContain("completed"); + expect(oauthCallbackOutcomeNotice("success_refresh_failed:connection-1", true)).toEqual({ + tone: "error", + message: + "OAuth authorization completed, but the source catalog could not be refreshed. The connection remains authorized; retry the source refresh.", + }); + expect(oauthCallbackOutcomeNotice("success:missing", false).message).toContain("no matching"); + }); + + it("handles a callback after loading when no OAuth-capable source can report it", () => { + expect( + oauthCallbackNoticeWithoutEligibleSources("failed:missing", ["mcp_stdio"], false)?.message, + ).toContain("did not complete"); + expect( + oauthCallbackNoticeWithoutEligibleSources("failed:missing", ["openapi"], false), + ).toBeNull(); + expect(oauthCallbackNoticeWithoutEligibleSources("failed:missing", [], true)).toBeNull(); + }); + + it("allows secure authorization destinations and loopback HTTP only", () => { + expect(safeAuthorizationUrl("https://identity.example.test/authorize?state=opaque")).toBe( + "https://identity.example.test/authorize?state=opaque", + ); + expect(safeAuthorizationUrl("http://127.0.0.42:9911/authorize?state=opaque")).toBe( + "http://127.0.0.42:9911/authorize?state=opaque", + ); + expect(safeAuthorizationUrl("http://identity.example.test/authorize")).toBeNull(); + expect(safeAuthorizationUrl("https://operator@identity.example.test/authorize")).toBeNull(); + expect(safeAuthorizationUrl("https://identity.example.test/authorize#token")).toBeNull(); + expect(safeAuthorizationUrl("javascript:alert(1)")).toBeNull(); + }); + + it("rejects unsafe issuer URLs before building a save payload", () => { + const draft = draftFromOAuthSummary(summary); + expect( + buildOAuthConnectionInput({ ...draft, issuer: "http://identity.example.test" }, 4), + ).toBeNull(); + expect( + buildOAuthConnectionInput({ ...draft, issuer: "https://user@identity.example.test" }, 4), + ).toBeNull(); + expect( + buildOAuthConnectionInput({ ...draft, issuer: "https://identity.example.test?tenant=a" }, 4), + ).toBeNull(); + expect( + buildOAuthConnectionInput({ ...draft, issuer: "https://identity.example.test#fragment" }, 4), + ).toBeNull(); + expect( + buildOAuthConnectionInput({ ...draft, issuer: "http://localhost:8443/issuer" }, 4), + ).not.toBeNull(); + }); + + it("disconnects token-bearing statuses without assuming a refresh token exists", () => { + expect(canDisconnectOAuth("connected")).toBe(true); + expect(canDisconnectOAuth("reauthorization_required")).toBe(true); + expect(canDisconnectOAuth("ready_to_connect")).toBe(false); + expect(canDisconnectOAuth("connecting")).toBe(false); + }); +}); diff --git a/web/src/lib/oauth-connection-state.ts b/web/src/lib/oauth-connection-state.ts new file mode 100644 index 000000000..9ab5c9263 --- /dev/null +++ b/web/src/lib/oauth-connection-state.ts @@ -0,0 +1,308 @@ +export type OAuthClientAuthentication = "none" | "client_secret_basic" | "client_secret_post"; + +export type OAuthConnectionStatus = + | "not_configured" + | "ready_to_connect" + | "connecting" + | "connected" + | "reauthorization_required" + | "error"; + +export type OAuthConnectionSummary = { + readonly id: string; + readonly credentialKey: string; + readonly revision: number; + readonly status: OAuthConnectionStatus; + readonly issuer: string; + readonly clientId: string; + readonly clientAuthMethod: OAuthClientAuthentication; + readonly callbackUrl: string; + readonly requestedScopes: readonly string[]; + readonly grantedScopes: readonly string[]; + readonly hasClientSecret: boolean; + readonly hasRefreshToken: boolean; + readonly accessExpiresAt: number | null; + readonly authorizedAt: number | null; + readonly lastRefreshedAt: number | null; + readonly errorCode: string | null; + readonly managedOAuthEligible: boolean; +}; + +export type OAuthConnectionList = { + readonly connections: readonly OAuthConnectionSummary[]; + readonly availableCredentials: readonly OAuthAvailableCredential[]; +}; + +export type OAuthAvailableCredential = { + readonly credentialKey: string; + readonly protocol: "openapi" | "graphql" | "mcp_http"; + readonly requestedScopes: readonly string[]; + readonly managedOAuthEligible: true; +}; + +export type OAuthClientSecretMutation = + | { readonly action: "preserve" } + | { readonly action: "replace"; readonly value: string }; + +export type OAuthConnectionInput = { + readonly expectedRevision: number; + readonly discovery: + | { readonly type: "issuer"; readonly issuer: string } + | { readonly type: "mcp"; readonly authorizationServer?: string }; + readonly client: + | { readonly clientId: string; readonly authentication: "none" } + | { + readonly clientId: string; + readonly authentication: "client_secret_basic" | "client_secret_post"; + readonly clientSecret: OAuthClientSecretMutation; + }; + readonly scopes: readonly string[]; +}; + +export type OAuthAuthorization = { + readonly authorizationUrl: string; +}; + +export type OAuthOperationError = { + readonly code: string; + readonly displayMessage: string; + readonly requestId: string | null; + readonly status: number; +}; + +export type OAuthOperationResult = + | { readonly ok: true; readonly value: Value } + | { readonly ok: false; readonly error: OAuthOperationError }; + +export type OAuthConnectionOperations = { + readonly load: ( + sourceId: string, + signal: AbortSignal, + ) => Promise>; + readonly save: ( + sourceId: string, + credentialKey: string, + input: OAuthConnectionInput, + signal: AbortSignal, + ) => Promise>; + readonly authorize: ( + sourceId: string, + credentialKey: string, + input: { readonly expectedRevision: number }, + signal: AbortSignal, + ) => Promise>; + readonly disconnect: ( + sourceId: string, + credentialKey: string, + input: { readonly expectedRevision: number }, + signal: AbortSignal, + ) => Promise>; + readonly remove: ( + sourceId: string, + credentialKey: string, + input: { readonly expectedRevision: number }, + signal: AbortSignal, + ) => Promise>; +}; + +export type OAuthConnectionDraft = { + readonly issuer: string; + readonly clientKind: "public" | "confidential"; + readonly clientId: string; + readonly clientAuthMethod: "client_secret_basic" | "client_secret_post"; + readonly clientSecretAction: "preserve" | "replace" | "clear"; + readonly clientSecret: string; + readonly scopes: string; +}; + +export function draftFromOAuthSummary(summary: OAuthConnectionSummary): OAuthConnectionDraft { + const confidential = summary.clientAuthMethod !== "none"; + return { + issuer: summary.issuer, + clientKind: confidential ? "confidential" : "public", + clientId: summary.clientId, + clientAuthMethod: confidential ? summary.clientAuthMethod : "client_secret_basic", + clientSecretAction: confidential && summary.hasClientSecret ? "preserve" : "clear", + clientSecret: "", + scopes: summary.requestedScopes.join(" "), + }; +} + +export function emptyOAuthDraft(): OAuthConnectionDraft { + return { + issuer: "", + clientKind: "public", + clientId: "", + clientAuthMethod: "client_secret_basic", + clientSecretAction: "clear", + clientSecret: "", + scopes: "", + }; +} + +export function buildOAuthConnectionInput( + draft: OAuthConnectionDraft, + expectedRevision: number, + discoveryType: "issuer" | "mcp" = "issuer", +): OAuthConnectionInput | null { + const clientId = draft.clientId.trim(); + const issuer = draft.issuer.trim() === "" ? null : normalizedHttpUrl(draft.issuer); + if (clientId === "" || (discoveryType === "issuer" && issuer === null)) return null; + if (draft.issuer.trim() !== "" && issuer === null) return null; + const discovery: OAuthConnectionInput["discovery"] = + discoveryType === "mcp" + ? { + type: "mcp", + ...(issuer === null ? {} : { authorizationServer: issuer }), + } + : { type: "issuer", issuer: issuer ?? "" }; + + const scopes = normalizeOAuthScopes(draft.scopes); + if (draft.clientKind === "public") { + return { + expectedRevision, + discovery, + client: { clientId, authentication: "none" }, + scopes, + }; + } + + if (draft.clientSecretAction === "clear") return null; + if (draft.clientSecretAction === "replace" && draft.clientSecret === "") return null; + const clientSecret = + draft.clientSecretAction === "replace" + ? { action: "replace" as const, value: draft.clientSecret } + : { action: "preserve" as const }; + return { + expectedRevision, + discovery, + client: { + clientId, + authentication: draft.clientAuthMethod, + clientSecret, + }, + scopes, + }; +} + +export function normalizeOAuthScopes(value: string) { + return [...new Set(value.split(/[\s,]+/u).filter(Boolean))]; +} + +export function oauthCallbackRefreshKey(parameters: URLSearchParams) { + const result = parameters.get("result"); + if (result !== "success" && result !== "success_refresh_failed" && result !== "failed") { + return null; + } + const connectionId = parameters.get("oauth"); + if (connectionId === null || connectionId.trim() === "") return null; + return `${result}:${connectionId}`; +} + +export function oauthCallbackConnectionId(refreshKey: string | null) { + if (refreshKey === null) return null; + const separator = refreshKey.indexOf(":"); + return separator < 0 ? null : refreshKey.slice(separator + 1); +} + +export function oauthCallbackOutcomeNotice(refreshKey: string, matched: boolean) { + if (refreshKey.startsWith("failed:")) { + return { + tone: "error" as const, + message: "OAuth authorization did not complete. Review the connection status and try again.", + }; + } + if (!matched) { + return { + tone: "error" as const, + message: "OAuth returned, but no matching managed connection is available.", + }; + } + if (refreshKey.startsWith("success_refresh_failed:")) { + return { + tone: "error" as const, + message: + "OAuth authorization completed, but the source catalog could not be refreshed. The connection remains authorized; retry the source refresh.", + }; + } + return { + tone: "success" as const, + message: "OAuth authorization completed and the connection status was refreshed.", + }; +} + +export function oauthCallbackNoticeWithoutEligibleSources( + refreshKey: string | null, + sourceKinds: readonly string[], + loading: boolean, +) { + if ( + refreshKey === null || + loading || + sourceKinds.some((kind) => kind === "openapi" || kind === "graphql" || kind === "mcp_http") + ) { + return null; + } + return oauthCallbackOutcomeNotice(refreshKey, false); +} + +export function withoutOAuthCallbackParameters(value: URL) { + const url = new URL(value); + url.searchParams.delete("oauth"); + url.searchParams.delete("result"); + return url; +} + +export function safeAuthorizationUrl(value: string) { + if (!URL.canParse(value)) return null; + const url = new URL(value); + if (!isSecureOAuthUrl(url) || hasUserInfo(url) || url.hash !== "") return null; + return url.toString(); +} + +export function oauthStatusTone(status: OAuthConnectionStatus) { + if (status === "connected") return "connected"; + if (status === "error" || status === "reauthorization_required") return "error"; + if (status === "connecting") return "pending"; + return "neutral"; +} + +export function oauthStatusLabel(status: OAuthConnectionStatus) { + if (status === "not_configured") return "Not configured"; + if (status === "ready_to_connect") return "Ready to connect"; + if (status === "connecting") return "Connecting"; + if (status === "connected") return "Connected"; + if (status === "reauthorization_required") return "Reconnect required"; + return "Connection error"; +} + +export function canDisconnectOAuth(status: OAuthConnectionStatus) { + return status === "connected" || status === "reauthorization_required"; +} + +function normalizedHttpUrl(value: string) { + const normalized = value.trim(); + if (!URL.canParse(normalized)) return null; + const url = new URL(normalized); + if (!isSecureOAuthUrl(url) || hasUserInfo(url) || url.search !== "" || url.hash !== "") { + return null; + } + return normalized; +} + +function isSecureOAuthUrl(url: URL) { + if (url.protocol === "https:") return true; + if (url.protocol !== "http:") return false; + const host = url.hostname.toLowerCase().replace(/^\[|\]$/gu, ""); + if (host === "localhost" || host.endsWith(".localhost") || host === "::1") return true; + const octets = host.split(".").map(Number); + return ( + octets.length === 4 && + octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) && + octets[0] === 127 + ); +} + +function hasUserInfo(url: URL) { + return url.username !== "" || url.password !== ""; +} diff --git a/web/src/lib/openapi-credentials.test.ts b/web/src/lib/openapi-credentials.test.ts new file mode 100644 index 000000000..3bdc15aa9 --- /dev/null +++ b/web/src/lib/openapi-credentials.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + buildCredentialMap, + duplicateCredentialNames, + type CredentialDraft, +} from "./openapi-credentials"; + +function draft( + name: string, + credentialType: CredentialDraft["credentialType"], + value: string, + username = "", +): CredentialDraft { + return { key: name, name, credentialType, enabled: true, value, username }; +} + +describe("OpenAPI credential drafts", () => { + it("serializes every supported credential without changing OAuth field names", () => { + expect( + buildCredentialMap([ + draft("key", "api_key", "key-secret"), + draft("bearer", "bearer", "bearer-secret"), + draft("basic", "basic", "password", "davis"), + draft("oauth", "oauth_access_token", "oauth-secret"), + ]), + ).toEqual({ + key: { type: "api_key", value: "key-secret" }, + bearer: { type: "bearer", token: "bearer-secret" }, + basic: { type: "basic", username: "davis", password: "password" }, + oauth: { type: "oauth_access_token", access_token: "oauth-secret" }, + }); + }); + + it("ignores disabled preview schemes", () => { + expect( + buildCredentialMap([{ ...draft("unused", "bearer", "secret"), enabled: false }]), + ).toEqual({}); + }); + + it("rejects duplicate trimmed scheme names without dropping a secret", () => { + const rows = [draft("auth", "bearer", "one"), draft(" auth ", "bearer", "two")]; + expect(duplicateCredentialNames(rows)).toEqual(["auth"]); + expect(buildCredentialMap(rows)).toBeNull(); + }); + + it("rejects enabled credentials with missing names or secret values", () => { + expect(buildCredentialMap([draft("", "api_key", "secret")])).toBeNull(); + expect(buildCredentialMap([draft("auth", "api_key", "")])).toBeNull(); + }); +}); diff --git a/web/src/lib/openapi-credentials.ts b/web/src/lib/openapi-credentials.ts new file mode 100644 index 000000000..bba9371e9 --- /dev/null +++ b/web/src/lib/openapi-credentials.ts @@ -0,0 +1,49 @@ +import type { OpenApiStaticCredential } from "$lib/api"; + +export type SupportedCredentialType = "api_key" | "bearer" | "basic" | "oauth_access_token"; + +export type CredentialDraft = { + key: string; + name: string; + credentialType: SupportedCredentialType; + enabled: boolean; + value: string; + username: string; +}; + +export function buildCredentialMap(rows: readonly CredentialDraft[]) { + const credentials: Record = {}; + const names = new Set(); + for (const row of rows) { + if (!row.enabled) continue; + const name = row.name.trim(); + if (name === "" || row.value === "" || names.has(name)) return null; + names.add(name); + if (row.credentialType === "api_key") { + credentials[name] = { type: "api_key", value: row.value }; + } else if (row.credentialType === "bearer") { + credentials[name] = { type: "bearer", token: row.value }; + } else if (row.credentialType === "oauth_access_token") { + credentials[name] = { type: "oauth_access_token", access_token: row.value }; + } else { + credentials[name] = { type: "basic", username: row.username, password: row.value }; + } + } + return credentials; +} + +export function duplicateCredentialNames(rows: readonly CredentialDraft[]) { + const seen = new Set(); + const duplicates = new Set(); + for (const row of rows) { + if (!row.enabled) continue; + const name = row.name.trim(); + if (name !== "" && seen.has(name)) duplicates.add(name); + seen.add(name); + } + return [...duplicates]; +} + +export function isSupportedCredentialType(value: string): value is SupportedCredentialType { + return ["api_key", "bearer", "basic", "oauth_access_token"].includes(value); +} diff --git a/web/src/lib/source-create-lifecycle.test.ts b/web/src/lib/source-create-lifecycle.test.ts new file mode 100644 index 000000000..8e57b9f67 --- /dev/null +++ b/web/src/lib/source-create-lifecycle.test.ts @@ -0,0 +1,678 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { Effect } from "effect"; +import { + ApiError, + createGraphqlSource, + type ApiResult, + type Source, + type SourceCreateApiResult, + type SourceCreationResolution, +} from "$lib/api"; +import { + SOURCE_CREATE_STORAGE_KEY, + createSourceCreateCoordinator, + isAmbiguousSourceCreateResult, + type SourceCreateEnvironment, + type SourceCreateInput, + type SourceCreateState, +} from "$lib/source-create-lifecycle"; + +function sourceFixture(): Source { + return { + id: "source-1", + kind: "graphql", + slug: "product", + displayName: "Product API", + description: null, + configuration: { endpoint: "https://api.example.test", allowPrivateNetwork: false }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 4, + tombstonedToolCount: 0, + }; +} + +function payload(secret = "exact-secret") { + return { + kind: "graphql", + displayName: "Product API", + endpoint: "https://api.example.test/graphql", + credential: { type: "bearer", token: secret }, + } satisfies SourceCreateInput; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +class MemoryStorage { + readonly values = new Map(); + readonly writes: Array<{ key: string; value: string }> = []; + + getItem(key: string) { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string) { + this.values.set(key, value); + this.writes.push({ key, value }); + } + + removeItem(key: string) { + this.values.delete(key); + } + + clone() { + const clone = new MemoryStorage(); + for (const [key, value] of this.values) clone.values.set(key, value); + return clone; + } +} + +function environment( + storage: MemoryStorage, + options: { + readonly fillRandom?: SourceCreateEnvironment["fillRandom"]; + readonly wait?: SourceCreateEnvironment["wait"]; + } = {}, +): SourceCreateEnvironment { + return { + getStorage: () => storage, + fillRandom: + options.fillRandom ?? + ((bytes) => { + bytes.fill(0xab); + }), + wait: options.wait ?? (() => Promise.resolve(true)), + }; +} + +function apiError(code: string, status: number) { + return new ApiError({ code, displayMessage: code, requestId: null, status }); +} + +function sourceCreateSuccess() { + return { + ok: true, + value: sourceFixture(), + replayProvenance: "none", + responseDisposition: "authoritative", + } as const satisfies SourceCreateApiResult; +} + +function sourceCreateFailure( + code: string, + status: number, + replayProvenance: SourceCreateApiResult["replayProvenance"] = "none", + responseDisposition: SourceCreateApiResult["responseDisposition"] = status >= 400 && status <= 499 + ? "authoritative" + : "ambiguous", +) { + return { + ok: false, + error: apiError(code, status), + replayProvenance, + responseDisposition, + } as const satisfies SourceCreateApiResult; +} + +function freshSourceResponse(status: number, includeNoStore = true) { + const nativeStatus = status < 200 ? 200 : status > 599 ? 599 : status; + const headers = includeNoStore ? { "cache-control": "no-store" } : undefined; + const response = + nativeStatus === 204 + ? new Response(null, { status: nativeStatus, headers }) + : Response.json(sourceFixture(), { status: nativeStatus, headers }); + if (nativeStatus !== status) Object.defineProperty(response, "status", { value: status }); + return response; +} + +function freshErrorResponse(status: number, code = "invalid_source", includeNoStore = true) { + const nativeStatus = status < 200 ? 200 : status > 599 ? 599 : status; + const response = Response.json( + { + error: { + code, + message: `Failure ${status}`, + requestId: `failure-${status}`, + }, + }, + { + status: nativeStatus, + headers: includeNoStore ? { "cache-control": "no-store" } : undefined, + }, + ); + if (nativeStatus !== status) Object.defineProperty(response, "status", { value: status }); + return response; +} + +function successfulList() { + return { + ok: true, + value: { sources: [sourceFixture()], catalogRevision: 1 }, + } as const; +} + +function coordinatorHarness( + storage: MemoryStorage, + overrides: Partial<{ + environment: SourceCreateEnvironment; + create: ( + input: SourceCreateInput, + key: string, + signal: AbortSignal, + ) => Promise; + lookup: (key: string, signal: AbortSignal) => Promise>; + seal: (key: string, signal: AbortSignal) => Promise>; + refresh: (signal: AbortSignal) => Promise | null>; + }> = {}, +) { + const states: SourceCreateState[] = []; + const completed: Source[] = []; + const coordinator = createSourceCreateCoordinator({ + environment: overrides.environment ?? environment(storage), + create: overrides.create ?? (() => Promise.resolve(sourceCreateSuccess())), + lookup: + overrides.lookup ?? + (() => + Promise.resolve({ + ok: true, + value: { kind: "status", status: "missing" }, + } as const)), + seal: + overrides.seal ?? + (() => + Promise.resolve({ + ok: true, + value: { kind: "status", status: "abandoned" }, + } as const)), + refresh: overrides.refresh ?? (() => Promise.resolve(successfulList())), + onstatechange: (state) => states.push(state), + oncompleted: (source) => completed.push(source), + }); + return { coordinator, states, completed }; +} + +describe("source create lifecycle", () => { + it("classifies only outcomes that require key reconciliation as ambiguous", () => { + expect(isAmbiguousSourceCreateResult(sourceCreateFailure("network_error", 0))).toBe(true); + expect(isAmbiguousSourceCreateResult(sourceCreateFailure("request_cancelled", 0))).toBe(true); + expect(isAmbiguousSourceCreateResult(sourceCreateFailure("invalid_response", 502))).toBe(true); + expect(isAmbiguousSourceCreateResult(sourceCreateFailure("unexpected_client_error", 0))).toBe( + true, + ); + expect(isAmbiguousSourceCreateResult(sourceCreateFailure("http_502", 502))).toBe(true); + expect(isAmbiguousSourceCreateResult(sourceCreateFailure("http_504", 504))).toBe(true); + expect(isAmbiguousSourceCreateResult(sourceCreateFailure("idempotency_in_progress", 409))).toBe( + true, + ); + expect(isAmbiguousSourceCreateResult(sourceCreateFailure("unauthorized", 401))).toBe(true); + expect(isAmbiguousSourceCreateResult(sourceCreateFailure("typed_gateway_error", 502))).toBe( + true, + ); + expect( + isAmbiguousSourceCreateResult( + sourceCreateFailure("internal_error", 500, "authoritative", "authoritative"), + ), + ).toBe(false); + expect( + isAmbiguousSourceCreateResult({ + ...sourceCreateSuccess(), + replayProvenance: "invalid", + responseDisposition: "ambiguous", + }), + ).toBe(true); + expect(isAmbiguousSourceCreateResult(sourceCreateFailure("invalid_source", 400))).toBe(false); + }); + + it("persists only a 128-bit opaque key synchronously before dispatch", async () => { + const storage = new MemoryStorage(); + const submitted: Array<{ input: SourceCreateInput; key: string; stored: string | null }> = []; + const input = payload("never-persist-this-secret"); + const { coordinator } = coordinatorHarness(storage, { + create: async (createdInput, key) => { + submitted.push({ + input: createdInput, + key, + stored: storage.getItem(SOURCE_CREATE_STORAGE_KEY), + }); + return sourceCreateSuccess(); + }, + }); + + const result = await coordinator.start(input); + + expect(result.ok).toBe(true); + expect(submitted).toHaveLength(1); + expect(submitted[0]?.key).toMatch(/^[0-9a-f]{32}$/); + expect(submitted[0]?.stored).toBe(submitted[0]?.key); + expect(submitted[0]?.input).toBe(input); + expect(storage.writes).toEqual([ + { key: SOURCE_CREATE_STORAGE_KEY, value: submitted[0]?.key ?? "" }, + ]); + expect(JSON.stringify(storage.writes)).not.toContain("never-persist-this-secret"); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + }); + + it("fails closed when secure randomness or tab storage is unavailable", async () => { + const randomStorage = new MemoryStorage(); + const randomCreate = vi.fn(); + const randomHarness = coordinatorHarness(randomStorage, { + environment: environment(randomStorage, { + fillRandom: () => Effect.runSync(Effect.fail("randomness unavailable")), + }), + create: randomCreate, + }); + const storageCreate = vi.fn(); + const storageHarness = coordinatorHarness(new MemoryStorage(), { + environment: { + getStorage: () => Effect.runSync(Effect.fail("storage unavailable")), + fillRandom: (bytes) => bytes.fill(1), + wait: () => Promise.resolve(true), + }, + create: storageCreate, + }); + + const randomResult = await randomHarness.coordinator.start(payload()); + const storageResult = await storageHarness.coordinator.start(payload()); + + expect(randomResult.ok).toBe(false); + expect(!randomResult.ok && randomResult.error.code).toBe( + "source_create_randomness_unavailable", + ); + expect(storageResult.ok).toBe(false); + expect(!storageResult.ok && storageResult.error.code).toBe("source_create_storage_unavailable"); + expect(randomCreate).not.toHaveBeenCalled(); + expect(storageCreate).not.toHaveBeenCalled(); + }); + + it("retries an ambiguous live request with the exact key and in-memory payload", async () => { + const storage = new MemoryStorage(); + const calls: Array<{ input: SourceCreateInput; key: string }> = []; + let createCalls = 0; + const { coordinator } = coordinatorHarness(storage, { + create: async (input, key) => { + calls.push({ input, key }); + createCalls += 1; + return createCalls === 1 ? sourceCreateFailure("network_error", 0) : sourceCreateSuccess(); + }, + }); + const input = payload("same-secret-payload"); + + const result = await coordinator.start(input); + + expect(result.ok).toBe(true); + expect(calls).toHaveLength(2); + expect(calls[0]?.key).toBe(calls[1]?.key); + expect(calls[0]?.input).toBe(input); + expect(calls[1]?.input).toBe(input); + expect(JSON.stringify(storage.writes)).not.toContain("same-secret-payload"); + }); + + it("retains a non-replayed structured 5xx while reconciling its key", async () => { + const storage = new MemoryStorage(); + const lookup = vi.fn(() => + Promise.resolve({ ok: false, error: apiError("network_error", 0) } as const), + ); + const { coordinator, states } = coordinatorHarness(storage, { + create: () => Promise.resolve(sourceCreateFailure("internal_error", 500)), + lookup, + }); + + const result = await coordinator.start(payload()); + const key = storage.writes[0]?.value ?? null; + + expect(result).toMatchObject({ ok: false, error: { code: "network_error" } }); + expect(lookup).toHaveBeenCalledOnce(); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + expect(states.at(-1)).toMatchObject({ phase: "blocked", key, retry: "lookup" }); + }); + + it("reports and clears a structured 5xx only when it is a valid stored replay", async () => { + const storage = new MemoryStorage(); + const lookup = vi.fn(); + const error = apiError("internal_error", 500); + const { coordinator, states } = coordinatorHarness(storage, { + create: () => + Promise.resolve({ + ok: false, + error, + replayProvenance: "authoritative", + responseDisposition: "authoritative", + } as const), + lookup, + }); + + const result = await coordinator.start(payload()); + + expect(result).toEqual({ ok: false, error }); + expect(lookup).not.toHaveBeenCalled(); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + expect(states.at(-1)).toMatchObject({ phase: "idle", key: null, error }); + }); + + it("keeps the key when source-create transport rejects during recovery", async () => { + const storage = new MemoryStorage(); + const lookup = vi.fn(() => + Promise.resolve({ ok: false, error: apiError("network_error", 0) } as const), + ); + const { coordinator } = coordinatorHarness(storage, { + create: () => Effect.runPromise(Effect.fail("transport disconnected")), + lookup, + }); + + const result = await coordinator.start(payload()); + const key = storage.writes[0]?.value ?? null; + + expect(result).toMatchObject({ ok: false, error: { code: "network_error" } }); + expect(lookup).toHaveBeenCalledOnce(); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + }); + + it("reconciles invalid fresh response contracts without clearing their keys", async () => { + const responses = [ + () => freshSourceResponse(199), + () => freshSourceResponse(200), + () => freshSourceResponse(204), + () => freshErrorResponse(302), + () => freshErrorResponse(399), + () => freshErrorResponse(500), + () => freshErrorResponse(600), + () => freshSourceResponse(201, false), + () => + Response.json( + { error: { code: "invalid_source", message: "Missing request ID." } }, + { status: 400, headers: { "cache-control": "no-store" } }, + ), + ]; + + for (const response of responses) { + const storage = new MemoryStorage(); + const lookup = vi.fn(() => + Promise.resolve({ ok: false, error: apiError("network_error", 0) } as const), + ); + const { coordinator, states } = coordinatorHarness(storage, { + create: (_input, key, signal) => + createGraphqlSource(payload(), key, async () => response(), signal), + lookup, + }); + + const result = await coordinator.start(payload()); + const key = storage.writes[0]?.value ?? null; + + expect(result).toMatchObject({ ok: false, error: { code: "network_error" } }); + expect(lookup).toHaveBeenCalledOnce(); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + expect(states.at(-1)).toMatchObject({ phase: "blocked", key, retry: "lookup" }); + } + }); + + it("settles valid fresh 201 successes and 400 through 499 client failures", async () => { + const successStorage = new MemoryStorage(); + const successLookup = vi.fn(); + const successHarness = coordinatorHarness(successStorage, { + create: (_input, key, signal) => + createGraphqlSource(payload(), key, async () => freshSourceResponse(201), signal), + lookup: successLookup, + }); + + const success = await successHarness.coordinator.start(payload()); + + expect(success).toMatchObject({ ok: true, value: { id: "source-1" } }); + expect(successLookup).not.toHaveBeenCalled(); + expect(successStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + + for (const status of [400, 499]) { + const storage = new MemoryStorage(); + const lookup = vi.fn(); + const { coordinator, states } = coordinatorHarness(storage, { + create: (_input, key, signal) => + createGraphqlSource(payload(), key, async () => freshErrorResponse(status), signal), + lookup, + }); + + const result = await coordinator.start(payload()); + + expect(result).toMatchObject({ + ok: false, + error: { code: "invalid_source", status }, + }); + expect(lookup).not.toHaveBeenCalled(); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + expect(states.at(-1)).toMatchObject({ phase: "idle", key: null }); + } + }); + + it("keeps typed auth and idempotency client failures in recovery", async () => { + const failures = [ + { status: 401, code: "unauthorized" }, + { status: 403, code: "invalid_csrf" }, + { status: 409, code: "idempotency_in_progress" }, + ] as const; + + for (const { status, code } of failures) { + const storage = new MemoryStorage(); + const lookup = vi.fn(() => + Promise.resolve({ ok: false, error: apiError("network_error", 0) } as const), + ); + const { coordinator } = coordinatorHarness(storage, { + create: (_input, key, signal) => + createGraphqlSource(payload(), key, async () => freshErrorResponse(status, code), signal), + lookup, + }); + + await coordinator.start(payload()); + const key = storage.writes[0]?.value ?? null; + + expect(lookup).toHaveBeenCalledOnce(); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + } + }); + + it("recovers a completed reload only after an authoritative refresh", async () => { + const storage = new MemoryStorage(); + const key = "1".repeat(32); + storage.setItem(SOURCE_CREATE_STORAGE_KEY, key); + const refresh = deferred | null>(); + const { coordinator, completed } = coordinatorHarness(storage, { + lookup: async (observedKey) => { + expect(observedKey).toBe(key); + return { + ok: true, + value: { kind: "replay", result: { ok: true, value: sourceFixture() } }, + }; + }, + refresh: () => refresh.promise, + }); + + const recovery = coordinator.recoverStored(); + await Promise.resolve(); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + refresh.resolve(successfulList()); + const result = await recovery; + + expect(result?.ok).toBe(true); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + expect(completed).toEqual([sourceFixture()]); + }); + + it("reports a stored definitive failure and compare-clears its key", async () => { + const storage = new MemoryStorage(); + const key = "2".repeat(32); + storage.setItem(SOURCE_CREATE_STORAGE_KEY, key); + const failure = apiError("invalid_source", 422); + const { coordinator, states } = coordinatorHarness(storage, { + lookup: () => + Promise.resolve({ + ok: true, + value: { kind: "replay", result: { ok: false, error: failure } }, + }), + }); + + const result = await coordinator.recoverStored(); + + expect(result).toEqual({ ok: false, error: failure }); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + expect(states.at(-1)).toMatchObject({ phase: "idle", error: failure }); + }); + + it("seals a missing reload key before clearing a cloned session store", async () => { + const original = new MemoryStorage(); + const key = "3".repeat(32); + original.setItem(SOURCE_CREATE_STORAGE_KEY, key); + const clone = original.clone(); + const seal = vi.fn(async (observedKey: string) => { + expect(clone.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + expect(observedKey).toBe(key); + return { + ok: true, + value: { kind: "status", status: "abandoned" }, + } as const; + }); + const { coordinator } = coordinatorHarness(clone, { seal }); + + await coordinator.recoverStored(); + + expect(seal).toHaveBeenCalledOnce(); + expect(clone.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + expect(original.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + }); + + it("retains the key and lock for unauthorized, network, and malformed status failures", async () => { + for (const error of [ + apiError("unauthorized", 401), + apiError("network_error", 0), + apiError("invalid_response", 502), + ]) { + const storage = new MemoryStorage(); + const key = "4".repeat(32); + storage.setItem(SOURCE_CREATE_STORAGE_KEY, key); + const { coordinator, states } = coordinatorHarness(storage, { + lookup: () => Promise.resolve({ ok: false, error }), + }); + + const result = await coordinator.recoverStored(); + + expect(result).toEqual({ ok: false, error }); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + expect(states.at(-1)).toMatchObject({ phase: "blocked", key, retry: "lookup" }); + } + }); + + it("aborts local polling on dispose without deleting the pending key", async () => { + const storage = new MemoryStorage(); + const key = "5".repeat(32); + storage.setItem(SOURCE_CREATE_STORAGE_KEY, key); + const polling = deferred(); + const enteredPolling = deferred(); + const poll = { signal: null as AbortSignal | null }; + const { coordinator } = coordinatorHarness(storage, { + environment: environment(storage, { + wait: (_milliseconds, signal) => { + poll.signal = signal; + enteredPolling.resolve(); + return polling.promise; + }, + }), + lookup: () => + Promise.resolve({ + ok: true, + value: { kind: "status", status: "in_progress" }, + }), + }); + + const recovery = coordinator.recoverStored(); + await enteredPolling.promise; + coordinator.dispose(); + + expect(poll.signal?.aborted).toBe(true); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + polling.resolve(false); + await recovery; + }); + + it("suspends recovery before sign-out and ignores a late missing lookup", async () => { + const storage = new MemoryStorage(); + const lookupResponse = deferred>(); + const lookupStarted = deferred(); + const lookupRequest = { signal: null as AbortSignal | null }; + const create = vi.fn(() => Promise.resolve(sourceCreateFailure("network_error", 0))); + const seal = vi.fn(); + const { coordinator, states } = coordinatorHarness(storage, { + create, + lookup: (_key, signal) => { + lookupRequest.signal = signal; + lookupStarted.resolve(); + return lookupResponse.promise; + }, + seal, + }); + + const creation = coordinator.start(payload()); + await lookupStarted.promise; + const key = storage.writes[0]?.value ?? null; + + expect(coordinator.suspendForSignOut()).toBe(true); + expect(lookupRequest.signal?.aborted).toBe(true); + expect(states.at(-1)).toMatchObject({ + phase: "paused", + key, + notice: "Source recovery paused for sign-out.", + retry: null, + }); + + lookupResponse.resolve({ + ok: true, + value: { kind: "status", status: "missing" }, + }); + await creation; + + expect(create).toHaveBeenCalledOnce(); + expect(seal).not.toHaveBeenCalled(); + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + + coordinator.signOutFailed(); + expect(states.at(-1)).toMatchObject({ + phase: "paused", + key, + notice: + "Source recovery is paused because sign-out failed. Resume source recovery or reload this page.", + retry: "lookup", + }); + }); + + it("never lets stale operation A clear or overwrite stored operation B", async () => { + const storage = new MemoryStorage(); + const refresh = deferred | null>(); + const keyB = "b".repeat(32); + const lookups: string[] = []; + const { coordinator, completed, states } = coordinatorHarness(storage, { + refresh: () => refresh.promise, + lookup: async (key) => { + lookups.push(key); + return { ok: false, error: apiError("network_error", 0) }; + }, + }); + + const operationA = coordinator.start(payload("operation-a-secret")); + await Promise.resolve(); + storage.setItem(SOURCE_CREATE_STORAGE_KEY, keyB); + refresh.resolve(successfulList()); + await operationA; + + expect(storage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(keyB); + expect(lookups).toEqual([keyB]); + expect(completed).toEqual([]); + expect(states.at(-1)).toMatchObject({ phase: "blocked", key: keyB }); + }); +}); diff --git a/web/src/lib/source-create-lifecycle.ts b/web/src/lib/source-create-lifecycle.ts new file mode 100644 index 000000000..17066c909 --- /dev/null +++ b/web/src/lib/source-create-lifecycle.ts @@ -0,0 +1,686 @@ +import { Effect, Exit } from "effect"; +import { + ApiError, + type ApiResult, + type GraphqlSourceInput, + type McpHttpSourceInput, + type McpStdioSourceInput, + type OpenApiSourceInput, + type Source, + type SourceCreateApiResult, + type SourceCreationResolution, + type SourceCreationStatus, + type SourceList, +} from "$lib/api"; + +export const SOURCE_CREATE_STORAGE_KEY = "executor.source-create.idempotency-key.v1"; + +export type SourceCreateInput = + | OpenApiSourceInput + | GraphqlSourceInput + | McpHttpSourceInput + | McpStdioSourceInput; + +export type SourceCreatePhase = "idle" | "dispatching" | "recovering" | "blocked" | "paused"; +export type SourceCreateRetry = "dispatch" | "lookup" | null; + +export type SourceCreateState = { + readonly phase: SourceCreatePhase; + readonly key: string | null; + readonly error: ApiError | null; + readonly notice: string | null; + readonly retry: SourceCreateRetry; +}; + +export type SourceCreateEnvironment = { + readonly getStorage: () => Pick; + readonly fillRandom: (bytes: Uint8Array) => void; + readonly wait: (milliseconds: number, signal: AbortSignal) => Promise; +}; + +type SourceCreateCoordinatorOptions = { + readonly environment: SourceCreateEnvironment; + readonly create: ( + input: SourceCreateInput, + idempotencyKey: string, + signal: AbortSignal, + ) => Promise; + readonly lookup: ( + idempotencyKey: string, + signal: AbortSignal, + ) => Promise>; + readonly seal: ( + idempotencyKey: string, + signal: AbortSignal, + ) => Promise>; + readonly refresh: (signal: AbortSignal) => Promise | null>; + readonly onstatechange: (state: SourceCreateState) => void; + readonly oncompleted: (source: Source) => void; +}; + +type ActiveSourceCreate = { + readonly key: string; + readonly payload: SourceCreateInput | null; + readonly generation: number; + readonly controller: AbortController; + readonly automaticRetryUsed: boolean; +}; + +type PausedSourceCreate = { + readonly key: string; + readonly payload: SourceCreateInput | null; + readonly automaticRetryUsed: boolean; + readonly retry: Exclude; +}; + +export function emptySourceCreateState(): SourceCreateState { + return { phase: "idle", key: null, error: null, notice: null, retry: null }; +} + +export function browserSourceCreateEnvironment(): SourceCreateEnvironment { + return { + getStorage: () => window.sessionStorage, + fillRandom: (bytes) => { + globalThis.crypto.getRandomValues(bytes); + }, + wait: waitForSourceCreatePoll, + }; +} + +export function isAmbiguousSourceCreateResult(result: SourceCreateApiResult) { + if (result.responseDisposition === "ambiguous") return true; + if (result.replayProvenance === "invalid") return true; + if (result.ok) return false; + const { error } = result; + if ( + [ + "invalid_response", + "network_error", + "request_cancelled", + "unexpected_client_error", + "idempotency_in_progress", + "invalid_csrf", + ].includes(error.code) + ) { + return true; + } + if (error.status === 401) return true; + return error.status >= 500 && result.replayProvenance !== "authoritative"; +} + +export function createSourceCreateCoordinator(options: SourceCreateCoordinatorOptions) { + let state = emptySourceCreateState(); + let active: ActiveSourceCreate | null = null; + let paused: PausedSourceCreate | null = null; + let generation = 0; + let disposed = false; + + function publish(next: SourceCreateState) { + state = next; + options.onstatechange(next); + } + + function owns(operation: ActiveSourceCreate) { + return ( + !disposed && + active === operation && + generation === operation.generation && + !operation.controller.signal.aborted + ); + } + + function storage() { + return syncResult(options.environment.getStorage); + } + + function readStoredKey() { + const available = storage(); + if (!available.ok) return available; + return syncResult(() => available.value.getItem(SOURCE_CREATE_STORAGE_KEY)); + } + + function persistNewKey(key: string) { + const available = storage(); + if (!available.ok) return { ok: false } as const; + const current = syncResult(() => available.value.getItem(SOURCE_CREATE_STORAGE_KEY)); + if (!current.ok) return current; + if (current.value !== null) return { ok: true, value: current.value } as const; + const stored = syncResult(() => available.value.setItem(SOURCE_CREATE_STORAGE_KEY, key)); + if (!stored.ok) return stored; + const verified = syncResult(() => available.value.getItem(SOURCE_CREATE_STORAGE_KEY)); + if (!verified.ok || verified.value !== key) return { ok: false } as const; + return { ok: true, value: key } as const; + } + + function compareAndClearStoredKey(key: string) { + const available = storage(); + if (!available.ok) return { ok: false, mismatch: false } as const; + const current = syncResult(() => available.value.getItem(SOURCE_CREATE_STORAGE_KEY)); + if (!current.ok) return { ok: false, mismatch: false } as const; + if (current.value !== key) return { ok: false, mismatch: true } as const; + const removed = syncResult(() => available.value.removeItem(SOURCE_CREATE_STORAGE_KEY)); + if (!removed.ok) return { ok: false, mismatch: false } as const; + const verified = syncResult(() => available.value.getItem(SOURCE_CREATE_STORAGE_KEY)); + return verified.ok && verified.value === null + ? ({ ok: true, mismatch: false } as const) + : ({ ok: false, mismatch: false } as const); + } + + function generateKey() { + const bytes = new Uint8Array(16); + const filled = syncResult(() => options.environment.fillRandom(bytes)); + if (!filled.ok) return null; + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + } + + function newOperation( + key: string, + payload: SourceCreateInput | null, + automaticRetryUsed: boolean, + ) { + active?.controller.abort(); + paused = null; + const operation = { + key, + payload, + generation: ++generation, + controller: new AbortController(), + automaticRetryUsed, + } satisfies ActiveSourceCreate; + active = operation; + return operation; + } + + function operationError( + code: string, + displayMessage: string, + status = 0, + requestId: string | null = null, + ) { + return new ApiError({ code, displayMessage, requestId, status }); + } + + function publishBlocked( + operation: ActiveSourceCreate, + error: ApiError, + retry: Exclude, + ) { + publish({ + phase: "blocked", + key: operation.key, + error, + notice: "Executor could not safely finish source recovery. The pending key was retained.", + retry, + }); + return { ok: false, error } as const; + } + + async function blocked( + operation: ActiveSourceCreate, + error: ApiError, + retry: Exclude, + ) { + if (!owns(operation)) return requestCancelled(); + const stored = readStoredKey(); + if (!stored.ok || stored.value === null) { + return publishBlocked(operation, storageUnavailableError(), "lookup"); + } + if (stored.value !== operation.key) return recoverDifferentStoredKey(operation); + return publishBlocked(operation, error, retry); + } + + async function fenceStoredKey(operation: ActiveSourceCreate) { + if (!owns(operation)) return requestCancelled(); + const stored = readStoredKey(); + if (!stored.ok || stored.value === null) { + return blocked(operation, storageUnavailableError(), "lookup"); + } + if (stored.value !== operation.key) return recoverDifferentStoredKey(operation); + return null; + } + + async function recoverDifferentStoredKey(operation: ActiveSourceCreate) { + if (!owns(operation)) return requestCancelled(); + operation.controller.abort(); + active = null; + generation += 1; + const recovered = await recoverStored(); + return ( + recovered ?? { + ok: false, + error: operationError( + "source_create_recovery_pending", + "Another source connection is pending in this browser tab.", + ), + } + ); + } + + async function settleSuccess(operation: ActiveSourceCreate, source: Source) { + if (!owns(operation)) return requestCancelled(); + const beforeRefresh = readStoredKey(); + if (!beforeRefresh.ok) { + return blocked(operation, storageUnavailableError(), "lookup"); + } + if (beforeRefresh.value !== operation.key) return recoverDifferentStoredKey(operation); + + const refreshed = await settlePromise(() => options.refresh(operation.controller.signal)); + if (!owns(operation)) return requestCancelled(); + if (!refreshed.ok || refreshed.value === null || !refreshed.value.ok) { + const error = + refreshed.ok && refreshed.value !== null && !refreshed.value.ok + ? refreshed.value.error + : operationError( + "source_list_refresh_failed", + "The source connection completed, but the authoritative source list could not be refreshed.", + ); + return blocked(operation, error, "lookup"); + } + + const cleared = compareAndClearStoredKey(operation.key); + if (cleared.mismatch) return recoverDifferentStoredKey(operation); + if (!cleared.ok) return blocked(operation, storageUnavailableError(), "lookup"); + if (!owns(operation)) return requestCancelled(); + active = null; + publish({ + phase: "idle", + key: null, + error: null, + notice: "Source connection completed and the authoritative source list was refreshed.", + retry: null, + }); + options.oncompleted(source); + return { ok: true, value: source } as const; + } + + async function settleFailure(operation: ActiveSourceCreate, error: ApiError) { + if (!owns(operation)) return requestCancelled(); + const cleared = compareAndClearStoredKey(operation.key); + if (cleared.mismatch) return recoverDifferentStoredKey(operation); + if (!cleared.ok) return blocked(operation, storageUnavailableError(), "lookup"); + if (!owns(operation)) return requestCancelled(); + active = null; + publish({ phase: "idle", key: null, error, notice: null, retry: null }); + return { ok: false, error } as const; + } + + async function settleTerminal( + operation: ActiveSourceCreate, + status: Exclude, + ) { + return settleFailure(operation, terminalStatusError(status)); + } + + async function handleResolution( + operation: ActiveSourceCreate, + resolution: ApiResult, + source: "lookup" | "seal", + ): Promise> { + if (!owns(operation)) return requestCancelled(); + const fenced = await fenceStoredKey(operation); + if (fenced !== null) return fenced; + if (!resolution.ok) return blocked(operation, resolution.error, "lookup"); + if (resolution.value.kind === "replay") { + return resolution.value.result.ok + ? settleSuccess(operation, resolution.value.result.value) + : settleFailure(operation, resolution.value.result.error); + } + + const { status } = resolution.value; + if (status === "in_progress") { + publish({ + phase: "recovering", + key: operation.key, + error: null, + notice: "Executor is still finishing this source connection.", + retry: null, + }); + const elapsed = await options.environment.wait(1_000, operation.controller.signal); + if (!elapsed || !owns(operation)) return requestCancelled(); + return lookup(operation); + } + if (status === "missing") { + if (operation.payload !== null) { + if (!operation.automaticRetryUsed) { + const retry = newOperation(operation.key, operation.payload, true); + return dispatch(retry); + } + return blocked( + operation, + operationError( + "source_create_retry_required", + "Executor has no record of this request. Retry the exact in-memory request with the retained key.", + ), + "dispatch", + ); + } + if (source === "seal") { + return blocked( + operation, + operationError( + "invalid_response", + "Executor returned an invalid seal result. The pending key was retained.", + 502, + ), + "lookup", + ); + } + return seal(operation); + } + return settleTerminal(operation, status); + } + + async function lookup(operation: ActiveSourceCreate): Promise> { + const fenced = await fenceStoredKey(operation); + if (fenced !== null) return fenced; + publish({ + phase: "recovering", + key: operation.key, + error: null, + notice: "Checking the pending source connection with Executor.", + retry: null, + }); + const resolution = await settlePromise(() => + options.lookup(operation.key, operation.controller.signal), + ); + if (!owns(operation)) return requestCancelled(); + return handleResolution( + operation, + resolution.ok ? resolution.value : { ok: false, error: unexpectedRequestError() }, + "lookup", + ); + } + + async function seal(operation: ActiveSourceCreate): Promise> { + const fenced = await fenceStoredKey(operation); + if (fenced !== null) return fenced; + publish({ + phase: "recovering", + key: operation.key, + error: null, + notice: "Securing the unused source connection key before unlocking this page.", + retry: null, + }); + const resolution = await settlePromise(() => + options.seal(operation.key, operation.controller.signal), + ); + if (!owns(operation)) return requestCancelled(); + return handleResolution( + operation, + resolution.ok ? resolution.value : { ok: false, error: unexpectedRequestError() }, + "seal", + ); + } + + async function dispatch(operation: ActiveSourceCreate): Promise> { + const payload = operation.payload; + if (!owns(operation) || payload === null) return requestCancelled(); + const fenced = await fenceStoredKey(operation); + if (fenced !== null) return fenced; + publish({ + phase: "dispatching", + key: operation.key, + error: null, + notice: "Submitting the source connection.", + retry: null, + }); + const settled = await settlePromise(() => + options.create(payload, operation.key, operation.controller.signal), + ); + if (!owns(operation)) return requestCancelled(); + if (!settled.ok) return lookup(operation); + if (isAmbiguousSourceCreateResult(settled.value)) return lookup(operation); + if (settled.value.ok) return settleSuccess(operation, settled.value.value); + return settleFailure(operation, settled.value.error); + } + + async function start(input: SourceCreateInput) { + if (disposed) return requestCancelled(); + if (state.phase !== "idle" || active !== null) { + return { + ok: false, + error: operationError( + "source_create_already_pending", + "Finish recovering the pending source connection before starting another.", + ), + } as const; + } + + const key = generateKey(); + if (key === null) { + const error = randomnessUnavailableError(); + publish({ phase: "idle", key: null, error, notice: null, retry: null }); + return { ok: false, error } as const; + } + const persisted = persistNewKey(key); + if (!persisted.ok) { + const error = storageUnavailableError(); + publish({ phase: "idle", key: null, error, notice: null, retry: null }); + return { ok: false, error } as const; + } + if (persisted.value !== key) { + void recoverStored(); + return { + ok: false, + error: operationError( + "source_create_recovery_pending", + "A source connection from this browser tab must be recovered first.", + ), + } as const; + } + + return dispatch(newOperation(key, input, false)); + } + + async function recoverStored() { + if (disposed || active !== null || state.phase === "paused") return null; + const stored = readStoredKey(); + if (!stored.ok) { + const error = storageUnavailableError(); + publish({ + phase: "blocked", + key: null, + error, + notice: "The pending source state could not be read safely.", + retry: "lookup", + }); + return { ok: false, error } as const; + } + if (stored.value === null) { + publish(emptySourceCreateState()); + return null; + } + if (!/^[0-9a-f]{32}$/.test(stored.value)) { + const error = operationError( + "source_create_key_invalid", + "The stored source connection key is invalid. It was retained for manual recovery.", + ); + publish({ phase: "blocked", key: null, error, notice: null, retry: "lookup" }); + return { ok: false, error } as const; + } + return lookup(newOperation(stored.value, null, true)); + } + + async function resume() { + if (disposed) return requestCancelled(); + if (state.phase === "paused") { + if (state.retry === null) return requestCancelled(); + const suspended = paused; + if (suspended === null) { + publish({ ...state, phase: "blocked", retry: "lookup" }); + return recoverStored(); + } + const retry = state.retry; + const operation = newOperation( + suspended.key, + suspended.payload, + suspended.automaticRetryUsed, + ); + if (retry === "dispatch" && operation.payload !== null) return dispatch(operation); + return lookup(operation); + } + if (active === null) return recoverStored(); + const operation = active; + const stored = readStoredKey(); + if (!stored.ok) return blocked(operation, storageUnavailableError(), "lookup"); + if (stored.value !== operation.key) return recoverDifferentStoredKey(operation); + const retry = state.retry; + if (retry === "dispatch" && operation.payload !== null) { + return dispatch(newOperation(operation.key, operation.payload, true)); + } + return lookup(newOperation(operation.key, operation.payload, operation.automaticRetryUsed)); + } + + function suspendForSignOut() { + if (disposed || state.phase === "dispatching") return false; + if (state.phase === "idle") return true; + if (active !== null) { + paused = { + key: active.key, + payload: active.payload, + automaticRetryUsed: active.automaticRetryUsed, + retry: state.retry ?? "lookup", + }; + } + generation += 1; + const controller = active?.controller; + active = null; + controller?.abort(); + publish({ + phase: "paused", + key: state.key, + error: state.error, + notice: "Source recovery paused for sign-out.", + retry: null, + }); + return true; + } + + function signOutFailed() { + if (disposed || state.phase !== "paused") return; + publish({ + ...state, + notice: + "Source recovery is paused because sign-out failed. Resume source recovery or reload this page.", + retry: paused?.retry ?? "lookup", + }); + } + + function dispose() { + disposed = true; + generation += 1; + active?.controller.abort(); + active = null; + paused = null; + } + + return { + start, + recoverStored, + resume, + suspendForSignOut, + signOutFailed, + dispose, + get state() { + return state; + }, + }; +} + +function syncResult(evaluate: () => Value) { + const result = Effect.runSyncExit(Effect.try({ try: evaluate, catch: () => undefined })); + return Exit.isSuccess(result) + ? ({ ok: true, value: result.value } as const) + : ({ ok: false } as const); +} + +async function settlePromise(evaluate: () => Promise) { + return Promise.resolve() + .then(evaluate) + .then( + (value) => ({ ok: true, value }) as const, + () => ({ ok: false }) as const, + ); +} + +function waitForSourceCreatePoll(milliseconds: number, signal: AbortSignal) { + if (signal.aborted) return Promise.resolve(false); + return new Promise((resolve) => { + const timeout = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(true); + }, milliseconds); + const abort = () => { + clearTimeout(timeout); + signal.removeEventListener("abort", abort); + resolve(false); + }; + signal.addEventListener("abort", abort, { once: true }); + }); +} + +function requestCancelled() { + return { + ok: false, + error: new ApiError({ + code: "request_cancelled", + displayMessage: "The request was cancelled.", + requestId: null, + status: 0, + }), + } as const; +} + +function unexpectedRequestError() { + return new ApiError({ + code: "unexpected_client_error", + displayMessage: "The request ended unexpectedly. Try again.", + requestId: null, + status: 0, + }); +} + +function storageUnavailableError() { + return new ApiError({ + code: "source_create_storage_unavailable", + displayMessage: + "Secure tab storage is unavailable. Executor did not start a new source connection.", + requestId: null, + status: 0, + }); +} + +function randomnessUnavailableError() { + return new ApiError({ + code: "source_create_randomness_unavailable", + displayMessage: + "Secure browser randomness is unavailable. Executor did not start a new source connection.", + requestId: null, + status: 0, + }); +} + +function terminalStatusError(status: Exclude) { + if (status === "abandoned") { + return new ApiError({ + code: "idempotency_abandoned", + displayMessage: "The unused source connection key was sealed. You can start a new request.", + requestId: null, + status: 409, + }); + } + if (status === "interrupted") { + return new ApiError({ + code: "idempotency_interrupted", + displayMessage: "The source connection was interrupted and cannot be retried.", + requestId: null, + status: 409, + }); + } + return new ApiError({ + code: "idempotency_expired_unknown", + displayMessage: "The source connection record expired without a confirmable outcome.", + requestId: null, + status: 409, + }); +} diff --git a/web/src/lib/token-create-lifecycle.test.ts b/web/src/lib/token-create-lifecycle.test.ts new file mode 100644 index 000000000..f695768f7 --- /dev/null +++ b/web/src/lib/token-create-lifecycle.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { Schema } from "effect"; +import { ApiError, type CreatedToken, type TokenCreateApiResult } from "./api"; +import { + TOKEN_CREATE_STORAGE_KEY, + createTokenCreateCoordinator, + isAmbiguousTokenCreateResult, + type TokenCreateEnvironment, + type TokenCreateState, +} from "./token-create-lifecycle"; + +const PendingRecordSchema = Schema.Struct({ + version: Schema.Literal(1), + key: Schema.String, + name: Schema.String, +}); +const decodePendingRecord = Schema.decodeUnknownSync(Schema.fromJsonString(PendingRecordSchema)); + +class MemoryStorage implements Pick { + readonly values = new Map(); + readonly writes: Array<{ key: string; value: string }> = []; + + getItem(key: string) { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string) { + this.values.set(key, value); + this.writes.push({ key, value }); + } + + removeItem(key: string) { + this.values.delete(key); + } +} + +function token(id = "token-1", name = "Laptop") { + return { + id, + name, + token: `exr_${"A".repeat(43)}`, + createdAt: 1_750_000_000, + } satisfies CreatedToken; +} + +function success(value = token(), replayed = false): TokenCreateApiResult { + return { + ok: true, + value, + replayProvenance: replayed ? "authoritative" : "none", + responseDisposition: "authoritative", + }; +} + +function failure( + code: string, + status: number, + responseDisposition: TokenCreateApiResult["responseDisposition"] = "authoritative", + replayProvenance: TokenCreateApiResult["replayProvenance"] = "none", +): TokenCreateApiResult { + return { + ok: false, + error: new ApiError({ code, displayMessage: code, requestId: null, status }), + replayProvenance, + responseDisposition, + }; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function environment(storage: MemoryStorage): TokenCreateEnvironment { + return { + getStorage: () => storage, + fillRandom: (bytes) => bytes.fill(0xab), + }; +} + +function harness( + storage: MemoryStorage, + create: (name: string, key: string, signal: AbortSignal) => Promise, +) { + const states: TokenCreateState[] = []; + const revealed: CreatedToken[] = []; + const coordinator = createTokenCreateCoordinator({ + environment: environment(storage), + create, + onstatechange: (state) => states.push(state), + onrevealed: (created) => revealed.push(created), + }); + return { coordinator, states, revealed }; +} + +function readPending(storage: MemoryStorage) { + const stored = storage.getItem(TOKEN_CREATE_STORAGE_KEY); + return stored === null ? null : decodePendingRecord(stored); +} + +describe("delivery-safe token creation", () => { + it("persists a versioned 256-bit key and name before dispatch, then retries exactly once", async () => { + const storage = new MemoryStorage(); + const calls: Array<{ name: string; key: string; stored: string | null }> = []; + const { coordinator, revealed } = harness(storage, async (name, key) => { + calls.push({ name, key, stored: storage.getItem(TOKEN_CREATE_STORAGE_KEY) }); + return calls.length === 1 + ? failure("network_error", 0, "ambiguous") + : success(token("token-replayed", name), true); + }); + + const result = await coordinator.start(" Laptop agent "); + + expect(result.ok).toBe(true); + expect(calls).toHaveLength(2); + expect(calls[0]?.key).toBe(calls[1]?.key); + expect(calls[0]?.key).toMatch(/^[0-9a-f]{64}$/); + expect(calls.map(({ name }) => name)).toEqual(["Laptop agent", "Laptop agent"]); + expect(calls[0]?.stored).toBe(calls[1]?.stored); + expect(readPending(storage)).toEqual({ + version: 1, + key: calls[0]?.key, + name: "Laptop agent", + }); + expect(JSON.stringify(storage.writes)).not.toContain(`exr_${"A".repeat(43)}`); + expect(revealed).toEqual([token("token-replayed", "Laptop agent")]); + + expect(coordinator.acknowledge()).toEqual({ ok: true, value: undefined }); + expect(storage.getItem(TOKEN_CREATE_STORAGE_KEY)).toBeNull(); + }); + + it("recovers a reload by reposting the exact pending name and key", async () => { + const storage = new MemoryStorage(); + const pending = { + version: 1, + key: "7".repeat(64), + name: "Reloaded agent", + } as const; + storage.setItem(TOKEN_CREATE_STORAGE_KEY, JSON.stringify(pending)); + const create = vi.fn(async (name: string, key: string) => { + expect({ name, key }).toEqual({ name: pending.name, key: pending.key }); + return success(token("token-recovered", name), true); + }); + const { coordinator, revealed } = harness(storage, create); + + const result = await coordinator.recoverStored(); + + expect(result?.ok).toBe(true); + expect(create).toHaveBeenCalledOnce(); + expect(revealed).toEqual([token("token-recovered", pending.name)]); + expect(storage.getItem(TOKEN_CREATE_STORAGE_KEY)).toBe(JSON.stringify(pending)); + }); + + it("retries a success with the wrong name and reveals only the exact pending name", async () => { + const storage = new MemoryStorage(); + let calls = 0; + const { coordinator, revealed } = harness(storage, async () => { + calls += 1; + return calls === 1 + ? success(token("wrong-name", "Different agent")) + : success(token("exact-name", "Laptop agent"), true); + }); + + const result = await coordinator.start("Laptop agent"); + + expect(result.ok).toBe(true); + expect(calls).toBe(2); + expect(revealed).toEqual([token("exact-name", "Laptop agent")]); + expect(readPending(storage)?.name).toBe("Laptop agent"); + }); + + it("retains auth, mismatch, network, and invalid responses but clears replay-revoked", async () => { + for (const ambiguous of [ + failure("unauthorized", 401), + failure("idempotency_mismatch", 409), + failure("network_error", 0, "ambiguous"), + failure("invalid_response", 502, "ambiguous"), + failure("internal_error", 500, "authoritative", "authoritative"), + failure("idempotency_replay_revoked", 500, "authoritative", "authoritative"), + ]) { + const storage = new MemoryStorage(); + const { coordinator, states } = harness(storage, async () => ambiguous); + + const result = await coordinator.start("Retained agent"); + + expect(result.ok).toBe(false); + expect(storage.getItem(TOKEN_CREATE_STORAGE_KEY)).not.toBeNull(); + expect(states.at(-1)).toMatchObject({ phase: "blocked", canRetry: true }); + } + + const storage = new MemoryStorage(); + const revoked = failure("idempotency_replay_revoked", 409, "authoritative", "authoritative"); + const { coordinator, states } = harness(storage, async () => revoked); + + const result = await coordinator.start("Revoked agent"); + + expect(result).toMatchObject({ ok: false, error: { code: "idempotency_replay_revoked" } }); + expect(storage.getItem(TOKEN_CREATE_STORAGE_KEY)).toBeNull(); + expect(states.at(-1)).toMatchObject({ phase: "idle", pending: null }); + }); + + it("reuses the retained key after reauthentication", async () => { + const storage = new MemoryStorage(); + let authenticated = false; + const calls: Array<{ name: string; key: string }> = []; + const { coordinator, revealed } = harness(storage, async (name, key) => { + calls.push({ name, key }); + return authenticated + ? success(token("recovered-after-login", name), true) + : failure("unauthorized", 401); + }); + + const blocked = await coordinator.start("Reauthenticated agent"); + const retained = readPending(storage); + authenticated = true; + const recovered = await coordinator.retry(); + + expect(blocked).toMatchObject({ ok: false, error: { code: "unauthorized" } }); + expect(recovered?.ok).toBe(true); + expect(calls).toHaveLength(3); + expect(calls.map(({ key }) => key)).toEqual([retained?.key, retained?.key, retained?.key]); + expect(calls.map(({ name }) => name)).toEqual([ + "Reauthenticated agent", + "Reauthenticated agent", + "Reauthenticated agent", + ]); + expect(revealed).toEqual([token("recovered-after-login", "Reauthenticated agent")]); + expect(readPending(storage)).toEqual(retained); + }); + + it("rejects stale completion A and recovers pending operation B", async () => { + const storage = new MemoryStorage(); + const operationA = deferred(); + const calls: string[] = []; + const { coordinator, revealed } = harness(storage, async (name) => { + calls.push(name); + return name === "Operation A" + ? operationA.promise + : success(token("token-b", "Operation B"), true); + }); + + const startedA = coordinator.start("Operation A"); + await Promise.resolve(); + const pendingB = { + version: 1, + key: "b".repeat(64), + name: "Operation B", + } as const; + storage.setItem(TOKEN_CREATE_STORAGE_KEY, JSON.stringify(pendingB)); + operationA.resolve(success(token("token-a", "Operation A"))); + const result = await startedA; + + expect(result.ok).toBe(true); + expect(calls).toEqual(["Operation A", "Operation B"]); + expect(revealed).toEqual([token("token-b", "Operation B")]); + expect(readPending(storage)).toEqual(pendingB); + }); + + it("aborts on dispose and ignores a late token secret", async () => { + const storage = new MemoryStorage(); + const response = deferred(); + const started = deferred(); + const request = { signal: null as AbortSignal | null }; + const { coordinator, revealed, states } = harness(storage, (_name, _key, signal) => { + request.signal = signal; + started.resolve(); + return response.promise; + }); + + const creation = coordinator.start("Unmounted agent"); + await started.promise; + const stored = storage.getItem(TOKEN_CREATE_STORAGE_KEY); + coordinator.dispose(); + + expect(request.signal?.aborted).toBe(true); + response.resolve(success(token("late-token"))); + await creation; + expect(revealed).toEqual([]); + expect(storage.getItem(TOKEN_CREATE_STORAGE_KEY)).toBe(stored); + expect(states.some(({ phase }) => phase === "revealed")).toBe(false); + }); +}); + +describe("token create ambiguity", () => { + it("keeps mismatch ambiguous while treating an authoritative revoked replay as final", () => { + expect(isAmbiguousTokenCreateResult(failure("idempotency_mismatch", 409))).toBe(true); + expect( + isAmbiguousTokenCreateResult( + failure("idempotency_replay_revoked", 409, "authoritative", "authoritative"), + ), + ).toBe(false); + expect( + isAmbiguousTokenCreateResult( + failure("idempotency_replay_revoked", 409, "authoritative", "none"), + ), + ).toBe(true); + expect( + isAmbiguousTokenCreateResult( + failure("internal_error", 500, "authoritative", "authoritative"), + ), + ).toBe(true); + expect( + isAmbiguousTokenCreateResult( + failure("idempotency_replay_revoked", 500, "authoritative", "authoritative"), + ), + ).toBe(true); + }); +}); diff --git a/web/src/lib/token-create-lifecycle.ts b/web/src/lib/token-create-lifecycle.ts new file mode 100644 index 000000000..c851aad4c --- /dev/null +++ b/web/src/lib/token-create-lifecycle.ts @@ -0,0 +1,508 @@ +import { Effect, Exit, Option, Schema } from "effect"; +import { ApiError, type ApiResult, type CreatedToken, type TokenCreateApiResult } from "$lib/api"; + +export const TOKEN_CREATE_STORAGE_KEY = "executor.token-create.pending.v1"; + +const PendingTokenCreationSchema = Schema.Struct({ + version: Schema.Literal(1), + key: Schema.String, + name: Schema.String, +}); + +const decodePendingTokenCreationJson = Schema.decodeUnknownOption( + Schema.fromJsonString(PendingTokenCreationSchema), +); +const decodePendingTokenCreation = (text: string) => + decodePendingTokenCreationJson(text, { onExcessProperty: "error" }); + +export type PendingTokenCreation = typeof PendingTokenCreationSchema.Type; +export type TokenCreatePhase = "idle" | "dispatching" | "blocked" | "revealed"; + +export type TokenCreateState = { + readonly phase: TokenCreatePhase; + readonly pending: PendingTokenCreation | null; + readonly error: ApiError | null; + readonly notice: string | null; + readonly canRetry: boolean; +}; + +export type TokenCreateEnvironment = { + readonly getStorage: () => Pick; + readonly fillRandom: (bytes: Uint8Array) => void; +}; + +type TokenCreateCoordinatorOptions = { + readonly environment: TokenCreateEnvironment; + readonly create: ( + name: string, + idempotencyKey: string, + signal: AbortSignal, + ) => Promise; + readonly onstatechange: (state: TokenCreateState) => void; + readonly onrevealed: (token: CreatedToken) => void; +}; + +type ActiveTokenCreate = { + readonly pending: PendingTokenCreation; + readonly generation: number; + readonly controller: AbortController; + readonly automaticRetryUsed: boolean; +}; + +type PendingRead = + | { readonly ok: true; readonly value: PendingTokenCreation | null } + | { readonly ok: false; readonly reason: "invalid" | "storage" }; + +export function emptyTokenCreateState(): TokenCreateState { + return { phase: "idle", pending: null, error: null, notice: null, canRetry: false }; +} + +export function browserTokenCreateEnvironment(): TokenCreateEnvironment { + return { + getStorage: () => window.sessionStorage, + fillRandom: (bytes) => { + globalThis.crypto.getRandomValues(bytes); + }, + }; +} + +export function isAmbiguousTokenCreateResult(result: TokenCreateApiResult) { + if ( + !result.ok && + result.error.code === "idempotency_replay_revoked" && + result.error.status === 409 && + result.replayProvenance === "authoritative" && + result.responseDisposition === "authoritative" + ) { + return false; + } + if (!result.ok && result.error.code === "idempotency_replay_revoked") return true; + if (!result.ok && result.replayProvenance === "authoritative") return true; + if (result.responseDisposition === "ambiguous" || result.replayProvenance === "invalid") { + return true; + } + if (result.ok) return false; + if ( + [ + "idempotency_mismatch", + "invalid_csrf", + "invalid_response", + "network_error", + "request_cancelled", + "unexpected_client_error", + ].includes(result.error.code) + ) { + return true; + } + if (result.error.status === 401) return true; + return result.error.status >= 500 && result.replayProvenance !== "authoritative"; +} + +export function createTokenCreateCoordinator(options: TokenCreateCoordinatorOptions) { + let state = emptyTokenCreateState(); + let active: ActiveTokenCreate | null = null; + let revealedPending: PendingTokenCreation | null = null; + let generation = 0; + let disposed = false; + + function publish(next: TokenCreateState) { + if (disposed) return; + state = next; + options.onstatechange(next); + } + + function owns(operation: ActiveTokenCreate) { + return ( + !disposed && + active === operation && + generation === operation.generation && + !operation.controller.signal.aborted + ); + } + + function storage() { + return syncResult(options.environment.getStorage); + } + + function readStoredPending(): PendingRead { + const available = storage(); + if (!available.ok) return { ok: false, reason: "storage" }; + const stored = syncResult(() => available.value.getItem(TOKEN_CREATE_STORAGE_KEY)); + if (!stored.ok) return { ok: false, reason: "storage" }; + if (stored.value === null) return { ok: true, value: null }; + const decoded = decodePendingTokenCreation(stored.value); + if (Option.isNone(decoded) || !validPending(decoded.value)) { + return { ok: false, reason: "invalid" }; + } + return { ok: true, value: decoded.value }; + } + + function persistPending(pending: PendingTokenCreation): PendingRead { + const current = readStoredPending(); + if (!current.ok || current.value !== null) return current; + const available = storage(); + if (!available.ok) return { ok: false, reason: "storage" }; + const written = syncResult(() => + available.value.setItem(TOKEN_CREATE_STORAGE_KEY, JSON.stringify(pending)), + ); + if (!written.ok) return { ok: false, reason: "storage" }; + const verified = readStoredPending(); + return verified.ok && samePending(verified.value, pending) + ? ({ ok: true, value: pending } as const) + : ({ ok: false, reason: "storage" } as const); + } + + function compareAndClearPending(pending: PendingTokenCreation) { + const current = readStoredPending(); + if (!current.ok) return { ok: false, mismatch: false, reason: current.reason } as const; + if (!samePending(current.value, pending)) { + return { ok: false, mismatch: true, reason: "invalid" } as const; + } + const available = storage(); + if (!available.ok) { + return { ok: false, mismatch: false, reason: "storage" } as const; + } + const removed = syncResult(() => available.value.removeItem(TOKEN_CREATE_STORAGE_KEY)); + if (!removed.ok) return { ok: false, mismatch: false, reason: "storage" } as const; + const verified = readStoredPending(); + return verified.ok && verified.value === null + ? ({ ok: true, mismatch: false } as const) + : ({ ok: false, mismatch: false, reason: "storage" } as const); + } + + function generateKey() { + const bytes = new Uint8Array(32); + const filled = syncResult(() => options.environment.fillRandom(bytes)); + if (!filled.ok) return null; + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + } + + function newOperation(pending: PendingTokenCreation, automaticRetryUsed: boolean) { + active?.controller.abort(); + const operation = { + pending, + generation: ++generation, + controller: new AbortController(), + automaticRetryUsed, + } satisfies ActiveTokenCreate; + active = operation; + return operation; + } + + function publishBlocked( + pending: PendingTokenCreation | null, + error: ApiError, + canRetry: boolean, + ) { + active = null; + publish({ + phase: "blocked", + pending, + error, + notice: "Executor could not safely finish token creation. The pending request was retained.", + canRetry, + }); + return { ok: false, error } as const; + } + + async function recoverDifferentPending(operation: ActiveTokenCreate) { + if (!owns(operation)) return requestCancelled(); + operation.controller.abort(); + active = null; + generation += 1; + const recovered = await recoverStored(); + return recovered ?? requestCancelled(); + } + + async function fencePending(operation: ActiveTokenCreate) { + if (!owns(operation)) return requestCancelled(); + const stored = readStoredPending(); + if (!stored.ok) { + return publishBlocked( + operation.pending, + stored.reason === "invalid" ? invalidStoredPendingError() : storageUnavailableError(), + stored.reason !== "invalid", + ); + } + if (!samePending(stored.value, operation.pending)) return recoverDifferentPending(operation); + return null; + } + + async function settleDefinitiveFailure(operation: ActiveTokenCreate, error: ApiError) { + if (!owns(operation)) return requestCancelled(); + const cleared = compareAndClearPending(operation.pending); + if (cleared.mismatch) return recoverDifferentPending(operation); + if (!cleared.ok) { + return publishBlocked(operation.pending, storageUnavailableError(), true); + } + if (!owns(operation)) return requestCancelled(); + active = null; + publish({ phase: "idle", pending: null, error, notice: null, canRetry: false }); + return { ok: false, error } as const; + } + + async function handleAmbiguous( + operation: ActiveTokenCreate, + result: TokenCreateApiResult | null, + ): Promise> { + if (!owns(operation)) return requestCancelled(); + if (!operation.automaticRetryUsed) { + return dispatch(newOperation(operation.pending, true)); + } + const error = result !== null && !result.ok ? result.error : invalidTokenCreateResponseError(); + return publishBlocked(operation.pending, error, true); + } + + async function dispatch(operation: ActiveTokenCreate): Promise> { + const before = await fencePending(operation); + if (before !== null) return before; + publish({ + phase: "dispatching", + pending: operation.pending, + error: null, + notice: operation.automaticRetryUsed + ? "Retrying the exact pending token request." + : "Submitting the token request.", + canRetry: false, + }); + const settled = await settlePromise(() => + options.create(operation.pending.name, operation.pending.key, operation.controller.signal), + ); + if (!owns(operation)) return requestCancelled(); + const after = await fencePending(operation); + if (after !== null) return after; + if (!settled.ok) return handleAmbiguous(operation, null); + if (isAmbiguousTokenCreateResult(settled.value)) { + return handleAmbiguous(operation, settled.value); + } + if (!settled.value.ok) { + return settleDefinitiveFailure(operation, settled.value.error); + } + if (settled.value.value.name !== operation.pending.name) { + return handleAmbiguous(operation, settled.value); + } + + const token = settled.value.value; + active = null; + revealedPending = operation.pending; + publish({ + phase: "revealed", + pending: operation.pending, + error: null, + notice: "Token created. Save the secret before acknowledging it.", + canRetry: false, + }); + if (!disposed && revealedPending === operation.pending) options.onrevealed(token); + return { ok: true, value: token }; + } + + async function start(name: string) { + if (disposed) return requestCancelled(); + if (active !== null || state.phase !== "idle" || revealedPending !== null) { + return { + ok: false, + error: operationError( + "token_create_already_pending", + "Finish the pending token request before creating another token.", + ), + } as const; + } + + const normalizedName = name.trim(); + if (normalizedName === "" || normalizedName.length > 80) { + const error = operationError( + "invalid_token_name", + "Token names must contain between 1 and 80 characters.", + 400, + ); + publish({ phase: "idle", pending: null, error, notice: null, canRetry: false }); + return { ok: false, error } as const; + } + const key = generateKey(); + if (key === null) { + const error = randomnessUnavailableError(); + publish({ phase: "idle", pending: null, error, notice: null, canRetry: false }); + return { ok: false, error } as const; + } + const pending = { version: 1, key, name: normalizedName } as const; + const persisted = persistPending(pending); + if (!persisted.ok) { + const error = + persisted.reason === "invalid" ? invalidStoredPendingError() : storageUnavailableError(); + publish({ phase: "blocked", pending: null, error, notice: null, canRetry: false }); + return { ok: false, error } as const; + } + if (!samePending(persisted.value, pending)) { + const recovered = await recoverStored(); + return ( + recovered ?? { + ok: false, + error: operationError( + "token_create_recovery_pending", + "A token request from this browser tab must be recovered first.", + ), + } + ); + } + return dispatch(newOperation(pending, false)); + } + + async function recoverStored() { + if (disposed || active !== null || state.phase === "revealed") return null; + const stored = readStoredPending(); + if (!stored.ok) { + const error = + stored.reason === "invalid" ? invalidStoredPendingError() : storageUnavailableError(); + publish({ + phase: "blocked", + pending: null, + error, + notice: "The pending token request could not be read safely.", + canRetry: false, + }); + return { ok: false, error } as const; + } + if (stored.value === null) { + publish(emptyTokenCreateState()); + return null; + } + return dispatch(newOperation(stored.value, false)); + } + + async function retry() { + if (disposed) return requestCancelled(); + if (state.phase !== "blocked" || !state.canRetry) return recoverStored(); + const stored = readStoredPending(); + if (!stored.ok || stored.value === null) { + const error = + !stored.ok && stored.reason === "storage" + ? storageUnavailableError() + : invalidStoredPendingError(); + return publishBlocked(null, error, false); + } + return dispatch(newOperation(stored.value, false)); + } + + function acknowledge() { + if (disposed || revealedPending === null || state.phase !== "revealed") { + return requestCancelled(); + } + const pending = revealedPending; + const cleared = compareAndClearPending(pending); + if (!cleared.ok) { + const error = cleared.mismatch ? pendingChangedError() : storageUnavailableError(); + publish({ ...state, error, notice: null }); + return { ok: false, error } as const; + } + revealedPending = null; + publish(emptyTokenCreateState()); + return { ok: true, value: undefined } as const; + } + + function dispose() { + disposed = true; + generation += 1; + active?.controller.abort(); + active = null; + revealedPending = null; + } + + return { + start, + recoverStored, + retry, + acknowledge, + dispose, + get state() { + return state; + }, + }; +} + +function validPending(pending: PendingTokenCreation) { + return ( + /^[0-9a-f]{64}$/.test(pending.key) && + pending.name === pending.name.trim() && + pending.name.length > 0 && + pending.name.length <= 80 + ); +} + +function samePending(left: PendingTokenCreation | null, right: PendingTokenCreation | null) { + return ( + left === right || + (left !== null && + right !== null && + left.version === right.version && + left.key === right.key && + left.name === right.name) + ); +} + +function syncResult(evaluate: () => Value) { + const result = Effect.runSyncExit(Effect.try({ try: evaluate, catch: () => undefined })); + return Exit.isSuccess(result) + ? ({ ok: true, value: result.value } as const) + : ({ ok: false } as const); +} + +async function settlePromise(evaluate: () => Promise) { + return Promise.resolve() + .then(evaluate) + .then( + (value) => ({ ok: true, value }) as const, + () => ({ ok: false }) as const, + ); +} + +function operationError( + code: string, + displayMessage: string, + status = 0, + requestId: string | null = null, +) { + return new ApiError({ code, displayMessage, requestId, status }); +} + +function requestCancelled() { + return { + ok: false, + error: operationError("request_cancelled", "The request was cancelled."), + } as const; +} + +function storageUnavailableError() { + return operationError( + "token_create_storage_unavailable", + "Secure tab storage is unavailable. Executor did not start a new token request.", + ); +} + +function randomnessUnavailableError() { + return operationError( + "token_create_randomness_unavailable", + "Secure browser randomness is unavailable. Executor did not start a new token request.", + ); +} + +function invalidStoredPendingError() { + return operationError( + "token_create_pending_invalid", + "The stored token request is invalid. It was retained for manual recovery.", + ); +} + +function invalidTokenCreateResponseError() { + return operationError( + "invalid_response", + "Executor returned a token creation response the dashboard could not safely use.", + 502, + ); +} + +function pendingChangedError() { + return operationError( + "token_create_pending_changed", + "A different token request is pending. Its recovery state was not cleared.", + ); +} diff --git a/web/src/lib/token-page-state.test.ts b/web/src/lib/token-page-state.test.ts new file mode 100644 index 000000000..73d3d3b29 --- /dev/null +++ b/web/src/lib/token-page-state.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + canCreateToken, + isTokenRecoveryAuthNavigation, + shouldBlockTokenExit, + tokenExitBlockReason, + tokenListView, +} from "./token-page-state"; + +describe("API token page state", () => { + it("distinguishes an unavailable initial load from an empty successful load", () => { + expect(tokenListView({ loading: false, hasLoaded: false, hasError: true, tokenCount: 0 })).toBe( + "unavailable", + ); + expect(tokenListView({ loading: false, hasLoaded: true, hasError: false, tokenCount: 0 })).toBe( + "empty", + ); + }); + + it("labels retained data as stale after a refresh failure", () => { + expect(tokenListView({ loading: false, hasLoaded: true, hasError: true, tokenCount: 2 })).toBe( + "stale", + ); + }); + + it("blocks another token creation until the revealed secret is explicitly saved", () => { + expect( + canCreateToken({ + name: "Laptop", + creating: false, + hasPendingCreate: true, + hasUnsavedToken: false, + }), + ).toBe(false); + expect( + canCreateToken({ + name: "Laptop", + creating: false, + hasPendingCreate: false, + hasUnsavedToken: true, + }), + ).toBe(false); + expect( + canCreateToken({ + name: "Laptop", + creating: false, + hasPendingCreate: false, + hasUnsavedToken: false, + }), + ).toBe(true); + }); + + it("guards navigation throughout creation and one-time reveal", () => { + expect( + shouldBlockTokenExit({ + creating: true, + hasPendingCreate: true, + hasUnsavedToken: false, + }), + ).toBe(true); + expect( + shouldBlockTokenExit({ + creating: false, + hasPendingCreate: true, + hasUnsavedToken: true, + }), + ).toBe(true); + expect( + shouldBlockTokenExit({ + creating: false, + hasPendingCreate: true, + hasUnsavedToken: false, + }), + ).toBe(true); + expect( + shouldBlockTokenExit({ + creating: false, + hasPendingCreate: false, + hasUnsavedToken: false, + }), + ).toBe(false); + }); + + it("reports why an exit is blocked so creation can show page-level feedback", () => { + expect( + tokenExitBlockReason({ + creating: true, + hasPendingCreate: true, + hasUnsavedToken: false, + }), + ).toBe("creating"); + expect( + tokenExitBlockReason({ + creating: false, + hasPendingCreate: true, + hasUnsavedToken: true, + }), + ).toBe("unsaved-token"); + expect( + tokenExitBlockReason({ + creating: false, + hasPendingCreate: true, + hasUnsavedToken: false, + }), + ).toBe("pending-recovery"); + expect( + tokenExitBlockReason({ + creating: false, + hasPendingCreate: false, + hasUnsavedToken: false, + }), + ).toBeNull(); + }); + + it("allows only forced reauthentication to bypass a pending recovery guard", () => { + expect(isTokenRecoveryAuthNavigation({ authenticated: false, destinationPath: "/login" })).toBe( + true, + ); + expect(isTokenRecoveryAuthNavigation({ authenticated: true, destinationPath: "/login" })).toBe( + false, + ); + expect( + isTokenRecoveryAuthNavigation({ authenticated: false, destinationPath: "/sources" }), + ).toBe(false); + expect(isTokenRecoveryAuthNavigation({ authenticated: false, destinationPath: null })).toBe( + false, + ); + }); +}); diff --git a/web/src/lib/token-page-state.ts b/web/src/lib/token-page-state.ts new file mode 100644 index 000000000..e296708f7 --- /dev/null +++ b/web/src/lib/token-page-state.ts @@ -0,0 +1,50 @@ +export type TokenListView = "loading" | "unavailable" | "empty" | "ready" | "stale"; + +export function tokenListView(input: { + loading: boolean; + hasLoaded: boolean; + hasError: boolean; + tokenCount: number; +}) { + if (!input.hasLoaded && input.loading) return "loading"; + if (!input.hasLoaded) return input.hasError ? "unavailable" : "loading"; + if (input.hasError) return "stale"; + return input.tokenCount === 0 ? "empty" : "ready"; +} + +export function canCreateToken(input: { + name: string; + creating: boolean; + hasPendingCreate: boolean; + hasUnsavedToken: boolean; +}) { + return ( + input.name.trim() !== "" && !input.creating && !input.hasPendingCreate && !input.hasUnsavedToken + ); +} + +export function tokenExitBlockReason(input: { + creating: boolean; + hasPendingCreate: boolean; + hasUnsavedToken: boolean; +}) { + if (input.creating) return "creating"; + if (input.hasUnsavedToken) return "unsaved-token"; + if (input.hasPendingCreate) return "pending-recovery"; + return null; +} + +export function shouldBlockTokenExit(input: { + creating: boolean; + hasPendingCreate: boolean; + hasUnsavedToken: boolean; +}) { + return tokenExitBlockReason(input) !== null; +} + +export function isTokenRecoveryAuthNavigation(input: { + authenticated: boolean; + destinationPath: string | null; +}) { + return !input.authenticated && input.destinationPath === "/login"; +} diff --git a/web/src/lib/token-reveal-focus.test.ts b/web/src/lib/token-reveal-focus.test.ts new file mode 100644 index 000000000..9e0a073a0 --- /dev/null +++ b/web/src/lib/token-reveal-focus.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { focusRevealedToken } from "./token-reveal-focus"; + +const token = { + id: "token-1", + name: "Laptop agent", + token: "exr_public_secret", + createdAt: 1_750_000_000, +}; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("one-time token focus", () => { + it("focuses and selects the revealed token after its input renders", async () => { + const previous = document.createElement("button"); + const field = document.createElement("input"); + field.readOnly = true; + field.value = token.token; + document.body.append(previous, field); + previous.focus(); + + await focusRevealedToken({ + token, + currentToken: () => token, + field: () => field, + isCurrentLifetime: () => true, + }); + + expect(document.activeElement).toBe(field); + expect(field.selectionStart).toBe(0); + expect(field.selectionEnd).toBe(token.token.length); + }); + + it("leaves focus alone for a stale lifetime or replaced token", async () => { + const previous = document.createElement("button"); + const field = document.createElement("input"); + document.body.append(previous, field); + previous.focus(); + + await focusRevealedToken({ + token, + currentToken: () => token, + field: () => field, + isCurrentLifetime: () => false, + }); + await focusRevealedToken({ + token, + currentToken: () => null, + field: () => field, + isCurrentLifetime: () => true, + }); + + expect(document.activeElement).toBe(previous); + }); +}); diff --git a/web/src/lib/token-reveal-focus.ts b/web/src/lib/token-reveal-focus.ts new file mode 100644 index 000000000..887b05b55 --- /dev/null +++ b/web/src/lib/token-reveal-focus.ts @@ -0,0 +1,17 @@ +import { tick } from "svelte"; +import type { CreatedToken } from "./api"; + +export async function focusRevealedToken(input: { + token: CreatedToken; + currentToken: () => CreatedToken | null; + field: () => HTMLInputElement | undefined; + isCurrentLifetime: () => boolean; +}) { + await tick(); + if (!input.isCurrentLifetime() || input.currentToken() !== input.token) return; + + const field = input.field(); + if (field === undefined) return; + field.focus(); + field.select(); +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 000000000..67f0edfec --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,82 @@ + + + + Executor + + + + +{#if auth.phase === "error" && auth.error !== null} +
+
+ +
+ + +
+
+
+{:else if canRender} + {@render children()} +{:else} +
+
+ +

Checking your Executor session...

+
+
+{/if} diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts new file mode 100644 index 000000000..a3d15781a --- /dev/null +++ b/web/src/routes/+layout.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 000000000..09e479b91 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,9 @@ +
+
+ +
+

Executor

+

Opening your local gateway...

+
+
+
diff --git a/web/src/routes/approvals/+page.svelte b/web/src/routes/approvals/+page.svelte new file mode 100644 index 000000000..3782ef272 --- /dev/null +++ b/web/src/routes/approvals/+page.svelte @@ -0,0 +1,807 @@ + + + + +{#snippet approvalError(error: ApiError, announce: boolean)} +
+ {error.displayMessage} + {#if error.requestId !== null} + Request reference: {error.requestId} + {/if} +
+{/snippet} + + +

{announcement}

+ +
+
+

One request at a time

+

Approval is exact and single-use

+
+

+ Approving permits only this invocation. The argument preview is structural and redacted; + hidden original values are what Executor will run. A tool, credential, or policy change makes + the request stale instead of silently applying your decision elsewhere. +

+
+ +
+
+

Decision queue

+

Approval requests

+
+ +
+ {#if resource.loading}Refreshing...{/if} + +
+
+ + {#if resource.stale && resource.error !== null} +
+ Showing the last loaded approval page while Executor reconnects. + {@render approvalError(resource.error, false)} +
+ {:else if resource.error !== null} +
+ {@render approvalError(resource.error, announceListError)} + +
+ {/if} + +
+ {#if resource.data === null && resource.loading} +
+ Loading approvals... +
+ {:else if resource.data !== null && resource.data.items.length === 0} +
+

+ {urlState.status === "pending" + ? "No tool calls are waiting for approval." + : "No approvals match this status on this page."} +

+ {#if urlState.cursor !== null || urlState.status !== "pending"} + Return to pending approvals + {/if} +
+ {:else if resource.data !== null} +
+ {#each resource.data.items as approval (approval.id)} +
+
+
+ + {approvalStatusLabel(approval.status)} + +

{approval.toolDisplayName ?? approval.path}

+ {approval.path} +
+
+ {formatAge(approval.createdAt)} + + {approval.status === "pending" + ? approvalCountdown(approval.expiresAt, now) + : `Updated ${formatAge(approval.updatedAt)}`} + +
+
+
+
+
Source
+
{approval.sourceDisplayName ?? "Unavailable source"}
+
+
+
Caller
+
{approval.actorLabel}
+ {approval.surface} +
+
+
Ask rule
+
{provenanceLabel(approval.provenance)}
+
+
+ Review request +
+ {/each} +
+ + {/if} +
+ + {#if urlState.approval !== null} + + {/if} +
diff --git a/web/src/routes/approvals/approvals-page.test-harness.svelte b/web/src/routes/approvals/approvals-page.test-harness.svelte new file mode 100644 index 000000000..f331568b3 --- /dev/null +++ b/web/src/routes/approvals/approvals-page.test-harness.svelte @@ -0,0 +1,27 @@ + + + diff --git a/web/src/routes/approvals/approvals-page.test.ts b/web/src/routes/approvals/approvals-page.test.ts new file mode 100644 index 000000000..4c625edb3 --- /dev/null +++ b/web/src/routes/approvals/approvals-page.test.ts @@ -0,0 +1,597 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; +import type { ApprovalDetail, ApprovalPage, ApprovalSummary } from "$lib/api"; + +const auth = { + authenticated: true, + username: "admin", + recoverFromApiError: vi.fn(() => false), + signOut: vi.fn(async () => ({ ok: true as const, value: undefined })), +}; +const fallbackGoto = vi.fn(async () => {}); + +vi.doMock("$app/navigation", () => ({ goto: fallbackGoto })); +vi.doMock("$app/state", () => ({ page: { url: new URL("http://localhost/approvals") } })); +vi.doMock("$lib/auth.svelte", () => ({ useAuthState: () => auth })); + +const { default: ApprovalsPageHarness } = await import("./approvals-page.test-harness.svelte"); + +function approvalSummary(overrides: Partial = {}) { + const now = Math.floor(Date.now() / 1_000); + return { + id: "approval-1", + status: "pending", + revision: 7, + sourceId: "source-1", + toolId: "tool-1", + path: "tools.product_api.create_issue", + sourceDisplayName: "Product API", + toolDisplayName: "Create issue", + actorKind: "api_token", + actorId: "actor-1", + actorName: "Automation", + actorLabel: "Automation token", + actorApiTokenId: "token-1", + actorTokenName: "Automation", + surface: "gateway", + mode: "ask", + provenance: "intrinsic", + executionId: "execution-1", + callId: "call-1", + createdAt: now - 10, + updatedAt: now - 5, + expiresAt: now + 3_600, + decidedAt: null, + startedAt: null, + completedAt: null, + decision: null, + failureCode: null, + ...overrides, + } satisfies ApprovalSummary; +} + +function approvalDetail(overrides: Partial = {}) { + return { + ...approvalSummary(overrides), + redactedArguments: { title: "[redacted]" }, + inputSchema: { type: "object" }, + ...overrides, + } satisfies ApprovalDetail; +} + +function approvalPage(items: readonly ApprovalSummary[] = []) { + return Response.json({ items, nextCursor: null } satisfies ApprovalPage); +} + +function errorResponse(status: number, code: string, message: string, requestId = "test-request") { + return Response.json({ error: { code, message, requestId } }, { status }); +} + +function deferred() { + let resolve = (_value: Value) => {}; + let reject = (_reason?: unknown) => {}; + const promise = new Promise((complete, fail) => { + resolve = complete; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function manualPollEnvironment() { + let callback: (() => void) | null = null; + let activeHandle = 0; + return { + environment: { + schedule(next: () => void) { + callback = next; + activeHandle += 1; + return activeHandle; + }, + cancel(handle: number) { + if (handle === activeHandle) callback = null; + }, + isVisible: () => true, + }, + trigger() { + const next = callback; + callback = null; + next?.(); + }, + }; +} + +async function approveOnce() { + const start = await screen.findByRole("button", { name: "Approve once" }); + await waitFor(() => expect(start.disabled).toBe(false)); + await fireEvent.click(start); + const confirmation = await screen.findByRole("group", { name: "Confirm approval" }); + await fireEvent.click(within(confirmation).getByRole("button", { name: "Yes, approve once" })); +} + +afterEach(() => { + cleanup(); + auth.authenticated = true; + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("Approvals page request coordination", () => { + it("allows a current pending detail decision while the list request never resolves", async () => { + const heldList = deferred(); + const decisions: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path.startsWith("/api/v1/approvals?")) return heldList.promise; + if (path === "/api/v1/approvals/approval-1/decision" && init?.method === "POST") { + decisions.push(String(init.body)); + return Promise.resolve( + Response.json( + approvalDetail({ + status: "approved", + revision: 8, + decision: "approve", + decidedAt: Math.floor(Date.now() / 1_000), + }), + ), + ); + } + if (path === "/api/v1/approvals/approval-1") { + return Promise.resolve(Response.json(approvalDetail())); + } + return Promise.resolve(errorResponse(500, "unexpected_test_request", path)); + }), + ); + + render(ApprovalsPageHarness, { + initialUrl: "http://localhost/approvals?approval=approval-1", + }); + + await approveOnce(); + + await waitFor(() => expect(decisions).toHaveLength(1)); + expect(decisions).toEqual([JSON.stringify({ decision: "approve", expectedRevision: 7 })]); + }); + + it("keeps decisions available when a silent background list refresh fails", async () => { + const poll = manualPollEnvironment(); + const failedRefresh = deferred(); + const backgroundDetail = deferred(); + let listCalls = 0; + let detailCalls = 0; + let decisionCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path.startsWith("/api/v1/approvals?")) { + listCalls += 1; + return listCalls === 1 + ? Promise.resolve(approvalPage([approvalSummary()])) + : failedRefresh.promise; + } + if (path === "/api/v1/approvals/approval-1/decision" && init?.method === "POST") { + decisionCalls += 1; + return Promise.resolve( + Response.json( + approvalDetail({ + status: "approved", + revision: 8, + decision: "approve", + decidedAt: Math.floor(Date.now() / 1_000), + }), + ), + ); + } + if (path === "/api/v1/approvals/approval-1") { + detailCalls += 1; + if (detailCalls === 1) return Promise.resolve(Response.json(approvalDetail())); + if (detailCalls === 2) return backgroundDetail.promise; + return Promise.resolve(Response.json(approvalDetail())); + } + return Promise.resolve(errorResponse(500, "unexpected_test_request", path)); + }), + ); + + render(ApprovalsPageHarness, { + initialUrl: "http://localhost/approvals?approval=approval-1", + pollEnvironment: poll.environment, + }); + + const approve = await screen.findByRole("button", { name: "Approve once" }); + await waitFor(() => expect(approve.disabled).toBe(false)); + poll.trigger(); + await waitFor(() => { + expect(listCalls).toBe(2); + expect(detailCalls).toBe(2); + }); + expect(screen.getByText("Refreshing...").getAttribute("role")).toBeNull(); + expect(screen.getByText("Refreshing approval detail...").getAttribute("role")).toBeNull(); + + backgroundDetail.resolve(Response.json(approvalDetail())); + await waitFor(() => expect(approve.disabled).toBe(false)); + failedRefresh.reject("offline"); + const refreshError = await screen.findByText( + "Executor could not be reached. Check that the local server is running.", + ); + expect(refreshError.closest(".notice")?.getAttribute("role") ?? null).toBeNull(); + const staleNotice = refreshError.closest(".stale-notice"); + expect(staleNotice?.getAttribute("role")).toBe("status"); + await waitFor(() => expect(approve.disabled).toBe(false)); + + poll.trigger(); + await waitFor(() => { + expect(listCalls).toBe(3); + expect(detailCalls).toBe(3); + }); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByRole("status")).toBe(staleNotice); + expect( + screen + .getByText("Executor could not be reached. Check that the local server is running.") + .closest(".stale-notice"), + ).toBe(staleNotice); + + await approveOnce(); + await waitFor(() => expect(decisionCalls).toBe(1)); + }); + + it("keeps an initial list error mounted through passive retries", async () => { + const poll = manualPollEnvironment(); + const initialLoad = deferred(); + const firstRetry = deferred(); + const secondRetry = deferred(); + let listCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path.startsWith("/api/v1/approvals?")) { + listCalls += 1; + if (listCalls === 1) return initialLoad.promise; + if (listCalls === 2) return firstRetry.promise; + return secondRetry.promise; + } + return Promise.resolve(errorResponse(500, "unexpected_test_request", path)); + }), + ); + + render(ApprovalsPageHarness, { + initialUrl: "http://localhost/approvals", + pollEnvironment: poll.environment, + }); + + initialLoad.resolve(errorResponse(503, "initial_failure", "Initial list failure", "list-a")); + const errorText = await screen.findByText("Initial list failure"); + const errorAlert = errorText.closest(".notice"); + expect(errorAlert?.getAttribute("role")).toBe("alert"); + expect(screen.getByText("list-a")).toBeDefined(); + + poll.trigger(); + await waitFor(() => expect(listCalls).toBe(2)); + expect(screen.getByText("Loading approvals...").getAttribute("aria-live")).toBeNull(); + expect(screen.getByText("Initial list failure").closest(".notice")).toBe(errorAlert); + firstRetry.resolve(errorResponse(502, "retry_failure", "Second list failure", "list-b")); + await waitFor(() => + expect(screen.getByRole("button", { name: "Refresh" }).disabled).toBe( + false, + ), + ); + + poll.trigger(); + await waitFor(() => expect(listCalls).toBe(3)); + expect(screen.getByText("Loading approvals...").getAttribute("aria-live")).toBeNull(); + expect(screen.getByRole("alert")).toBe(errorAlert); + expect(screen.getByText("Initial list failure")).toBeDefined(); + expect(screen.getByText("list-a")).toBeDefined(); + expect(screen.queryByText("Second list failure")).toBeNull(); + expect(screen.queryByText("list-b")).toBeNull(); + secondRetry.resolve(errorResponse(500, "retry_failure", "Third list failure", "list-c")); + await waitFor(() => + expect(screen.getByRole("button", { name: "Refresh" }).disabled).toBe( + false, + ), + ); + expect(screen.getByRole("alert")).toBe(errorAlert); + expect(screen.getByText("Initial list failure")).toBeDefined(); + expect(screen.getByText("list-a")).toBeDefined(); + expect(screen.queryByText("Third list failure")).toBeNull(); + expect(screen.queryByText("list-c")).toBeNull(); + }); + + it("keeps a passive detail error mounted through its next passive retry", async () => { + const poll = manualPollEnvironment(); + const firstFailure = deferred(); + const secondFailure = deferred(); + let detailCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path.startsWith("/api/v1/approvals?")) { + return Promise.resolve(approvalPage([approvalSummary()])); + } + if (path === "/api/v1/approvals/approval-1") { + detailCalls += 1; + if (detailCalls === 1) return Promise.resolve(Response.json(approvalDetail())); + if (detailCalls === 2) return firstFailure.promise; + return secondFailure.promise; + } + return Promise.resolve(errorResponse(500, "unexpected_test_request", path)); + }), + ); + + render(ApprovalsPageHarness, { + initialUrl: "http://localhost/approvals?approval=approval-1", + pollEnvironment: poll.environment, + }); + + await screen.findByRole("button", { name: "Approve once" }); + poll.trigger(); + await waitFor(() => expect(detailCalls).toBe(2)); + firstFailure.resolve( + errorResponse(503, "initial_failure", "Initial detail failure", "detail-a"), + ); + const errorText = await screen.findByText("Initial detail failure"); + const errorAlert = errorText.closest(".notice"); + expect(errorAlert?.getAttribute("role")).toBe("alert"); + expect(screen.getByText("detail-a")).toBeDefined(); + + poll.trigger(); + await waitFor(() => expect(detailCalls).toBe(3)); + expect(screen.queryByText("Loading approval detail...")).toBeNull(); + expect(screen.getByRole("alert")).toBe(errorAlert); + expect(screen.getByText("Initial detail failure").closest(".notice")).toBe(errorAlert); + secondFailure.resolve(errorResponse(502, "retry_failure", "Second detail failure", "detail-b")); + await waitFor(() => + expect(document.getElementById("approval-detail-panel")?.getAttribute("aria-busy")).toBe( + "false", + ), + ); + expect(screen.getByRole("alert")).toBe(errorAlert); + expect(screen.getByText("Initial detail failure")).toBeDefined(); + expect(screen.getByText("detail-a")).toBeDefined(); + expect(screen.queryByText("Second detail failure")).toBeNull(); + expect(screen.queryByText("detail-b")).toBeNull(); + }); + + it("keeps decision controls disabled without an authenticated admin", async () => { + auth.authenticated = false; + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path.startsWith("/api/v1/approvals?")) { + return Promise.resolve(approvalPage([approvalSummary()])); + } + if (path === "/api/v1/approvals/approval-1") { + return Promise.resolve(Response.json(approvalDetail())); + } + return Promise.resolve(errorResponse(500, "unexpected_test_request", path)); + }), + ); + + render(ApprovalsPageHarness, { + initialUrl: "http://localhost/approvals?approval=approval-1", + }); + + const approve = await screen.findByRole("button", { name: "Approve once" }); + const deny = screen.getByRole("button", { name: "Deny" }); + expect(approve.disabled).toBe(true); + expect(deny.disabled).toBe(true); + }); + + it("resets list data when a new filter request is held and then rejected", async () => { + const deniedPage = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path.includes("status=pending")) { + return Promise.resolve( + approvalPage([approvalSummary({ id: "approval-a", toolDisplayName: "Alpha tool" })]), + ); + } + if (path.includes("status=denied")) return deniedPage.promise; + return Promise.resolve(errorResponse(500, "unexpected_test_request", path)); + }), + ); + + render(ApprovalsPageHarness, { initialUrl: "http://localhost/approvals" }); + + expect(await screen.findByRole("heading", { name: "Alpha tool" })).toBeDefined(); + await fireEvent.change(screen.getByLabelText("Status"), { target: { value: "denied" } }); + + expect(await screen.findByText("Loading approvals...")).toBeDefined(); + expect(screen.queryByRole("heading", { name: "Alpha tool" })).toBeNull(); + deniedPage.reject("offline"); + expect( + await screen.findByText( + "Executor could not be reached. Check that the local server is running.", + ), + ).toBeDefined(); + expect(screen.queryByRole("heading", { name: "Alpha tool" })).toBeNull(); + }); + + it("ignores an out-of-order list response from the replaced filter identity", async () => { + const pendingPage = deferred(); + const pendingRequest = { signal: null as AbortSignal | null }; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path.includes("status=pending")) { + pendingRequest.signal = init?.signal ?? null; + return pendingPage.promise; + } + if (path.includes("status=denied")) { + return Promise.resolve( + approvalPage([ + approvalSummary({ + id: "approval-b", + status: "denied", + toolDisplayName: "Beta tool", + }), + ]), + ); + } + return Promise.resolve(errorResponse(500, "unexpected_test_request", path)); + }), + ); + + render(ApprovalsPageHarness, { initialUrl: "http://localhost/approvals" }); + await waitFor(() => expect(pendingRequest.signal).not.toBeNull()); + await fireEvent.change(screen.getByLabelText("Status"), { target: { value: "denied" } }); + + expect(await screen.findByRole("heading", { name: "Beta tool" })).toBeDefined(); + expect(pendingRequest.signal?.aborted).toBe(true); + pendingPage.resolve( + approvalPage([approvalSummary({ id: "approval-a", toolDisplayName: "Alpha tool" })]), + ); + await pendingPage.promise; + await Promise.resolve(); + await Promise.resolve(); + + expect(screen.getByRole("heading", { name: "Beta tool" })).toBeDefined(); + expect(screen.queryByRole("heading", { name: "Alpha tool" })).toBeNull(); + }); + + it("closes an existing confirmation when a background detail refresh changes status", async () => { + const poll = manualPollEnvironment(); + let detailCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path.startsWith("/api/v1/approvals?")) { + return Promise.resolve(approvalPage([approvalSummary()])); + } + if (path === "/api/v1/approvals/approval-1") { + detailCalls += 1; + return Promise.resolve( + Response.json( + detailCalls === 1 + ? approvalDetail() + : approvalDetail({ status: "denied", revision: 8, decision: "deny" }), + ), + ); + } + return Promise.resolve(errorResponse(500, "unexpected_test_request", path)); + }), + ); + + render(ApprovalsPageHarness, { + initialUrl: "http://localhost/approvals?approval=approval-1", + pollEnvironment: poll.environment, + }); + + const approve = await screen.findByRole("button", { name: "Approve once" }); + await waitFor(() => expect(approve.disabled).toBe(false)); + await fireEvent.click(approve); + expect(await screen.findByRole("group", { name: "Confirm approval" })).toBeDefined(); + + poll.trigger(); + + expect(await screen.findByText("This request can no longer be decided.")).toBeDefined(); + expect(screen.queryByRole("group", { name: "Confirm approval" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Approve once" })).toBeNull(); + }); + + it("fences a conflicting decision until a newer detail revision loads", async () => { + const rollbackDetail = deferred(); + const newerDetail = deferred(); + let detailCalls = 0; + const decisionBodies: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path.startsWith("/api/v1/approvals?")) { + return Promise.resolve(approvalPage([approvalSummary()])); + } + if (path === "/api/v1/approvals/approval-1") { + detailCalls += 1; + if (detailCalls === 1) return Promise.resolve(Response.json(approvalDetail())); + if (detailCalls === 2) return rollbackDetail.promise; + return newerDetail.promise; + } + if (path === "/api/v1/approvals/approval-1/decision" && init?.method === "POST") { + decisionBodies.push(String(init.body)); + return Promise.resolve(errorResponse(409, "approval_conflict", "The approval changed.")); + } + return Promise.resolve(errorResponse(500, "unexpected_test_request", path)); + }), + ); + + render(ApprovalsPageHarness, { + initialUrl: "http://localhost/approvals?approval=approval-1", + }); + + await approveOnce(); + await waitFor(() => expect(detailCalls).toBe(2)); + const approve = await screen.findByRole("button", { name: "Approve once" }); + expect(approve.disabled).toBe(true); + await fireEvent.click(approve); + expect(decisionBodies).toEqual([JSON.stringify({ decision: "approve", expectedRevision: 7 })]); + + rollbackDetail.resolve(Response.json(approvalDetail({ revision: 6 }))); + await waitFor(() => + expect(screen.getByRole("button", { name: "Approve once" }).disabled).toBe( + true, + ), + ); + await fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(detailCalls).toBe(3)); + newerDetail.resolve(Response.json(approvalDetail({ revision: 8 }))); + await waitFor(() => + expect(screen.getByRole("button", { name: "Approve once" }).disabled).toBe( + false, + ), + ); + expect(screen.queryByText("The approval changed.")).toBeNull(); + }); + + it("aborts list and detail loads on unmount and ignores their late responses", async () => { + const list = deferred(); + const detail = deferred(); + const requests = { + listSignal: null as AbortSignal | null, + detailSignal: null as AbortSignal | null, + }; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path.startsWith("/api/v1/approvals?")) { + requests.listSignal = init?.signal ?? null; + return list.promise; + } + if (path === "/api/v1/approvals/approval-1") { + requests.detailSignal = init?.signal ?? null; + return detail.promise; + } + return Promise.resolve(errorResponse(500, "unexpected_test_request", path)); + }), + ); + + const rendered = render(ApprovalsPageHarness, { + initialUrl: "http://localhost/approvals?approval=approval-1", + }); + await waitFor(() => { + expect(requests.listSignal).not.toBeNull(); + expect(requests.detailSignal).not.toBeNull(); + }); + + rendered.unmount(); + expect(requests.listSignal?.aborted).toBe(true); + expect(requests.detailSignal?.aborted).toBe(true); + list.resolve(approvalPage([approvalSummary()])); + detail.resolve(Response.json(approvalDetail())); + await Promise.all([list.promise, detail.promise]); + await Promise.resolve(); + await Promise.resolve(); + + expect(rendered.container.innerHTML).toBe(""); + }); +}); diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte new file mode 100644 index 000000000..260c401fb --- /dev/null +++ b/web/src/routes/login/+page.svelte @@ -0,0 +1,73 @@ + + + + Sign in | Executor + + +
+
+
+ +
+

Administrator

+

Open your gateway.

+
+
+

The dashboard session controls configuration. API tokens only reach tools.

+ + {#if auth.notice !== null} +
+ {auth.notice} + +
+ {/if} + +
+ + + {#if error !== null}{/if} + + +
+
diff --git a/web/src/routes/logs/+page.svelte b/web/src/routes/logs/+page.svelte new file mode 100644 index 000000000..d6f1f1be4 --- /dev/null +++ b/web/src/routes/logs/+page.svelte @@ -0,0 +1,300 @@ + + + +
+
+

Metadata only

+

Safe operational history

+
+

+ Arguments, response bodies, headers, credentials, and tokens are never included in this view. +

+
+ + {#if resource.stale && resource.error !== null} +
+ Showing the last loaded request page while Executor reconnects. + +
+ {:else if resource.error !== null} +
+ + +
+ {/if} + +
+
+
+

Newest first

+

Gateway activity

+
+
+ {#if resource.loading}Refreshing...{/if} + +
+
+ {#if resource.data === null && resource.loading} +
Loading request logs...
+ {:else if resource.data !== null && resource.data.items.length === 0} +
+

No requests have been recorded on this page.

+ {#if urlState.cursor !== null}Return to newest requests{/if} +
+ {:else if resource.data !== null} +
+ + + + + {#each resource.data.items as log (log.requestId)} + + + + + + + + {/each} + +
Request metadata, newest first.
RequestRouteOutcomeTimingDetails
{log.requestId}{formatTime(log.createdAt)} · {log.surface}{log.pathSnapshot ?? "Unknown path"}{log.sourceId ?? "Source unavailable"}{outcomeLabel(log.outcome)}{#if log.errorCode !== null}{log.errorCode}{/if}{log.durationMs} ms (detailReturnId = log.requestId)}>Inspect
+
+ + {/if} +
+ + {#if urlState.request !== null} + + {/if} +
diff --git a/web/src/routes/logs/logs-page.test-harness.svelte b/web/src/routes/logs/logs-page.test-harness.svelte new file mode 100644 index 000000000..780161c44 --- /dev/null +++ b/web/src/routes/logs/logs-page.test-harness.svelte @@ -0,0 +1,10 @@ + + + diff --git a/web/src/routes/logs/logs-page.test.ts b/web/src/routes/logs/logs-page.test.ts new file mode 100644 index 000000000..ef430840b --- /dev/null +++ b/web/src/routes/logs/logs-page.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/svelte"; +import type { RequestLog, RequestLogPage } from "$lib/api"; +import LogsPageHarness from "./logs-page.test-harness.svelte"; + +function requestLog(requestId: string) { + return { + requestId, + actorApiTokenId: null, + surface: "gateway", + sourceId: "source-1", + toolId: "tool-1", + pathSnapshot: `tools.source_1.${requestId}`, + outcome: "succeeded", + errorCode: null, + durationMs: 12, + approvalId: null, + createdAt: 100, + } satisfies RequestLog; +} + +function logPage(requestId: string, nextCursor: string | null = null) { + return Response.json({ items: [requestLog(requestId)], nextCursor } satisfies RequestLogPage); +} + +function deferred() { + let resolve = (_value: Value) => {}; + let reject = (_error: unknown) => {}; + const promise = new Promise((complete, fail) => { + resolve = complete; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function errorResponse(code: string, message: string, status = 503) { + return Response.json({ error: { code, message, requestId: `${code}-request` } }, { status }); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("Request logs list identity", () => { + it("does not present cursor A as cursor B while B is held or after B is rejected", async () => { + const pageB = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path === "/api/v1/request-logs?limit=50&cursor=cursor-a") { + return Promise.resolve(logPage("request-a")); + } + if (path === "/api/v1/request-logs?limit=50&cursor=cursor-b") return pageB.promise; + return Promise.resolve(errorResponse("unexpected_test_request", path, 500)); + }), + ); + const mounted = render(LogsPageHarness, { search: "?cursor=cursor-a" }); + + expect(await screen.findByText("request-a")).toBeDefined(); + await mounted.rerender({ search: "?cursor=cursor-b" }); + + expect(screen.queryByText("request-a")).toBeNull(); + expect(screen.getByText("Loading request logs...")).toBeDefined(); + + pageB.reject({ _tag: "CursorBNetworkFailure" }); + + expect( + await screen.findByText( + "Executor could not be reached. Check that the local server is running.", + ), + ).toBeDefined(); + expect(screen.queryByText("request-a")).toBeNull(); + expect( + screen.queryByText("Showing the last loaded request page while Executor reconnects."), + ).toBeNull(); + }); + + it("keeps cursor B after an out-of-order cursor A completion", async () => { + const pageA = deferred(); + const pageB = deferred(); + const signals = new Map(); + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + const signal = init?.signal; + if (signal !== undefined && signal !== null) signals.set(path, signal); + if (path === "/api/v1/request-logs?limit=50&cursor=cursor-a") return pageA.promise; + if (path === "/api/v1/request-logs?limit=50&cursor=cursor-b") return pageB.promise; + return Promise.resolve(errorResponse("unexpected_test_request", path, 500)); + }), + ); + const mounted = render(LogsPageHarness, { search: "?cursor=cursor-a" }); + + await waitFor(() => expect(signals.size).toBe(1)); + await mounted.rerender({ search: "?cursor=cursor-b" }); + await waitFor(() => expect(signals.size).toBe(2)); + expect(signals.get("/api/v1/request-logs?limit=50&cursor=cursor-a")?.aborted).toBe(true); + + pageB.resolve(logPage("request-b")); + expect(await screen.findByText("request-b")).toBeDefined(); + pageA.resolve(logPage("request-a")); + await pageA.promise; + await Promise.resolve(); + await Promise.resolve(); + + expect(screen.getByText("request-b")).toBeDefined(); + expect(screen.queryByText("request-a")).toBeNull(); + }); + + it("preserves the honestly labeled list across detail-only query changes", async () => { + let listCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path === "/api/v1/request-logs?limit=50&cursor=cursor-b") { + listCalls += 1; + return Promise.resolve(logPage("request-b")); + } + if (path === "/api/v1/request-logs/request-b") { + return Promise.resolve(Response.json(requestLog("request-b"))); + } + return Promise.resolve(errorResponse("unexpected_test_request", path, 500)); + }), + ); + const mounted = render(LogsPageHarness, { search: "?cursor=cursor-b" }); + + expect(await screen.findByText("request-b")).toBeDefined(); + await mounted.rerender({ search: "?cursor=cursor-b&request=request-b" }); + + expect(await screen.findByRole("heading", { name: "request-b" })).toBeDefined(); + expect(screen.getAllByText("request-b").length).toBeGreaterThan(1); + expect(listCalls).toBe(1); + }); + + it("aborts its active page request and ignores completion after unmount", async () => { + const pending = deferred(); + const request = { signal: null as AbortSignal | null }; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/request-logs?limit=50&cursor=cursor-a") { + request.signal = init?.signal ?? null; + return pending.promise; + } + return Promise.resolve(errorResponse("unexpected_test_request", path, 500)); + }), + ); + const mounted = render(LogsPageHarness, { search: "?cursor=cursor-a" }); + + await waitFor(() => expect(request.signal).not.toBeNull()); + mounted.unmount(); + expect(request.signal?.aborted).toBe(true); + + pending.resolve(logPage("request-after-unmount")); + await pending.promise; + await Promise.resolve(); + await Promise.resolve(); + + expect(screen.queryByText("request-after-unmount")).toBeNull(); + }); +}); diff --git a/web/src/routes/setup/+page.svelte b/web/src/routes/setup/+page.svelte new file mode 100644 index 000000000..afac011fc --- /dev/null +++ b/web/src/routes/setup/+page.svelte @@ -0,0 +1,122 @@ + + + + Set up Executor + + +
+
+
+ +
+

First boot

+

Make this instance yours.

+
+
+

+ Create the only administrator account. The one-time setup secret is removed from browser + history as soon as this page opens. +

+ + {#if fragmentRead && setupToken === null} + + {/if} + +
+ + + + {#if passwordConfirmation !== "" && !passwordsMatch} + + {/if} + {#if error !== null}{/if} + + +
+
diff --git a/web/src/routes/setup/page.test.ts b/web/src/routes/setup/page.test.ts new file mode 100644 index 000000000..ee564c132 --- /dev/null +++ b/web/src/routes/setup/page.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "@effect/vitest"; +import { render, screen, waitFor } from "@testing-library/svelte"; +import Page from "./+page.svelte"; + +it("removes the first-boot token from browser history without rendering it", async () => { + history.replaceState({}, "", "/setup#token=set_test_secret"); + render(Page); + + await waitFor(() => expect(window.location.hash).toBe("")); + expect(screen.getByRole("heading", { name: "Make this instance yours." })).toBeDefined(); + expect(document.body.textContent).not.toContain("set_test_secret"); +}); diff --git a/web/src/routes/sources/+page.svelte b/web/src/routes/sources/+page.svelte new file mode 100644 index 000000000..2a30d8da0 --- /dev/null +++ b/web/src/routes/sources/+page.svelte @@ -0,0 +1,1765 @@ + + + + + + {#if navigationNotice !== null} +
+ {navigationNotice} +
+ {/if} + {#if sourceCreateState.phase !== "idle" && sourceCreateState.notice !== null} +
{sourceCreateState.notice}
+ {/if} + {#if sourceCreateState.error !== null || (sourceCreateState.phase === "paused" && sourceCreateState.retry !== null)} +
+ {#if sourceCreateState.error !== null}{/if} + {#if sourceCreateState.phase === "blocked"} + + {:else if sourceCreateState.phase === "paused" && sourceCreateState.retry !== null} + + {/if} +
+ {/if} + {#if conflictNotice !== null} +
+ {conflictNotice} +
+ {/if} + {#if importNotice !== null}
+ {importNotice} +
{/if} + +
+ (connectSourceOpenOwnedByUser = true)}>Connect a source +
+ Source type + + + + +

+ OpenAPI compiles an API specification. GraphQL imports an introspected schema. MCP connects + using Streamable HTTP or a locally configured process template. +

+
+ {#if sourceType === "openapi"} +
+
+ Specification location + + +
+ {#if locatorType === "url"} + + {:else} + + {/if} + +

+ Keep this off unless the specification or API intentionally runs on your local network. +

+ + +
+ + {#if importError !== null}
+ +
{/if} + {#if preview !== null} +
{ + event.preventDefault(); + void createSource(); + }} + > +
+

Preview

+

{preview.title}

+

{preview.description ?? "No API description provided."}

+
+ {preview.toolCount} tools found + {#if !previewCurrent}

+ The specification changed. Preview it again before importing. +

{/if} +
+ {#each preview.tools.slice(0, 8) as tool, index (previewToolKey(tool.preferredName, index))} + {tool.displayName}{modeLabel(tool.intrinsicMode)} + {/each} + {#if preview.tools.length > 8}+ {preview.tools.length - 8} more{/if} +
+
+ + + + {#if preview.securitySchemes.length > 0} +
+ Authentication schemes +

+ Select every credential you want to configure. Tool requirements use OR between + groups and AND within a group. +

+ {#each preview.securitySchemes as scheme (scheme.name)} + {@const row = importCredentialRows.find( + (candidate) => candidate.name === scheme.name, + )} +
+ + {#if row?.enabled} + {#if row.credentialType === "basic"}{/if} + + {/if} + {#if row?.enabled && row.credentialType === "oauth_access_token"} +

+ Advanced: supply an access token manually. Managed OAuth becomes available + after import when this security scheme supports it. +

+ {/if} +
+ {/each} +
+ {/if} +
+ + {#if authenticatedPreviewWithoutCredentials()} +

+ This specification declares authentication, but no credentials are selected. Protected + tools will fail until credentials are configured. +

+ {/if} +

Credentials are encrypted locally and are never shown again.

+
+ {/if} + {:else if sourceType === "graphql"} + {#key sourceFormGeneration} + + {/key} + {:else if sourceType === "mcp_http"} + {#key sourceFormGeneration} + + {/key} + {:else} + {#key sourceFormGeneration} + + {/key} + {/if} +
+ + {#if resource.stale && resource.error !== null} +
+ Showing the last loaded sources while Executor reconnects. + +
+ {:else if resource.error !== null} +
+ + +
+ {/if} + + {#if resource.data === null && resource.loading} +
Loading sources...
+ {:else if resource.data !== null && resource.data.sources.length === 0} +
+ 01 +

No sources connected

+

+ This instance has no source data yet. Use the source connector above to add an OpenAPI, + GraphQL, or MCP source. +

+
+ {:else if resource.data !== null} +
+ {#each resource.data.sources as source (source.id)} +
+
+
+

{kindLabel(source.kind)} · {source.slug}

+

{source.displayName}

+ {#if source.description !== null}

{source.description}

{/if} +
+ + {source.healthStatus} + +
+ +
+
+
Active tools
+
{source.toolCount}
+
+
+
Removed tools
+
{source.tombstonedToolCount}
+
+
+
Last refresh
+
{formatTime(source.lastRefreshedAt)}
+
+
+ + {#if source.healthErrorCode !== null} +

Refresh error: {source.healthErrorCode}

+ {/if} + + {#if source.kind === "graphql"} + {@const details = safeGraphqlSourceDetails(source.configuration)} +
+ {#if details.endpoint !== null} +
+
Endpoint
+
{details.endpoint}
+
+ {/if} +
+
Private network
+
{details.allowPrivateNetwork ? "Allowed" : "Blocked"}
+
+
+
Schema discovery
+
GraphQL introspection
+
+
+ {/if} + + {#if source.kind === "mcp_http" || source.kind === "mcp_stdio"} + {@const details = safeMcpSourceDetails(source.kind, source.configuration)} +
+
+
Transport
+
{source.kind === "mcp_http" ? "Streamable HTTP" : "Trusted local template"}
+
+ {#if details.endpointLabel !== null} +
+
Endpoint
+
{details.endpointLabel}
+
+ {/if} + {#if details.templateName !== null} +
+
Template
+
{details.templateName}
+
+ {/if} + {#if source.kind === "mcp_http"} +
+
Private network
+
{details.allowPrivateNetwork ? "Allowed" : "Blocked"}
+
+
+
Upstream sessions
+
Memory only
+
+ {/if} + {#if details.negotiatedProtocolVersion !== null} +
+
MCP version
+
{details.negotiatedProtocolVersion}
+
+ {/if} +
+ {/if} + +
+ Default tool behavior + + {#each toolModes as mode} + + {/each} +

{sourceModeImpact(source.toolCount)}

+ +
+ + {#if confirmingSourceMode === source.id} +
+ Confirm {modeLabel(stagedSourceMode(source.id, source.modeOverride) ?? "ask")} as the + source default? + {sourceModeImpact(source.toolCount)} + + +
+ {/if} + + {#if mutationErrors[source.id] !== undefined} + + {/if} + {#if credentialFailure?.sourceId === source.id} + + {/if} + +
+ + View tools + + {#if source.kind === "openapi" || source.kind === "graphql" || source.kind === "mcp_http" || source.kind === "mcp_stdio"} + + {/if} + {#if source.kind === "openapi"} + + {/if} + {#if source.kind === "mcp_http" || source.kind === "mcp_stdio"} + setMcpCredentialBusy(source.id, busy)} + onmutationchange={(busy) => setMcpCredentialMutation(source.id, busy)} + /> + {/if} + {#if source.kind === "graphql"} + setGraphqlCredentialBusy(source.id, busy)} + onmutationchange={(busy) => setGraphqlCredentialMutation(source.id, busy)} + /> + {/if} + {#if source.kind === "openapi" || source.kind === "graphql" || source.kind === "mcp_http"} + {@const oauthCheckContext = { + callbackKey: oauthCallbackKey, + sourceIdentity: eligibleOAuthSourceIdentity, + }} + {#key `${source.id}:${source.revision}:${source.catalogRevision}`} + setOAuthBusy(source.id, busy)} + onmutationchange={(busy) => setOAuthMutation(source.id, busy)} + oncallbackchecked={(matched) => + completeOAuthCallbackCheck( + source.id, + matched, + oauthCheckContext.callbackKey, + oauthCheckContext.sourceIdentity, + )} + /> + {/key} + {/if} + {#if confirmingDelete === source.id} +
+ Delete source, compiled tools, and encrypted credentials? + Request-log metadata is retained without secret values. + + +
+ {:else} + + {/if} +
+ + {#if credentialEditorSource === source.id} +
{ + event.preventDefault(); + void saveCredentials(source.id); + }} + > +
+

Replace credentials

+

Existing values are hidden. Re-enter every credential you want to keep.

+
+ {#each credentialRows as row (row.key)} +
+ Credential + + + {#if row.credentialType === "basic"}{/if} + + {#if row.credentialType === "oauth_access_token"} +

+ Advanced: supply an access token manually. Managed OAuth is available above + when this security scheme supports it. +

+ {/if} + +
+ {/each} + {#if credentialRows.length === 0}

+ No credentials are currently configured. +

{/if} +
+ + + {#if currentDuplicateCredentialNames.length > 0}

+ Each security scheme name must be unique. Duplicates: {currentDuplicateCredentialNames.join( + ", ", + )}. +

{/if} + {#if confirmingCredentialClear === source.id} + + + {:else} + + {/if} +
+
+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/web/src/routes/sources/sources-page.test-harness.svelte b/web/src/routes/sources/sources-page.test-harness.svelte new file mode 100644 index 000000000..dc1e4a47e --- /dev/null +++ b/web/src/routes/sources/sources-page.test-harness.svelte @@ -0,0 +1,39 @@ + + + diff --git a/web/src/routes/sources/sources-page.test.ts b/web/src/routes/sources/sources-page.test.ts new file mode 100644 index 000000000..1cf63da07 --- /dev/null +++ b/web/src/routes/sources/sources-page.test.ts @@ -0,0 +1,1949 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; +import { Effect, Schema } from "effect"; +import { + SOURCE_CREATE_STORAGE_KEY, + type SourceCreateEnvironment, +} from "$lib/source-create-lifecycle"; +import SourcesPageHarness from "./sources-page.test-harness.svelte"; + +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function sourceFixture(id = "graphql-source", displayName = "Product API") { + return { + id, + kind: "graphql", + slug: id, + displayName, + description: null, + configuration: { endpoint: "https://api.example.test/", allowPrivateNetwork: false }, + modeOverride: null, + healthStatus: "healthy", + healthErrorCode: null, + revision: 1, + catalogRevision: 1, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 4, + tombstonedToolCount: 0, + }; +} + +function openApiSourceFixture(id = "openapi-source", displayName = "Weather API") { + return { + ...sourceFixture(id, displayName), + kind: "openapi", + configuration: {}, + }; +} + +function mcpHttpSourceFixture(id = "mcp-http-source", displayName = "Issue tracker") { + return { + ...sourceFixture(id, displayName), + kind: "mcp_http", + configuration: { endpoint: "https://mcp.example.test", allowPrivateNetwork: false }, + }; +} + +function mcpStdioSourceFixture(id = "mcp-stdio-source", displayName = "Local tools") { + return { + ...sourceFixture(id, displayName), + kind: "mcp_stdio", + configuration: { templateName: "local" }, + }; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function emptyOAuthConnections() { + return Response.json({ connections: [], availableCredentials: [] }); +} + +function noStoreJson(value: unknown, init: ResponseInit = {}) { + const headers = new Headers(init.headers); + headers.set("cache-control", "no-store"); + return Response.json(value, { ...init, headers }); +} + +function requestIdempotencyKey(init: RequestInit | undefined) { + return new Headers(init?.headers).get("idempotency-key"); +} + +function fixedSourceCreateEnvironment( + options: { readonly wait?: SourceCreateEnvironment["wait"] } = {}, +): SourceCreateEnvironment { + return { + getStorage: () => window.sessionStorage, + fillRandom: (bytes) => bytes.fill(0x7c), + wait: options.wait ?? (() => Promise.resolve(true)), + }; +} + +function oauthConnectionFixture(id: string) { + return { + id, + credentialKey: "default", + revision: 1, + status: "connected", + issuer: "https://identity.example.test/", + clientId: "executor-client", + clientAuthMethod: "none", + callbackUrl: `https://executor.example.test/api/v1/oauth/callback/${id}`, + requestedScopes: [], + grantedScopes: [], + hasClientSecret: false, + hasRefreshToken: true, + accessExpiresAt: null, + authorizedAt: 100, + lastRefreshedAt: 100, + errorCode: null, + managedOAuthEligible: true, + }; +} + +function expectPreservedOAuthCleanup(url: URL | null) { + expect(url).not.toBeNull(); + expect(url?.pathname).toBe("/sources"); + expect(url?.searchParams.get("keep")).toBe("present"); + expect(url?.searchParams.has("oauth")).toBe(false); + expect(url?.searchParams.has("result")).toBe(false); + expect(url?.hash).toBe("#details"); +} + +async function expectPendingWriteFence(logoutCalls: () => number) { + const unload = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(unload); + expect(unload.defaultPrevented).toBe(true); + + await fireEvent.click(screen.getByRole("button", { name: "Sign out" })); + expect( + await screen.findByText(/source or credential change is still being saved/i), + ).toBeDefined(); + await waitFor(() => + expect(document.activeElement).toBe(document.getElementById("source-navigation-status")), + ); + expect(logoutCalls()).toBe(0); +} + +afterEach(() => { + cleanup(); + window.sessionStorage.clear(); + vi.unstubAllGlobals(); +}); + +describe("Sources page coordination", () => { + it("keeps a user-opened connector visible while switching every source type", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [sourceFixture()], catalogRevision: 1 })); + } + if (path === "/api/v1/sources/graphql-source/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + if (path === "/api/v1/mcp/stdio/templates") { + return Promise.resolve( + Response.json({ templates: [{ name: "local", secretFields: [] }] }), + ); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness); + + await screen.findByRole("article", { name: /^Product API$/ }); + const summary = screen.getByText("Connect a source", { exact: true }); + const panel = summary.closest("details"); + expect(panel?.open).toBe(false); + await fireEvent.click(summary); + expect(panel?.open).toBe(true); + + for (const sourceType of [ + "GraphQL API", + "MCP over HTTP", + "Trusted local MCP template", + "OpenAPI service", + ]) { + await fireEvent.click(screen.getByLabelText(sourceType)); + await waitFor(() => expect(panel?.open).toBe(true)); + } + }); + + it("does not close a user-opened connector when the first source list arrives late", async () => { + const sourceList = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path === "/api/v1/sources") return sourceList.promise; + if (path === "/api/v1/sources/graphql-source/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness); + + const summary = screen.getByText("Connect a source", { exact: true }); + const panel = summary.closest("details"); + await fireEvent.click(summary); + expect(panel?.open).toBe(true); + + sourceList.resolve(Response.json({ sources: [sourceFixture()], catalogRevision: 1 })); + await screen.findByRole("article", { name: /^Product API$/ }); + expect(panel?.open).toBe(true); + }); + + it("guards dispatch and retries the exact OpenAPI payload with one retained key", async () => { + const firstCreation = deferred(); + let listCalls = 0; + const creates: Array<{ key: string | null; body: string }> = []; + const statusRequest = { + value: null as null | { + method: string | undefined; + body: BodyInit | null | undefined; + cache: RequestCache | undefined; + key: string | null; + }, + }; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources/openapi/preview" && init?.method === "POST") { + return Promise.resolve( + Response.json({ + title: "Weather API", + description: null, + toolCount: 1, + tools: [ + { + preferredName: "forecast", + displayName: "Forecast", + description: null, + intrinsicMode: "enabled", + security: [["bearerAuth"]], + }, + ], + securitySchemes: [ + { + name: "bearerAuth", + credentialType: "bearer", + placement: "header", + supported: true, + oauthFlows: null, + }, + ], + }), + ); + } + if (path === "/api/v1/sources/idempotency") { + statusRequest.value = { + method: init?.method, + body: init?.body, + cache: init?.cache, + key: requestIdempotencyKey(init), + }; + return Promise.resolve(noStoreJson({ status: "missing" })); + } + if (path === "/api/v1/sources" && init?.method === "POST") { + creates.push({ + key: requestIdempotencyKey(init), + body: String(init?.body), + }); + return creates.length === 1 + ? firstCreation.promise + : creates.length === 2 + ? Promise.resolve(new Response("gateway timeout", { status: 504 })) + : Promise.resolve( + noStoreJson(openApiSourceFixture(), { + status: 201, + }), + ); + } + if (path === "/api/v1/sources") { + listCalls += 1; + return Promise.resolve( + Response.json({ + sources: listCalls === 1 ? [] : [openApiSourceFixture()], + catalogRevision: listCalls, + }), + ); + } + if (path === "/api/v1/sources/openapi-source/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + return Promise.resolve( + Response.json( + { + error: { + code: "unexpected_test_request", + message: `Unexpected request: ${path}`, + requestId: "test-request", + }, + }, + { status: 500 }, + ), + ); + }), + ); + render(SourcesPageHarness, { + sourceCreateEnvironment: fixedSourceCreateEnvironment(), + }); + + await screen.findByText("No sources connected"); + await fireEvent.input(screen.getByLabelText("OpenAPI URL"), { + target: { value: "https://api.example.test/openapi.json" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Preview tools" })); + await screen.findByRole("heading", { name: "Weather API" }); + await fireEvent.click(screen.getByRole("checkbox", { name: /bearerAuth/ })); + await fireEvent.input(screen.getByLabelText("Bearer token"), { + target: { value: "never-store-this-secret" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Import source" })); + + const sourceTypePicker = screen.getByRole("group", { + name: "Source type", + }); + const resetImporter = screen.getByRole("button", { + name: "Reset importer", + }); + await waitFor(() => expect(sourceTypePicker.disabled).toBe(true)); + expect(resetImporter.disabled).toBe(true); + await fireEvent.click(screen.getByRole("button", { name: "Sign out" })); + expect(await screen.findByText(/source connection is still being submitted/i)).toBeDefined(); + expect(document.activeElement).toBe(document.getElementById("source-navigation-status")); + const unload = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(unload); + expect(unload.defaultPrevented).toBe(true); + const storedKey = window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY); + expect(storedKey).toMatch(/^[0-9a-f]{32}$/); + expect(storedKey).toBe(creates[0]?.key); + expect(JSON.stringify([...Object.entries(window.sessionStorage)])).not.toContain( + "never-store-this-secret", + ); + await fireEvent.click(screen.getByLabelText("GraphQL API")); + await fireEvent.click(resetImporter); + expect(screen.queryByRole("group", { name: "GraphQL API" })).toBeNull(); + + firstCreation.resolve(new Response("gateway timeout", { status: 504 })); + await waitFor(() => expect(creates).toHaveLength(2)); + const retryExact = await screen.findByRole("button", { name: "Retry exact request" }); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(storedKey); + await fireEvent.click(retryExact); + await waitFor(() => expect(creates).toHaveLength(3)); + await waitFor(() => expect(listCalls).toBe(2)); + await waitFor(() => + expect(screen.getAllByRole("heading", { name: "Weather API" })).toHaveLength(1), + ); + expect(creates[1]?.key).toBe(creates[0]?.key); + expect(creates[1]?.body).toBe(creates[0]?.body); + expect(creates[2]?.key).toBe(creates[0]?.key); + expect(creates[2]?.body).toBe(creates[0]?.body); + expect(creates[0]?.body).toContain("never-store-this-secret"); + expect(statusRequest.value).toEqual({ + method: "GET", + body: undefined, + cache: "no-store", + key: creates[0]?.key, + }); + await waitFor(() => + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(), + ); + await waitFor(() => expect(sourceTypePicker.disabled).toBe(false)); + }); + + it("allows sign-out and unload during recovery polling while retaining the key", async () => { + const key = "1".repeat(32); + window.sessionStorage.setItem(SOURCE_CREATE_STORAGE_KEY, key); + const polling = deferred(); + const enteredPolling = deferred(); + const poll = { signal: null as AbortSignal | null }; + const statusKeys: Array = []; + let logoutCalls = 0; + let sourceCreateCalls = 0; + let sealCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources/idempotency") { + statusKeys.push(requestIdempotencyKey(init)); + return Promise.resolve(noStoreJson({ status: "in_progress" })); + } + if (path === "/api/v1/sources/idempotency/seal") { + sealCalls += 1; + return Promise.resolve(noStoreJson({ status: "abandoned" })); + } + if (path === "/api/v1/sources" && init?.method === "POST") { + sourceCreateCalls += 1; + return Promise.resolve(noStoreJson(sourceFixture(), { status: 201 })); + } + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [], catalogRevision: 1 })); + } + if (path === "/api/v1/session" && init?.method === "DELETE") { + logoutCalls += 1; + return Promise.resolve( + Response.json( + { + error: { + code: "logout_unavailable", + message: "Logout is unavailable in this test.", + requestId: "logout-test", + }, + }, + { status: 503 }, + ), + ); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + const mounted = render(SourcesPageHarness, { + sourceCreateEnvironment: fixedSourceCreateEnvironment({ + wait: (_milliseconds, signal) => { + poll.signal = signal; + enteredPolling.resolve(); + return polling.promise; + }, + }), + }); + + await enteredPolling.promise; + expect(statusKeys).toEqual([key]); + expect(screen.getByText(/still finishing this source connection/i)).toBeDefined(); + expect(screen.getByRole("group", { name: "Source type" }).disabled).toBe( + true, + ); + const unload = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(unload); + expect(unload.defaultPrevented).toBe(false); + await fireEvent.click(screen.getByRole("button", { name: "Sign out" })); + await waitFor(() => expect(logoutCalls).toBe(1)); + expect(screen.queryByText(/source connection is still being submitted/i)).toBeNull(); + + mounted.unmount(); + expect(poll.signal?.aborted).toBe(true); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + polling.resolve(false); + await polling.promise; + await Promise.resolve(); + await Promise.resolve(); + expect(statusKeys).toEqual([key]); + expect(sourceCreateCalls).toBe(0); + expect(sealCalls).toBe(0); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + }); + + it("suspends a gated live recovery before sign-out starts session deletion", async () => { + const lookupResponse = deferred(); + const lookupStarted = deferred(); + const deleteResponse = deferred(); + const deleteStarted = deferred(); + const lookupRequest = { signal: null as AbortSignal | null }; + let sourceCreateCalls = 0; + let sealCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources/idempotency") { + lookupRequest.signal = init?.signal ?? null; + lookupStarted.resolve(); + return lookupResponse.promise; + } + if (path === "/api/v1/sources/idempotency/seal") { + sealCalls += 1; + return Promise.resolve(noStoreJson({ status: "abandoned" })); + } + if (path === "/api/v1/sources" && init?.method === "POST") { + sourceCreateCalls += 1; + return Promise.resolve(new Response("gateway timeout", { status: 504 })); + } + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [], catalogRevision: 1 })); + } + if (path === "/api/v1/session" && init?.method === "DELETE") { + deleteStarted.resolve(); + return deleteResponse.promise; + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + sourceCreateEnvironment: fixedSourceCreateEnvironment(), + }); + + await screen.findByText("No sources connected"); + await fireEvent.click(screen.getByLabelText("GraphQL API")); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://api.example.test/graphql" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Product API" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + await lookupStarted.promise; + const key = window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY); + expect(key).toMatch(/^[0-9a-f]{32}$/); + + await fireEvent.click(screen.getByRole("button", { name: "Sign out" })); + await deleteStarted.promise; + expect(lookupRequest.signal?.aborted).toBe(true); + expect(screen.getByText("Source recovery paused for sign-out.")).toBeDefined(); + + lookupResponse.resolve(noStoreJson({ status: "missing" })); + await lookupResponse.promise; + await Promise.resolve(); + await Promise.resolve(); + expect(sourceCreateCalls).toBe(1); + expect(sealCalls).toBe(0); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + + deleteResponse.resolve( + Response.json( + { + error: { + code: "logout_unavailable", + message: "Logout is unavailable in this test.", + requestId: "logout-test", + }, + }, + { status: 503 }, + ), + ); + expect( + await screen.findByText( + "Source recovery is paused because sign-out failed. Resume source recovery or reload this page.", + ), + ).toBeDefined(); + expect(screen.getByRole("button", { name: "Resume source recovery" })).toBeDefined(); + expect(sourceCreateCalls).toBe(1); + expect(sealCalls).toBe(0); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + }); + + it("routes GraphQL creation through the page coordinator", async () => { + let listCalls = 0; + const creates: Array<{ key: string | null; body: string }> = []; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources" && init?.method === "POST") { + creates.push({ key: requestIdempotencyKey(init), body: String(init.body) }); + return Promise.resolve(noStoreJson(sourceFixture(), { status: 201 })); + } + if (path === "/api/v1/sources") { + listCalls += 1; + return Promise.resolve( + Response.json({ + sources: listCalls === 1 ? [] : [sourceFixture()], + catalogRevision: listCalls, + }), + ); + } + if (path === "/api/v1/sources/graphql-source/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + sourceCreateEnvironment: fixedSourceCreateEnvironment(), + }); + + await screen.findByText("No sources connected"); + await fireEvent.click(screen.getByLabelText("GraphQL API")); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://api.example.test/graphql" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Product API" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + expect(await screen.findByRole("heading", { name: "Product API" })).toBeDefined(); + expect(listCalls).toBe(2); + expect(creates).toHaveLength(1); + expect(creates[0]?.key).toMatch(/^[0-9a-f]{32}$/); + expect(decodeJson(creates[0]?.body ?? "{}")).toMatchObject({ kind: "graphql" }); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + }); + + it("routes MCP HTTP creation through the page coordinator", async () => { + let listCalls = 0; + const keys: Array = []; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources" && init?.method === "POST") { + keys.push(requestIdempotencyKey(init)); + return Promise.resolve( + noStoreJson(mcpHttpSourceFixture(), { + status: 201, + }), + ); + } + if (path === "/api/v1/sources") { + listCalls += 1; + return Promise.resolve( + Response.json({ + sources: listCalls === 1 ? [] : [mcpHttpSourceFixture()], + catalogRevision: listCalls, + }), + ); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + sourceCreateEnvironment: fixedSourceCreateEnvironment(), + }); + + await screen.findByText("No sources connected"); + await fireEvent.click(screen.getByLabelText("MCP over HTTP")); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://mcp.example.test/mcp" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Issue tracker" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + expect(await screen.findByRole("heading", { name: "Issue tracker" })).toBeDefined(); + expect(keys).toHaveLength(1); + expect(keys[0]).toMatch(/^[0-9a-f]{32}$/); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + }); + + it("routes MCP stdio creation through the page coordinator", async () => { + let listCalls = 0; + const keys: Array = []; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/mcp/stdio/templates") { + return Promise.resolve( + Response.json({ templates: [{ name: "local", secretFields: [] }] }), + ); + } + if (path === "/api/v1/sources" && init?.method === "POST") { + keys.push(requestIdempotencyKey(init)); + return Promise.resolve(noStoreJson(mcpStdioSourceFixture(), { status: 201 })); + } + if (path === "/api/v1/sources") { + listCalls += 1; + return Promise.resolve( + Response.json({ + sources: listCalls === 1 ? [] : [mcpStdioSourceFixture()], + catalogRevision: listCalls, + }), + ); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + sourceCreateEnvironment: fixedSourceCreateEnvironment(), + }); + + await screen.findByText("No sources connected"); + await fireEvent.click(screen.getByLabelText("Trusted local MCP template")); + await screen.findByRole("option", { name: "local" }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Local tools" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + expect(await screen.findByRole("heading", { name: "Local tools" })).toBeDefined(); + expect(keys).toHaveLength(1); + expect(keys[0]).toMatch(/^[0-9a-f]{32}$/); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + }); + + it("refreshes the authoritative list before clearing a completed reload key", async () => { + const key = "2".repeat(32); + window.sessionStorage.setItem(SOURCE_CREATE_STORAGE_KEY, key); + const status = deferred(); + const refreshedList = deferred(); + let listCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path === "/api/v1/sources/idempotency") return status.promise; + if (path === "/api/v1/sources") { + listCalls += 1; + return listCalls === 1 + ? Promise.resolve(Response.json({ sources: [], catalogRevision: 1 })) + : refreshedList.promise; + } + if (path === "/api/v1/sources/graphql-source/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + sourceCreateEnvironment: fixedSourceCreateEnvironment(), + }); + + await screen.findByText("No sources connected"); + status.resolve( + Response.json(sourceFixture(), { + status: 201, + headers: { + "cache-control": "no-store", + "idempotency-replayed": "true", + }, + }), + ); + await waitFor(() => expect(listCalls).toBe(2)); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + + refreshedList.resolve(Response.json({ sources: [sourceFixture()], catalogRevision: 2 })); + expect(await screen.findByRole("heading", { name: "Product API" })).toBeDefined(); + await waitFor(() => + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(), + ); + }); + + it("reports a definitive failed replay and safely clears its key", async () => { + const key = "3".repeat(32); + window.sessionStorage.setItem(SOURCE_CREATE_STORAGE_KEY, key); + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path === "/api/v1/sources/idempotency") { + return Promise.resolve( + Response.json( + { + error: { + code: "invalid_source", + message: "The source document is invalid.", + requestId: "failed-source", + }, + }, + { + status: 422, + headers: { + "cache-control": "no-store", + "idempotency-replayed": "true", + }, + }, + ), + ); + } + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [], catalogRevision: 1 })); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + sourceCreateEnvironment: fixedSourceCreateEnvironment(), + }); + + expect(await screen.findByText("The source document is invalid.")).toBeDefined(); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + expect(screen.getByRole("group", { name: "Source type" }).disabled).toBe( + false, + ); + expect(document.activeElement).toBe(document.getElementById("source-create-status")); + }); + + it("seals a missing reload key before unlocking the source forms", async () => { + const key = "4".repeat(32); + window.sessionStorage.setItem(SOURCE_CREATE_STORAGE_KEY, key); + let sealCalls = 0; + const sealRequest = { + value: null as null | { + method: string | undefined; + body: BodyInit | null | undefined; + key: string | null; + }, + }; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources/idempotency") { + return Promise.resolve(noStoreJson({ status: "missing" })); + } + if (path === "/api/v1/sources/idempotency/seal") { + sealCalls += 1; + sealRequest.value = { + method: init?.method, + body: init?.body, + key: requestIdempotencyKey(init), + }; + return Promise.resolve(noStoreJson({ status: "abandoned" })); + } + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [], catalogRevision: 1 })); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + sourceCreateEnvironment: fixedSourceCreateEnvironment(), + }); + + expect(await screen.findByText(/unused source connection key was sealed/i)).toBeDefined(); + expect(sealCalls).toBe(1); + expect(sealRequest.value).toEqual({ method: "POST", body: undefined, key }); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + expect(screen.getByRole("group", { name: "Source type" }).disabled).toBe( + false, + ); + }); + + it("retains the key and lock when a recovery status is malformed", async () => { + const key = "5".repeat(32); + window.sessionStorage.setItem(SOURCE_CREATE_STORAGE_KEY, key); + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path === "/api/v1/sources/idempotency") { + return Promise.resolve(Response.json({ status: "missing" })); + } + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [], catalogRevision: 1 })); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + sourceCreateEnvironment: fixedSourceCreateEnvironment(), + }); + + expect( + await screen.findByText(/idempotency status the dashboard could not understand/i), + ).toBeDefined(); + expect(screen.getByRole("button", { name: "Check again" })).toBeDefined(); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(key); + expect(screen.getByRole("group", { name: "Source type" }).disabled).toBe( + true, + ); + }); + + it("fails closed before dispatch when secure randomness is unavailable", async () => { + let createCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + if (String(input) === "/api/v1/sources" && init?.method === "POST") createCalls += 1; + return Promise.resolve(Response.json({ sources: [], catalogRevision: 1 })); + }), + ); + render(SourcesPageHarness, { + sourceCreateEnvironment: { + getStorage: () => window.sessionStorage, + fillRandom: () => Effect.runSync(Effect.fail("randomness unavailable")), + wait: () => Promise.resolve(true), + }, + }); + + await screen.findByText("No sources connected"); + await fireEvent.click(screen.getByLabelText("GraphQL API")); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://api.example.test/graphql" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Product API" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + + await waitFor(() => + expect(screen.getAllByText(/secure browser randomness is unavailable/i)).toHaveLength(2), + ); + expect(createCalls).toBe(0); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBeNull(); + }); + + it("locks recovery when tab storage cannot be read safely", async () => { + let createCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + if (String(input) === "/api/v1/sources" && init?.method === "POST") createCalls += 1; + return Promise.resolve(Response.json({ sources: [], catalogRevision: 1 })); + }), + ); + render(SourcesPageHarness, { + sourceCreateEnvironment: { + getStorage: () => Effect.runSync(Effect.fail("storage unavailable")), + fillRandom: (bytes) => bytes.fill(1), + wait: () => Promise.resolve(true), + }, + }); + + expect(await screen.findByText(/secure tab storage is unavailable/i)).toBeDefined(); + expect(createCalls).toBe(0); + expect(screen.getByRole("group", { name: "Source type" }).disabled).toBe( + true, + ); + expect(screen.getByRole("button", { name: "Check again" })).toBeDefined(); + }); + + it("keeps stored operation B when operation A completes late", async () => { + const keyB = "b".repeat(32); + const refreshedList = deferred(); + let listCalls = 0; + const statusKeys: Array = []; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources/idempotency") { + statusKeys.push(requestIdempotencyKey(init)); + return Effect.runPromise(Effect.fail("offline")); + } + if (path === "/api/v1/sources" && init?.method === "POST") { + return Promise.resolve(noStoreJson(sourceFixture(), { status: 201 })); + } + if (path === "/api/v1/sources") { + listCalls += 1; + return listCalls === 1 + ? Promise.resolve(Response.json({ sources: [], catalogRevision: 1 })) + : refreshedList.promise; + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + sourceCreateEnvironment: fixedSourceCreateEnvironment(), + }); + + await screen.findByText("No sources connected"); + await fireEvent.click(screen.getByLabelText("GraphQL API")); + await fireEvent.input(screen.getByLabelText("Endpoint"), { + target: { value: "https://api.example.test/graphql" }, + }); + await fireEvent.input(screen.getByLabelText("Source name"), { + target: { value: "Product API" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + await waitFor(() => expect(listCalls).toBe(2)); + window.sessionStorage.setItem(SOURCE_CREATE_STORAGE_KEY, keyB); + refreshedList.resolve(Response.json({ sources: [sourceFixture()], catalogRevision: 2 })); + + expect(await screen.findByText(/Executor could not be reached/i)).toBeDefined(); + expect(statusKeys).toEqual([keyB]); + expect(window.sessionStorage.getItem(SOURCE_CREATE_STORAGE_KEY)).toBe(keyB); + expect(screen.queryByText(/connected with 4 tools/i)).toBeNull(); + expect(screen.getByRole("group", { name: "Source type" }).disabled).toBe( + true, + ); + }); + + it("cleans a matched OAuth callback while preserving unrelated URL and history state", async () => { + const initialState = { preserved: "history-state" }; + const replacement = { + url: null as URL | null, + state: null as App.PageState | null, + }; + const onOAuthReplace = vi.fn((url: URL, state: App.PageState) => { + replacement.url = url; + replacement.state = state; + }); + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [sourceFixture()], catalogRevision: 1 })); + } + if (path === "/api/v1/sources/graphql-source/oauth") { + return Promise.resolve( + Response.json({ + connections: [oauthConnectionFixture("matched-connection")], + availableCredentials: [], + }), + ); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + initialOAuthUrl: + "https://executor.example.test/sources?keep=present&oauth=matched-connection&result=success#details", + initialOAuthState: initialState, + onOAuthReplace, + }); + + expect( + await screen.findByText( + "OAuth authorization completed and the connection status was refreshed.", + ), + ).toBeDefined(); + await waitFor(() => expect(onOAuthReplace).toHaveBeenCalledOnce()); + expectPreservedOAuthCleanup(replacement.url); + expect(replacement.state).toBe(initialState); + }); + + it("focuses an unmatched OAuth notice before cleaning callback state", async () => { + const initialState = { preserved: "unmatched-state" }; + const replacement = { + url: null as URL | null, + state: null as App.PageState | null, + focusedId: null as string | null, + }; + const onOAuthReplace = vi.fn((url: URL, state: App.PageState) => { + replacement.url = url; + replacement.state = state; + replacement.focusedId = document.activeElement?.id ?? null; + }); + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [sourceFixture()], catalogRevision: 2 })); + } + if (path === "/api/v1/sources/graphql-source/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + initialOAuthUrl: + "https://executor.example.test/sources?keep=present&oauth=missing-connection&result=success#details", + initialOAuthState: initialState, + onOAuthReplace, + }); + + expect( + await screen.findByText("OAuth returned, but no matching managed connection is available."), + ).toBeDefined(); + await waitFor(() => expect(onOAuthReplace).toHaveBeenCalledOnce()); + expect(replacement.focusedId).toBe("source-status"); + expectPreservedOAuthCleanup(replacement.url); + expect(replacement.state).toBe(initialState); + }); + + it("focuses a safe OAuth notice when the current source set has no eligible connection", async () => { + const replacement = { + url: null as URL | null, + focusedId: null as string | null, + }; + const onOAuthReplace = vi.fn((url: URL) => { + replacement.url = url; + replacement.focusedId = document.activeElement?.id ?? null; + }); + vi.stubGlobal( + "fetch", + vi.fn((input) => { + if (String(input) === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [], catalogRevision: 3 })); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + initialOAuthUrl: + "https://executor.example.test/sources?keep=present&oauth=missing-connection&result=failed#details", + onOAuthReplace, + }); + + expect( + await screen.findByText( + "OAuth authorization did not complete. Review the connection status and try again.", + ), + ).toBeDefined(); + await waitFor(() => expect(onOAuthReplace).toHaveBeenCalledOnce()); + expect(replacement.focusedId).toBe("source-status"); + expectPreservedOAuthCleanup(replacement.url); + }); + + it("ignores an out-of-order OAuth check from a replaced eligible source set", async () => { + const staleOAuth = deferred(); + let listCalls = 0; + const onOAuthReplace = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + if (init?.method === "POST") { + return Promise.resolve( + noStoreJson(openApiSourceFixture("replacement", "Replacement source"), { + status: 201, + }), + ); + } + listCalls += 1; + return Promise.resolve( + Response.json({ + sources: + listCalls === 1 + ? [sourceFixture("source-a", "Source A"), sourceFixture("source-b", "Source B")] + : [openApiSourceFixture("replacement", "Replacement source")], + catalogRevision: listCalls, + }), + ); + } + if (path === "/api/v1/sources/openapi/preview" && init?.method === "POST") { + return Promise.resolve( + Response.json({ + title: "Replacement source", + description: null, + toolCount: 1, + tools: [], + securitySchemes: [], + }), + ); + } + if (path === "/api/v1/sources/source-a/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + if (path === "/api/v1/sources/source-b/oauth") return staleOAuth.promise; + if (path === "/api/v1/sources/replacement/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness, { + initialOAuthUrl: + "https://executor.example.test/sources?keep=present&oauth=late-match&result=success#details", + onOAuthReplace, + }); + + await screen.findByRole("heading", { name: "Source A" }); + await screen.findByRole("heading", { name: "Source B" }); + await fireEvent.click(screen.getByText("Connect a source")); + await fireEvent.input(screen.getByLabelText("OpenAPI URL"), { + target: { value: "https://api.example.test/openapi.json" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Preview tools" })); + await screen.findByRole("heading", { name: "Replacement source" }); + await fireEvent.click(screen.getByRole("button", { name: "Import source" })); + + await waitFor(() => + expect(screen.getAllByRole("heading", { name: "Replacement source" })).toHaveLength(1), + ); + await waitFor(() => expect(onOAuthReplace).toHaveBeenCalledOnce()); + staleOAuth.resolve( + Response.json({ + connections: [oauthConnectionFixture("late-match")], + availableCredentials: [], + }), + ); + await staleOAuth.promise; + await Promise.resolve(); + await Promise.resolve(); + + expect(onOAuthReplace).toHaveBeenCalledOnce(); + expect( + screen.queryByText("OAuth authorization completed and the connection status was refreshed."), + ).toBeNull(); + }); + + it("includes OpenAPI credential loading in the shared source lock", async () => { + const credentials = deferred(); + let logoutCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + return Promise.resolve( + Response.json({ sources: [openApiSourceFixture()], catalogRevision: 1 }), + ); + } + if (path === "/api/v1/sources/openapi-source/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + if (path === "/api/v1/sources/openapi-source/credentials") { + return credentials.promise; + } + if (path === "/api/v1/session" && init?.method === "DELETE") { + logoutCalls += 1; + return Promise.resolve( + Response.json( + { + error: { + code: "logout_unavailable", + message: "Logout is unavailable in this test.", + requestId: "logout-test", + }, + }, + { status: 503 }, + ), + ); + } + return Promise.resolve( + Response.json( + { + error: { + code: "unexpected_test_request", + message: `Unexpected request: ${path}`, + requestId: "test-request", + }, + }, + { status: 500 }, + ), + ); + }), + ); + render(SourcesPageHarness); + + const manageCredentials = await screen.findByRole("button", { + name: "Manage credentials", + }); + await waitFor(() => expect(manageCredentials.disabled).toBe(false)); + await fireEvent.click(manageCredentials); + + await waitFor(() => + expect( + screen.getByRole("group", { name: "Default tool behavior" }).disabled, + ).toBe(true), + ); + expect(screen.getByRole("button", { name: "Refresh" }).disabled).toBe(true); + expect(screen.getByRole("button", { name: "Delete" }).disabled).toBe(true); + const unload = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(unload); + expect(unload.defaultPrevented).toBe(false); + await fireEvent.click(screen.getByRole("button", { name: "Sign out" })); + await waitFor(() => expect(logoutCalls).toBe(1)); + + credentials.resolve( + Response.json({ + revision: 1, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + await credentials.promise; + await screen.findByRole("form", { name: "Credentials for Weather API" }); + }); + + it("releases the OpenAPI credential lock after a rejected metadata request", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input) => { + const path = String(input); + if (path === "/api/v1/sources") { + return Promise.resolve( + Response.json({ sources: [openApiSourceFixture()], catalogRevision: 1 }), + ); + } + if (path === "/api/v1/sources/openapi-source/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + if (path === "/api/v1/sources/openapi-source/credentials") { + return Effect.runPromise(Effect.fail("offline")); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness); + + const manageCredentials = await screen.findByRole("button", { + name: "Manage credentials", + }); + await waitFor(() => expect(manageCredentials.disabled).toBe(false)); + await fireEvent.click(manageCredentials); + + expect( + await screen.findByText( + "Executor could not be reached. Check that the local server is running.", + ), + ).toBeDefined(); + await waitFor(() => expect(manageCredentials.disabled).toBe(false)); + expect( + screen.getByRole("group", { name: "Default tool behavior" }).disabled, + ).toBe(false); + expect(manageCredentials.getAttribute("aria-expanded")).toBe("false"); + }); + + it("disables sibling source controls while managed OAuth saves", async () => { + const replacement = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [sourceFixture()], catalogRevision: 1 })); + } + if (path === "/api/v1/sources/graphql-source/oauth/default" && init?.method === "PUT") { + return replacement.promise; + } + if (path === "/api/v1/sources/graphql-source/oauth") { + return Promise.resolve( + Response.json({ + connections: [], + availableCredentials: [ + { + credentialKey: "default", + protocol: "graphql", + requestedScopes: [], + managedOAuthEligible: true, + }, + ], + }), + ); + } + return Promise.resolve( + Response.json( + { + error: { + code: "unexpected_test_request", + message: `Unexpected request: ${path}`, + requestId: "test-request", + }, + }, + { status: 500 }, + ), + ); + }), + ); + render(SourcesPageHarness); + + const manageCredentials = await screen.findByRole("button", { + name: "Manage credentials", + }); + await waitFor(() => expect(manageCredentials.disabled).toBe(false)); + await fireEvent.input(screen.getByLabelText("Issuer URL"), { + target: { value: "https://identity.example.test" }, + }); + await fireEvent.input(screen.getByLabelText("Client ID"), { + target: { value: "executor-client" }, + }); + const saveConfiguration = screen.getByRole("button", { + name: "Save configuration", + }); + await waitFor(() => expect(saveConfiguration.disabled).toBe(false)); + await fireEvent.click(saveConfiguration); + + await waitFor(() => expect(manageCredentials.disabled).toBe(true)); + expect( + screen.getByRole("button", { name: "Refresh schema and tools" }).disabled, + ).toBe(true); + expect(screen.getByRole("button", { name: "Delete" }).disabled).toBe(true); + + replacement.resolve( + Response.json({ + id: "connection-1", + credentialKey: "default", + revision: 1, + status: "ready_to_connect", + issuer: "https://identity.example.test/", + clientId: "executor-client", + clientAuthMethod: "none", + callbackUrl: "https://executor.example.test/api/v1/oauth/callback/connection-1", + requestedScopes: [], + grantedScopes: [], + hasClientSecret: false, + hasRefreshToken: false, + accessExpiresAt: null, + authorizedAt: null, + lastRefreshedAt: null, + errorCode: null, + managedOAuthEligible: true, + }), + ); + await replacement.promise; + }); + + it("disables an open mode confirmation and sibling actions while credentials save", async () => { + const replacement = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [sourceFixture()], catalogRevision: 1 })); + } + if (path.endsWith("/credentials") && init?.method === "PUT") { + return replacement.promise; + } + if (path.endsWith("/credentials")) { + return Promise.resolve( + Response.json({ + revision: 4, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + } + if (path.endsWith("/oauth") && init?.method === undefined) { + return Promise.resolve(emptyOAuthConnections()); + } + return Promise.resolve( + Response.json( + { + error: { + code: "unexpected_test_request", + message: `Unexpected request: ${path}`, + requestId: "test-request", + }, + }, + { status: 500 }, + ), + ); + }), + ); + render(SourcesPageHarness); + + await screen.findByRole("heading", { name: "Product API" }); + const manageCredentials = screen.getByRole("button", { + name: "Manage credentials", + }); + await waitFor(() => expect(manageCredentials.disabled).toBe(false)); + await fireEvent.click(screen.getByLabelText("Enabled")); + await fireEvent.click(screen.getByRole("button", { name: "Apply source default" })); + const confirmMode = screen.getByRole("button", { + name: "Confirm broad change", + }); + expect(confirmMode.disabled).toBe(false); + + await fireEvent.click(manageCredentials); + const token = await screen.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "credential-secret" } }); + const saveReplacement = screen.getByRole("button", { + name: "Save replacement", + }); + await waitFor(() => expect(saveReplacement.disabled).toBe(false)); + await fireEvent.click(saveReplacement); + + await waitFor(() => expect(confirmMode.disabled).toBe(true)); + expect( + screen.getByRole("button", { name: "Refresh schema and tools" }).disabled, + ).toBe(true); + expect(screen.getByRole("button", { name: "Delete" }).disabled).toBe(true); + + replacement.resolve( + Response.json({ + revision: 5, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + await replacement.promise; + }); + + it("does not abort a source save while a different source refreshes the list", async () => { + const replacement = deferred(); + const saveRequest = { signal: null as AbortSignal | null }; + let listCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + listCalls += 1; + return Promise.resolve( + Response.json({ + sources: [ + sourceFixture("source-a", "Source A"), + sourceFixture("source-b", "Source B"), + ], + catalogRevision: listCalls, + }), + ); + } + if (path === "/api/v1/sources/source-a/credentials" && init?.method === "PUT") { + saveRequest.signal = init.signal ?? null; + return replacement.promise; + } + if (path === "/api/v1/sources/source-a/credentials") { + return Promise.resolve( + Response.json({ + revision: 4, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + } + if (path === "/api/v1/sources/source-b/refresh" && init?.method === "POST") { + return Promise.resolve( + Response.json({ + sourceId: "source-b", + sourceRevision: 2, + catalogRevision: 2, + globalRevision: 2, + activeToolCount: 4, + tombstonedToolCount: 0, + }), + ); + } + if (path.endsWith("/oauth") && init?.method === undefined) { + return Promise.resolve(emptyOAuthConnections()); + } + return Promise.resolve( + Response.json( + { + error: { + code: "unexpected_test_request", + message: `Unexpected request: ${path}`, + requestId: "test-request", + }, + }, + { status: 500 }, + ), + ); + }), + ); + render(SourcesPageHarness); + + const sourceAHeading = await screen.findByRole("heading", { name: "Source A" }); + const sourceBHeading = screen.getByRole("heading", { name: "Source B" }); + const sourceA = within(sourceAHeading.closest("article") ?? document.body); + const sourceB = within(sourceBHeading.closest("article") ?? document.body); + const manageCredentials = sourceA.getByRole("button", { + name: "Manage credentials", + }); + await waitFor(() => expect(manageCredentials.disabled).toBe(false)); + await fireEvent.click(manageCredentials); + const token = await sourceA.findByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "source-a-secret" } }); + const saveReplacement = sourceA.getByRole("button", { + name: "Save replacement", + }); + await waitFor(() => expect(saveReplacement.disabled).toBe(false)); + await fireEvent.click(saveReplacement); + await waitFor(() => expect(saveRequest.signal).not.toBeNull()); + + const refreshSourceB = sourceB.getByRole("button", { + name: "Refresh schema and tools", + }); + await waitFor(() => expect(refreshSourceB.disabled).toBe(false)); + await fireEvent.click(refreshSourceB); + await waitFor(() => expect(listCalls).toBe(2)); + expect(saveRequest.signal?.aborted).toBe(false); + + replacement.resolve( + Response.json({ + revision: 5, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + await replacement.promise; + expect(await sourceA.findByText(/Credentials replaced/)).toBeDefined(); + }); + + it("fences source writes through settlement and aborts a forced unmount", async () => { + const firstRefresh = deferred(); + const secondRefresh = deferred(); + const refreshSignals: AbortSignal[] = []; + let refreshCalls = 0; + let listCalls = 0; + let logoutCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + listCalls += 1; + return Promise.resolve( + Response.json({ sources: [openApiSourceFixture()], catalogRevision: listCalls }), + ); + } + if (path === "/api/v1/sources/openapi-source/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + if (path === "/api/v1/sources/openapi-source/refresh" && init?.method === "POST") { + if (init.signal instanceof AbortSignal) refreshSignals.push(init.signal); + refreshCalls += 1; + return refreshCalls === 1 ? firstRefresh.promise : secondRefresh.promise; + } + if (path === "/api/v1/session" && init?.method === "DELETE") { + logoutCalls += 1; + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + const mounted = render(SourcesPageHarness); + + const refresh = await screen.findByRole("button", { name: "Refresh" }); + await waitFor(() => expect(refresh.disabled).toBe(false)); + await fireEvent.click(refresh); + await waitFor(() => expect(refreshCalls).toBe(1)); + await expectPendingWriteFence(() => logoutCalls); + expect(refreshSignals[0]?.aborted).toBe(false); + + firstRefresh.resolve( + Response.json({ + sourceId: "openapi-source", + sourceRevision: 2, + catalogRevision: 2, + globalRevision: 2, + activeToolCount: 4, + tombstonedToolCount: 0, + }), + ); + await waitFor(() => expect(listCalls).toBe(2)); + await waitFor(() => expect(refresh.disabled).toBe(false)); + const settledUnload = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(settledUnload); + expect(settledUnload.defaultPrevented).toBe(false); + + await fireEvent.click(refresh); + await waitFor(() => expect(refreshCalls).toBe(2)); + mounted.unmount(); + expect(refreshSignals[1]?.aborted).toBe(true); + secondRefresh.resolve( + Response.json({ + sourceId: "openapi-source", + sourceRevision: 3, + catalogRevision: 3, + globalRevision: 3, + activeToolCount: 4, + tombstonedToolCount: 0, + }), + ); + await secondRefresh.promise; + }); + + it("freezes the complete OpenAPI credential draft and fences each deferred save", async () => { + const firstSave = deferred(); + const secondSave = deferred(); + const saveSignals: AbortSignal[] = []; + let saveCalls = 0; + let logoutCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + return Promise.resolve( + Response.json({ sources: [openApiSourceFixture()], catalogRevision: 1 }), + ); + } + if (path === "/api/v1/sources/openapi-source/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + if (path === "/api/v1/sources/openapi-source/credentials" && init?.method === "PUT") { + if (init.signal instanceof AbortSignal) saveSignals.push(init.signal); + saveCalls += 1; + return saveCalls === 1 ? firstSave.promise : secondSave.promise; + } + if (path === "/api/v1/sources/openapi-source/credentials") { + return Promise.resolve( + Response.json({ + revision: saveCalls + 4, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + } + if (path === "/api/v1/session" && init?.method === "DELETE") { + logoutCalls += 1; + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + const mounted = render(SourcesPageHarness); + + const manage = await screen.findByRole("button", { + name: "Manage credentials", + }); + await waitFor(() => expect(manage.disabled).toBe(false)); + await fireEvent.click(manage); + const name = await screen.findByLabelText("Security scheme name"); + const type = screen.getByLabelText("Type"); + const token = screen.getByLabelText("Bearer token"); + await fireEvent.input(token, { target: { value: "visible-only-in-disabled-input" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + + const row = screen.getByRole("group", { name: "Credential" }); + await waitFor(() => expect(row.disabled).toBe(true)); + expect(name.value).toBe("default"); + expect(type.value).toBe("bearer"); + expect(token.value).toBe("visible-only-in-disabled-input"); + expect(screen.getByRole("button", { name: "Saving..." })).toBeDefined(); + expect(screen.getByRole("button", { name: "Add credential" }).disabled).toBe( + true, + ); + expect( + screen.getByRole("button", { name: "Clear all credentials" }).disabled, + ).toBe(true); + await expectPendingWriteFence(() => logoutCalls); + expect(saveSignals[0]?.aborted).toBe(false); + + firstSave.resolve( + Response.json({ + revision: 5, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + expect(await screen.findByText(/Credentials were replaced/)).toBeDefined(); + await waitFor(() => expect(manage.disabled).toBe(false)); + + await fireEvent.click(manage); + const nextToken = await screen.findByLabelText("Bearer token"); + await fireEvent.input(nextToken, { target: { value: "second-save" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save replacement" })); + await waitFor(() => expect(saveCalls).toBe(2)); + mounted.unmount(); + expect(saveSignals[1]?.aborted).toBe(true); + secondSave.resolve( + Response.json({ + revision: 6, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + await secondSave.promise; + }); + + it("fences a deferred GraphQL credential write without trapping its metadata load", async () => { + const replacement = deferred(); + const request = { signal: null as AbortSignal | null }; + let logoutCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [sourceFixture()], catalogRevision: 1 })); + } + if (path === "/api/v1/sources/graphql-source/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + if (path === "/api/v1/sources/graphql-source/credentials" && init?.method === "PUT") { + request.signal = init.signal ?? null; + return replacement.promise; + } + if (path === "/api/v1/sources/graphql-source/credentials") { + return Promise.resolve( + Response.json({ + revision: 4, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + } + if (path === "/api/v1/session" && init?.method === "DELETE") logoutCalls += 1; + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness); + + await screen.findByRole("region", { name: "Managed OAuth for Product API" }); + const manage = await screen.findByRole("button", { + name: "Manage credentials", + }); + await waitFor(() => expect(manage.disabled).toBe(false)); + await fireEvent.click(manage); + const token = await screen.findByLabelText("Bearer token"); + const loadUnload = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(loadUnload); + expect(loadUnload.defaultPrevented).toBe(false); + await fireEvent.input(token, { target: { value: "graphql-secret" } }); + const saveReplacement = screen.getByRole("button", { + name: "Save replacement", + }); + await waitFor(() => expect(saveReplacement.disabled).toBe(false)); + await fireEvent.click(saveReplacement); + await waitFor(() => expect(request.signal).not.toBeNull()); + await expectPendingWriteFence(() => logoutCalls); + expect(request.signal?.aborted).toBe(false); + + replacement.resolve( + Response.json({ + revision: 5, + configuredSchemes: [{ name: "default", credentialType: "bearer" }], + }), + ); + expect(await screen.findByText(/Credentials replaced/)).toBeDefined(); + }); + + it("fences a deferred MCP credential write without trapping its metadata load", async () => { + const replacement = deferred(); + const request = { signal: null as AbortSignal | null }; + let logoutCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + return Promise.resolve( + Response.json({ sources: [mcpHttpSourceFixture()], catalogRevision: 1 }), + ); + } + if (path === "/api/v1/sources/mcp-http-source/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + if (path === "/api/v1/sources/mcp-http-source/credentials" && init?.method === "PUT") { + request.signal = init.signal ?? null; + return replacement.promise; + } + if (path === "/api/v1/sources/mcp-http-source/credentials") { + return Promise.resolve( + Response.json({ + revision: 4, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }), + ); + } + if (path === "/api/v1/session" && init?.method === "DELETE") logoutCalls += 1; + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness); + + await screen.findByRole("region", { name: "Managed OAuth for Issue tracker" }); + const manage = await screen.findByRole("button", { + name: "Manage credentials", + }); + await waitFor(() => expect(manage.disabled).toBe(false)); + await fireEvent.click(manage); + const token = await screen.findByLabelText("Bearer token"); + const loadUnload = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(loadUnload); + expect(loadUnload.defaultPrevented).toBe(false); + await fireEvent.input(token, { target: { value: "mcp-secret" } }); + const saveReplacement = screen.getByRole("button", { + name: "Save replacement", + }); + await waitFor(() => expect(saveReplacement.disabled).toBe(false)); + await fireEvent.click(saveReplacement); + await waitFor(() => expect(request.signal).not.toBeNull()); + await expectPendingWriteFence(() => logoutCalls); + expect(request.signal?.aborted).toBe(false); + + replacement.resolve( + Response.json({ + revision: 5, + configuredSchemes: [{ name: "authorization", credentialType: "bearer" }], + }), + ); + expect(await screen.findByText(/Credentials replaced/)).toBeDefined(); + }); + + it("keeps source A OAuth drafts and writes stable while source B refreshes", async () => { + const saveResponse = deferred(); + const deleteResponse = deferred(); + const saveRequest = { signal: null as AbortSignal | null }; + const deleteRequest = { signal: null as AbortSignal | null }; + let listCalls = 0; + let sourceAOAuthLoads = 0; + let sourceBRefreshes = 0; + let logoutCalls = 0; + const sourceAConnection = { + ...oauthConnectionFixture("source-a-connection"), + credentialKey: "default", + requestedScopes: ["read"], + }; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/sources") { + listCalls += 1; + return Promise.resolve( + Response.json({ + sources: [ + sourceFixture("source-a", "Source A"), + sourceFixture("source-b", "Source B"), + ], + catalogRevision: listCalls, + }), + ); + } + if (path === "/api/v1/sources/source-a/oauth/default" && init?.method === "PUT") { + saveRequest.signal = init.signal ?? null; + return saveResponse.promise; + } + if ( + path.startsWith("/api/v1/sources/source-a/oauth/default") && + init?.method === "DELETE" + ) { + deleteRequest.signal = init.signal ?? null; + return deleteResponse.promise; + } + if (path === "/api/v1/sources/source-a/oauth") { + sourceAOAuthLoads += 1; + return Promise.resolve( + Response.json({ + connections: [sourceAConnection], + availableCredentials: [], + }), + ); + } + if (path === "/api/v1/sources/source-b/oauth") { + return Promise.resolve(emptyOAuthConnections()); + } + if (path === "/api/v1/sources/source-b/refresh" && init?.method === "POST") { + sourceBRefreshes += 1; + return Promise.resolve( + Response.json({ + sourceId: "source-b", + sourceRevision: sourceBRefreshes + 1, + catalogRevision: sourceBRefreshes + 1, + globalRevision: sourceBRefreshes + 1, + activeToolCount: 4, + tombstonedToolCount: 0, + }), + ); + } + if (path === "/api/v1/session" && init?.method === "DELETE") logoutCalls += 1; + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(SourcesPageHarness); + + const sourceAHeading = await screen.findByRole("heading", { name: "Source A" }); + const sourceBHeading = screen.getByRole("heading", { name: "Source B" }); + const sourceA = within(sourceAHeading.closest("article") ?? document.body); + const sourceB = within(sourceBHeading.closest("article") ?? document.body); + const scopes = await sourceA.findByLabelText(/Requested scopes/); + const initialOAuthLoads = sourceAOAuthLoads; + await fireEvent.input(scopes, { target: { value: "read profile" } }); + await fireEvent.click(sourceA.getByRole("button", { name: "Save configuration" })); + await waitFor(() => expect(saveRequest.signal).not.toBeNull()); + + const refreshSourceB = sourceB.getByRole("button", { + name: "Refresh schema and tools", + }); + await waitFor(() => expect(refreshSourceB.disabled).toBe(false)); + await fireEvent.click(refreshSourceB); + await waitFor(() => expect(listCalls).toBe(2)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(saveRequest.signal?.aborted).toBe(false); + expect(scopes.value).toBe("read profile"); + expect(sourceAOAuthLoads).toBe(initialOAuthLoads); + expect(sourceA.getByRole("button", { name: "Saving..." })).toBeDefined(); + await expectPendingWriteFence(() => logoutCalls); + + saveResponse.resolve( + Response.json({ + ...sourceAConnection, + revision: 2, + requestedScopes: ["read", "profile"], + }), + ); + expect(await sourceA.findByText(/OAuth configuration saved/)).toBeDefined(); + await waitFor(() => + expect(screen.queryByText(/source or credential change is still being saved/i)).toBeNull(), + ); + + await fireEvent.click(sourceA.getByRole("button", { name: "Delete configuration" })); + const deleteDialog = sourceA.getByRole("dialog", { + name: "Delete this OAuth configuration?", + }); + await fireEvent.click(within(deleteDialog).getByRole("button", { name: "Delete" })); + await waitFor(() => expect(deleteRequest.signal).not.toBeNull()); + await fireEvent.click(refreshSourceB); + await waitFor(() => expect(listCalls).toBe(3)); + expect(deleteRequest.signal?.aborted).toBe(false); + expect(sourceAOAuthLoads).toBe(initialOAuthLoads); + await expectPendingWriteFence(() => logoutCalls); + + deleteResponse.resolve(new Response(null, { status: 204 })); + expect(await sourceA.findByText("OAuth configuration deleted.")).toBeDefined(); + }); +}); diff --git a/web/src/routes/tokens/+page.svelte b/web/src/routes/tokens/+page.svelte new file mode 100644 index 000000000..8ade1efea --- /dev/null +++ b/web/src/routes/tokens/+page.svelte @@ -0,0 +1,456 @@ + + + + + + {#if exitBlocked && exitBlockReason !== null} +

+ {exitBlockReason === "creating" + ? "Wait for token creation to finish before leaving this page or signing out." + : exitBlockReason === "unsaved-token" + ? "Save the token and choose “I saved it” before leaving this page or signing out." + : "Recover the pending token request before leaving this page or signing out."} +

+ {/if} + +
+
+

New credential

+

Create an API token

+

+ Name it for the device or agent that will use it. All tokens share the enabled tool set. +

+
+ + + {#if hasUnsavedToken} + Save the revealed token before creating another. + {:else if hasPendingCreate} + Finish recovering the pending token request first. + {/if} + {#if mutationError !== null}{/if} + {#if tokenCreateState.phase === "blocked" && tokenCreateState.canRetry} + + {/if} + + +
+ + {#if revealed !== null} +
+

Shown once

+

Copy this token now.

+ + event.currentTarget.select()} + /> +

+ Executor stores only keyed digests. This tab can recover the same secret until you choose + “I saved it”. +

+
+ + +
+

+ {copyMessage} +

+
+ {/if} +
+ + {#if listView === "stale" && listError !== null} +
+ Showing the last successfully loaded token list. + +
+ {/if} + +
+
+
+

Gateway access

+

Issued tokens

+
+ +
+ + {#if listView === "loading"} +

Loading tokens...

+ {:else if listView === "unavailable" && listError !== null} +
+ Token list unavailable +

We could not load your API tokens. Try again to see which tokens have been issued.

+ +
+ {:else if listView === "empty"} +

No API tokens have been issued.

+ {:else if listView === "stale" && tokens.length === 0} +

The last successful load contained no API tokens.

+ {:else} +
+ + + + + + + + + + + + + {#each tokens as token (token.id)} + + + + + + + + {/each} + +
API tokens issued by this Executor instance
NameTokenLast usedStatusAction
+ {token.name}Created {displayDate(token.createdAt)} + {token.maskedToken}{displayDate(token.lastUsedAt)} + + {token.revokedAt === null ? "Active" : "Revoked"} + + + +
+
+ {/if} +
+ + { + if (revoking) event.preventDefault(); + }} + onclose={() => { + if (!revoking) pendingRevoke = null; + }} + > +

Revoke credential

+

Stop using {pendingRevoke?.name ?? "this token"}?

+

Calls using this token will fail immediately. This action cannot be undone.

+ {#if revokeError !== null}{/if} +
+ + +
+
+
diff --git a/web/src/routes/tokens/tokens-page.test-harness.svelte b/web/src/routes/tokens/tokens-page.test-harness.svelte new file mode 100644 index 000000000..7d26bfcf5 --- /dev/null +++ b/web/src/routes/tokens/tokens-page.test-harness.svelte @@ -0,0 +1,13 @@ + + + diff --git a/web/src/routes/tokens/tokens-page.test.ts b/web/src/routes/tokens/tokens-page.test.ts new file mode 100644 index 000000000..2fe3a5640 --- /dev/null +++ b/web/src/routes/tokens/tokens-page.test.ts @@ -0,0 +1,333 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; +import { Effect, Schema } from "effect"; +import { TOKEN_CREATE_STORAGE_KEY, type TokenCreateEnvironment } from "$lib/token-create-lifecycle"; +import TokensPageHarness from "./tokens-page.test-harness.svelte"; + +const PendingRecordSchema = Schema.Struct({ + version: Schema.Literal(1), + key: Schema.String, + name: Schema.String, +}); +const decodePendingRecord = Schema.decodeUnknownSync(Schema.fromJsonString(PendingRecordSchema)); +const oneTimeSecret = `exr_${"A".repeat(43)}`; + +function tokenMetadata(revokedAt: number | null = null) { + return { + id: "token-1", + name: "Laptop agent", + maskedToken: "exr_••••••••1234", + createdAt: 100, + lastUsedAt: null, + revokedAt, + }; +} + +function createdToken(name = "Laptop agent") { + return { + id: "token-1", + name, + token: oneTimeSecret, + createdAt: 100, + }; +} + +function noStoreJson(value: unknown, init: ResponseInit = {}) { + const headers = new Headers(init.headers); + headers.set("cache-control", "no-store"); + return Response.json(value, { ...init, headers }); +} + +function fixedEnvironment(): TokenCreateEnvironment { + return { + getStorage: () => window.sessionStorage, + fillRandom: (bytes) => bytes.fill(0x5a), + }; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +beforeEach(() => { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, + value(this: HTMLDialogElement) { + this.open = true; + }, + }); + Object.defineProperty(HTMLDialogElement.prototype, "close", { + configurable: true, + value(this: HTMLDialogElement) { + this.open = false; + }, + }); +}); + +afterEach(() => { + cleanup(); + window.sessionStorage.clear(); + vi.unstubAllGlobals(); +}); + +describe("API token page delivery safety", () => { + it("replays a lost first response with the exact persisted request and never stores the secret", async () => { + let listCalls = 0; + const creates: Array<{ key: string | null; body: string; keyHeaderCount: number }> = []; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/tokens" && init?.method === "POST") { + const headers = new Headers(init.headers); + creates.push({ + key: headers.get("idempotency-key"), + body: String(init.body), + keyHeaderCount: [...headers.keys()].filter((name) => name === "idempotency-key").length, + }); + if (creates.length === 1) return Effect.runPromise(Effect.fail("response lost")); + return Promise.resolve( + noStoreJson(createdToken(), { + status: 201, + headers: { "idempotency-replayed": "true" }, + }), + ); + } + if (path === "/api/v1/tokens") { + listCalls += 1; + return Promise.resolve( + Response.json({ tokens: listCalls === 1 ? [] : [tokenMetadata()] }), + ); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(TokensPageHarness, { tokenCreateEnvironment: fixedEnvironment() }); + + await screen.findByText("No API tokens have been issued."); + await fireEvent.input(screen.getByLabelText("Token name"), { + target: { value: "Laptop agent" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Create token" })); + + const secretField = await screen.findByDisplayValue(oneTimeSecret); + const stored = window.sessionStorage.getItem(TOKEN_CREATE_STORAGE_KEY); + expect(stored).not.toBeNull(); + expect(stored === null ? null : decodePendingRecord(stored)).toEqual({ + version: 1, + key: "5a".repeat(32), + name: "Laptop agent", + }); + expect(stored).not.toContain(oneTimeSecret); + expect(creates).toHaveLength(2); + expect(creates[0]).toEqual(creates[1]); + expect(creates[0]?.key).toMatch(/^[0-9a-f]{64}$/); + expect(creates[0]?.keyHeaderCount).toBe(1); + expect(screen.getByText(/this tab can recover the same secret/i)).toBeDefined(); + await waitFor(() => expect(secretField).toBe(document.activeElement)); + + await fireEvent.click(screen.getByRole("button", { name: "I saved it" })); + await waitFor(() => expect(window.sessionStorage.getItem(TOKEN_CREATE_STORAGE_KEY)).toBeNull()); + await waitFor(() => expect(listCalls).toBe(2)); + expect(screen.queryByDisplayValue(oneTimeSecret)).toBeNull(); + }); + + it("automatically recovers a pending record after reload", async () => { + const pending = { + version: 1, + key: "6".repeat(64), + name: "Reloaded agent", + } as const; + window.sessionStorage.setItem(TOKEN_CREATE_STORAGE_KEY, JSON.stringify(pending)); + const creates: Array<{ key: string | null; body: string }> = []; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/tokens" && init?.method === "POST") { + creates.push({ + key: new Headers(init.headers).get("idempotency-key"), + body: String(init.body), + }); + return Promise.resolve( + noStoreJson(createdToken(pending.name), { + status: 201, + headers: { "idempotency-replayed": "true" }, + }), + ); + } + if (path === "/api/v1/tokens") { + return Promise.resolve(Response.json({ tokens: [tokenMetadata()] })); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + + render(TokensPageHarness, { tokenCreateEnvironment: fixedEnvironment() }); + + expect(await screen.findByDisplayValue(oneTimeSecret)).toBeDefined(); + expect(creates).toEqual([ + { + key: pending.key, + body: JSON.stringify({ name: pending.name }), + }, + ]); + expect(window.sessionStorage.getItem(TOKEN_CREATE_STORAGE_KEY)).toBe(JSON.stringify(pending)); + }); + + it("guards unload and sign-out while a pending request is blocked", async () => { + let createCalls = 0; + let logoutCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/tokens" && init?.method === "POST") { + createCalls += 1; + return Effect.runPromise(Effect.fail("response lost")); + } + if (path === "/api/v1/tokens") { + return Promise.resolve(Response.json({ tokens: [] })); + } + if (path === "/api/v1/session" && init?.method === "DELETE") { + logoutCalls += 1; + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(TokensPageHarness, { tokenCreateEnvironment: fixedEnvironment() }); + + await screen.findByText("No API tokens have been issued."); + await fireEvent.input(screen.getByLabelText("Token name"), { + target: { value: "Blocked agent" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Create token" })); + expect( + await screen.findByRole("button", { name: "Retry pending token request" }), + ).toBeDefined(); + expect(createCalls).toBe(2); + expect(window.sessionStorage.getItem(TOKEN_CREATE_STORAGE_KEY)).not.toBeNull(); + + const unload = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(unload); + expect(unload.defaultPrevented).toBe(true); + await fireEvent.click(screen.getByRole("button", { name: "Sign out" })); + expect( + await screen.findByText( + "Recover the pending token request before leaving this page or signing out.", + ), + ).toBeDefined(); + expect(logoutCalls).toBe(0); + expect(window.sessionStorage.getItem(TOKEN_CREATE_STORAGE_KEY)).not.toBeNull(); + }); + + it("fails closed around an unreadable retained record", async () => { + window.sessionStorage.setItem( + TOKEN_CREATE_STORAGE_KEY, + JSON.stringify({ version: 1, key: "invalid", name: "Unknown agent" }), + ); + vi.stubGlobal( + "fetch", + vi.fn((input) => { + if (String(input) === "/api/v1/tokens") { + return Promise.resolve(Response.json({ tokens: [] })); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(TokensPageHarness, { tokenCreateEnvironment: fixedEnvironment() }); + + expect(await screen.findByText(/stored token request is invalid/i)).toBeDefined(); + expect(screen.getByLabelText("Token name").disabled).toBe(true); + expect(screen.getByRole("button", { name: "Create token" }).disabled).toBe( + true, + ); + const unload = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(unload); + expect(unload.defaultPrevented).toBe(true); + expect(window.sessionStorage.getItem(TOKEN_CREATE_STORAGE_KEY)).not.toBeNull(); + }); + + it("aborts recovery on unmount, ignores the late secret, and retains the pending record", async () => { + const pending = { + version: 1, + key: "8".repeat(64), + name: "Unmounted agent", + } as const; + window.sessionStorage.setItem(TOKEN_CREATE_STORAGE_KEY, JSON.stringify(pending)); + const response = deferred(); + const request = { signal: null as AbortSignal | null }; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/tokens" && init?.method === "POST") { + request.signal = init.signal ?? null; + return response.promise; + } + if (path === "/api/v1/tokens") { + return Promise.resolve(Response.json({ tokens: [] })); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + const mounted = render(TokensPageHarness, { + tokenCreateEnvironment: fixedEnvironment(), + }); + + await waitFor(() => expect(request.signal).not.toBeNull()); + mounted.unmount(); + expect(request.signal?.aborted).toBe(true); + response.resolve( + noStoreJson(createdToken(pending.name), { + status: 201, + headers: { "idempotency-replayed": "true" }, + }), + ); + await response.promise; + await Promise.resolve(); + await Promise.resolve(); + + expect(screen.queryByDisplayValue(oneTimeSecret)).toBeNull(); + expect(window.sessionStorage.getItem(TOKEN_CREATE_STORAGE_KEY)).toBe(JSON.stringify(pending)); + }); + + it("retries one ambiguous revoke and refreshes the revoked row", async () => { + let listCalls = 0; + let revokeCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path === "/api/v1/tokens") { + listCalls += 1; + return Promise.resolve( + Response.json({ tokens: [tokenMetadata(listCalls === 1 ? null : 200)] }), + ); + } + if (path === "/api/v1/tokens/token-1" && init?.method === "DELETE") { + revokeCalls += 1; + return revokeCalls === 1 + ? Effect.runPromise(Effect.fail("response lost")) + : Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve(Response.json({}, { status: 500 })); + }), + ); + render(TokensPageHarness, { tokenCreateEnvironment: fixedEnvironment() }); + + const revoke = await screen.findByRole("button", { name: "Revoke Laptop agent" }); + await fireEvent.click(revoke); + const dialog = await screen.findByRole("dialog", { name: "Stop using Laptop agent?" }); + await fireEvent.click(within(dialog).getByRole("button", { name: "Revoke token" })); + + await waitFor(() => expect(revokeCalls).toBe(2)); + await waitFor(() => expect(listCalls).toBe(2)); + expect(await screen.findByText("Revoked")).toBeDefined(); + }); +}); diff --git a/web/src/routes/tools/+page.svelte b/web/src/routes/tools/+page.svelte new file mode 100644 index 000000000..75902d07e --- /dev/null +++ b/web/src/routes/tools/+page.svelte @@ -0,0 +1,855 @@ + + + + + +
+ + + + + +
+ {#if sources.error !== null} +
+ Source names could not be loaded. The active source filter is still applied. +
+ {/if} + +
+
+

Behavior semantics

+

How tool modes work

+
+
+
+
Inherit
+
Follow the source default, then the tool's built-in default.
+
+
+
Enabled
+
Available without interactive approval.
+
+
+
Ask
+
Require interactive approval before execution.
+
+
+
Disabled
+
Unavailable to gateway callers.
+
+
+
+ + {#if conflictNotice !== null}
+ {conflictNotice} +
{/if} + {#if resource.stale && resource.error !== null} +
+ Showing the last loaded tool page while Executor reconnects. + +
+ {:else if resource.error !== null} +
+ + +
+ {/if} + + {#if resource.data !== null} +
+
+ {displayedSelection.length} selected on this page + Bulk changes use catalog revision {confirmingBulk?.catalogRevision ?? + resource.data.catalogRevision}. +
+
0 || + confirmingBulk !== null} + > + Set selected tools + {#each toolModes as mode} + + {/each} +
+
+ + +
+ {#if confirmingBulk !== null} +
+ {bulkConfirmationText(confirmingBulk)} + + +
+ {/if} + {#if bulkNotice !== null}
+ {bulkNotice} +
{/if} + {#if mutationErrors.bulk !== undefined}{/if} +
+ {/if} + +
+
+
+

Global tool set

+

{resource.data?.total ?? 0} tools

+
+ {#if resource.loading}Refreshing...{/if} +
+ {#if resource.data === null && resource.loading} +
Loading tools...
+ {:else if resource.data !== null && resource.data.items.length === 0} +
+

No tools match this page or filter set.

+ {#if urlState.offset > 0} + + {/if} +
+ {:else if resource.data !== null} + {#if mobileToolsLayout} + + {/if} +
+ + + + + + + + {#each resource.data.items as tool (tool.id)} + {@const inherited = inheritedMode(tool)} + + + + + + + {/each} + +
Only tools on this page are included in bulk changes.
+ {#if !mobileToolsLayout} + toggleCurrentPage(event.currentTarget.checked)} + /> + {/if} + ToolBehaviorDetails
+ toggleSelected(tool.id, event.currentTarget.checked)} + /> + + {tool.displayName} + {tool.callablePath} + {tool.sourceSlug}{tool.description ? ` · ${tool.description}` : ""} + {#if !tool.present}Removed{/if} + +
+ Behavior for {tool.displayName} + + {#each toolModes as mode} + + {/each} +
+ {provenanceLabel(tool.effectiveMode.provenance)} + {#if mutationErrors[tool.id] !== undefined}{/if} +
(detailReturnId = tool.id)}>Inspect
+
+ + {/if} +
+ + {#if urlState.tool !== null} + + {/if} +
+ + diff --git a/web/src/routes/tools/tools-page.test-harness.svelte b/web/src/routes/tools/tools-page.test-harness.svelte new file mode 100644 index 000000000..8f1b76655 --- /dev/null +++ b/web/src/routes/tools/tools-page.test-harness.svelte @@ -0,0 +1,8 @@ + + + diff --git a/web/src/routes/tools/tools-page.test.ts b/web/src/routes/tools/tools-page.test.ts new file mode 100644 index 000000000..fac402d6f --- /dev/null +++ b/web/src/routes/tools/tools-page.test.ts @@ -0,0 +1,608 @@ +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; +import type { SourceList, ToolPage, ToolRecord } from "$lib/api"; +import ToolsPageHarness from "./tools-page.test-harness.svelte"; + +const catalogRevision = 37; + +function toolFixture(id: string, displayName: string) { + return { + id, + sourceId: "source-1", + sourceSlug: "product-api", + stableKey: id, + localName: id, + callablePath: `tools.product_api.${id}`, + sandboxPath: `tools.product_api.${id}`, + displayName, + description: null, + intrinsicMode: "ask", + modeOverride: null, + effectiveMode: { mode: "ask", provenance: "intrinsic" }, + present: true, + revision: 3, + createdAt: 100, + updatedAt: 100, + lastSeenAt: 100, + tombstonedAt: null, + } satisfies ToolPage["items"][number]; +} + +const alphaTool = toolFixture("tool-a", "Alpha tool"); +const tools = [alphaTool, toolFixture("tool-b", "Beta tool"), toolFixture("tool-c", "Gamma tool")]; + +function toolRecordFixture(tool: ToolPage["items"][number], mode: ToolRecord["modeOverride"]) { + return { + ...tool, + modeOverride: mode, + effectiveMode: { + mode: mode ?? tool.intrinsicMode, + provenance: mode === null ? "intrinsic" : "tool_override", + }, + inputSchema: { type: "object" }, + outputSchema: null, + inputTypescript: null, + outputTypescript: null, + typescriptDefinitions: {}, + } satisfies ToolRecord; +} + +function toolPageResponse(revision = catalogRevision, items: ToolPage["items"] = tools) { + return Response.json({ + items, + total: items.length, + hasMore: false, + nextOffset: null, + catalogRevision: revision, + }); +} + +function sourceFixture() { + return { + id: "source-1", + kind: "graphql", + slug: "product-api", + displayName: "Product API", + description: null, + configuration: { endpoint: "https://api.example.test/", allowPrivateNetwork: false }, + modeOverride: "enabled", + healthStatus: "healthy", + healthErrorCode: null, + revision: 4, + catalogRevision, + createdAt: 100, + updatedAt: 100, + lastRefreshedAt: 100, + toolCount: 1, + tombstonedToolCount: 0, + } satisfies SourceList["sources"][number]; +} + +function deferred() { + let resolve = (_value: Value) => {}; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function stubToolsApi() { + const bulkRequestBodies: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path.startsWith("/api/v1/tools?")) { + return Promise.resolve(toolPageResponse()); + } + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [], catalogRevision })); + } + if (path === "/api/v1/tools/modes" && init?.method === "PATCH") { + bulkRequestBodies.push(String(init.body)); + return Promise.resolve( + Response.json({ + updatedCount: 2, + catalogRevision: catalogRevision + 1, + sourceRevisions: { "source-1": 4 }, + }), + ); + } + return Promise.resolve( + Response.json( + { + error: { + code: "unexpected_test_request", + message: `Unexpected request: ${path}`, + requestId: "test-request", + }, + }, + { status: 500 }, + ), + ); + }), + ); + return bulkRequestBodies; +} + +async function openDisabledConfirmation() { + const alphaSelection = await screen.findByLabelText("Select Alpha tool"); + const betaSelection = screen.getByLabelText("Select Beta tool"); + const gammaSelection = screen.getByLabelText("Select Gamma tool"); + await fireEvent.click(alphaSelection); + await fireEvent.click(betaSelection); + + const bulkModes = screen.getByRole("group", { + name: "Set selected tools", + }); + await fireEvent.click(within(bulkModes).getByLabelText("Disabled")); + const applyButton = screen.getByRole("button", { + name: "Apply Disabled to 2 selected", + }); + applyButton.focus(); + await fireEvent.click(applyButton); + + const confirmation = await screen.findByRole("group", { + name: "Confirm bulk tool behavior", + }); + return { + alphaSelection, + betaSelection, + gammaSelection, + bulkModes, + applyButton, + confirmation, + }; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("Tools page bulk confirmation", () => { + it("freezes the visible target snapshot and restores controls and opener focus on cancel", async () => { + stubToolsApi(); + render(ToolsPageHarness); + + const { alphaSelection, betaSelection, gammaSelection, bulkModes, applyButton, confirmation } = + await openDisabledConfirmation(); + const selectAll = screen.getByLabelText( + "Select all active tools on this page", + ); + const inheritButton = screen.getByRole("button", { + name: "Apply Inherit to 2 selected", + }); + const alphaModes = within( + alphaSelection.closest("tr") ?? document.body, + ).getByRole("group", { name: "Behavior for Alpha tool" }); + const betaModes = within( + betaSelection.closest("tr") ?? document.body, + ).getByRole("group", { name: "Behavior for Beta tool" }); + const gammaModes = within( + gammaSelection.closest("tr") ?? document.body, + ).getByRole("group", { name: "Behavior for Gamma tool" }); + const cancelButton = within(confirmation).getByRole("button", { + name: "Cancel", + }); + const confirmButton = within(confirmation).getByRole("button", { + name: "Confirm Disabled", + }); + + await waitFor(() => expect(document.activeElement).toBe(cancelButton)); + expect(screen.getByText("2 selected on this page")).toBeDefined(); + expect(screen.getByText(`Bulk changes use catalog revision ${catalogRevision}.`)).toBeDefined(); + expect(alphaSelection.checked).toBe(true); + expect(betaSelection.checked).toBe(true); + expect(gammaSelection.checked).toBe(false); + expect(selectAll.disabled).toBe(true); + expect(alphaSelection.disabled).toBe(true); + expect(betaSelection.disabled).toBe(true); + expect(gammaSelection.disabled).toBe(true); + expect(bulkModes.disabled).toBe(true); + expect(applyButton.disabled).toBe(true); + expect(inheritButton.disabled).toBe(true); + expect(alphaModes.disabled).toBe(true); + expect(betaModes.disabled).toBe(true); + expect(gammaModes.disabled).toBe(true); + expect(cancelButton.disabled).toBe(false); + expect(confirmButton.disabled).toBe(false); + + await fireEvent.click(cancelButton); + + await waitFor(() => expect(document.activeElement).toBe(applyButton)); + expect(screen.queryByRole("group", { name: "Confirm bulk tool behavior" })).toBeNull(); + expect(selectAll.disabled).toBe(false); + expect(alphaSelection.disabled).toBe(false); + expect(betaSelection.disabled).toBe(false); + expect(gammaSelection.disabled).toBe(false); + expect(bulkModes.disabled).toBe(false); + expect(applyButton.disabled).toBe(false); + expect(inheritButton.disabled).toBe(false); + expect(alphaModes.disabled).toBe(false); + expect(betaModes.disabled).toBe(false); + expect(gammaModes.disabled).toBe(false); + }); + + it("blocks broad confirmation through a per-tool mutation and its list refresh", async () => { + const modeMutation = deferred(); + const listRefresh = deferred(); + let listCalls = 0; + let modeMutationCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path.startsWith("/api/v1/tools?")) { + listCalls += 1; + return listCalls === 1 ? Promise.resolve(toolPageResponse()) : listRefresh.promise; + } + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [], catalogRevision })); + } + if (path === "/api/v1/tools/tool-a/mode" && init?.method === "PATCH") { + modeMutationCalls += 1; + return modeMutation.promise; + } + return Promise.resolve( + Response.json( + { + error: { + code: "unexpected_test_request", + message: `Unexpected request: ${path}`, + requestId: "test-request", + }, + }, + { status: 500 }, + ), + ); + }), + ); + render(ToolsPageHarness); + + const alphaSelection = await screen.findByLabelText("Select Alpha tool"); + const betaSelection = screen.getByLabelText("Select Beta tool"); + await fireEvent.click(alphaSelection); + await fireEvent.click(betaSelection); + const bulkModes = screen.getByRole("group", { + name: "Set selected tools", + }); + await fireEvent.click(within(bulkModes).getByLabelText("Disabled")); + const applyButton = screen.getByRole("button", { + name: "Apply Disabled to 2 selected", + }); + const alphaModes = within( + alphaSelection.closest("tr") ?? document.body, + ).getByRole("group", { name: "Behavior for Alpha tool" }); + + await fireEvent.click(within(alphaModes).getByLabelText("Enabled")); + + await waitFor(() => expect(modeMutationCalls).toBe(1)); + expect(screen.queryByText("Refreshing...")).toBeNull(); + expect(bulkModes.disabled).toBe(true); + expect(applyButton.disabled).toBe(true); + await fireEvent.click(applyButton); + expect(screen.queryByRole("group", { name: "Confirm bulk tool behavior" })).toBeNull(); + + modeMutation.resolve(Response.json(toolRecordFixture(alphaTool, "enabled"))); + + await waitFor(() => expect(listCalls).toBe(2)); + expect(await screen.findByText("Refreshing...")).toBeDefined(); + expect(bulkModes.disabled).toBe(true); + expect(applyButton.disabled).toBe(true); + await fireEvent.click(applyButton); + expect(screen.queryByRole("group", { name: "Confirm bulk tool behavior" })).toBeNull(); + + listRefresh.resolve(toolPageResponse(catalogRevision + 1)); + + await waitFor(() => expect(applyButton.disabled).toBe(false)); + expect(screen.queryByText("Refreshing...")).toBeNull(); + await fireEvent.click(applyButton); + expect(await screen.findByRole("group", { name: "Confirm bulk tool behavior" })).toBeDefined(); + }); + + for (const scenario of [ + { label: "Ask", mode: "ask", buttonName: "Apply Ask to 2 selected" }, + { label: "Inherit", mode: null, buttonName: "Apply Inherit to 2 selected" }, + ] as const) { + it(`locks every row through a deferred bulk ${scenario.label} mutation`, async () => { + const bulkMutation = deferred(); + const patchRequests: Array<{ path: string; body: string }> = []; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path.startsWith("/api/v1/tools?")) { + return Promise.resolve(toolPageResponse()); + } + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [], catalogRevision })); + } + if (init?.method === "PATCH") { + patchRequests.push({ path, body: String(init.body) }); + if (path === "/api/v1/tools/modes") return bulkMutation.promise; + return Promise.resolve(Response.json(toolRecordFixture(alphaTool, "enabled"))); + } + return Promise.resolve( + Response.json( + { + error: { + code: "unexpected_test_request", + message: `Unexpected request: ${path}`, + requestId: "test-request", + }, + }, + { status: 500 }, + ), + ); + }), + ); + render(ToolsPageHarness); + + const alphaSelection = await screen.findByLabelText("Select Alpha tool"); + const betaSelection = screen.getByLabelText("Select Beta tool"); + const gammaSelection = screen.getByLabelText("Select Gamma tool"); + await fireEvent.click(alphaSelection); + await fireEvent.click(betaSelection); + const bulkModes = screen.getByRole("group", { + name: "Set selected tools", + }); + const applyButton = screen.getByRole("button", { + name: scenario.buttonName, + }); + const alphaModes = within( + alphaSelection.closest("tr") ?? document.body, + ).getByRole("group", { name: "Behavior for Alpha tool" }); + const betaModes = within( + betaSelection.closest("tr") ?? document.body, + ).getByRole("group", { name: "Behavior for Beta tool" }); + const gammaModes = within( + gammaSelection.closest("tr") ?? document.body, + ).getByRole("group", { name: "Behavior for Gamma tool" }); + applyButton.focus(); + + await fireEvent.click(applyButton); + + await waitFor(() => expect(patchRequests).toHaveLength(1)); + expect(screen.queryByRole("group", { name: "Confirm bulk tool behavior" })).toBeNull(); + expect(alphaSelection.disabled).toBe(true); + expect(betaSelection.disabled).toBe(true); + expect(gammaSelection.disabled).toBe(true); + expect(bulkModes.disabled).toBe(true); + expect(alphaModes.disabled).toBe(true); + expect(betaModes.disabled).toBe(true); + expect(gammaModes.disabled).toBe(true); + + await fireEvent.change(within(alphaModes).getByLabelText("Enabled")); + + expect(patchRequests).toEqual([ + { + path: "/api/v1/tools/modes", + body: JSON.stringify({ + selection: { + type: "tool_ids", + toolIds: ["tool-a", "tool-b"], + expectedCatalogRevision: catalogRevision, + }, + mode: scenario.mode, + }), + }, + ]); + + bulkMutation.resolve( + Response.json({ + updatedCount: 2, + catalogRevision: catalogRevision + 1, + sourceRevisions: { "source-1": 4 }, + }), + ); + + const status = await screen.findByText(`${scenario.label} applied to 2 selected tools.`); + await waitFor(() => expect(document.activeElement).toBe(status)); + await waitFor(() => expect(alphaModes.disabled).toBe(false)); + expect(patchRequests).toHaveLength(1); + }); + } + + it("confirms a frozen Inherit transition from Disabled to source Enabled", async () => { + const disabledTool = { + ...alphaTool, + modeOverride: "disabled", + effectiveMode: { mode: "disabled", provenance: "tool_override" }, + } satisfies ToolPage["items"][number]; + const bulkMutation = deferred(); + const patchRequests: Array<{ path: string; body: string }> = []; + vi.stubGlobal( + "fetch", + vi.fn((input, init) => { + const path = String(input); + if (path.startsWith("/api/v1/tools?")) { + return Promise.resolve(toolPageResponse(catalogRevision, [disabledTool])); + } + if (path === "/api/v1/sources") { + return Promise.resolve(Response.json({ sources: [sourceFixture()], catalogRevision })); + } + if (init?.method === "PATCH") { + patchRequests.push({ path, body: String(init.body) }); + if (path === "/api/v1/tools/modes") return bulkMutation.promise; + return Promise.resolve(Response.json(toolRecordFixture(disabledTool, "ask"))); + } + return Promise.resolve( + Response.json( + { + error: { + code: "unexpected_test_request", + message: `Unexpected request: ${path}`, + requestId: "test-request", + }, + }, + { status: 500 }, + ), + ); + }), + ); + render(ToolsPageHarness); + + const selection = await screen.findByLabelText("Select Alpha tool"); + const rowModes = within( + selection.closest("tr") ?? document.body, + ).getByRole("group", { name: "Behavior for Alpha tool" }); + await within(rowModes).findByLabelText("Inherit (currently Enabled)"); + await fireEvent.click(selection); + const bulkModes = screen.getByRole("group", { + name: "Set selected tools", + }); + const inheritButton = screen.getByRole("button", { + name: "Apply Inherit to 1 selected", + }); + + await fireEvent.click(inheritButton); + + const confirmation = await screen.findByRole("group", { + name: "Confirm bulk tool behavior", + }); + expect( + within(confirmation).getByText( + "Inherit 1 selected tool? 1 tool will become Enabled under its source default.", + ), + ).toBeDefined(); + expect(patchRequests).toHaveLength(0); + expect(selection.checked).toBe(true); + expect(selection.disabled).toBe(true); + expect(bulkModes.disabled).toBe(true); + expect(rowModes.disabled).toBe(true); + const cancelButton = within(confirmation).getByRole("button", { + name: "Cancel", + }); + const confirmButton = within(confirmation).getByRole("button", { + name: "Confirm Inherit", + }); + expect(cancelButton.disabled).toBe(false); + expect(confirmButton.disabled).toBe(false); + + await fireEvent.click(cancelButton); + + await waitFor(() => expect(document.activeElement).toBe(inheritButton)); + expect(screen.queryByRole("group", { name: "Confirm bulk tool behavior" })).toBeNull(); + await fireEvent.click(inheritButton); + const applyConfirmation = await screen.findByRole("group", { + name: "Confirm bulk tool behavior", + }); + const applyCancelButton = within(applyConfirmation).getByRole("button", { + name: "Cancel", + }); + const applyConfirmButton = within(applyConfirmation).getByRole("button", { + name: "Confirm Inherit", + }); + + await fireEvent.click(applyConfirmButton); + + await waitFor(() => expect(patchRequests).toHaveLength(1)); + expect(patchRequests[0]).toEqual({ + path: "/api/v1/tools/modes", + body: JSON.stringify({ + selection: { + type: "tool_ids", + toolIds: ["tool-a"], + expectedCatalogRevision: catalogRevision, + }, + mode: null, + }), + }); + expect(selection.disabled).toBe(true); + expect(bulkModes.disabled).toBe(true); + expect(rowModes.disabled).toBe(true); + expect(applyCancelButton.disabled).toBe(true); + expect(applyConfirmButton.disabled).toBe(true); + + await fireEvent.change(within(rowModes).getByLabelText("Ask")); + expect(patchRequests).toHaveLength(1); + + bulkMutation.resolve( + Response.json({ + updatedCount: 1, + catalogRevision: catalogRevision + 1, + sourceRevisions: { "source-1": 5 }, + }), + ); + + const status = await screen.findByText("Inherit applied to 1 selected tool."); + await waitFor(() => expect(document.activeElement).toBe(status)); + }); + + it("uses a visible accessible Select all control at 390px", async () => { + vi.stubGlobal("innerWidth", 390); + stubToolsApi(); + render(ToolsPageHarness); + + const alphaSelection = await screen.findByLabelText("Select Alpha tool"); + const selectAll = screen.getByLabelText( + "Select all active tools on this page", + ); + expect(selectAll.closest("thead")).toBeNull(); + expect(selectAll.closest(".mobile-select-all")).not.toBeNull(); + expect( + document.querySelector('thead input[aria-label="Select all active tools on this page"]'), + ).toBeNull(); + + await fireEvent.click(alphaSelection); + await waitFor(() => expect(selectAll.indeterminate).toBe(true)); + await fireEvent.click(selectAll); + await waitFor(() => expect(selectAll.checked).toBe(true)); + expect(screen.getByText("3 selected on this page")).toBeDefined(); + + const bulkModes = screen.getByRole("group", { + name: "Set selected tools", + }); + await fireEvent.click(within(bulkModes).getByLabelText("Disabled")); + await fireEvent.click(screen.getByRole("button", { name: "Apply Disabled to 3 selected" })); + + expect(await screen.findByRole("group", { name: "Confirm bulk tool behavior" })).toBeDefined(); + expect(selectAll.disabled).toBe(true); + expect(selectAll.closest("thead")).toBeNull(); + }); + + it("cancels with Escape and restores focus to the apply button", async () => { + stubToolsApi(); + render(ToolsPageHarness); + + const { applyButton, confirmation } = await openDisabledConfirmation(); + const confirmButton = within(confirmation).getByRole("button", { + name: "Confirm Disabled", + }); + confirmButton.focus(); + await fireEvent.keyDown(confirmButton, { key: "Escape" }); + + await waitFor(() => expect(document.activeElement).toBe(applyButton)); + expect(screen.queryByRole("group", { name: "Confirm bulk tool behavior" })).toBeNull(); + expect(applyButton.disabled).toBe(false); + }); + + it("submits the displayed snapshot and focuses the success status", async () => { + const bulkRequestBodies = stubToolsApi(); + render(ToolsPageHarness); + + const { alphaSelection, betaSelection, gammaSelection, confirmation } = + await openDisabledConfirmation(); + expect(alphaSelection.checked).toBe(true); + expect(betaSelection.checked).toBe(true); + expect(gammaSelection.checked).toBe(false); + expect(screen.getByText(`Bulk changes use catalog revision ${catalogRevision}.`)).toBeDefined(); + + await fireEvent.click(within(confirmation).getByRole("button", { name: "Confirm Disabled" })); + + await waitFor(() => expect(bulkRequestBodies).toHaveLength(1)); + expect(bulkRequestBodies).toEqual([ + JSON.stringify({ + selection: { + type: "tool_ids", + toolIds: ["tool-a", "tool-b"], + expectedCatalogRevision: catalogRevision, + }, + mode: "disabled", + }), + ]); + const status = await screen.findByText("Disabled applied to 2 selected tools."); + await waitFor(() => expect(document.activeElement).toBe(status)); + }); +}); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 000000000..f00b0b773 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,1640 @@ +:root { + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + color: #e9edf5; + background: #080b12; + font-synthesis: none; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: + radial-gradient(circle at 80% -20%, rgba(88, 101, 242, 0.2), transparent 35%), #080b12; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +a { + color: inherit; +} + +button { + min-height: 2.55rem; + border: 1px solid #30384a; + border-radius: 10px; + padding: 0.68rem 0.95rem; + color: #dfe5f2; + background: #151b28; + cursor: pointer; +} + +:where(a, button, input, select, textarea):focus-visible { + outline: 3px solid rgba(145, 160, 255, 0.72); + outline-offset: 3px; +} + +button:hover:not(:disabled) { + border-color: #66728b; + background: #1b2333; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.64; +} + +button.primary { + border-color: #7181ff; + color: #07101d; + background: #91a0ff; + font-weight: 750; +} + +button.primary:hover:not(:disabled) { + border-color: #aab4ff; + background: #aab4ff; +} + +.skip-link { + position: fixed; + z-index: 100; + top: 0.75rem; + left: 0.75rem; + transform: translateY(-180%); + border-radius: 8px; + padding: 0.65rem 0.9rem; + color: #07101d; + background: #c2caff; + font-weight: 750; + text-decoration: none; +} + +.skip-link:focus-visible { + transform: translateY(0); +} + +.app-frame { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + position: sticky; + top: 0; + display: flex; + flex-direction: column; + height: 100vh; + padding: 1.25rem; + border-right: 1px solid #202738; + background: rgba(10, 14, 22, 0.9); + backdrop-filter: blur(18px); +} + +.brand { + display: flex; + gap: 0.75rem; + align-items: center; + margin-bottom: 2rem; + text-decoration: none; +} + +.brand-mark { + display: inline-grid; + width: 2.5rem; + height: 2.5rem; + place-items: center; + border: 1px solid #7181ff; + border-radius: 11px; + color: #b6c0ff; + background: #1c2443; + box-shadow: inset 0 0 20px rgba(113, 129, 255, 0.15); + font-size: 0.75rem; + font-weight: 850; + letter-spacing: 0.08em; +} + +.brand strong, +.brand small, +.instance-card strong, +.instance-card small, +td small { + display: block; +} + +.brand small, +.instance-card small, +td small { + margin-top: 0.2rem; + color: #9da8bc; + font-size: 0.72rem; +} + +nav { + display: grid; + gap: 0.35rem; +} + +nav a { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.72rem 0.8rem; + border-radius: 9px; + color: #b2bccf; + text-decoration: none; + font-size: 0.9rem; +} + +.nav-marker { + color: #929eb4; + font-family: ui-monospace, "SFMono-Regular", monospace; + font-size: 0.68rem; +} + +.nav-label { + min-width: 0; +} + +.nav-count { + min-width: 1.65rem; + margin-left: auto; + padding: 0.14rem 0.35rem; + border: 1px solid #343d51; + border-radius: 999px; + color: #aab5c8; + font-family: ui-monospace, "SFMono-Regular", monospace; + font-size: 0.62rem; + text-align: center; +} + +nav a:hover, +nav a.active { + color: #f1f3f9; + background: #161c29; +} + +nav a.active { + box-shadow: inset 3px 0 0 #91a0ff; + font-weight: 750; +} + +nav a.active .nav-marker { + color: #91a0ff; +} + +.instance-card { + display: flex; + gap: 0.65rem; + align-items: center; + margin-top: auto; + padding: 0.85rem; + border: 1px solid #242c3d; + border-radius: 12px; + background: #10151f; + font-size: 0.8rem; +} + +.status-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: #55d6a5; + box-shadow: 0 0 10px rgba(85, 214, 165, 0.6); +} + +main { + min-width: 0; +} + +.page-header { + display: flex; + justify-content: space-between; + gap: 2rem; + align-items: flex-start; + padding: 2.4rem clamp(1.25rem, 4vw, 4rem) 1.8rem; + border-bottom: 1px solid #202738; +} + +.page-header h1, +.auth-card h1 { + margin: 0.15rem 0 0.55rem; + color: #f7f8fb; + font-size: clamp(1.7rem, 3vw, 2.5rem); + line-height: 1.05; + letter-spacing: -0.04em; +} + +.page-header > div > p:last-child, +.lede, +.surface > p { + max-width: 650px; + margin: 0; + color: #a8b2c4; + line-height: 1.65; +} + +.eyebrow { + margin: 0; + color: #a6b1ff; + font-family: ui-monospace, "SFMono-Regular", monospace; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.admin-actions { + display: flex; + gap: 0.75rem; + align-items: center; + color: #a3aec1; + font-size: 0.78rem; + white-space: nowrap; +} + +.admin-actions strong { + color: #cbd2df; + font-weight: 650; +} + +.shell-notice { + padding: 0 clamp(1.25rem, 4vw, 4rem); +} + +.page-content { + display: grid; + gap: 1.25rem; + padding: 2rem clamp(1.25rem, 4vw, 4rem) 4rem; +} + +.surface { + border: 1px solid #242c3d; + border-radius: 16px; + background: linear-gradient(145deg, rgba(20, 26, 39, 0.95), rgba(13, 17, 26, 0.95)); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.16); +} + +.placeholder-grid, +.token-layout { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.6fr); + gap: 1.25rem; +} + +.empty-state, +.scope-card, +.token-form, +.reveal-card { + padding: clamp(1.35rem, 4vw, 2.2rem); +} + +.empty-state h2, +.scope-card h3, +.surface h2 { + margin: 1rem 0 0.65rem; + color: #eef1f7; + letter-spacing: -0.025em; +} + +.number-chip { + display: inline-block; + padding: 0.25rem 0.4rem; + border: 1px solid #3b4560; + border-radius: 6px; + color: #9dabff; + font-family: ui-monospace, "SFMono-Regular", monospace; + font-size: 0.7rem; +} + +.availability { + display: inline-block; + margin-top: 1.4rem !important; + border: 1px solid #4a556d; + border-radius: 999px; + padding: 0.35rem 0.65rem; + color: #c1cada !important; + background: #171e2b; + font-size: 0.76rem; + font-weight: 700; +} + +.centered-page { + display: grid; + min-height: 100vh; + padding: 2rem 1.25rem; + place-items: center; +} + +.auth-card { + width: min(100%, 510px); + padding: clamp(1.4rem, 5vw, 2.6rem); + border: 1px solid #273044; + border-radius: 20px; + background: rgba(14, 19, 29, 0.94); + box-shadow: 0 30px 90px rgba(0, 0, 0, 0.35); +} + +.auth-card.compact { + display: flex; + gap: 1rem; + align-items: center; + max-width: 430px; +} + +.auth-heading { + display: flex; + gap: 1rem; + align-items: center; +} + +.auth-card form, +.token-form form { + display: grid; + gap: 1rem; + margin-top: 1.6rem; +} + +label { + display: grid; + gap: 0.45rem; + color: #cbd2df; + font-size: 0.82rem; + font-weight: 650; +} + +label small { + color: #9ba6ba; + font-weight: 400; +} + +input, +textarea { + width: 100%; + border: 1px solid #66728b; + border-radius: 10px; + outline: none; + padding: 0.78rem 0.85rem; + color: #edf0f6; + background: #0b1019; +} + +input:focus { + border-color: #7181ff; + box-shadow: 0 0 0 3px rgba(113, 129, 255, 0.13); +} + +textarea:focus { + border-color: #7181ff; + box-shadow: 0 0 0 3px rgba(113, 129, 255, 0.13); +} + +textarea { + resize: vertical; +} + +input:disabled { + color: #a4aec0; + background: #141923; +} + +input[readonly] { + cursor: text; +} + +select { + width: 100%; + min-height: 2.75rem; + border: 1px solid #66728b; + border-radius: 10px; + padding: 0.68rem 2rem 0.68rem 0.8rem; + color: #edf0f6; + background: #0b1019; +} + +.notice { + margin-top: 1rem; + padding: 0.8rem 0.9rem; + border: 1px solid #3a4356; + border-radius: 10px; + color: #cbd2df; + background: #161c28; + font-size: 0.82rem; + line-height: 1.5; +} + +.notice.warning { + border-color: #635739; + color: #e5d7ad; + background: #211d14; +} + +.notice.error { + border-color: #6a3c48; + color: #f4bdc8; + background: #24141a; +} + +.notice strong, +.notice small { + display: block; +} + +.notice small { + margin-top: 0.4rem; + color: inherit; + opacity: 0.8; +} + +.text-button { + min-height: auto; + margin: 0.55rem 0 0; + border: 0; + padding: 0; + color: inherit; + background: transparent; + text-decoration: underline; + text-underline-offset: 0.2rem; +} + +.field-error { + margin: -0.45rem 0 0; + color: #f4bdc8; + font-size: 0.78rem; +} + +code { + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} + +.reveal-card { + border-color: #5a5794; + background: linear-gradient(145deg, #202344, #14182b); +} + +.reveal-card code { + display: block; + overflow-wrap: anywhere; + margin: 1rem 0; + padding: 0.8rem; + border-radius: 8px; + color: #d9deff; + background: #0d1020; + font-size: 0.76rem; +} + +.token-secret { + margin-top: 0.45rem; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 0.78rem; +} + +.copy-status { + min-height: 1.35rem; + margin-top: 0.8rem !important; + color: #c4ccda !important; + font-size: 0.78rem; +} + +.reveal-card button { + margin-top: 1rem; +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; + align-items: center; +} + +.button-row button { + margin-top: 0; +} + +.table-card { + overflow: hidden; +} + +.stale-notice { + border: 1px solid #75683f; + border-radius: 14px; + padding: 1rem; + color: #f0e2b7; + background: #252015; +} + +.stale-notice > .notice { + margin-top: 0.75rem; +} + +.table-unavailable { + display: grid; + gap: 0.65rem; + padding: 1.4rem; + color: #c5cddc; +} + +.table-unavailable p { + margin: 0; + color: #a6b0c2; +} + +.section-heading { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.4rem; + border-bottom: 1px solid #252d3e; +} + +.section-heading h2 { + margin: 0.25rem 0 0; +} + +.table-scroll { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +caption { + padding: 0.85rem 1.2rem; + color: #a8b3c7; + text-align: left; + font-size: 0.76rem; +} + +th, +td { + padding: 0.95rem 1.2rem; + border-bottom: 1px solid #202738; + text-align: left; + white-space: nowrap; +} + +th { + color: #9da9bd; + font-size: 0.67rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +td { + color: #c1c9d8; +} + +.table-empty { + margin: 0; + padding: 2rem; + color: #a2aec2; + text-align: center; +} + +.status-pill { + display: inline-block; + padding: 0.25rem 0.5rem; + border: 1px solid #315b50; + border-radius: 999px; + color: #75d5b3; + background: #11231f; + font-size: 0.68rem; +} + +.status-pill.revoked { + border-color: #493f48; + color: #b8adb7; + background: #1c181d; +} + +.danger-link { + min-height: 2rem; + border-color: #583945; + padding: 0.3rem 0.55rem; + color: #e29aaa; + background: #21151a; +} + +.danger-button { + border-color: #875061; + color: #ffe5eb; + background: #6a3041; +} + +.confirm-dialog { + width: min(calc(100% - 2rem), 480px); + border: 1px solid #364056; + border-radius: 18px; + padding: 1.5rem; + color: #e9edf5; + background: #121824; + box-shadow: 0 30px 100px rgba(0, 0, 0, 0.58); +} + +.confirm-dialog::backdrop { + background: rgba(2, 5, 10, 0.76); + backdrop-filter: blur(4px); +} + +.confirm-dialog h2 { + margin: 0.65rem 0; + color: #f2f4f9; +} + +.confirm-dialog > p:not(.eyebrow) { + color: #9ba5b9; + line-height: 1.6; +} + +.dialog-actions { + justify-content: flex-end; + margin-top: 1.4rem; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +.loading-panel { + padding: 2rem; + color: #aeb8ca; +} + +.import-panel { + overflow: hidden; +} + +.import-panel > summary { + padding: 1.1rem 1.35rem; + color: #e4e8f1; + cursor: pointer; + font-weight: 750; +} + +.import-panel[open] > summary { + border-bottom: 1px solid #293145; +} + +.import-form, +.preview-panel { + display: grid; + gap: 1rem; + padding: 1.3rem; +} + +.import-form { + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; +} + +.import-form > :not(button) { + grid-column: 1 / -1; +} + +.private-network-choice { + margin-top: 0.25rem; +} + +.field-help { + margin: 0; + color: #9da8bb; + font-size: 0.74rem; + line-height: 1.5; +} + +.preview-panel { + border-top: 1px solid #293145; + background: rgba(12, 17, 27, 0.65); +} + +.preview-panel > div:first-child p:last-child { + margin: 0; + color: #a8b2c4; +} + +.preview-tools { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.preview-tools span { + display: grid; + gap: 0.2rem; + border: 1px solid #30394c; + border-radius: 9px; + padding: 0.55rem 0.7rem; + color: #d5dbe7; + background: #111722; + font-size: 0.75rem; +} + +.preview-tools small { + color: #909caf; +} + +.import-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.credential-schemes, +.credential-editor { + display: grid; + gap: 0.85rem; +} + +.credential-schemes { + margin: 0; + border: 1px solid #30394c; + border-radius: 11px; + padding: 1rem; +} + +.credential-schemes > legend, +.credential-row > legend { + padding: 0 0.35rem; + color: #aeb8ca; + font-size: 0.72rem; + font-weight: 750; +} + +.credential-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + min-width: 0; + border: 1px solid #293247; + border-radius: 10px; + padding: 0.85rem; + background: #0d131d; +} + +.credential-row > .checkbox-label, +.credential-row > .danger-link { + grid-column: 1 / -1; + justify-self: start; +} + +.credential-row .checkbox-label span, +.credential-row .checkbox-label small { + display: block; +} + +.credential-row .checkbox-label small { + margin-top: 0.2rem; + color: #939fb3; +} + +.credential-editor { + border-top: 1px solid #293247; + padding-top: 1rem; +} + +.credential-editor h3, +.credential-editor p { + margin: 0; +} + +.credential-editor p { + margin-top: 0.3rem; + color: #9da8bb; + font-size: 0.78rem; +} + +.wide-field { + grid-column: 1 / -1; +} + +.source-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 390px), 1fr)); + gap: 1.25rem; +} + +.source-card { + display: grid; + gap: 1.25rem; + padding: 1.45rem; +} + +.source-card-heading { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.source-card-heading h2, +.privacy-note h2 { + margin: 0.35rem 0 0.45rem; +} + +.source-card-heading p:last-child { + margin: 0; + color: #a8b2c4; + line-height: 1.55; +} + +.health-pill, +.outcome-pill { + display: inline-block; + border: 1px solid #4a556d; + border-radius: 999px; + padding: 0.28rem 0.55rem; + color: #c1cada; + background: #171e2b; + font-size: 0.68rem; + font-weight: 750; + text-transform: capitalize; +} + +.health-pill.healthy, +.outcome-pill.success { + border-color: #315b50; + color: #75d5b3; + background: #11231f; +} + +.health-pill.error, +.outcome-pill.error { + border-color: #6a3c48; + color: #f4bdc8; + background: #24141a; +} + +.outcome-pill.pending { + border-color: #75683f; + color: #f0e2b7; + background: #252015; +} + +.outcome-pill.active { + border-color: #485b96; + color: #b8c4ff; + background: #171d36; +} + +.outcome-pill.warning { + border-color: #675b3e; + color: #dccb9a; + background: #211d14; +} + +.source-stats, +.detail-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin: 0; +} + +.source-stats div, +.detail-list div { + min-width: 0; + border: 1px solid #283146; + border-radius: 10px; + padding: 0.8rem; + background: #0d131d; +} + +.source-stats dt, +.detail-list dt { + color: #8f9bb1; + font-size: 0.65rem; + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.source-stats dd, +.detail-list dd { + margin: 0.35rem 0 0; + overflow-wrap: anywhere; + color: #d7dde8; + font-size: 0.82rem; +} + +.source-error { + margin: 0; + color: #f0c1cb; + font-size: 0.78rem; +} + +.mode-control { + display: flex; + flex-wrap: wrap; + gap: 0.55rem 1rem; + min-width: 0; + margin: 0; + border: 1px solid #30394c; + border-radius: 11px; + padding: 0.85rem; +} + +.mode-control legend { + padding: 0 0.35rem; + color: #aeb8ca; + font-size: 0.72rem; + font-weight: 750; +} + +.mode-control label, +.checkbox-label { + display: flex; + gap: 0.4rem; + align-items: center; + color: #c7cfdd; + font-size: 0.76rem; + font-weight: 600; +} + +.source-mode-control .mode-impact, +.source-mode-control > button { + flex-basis: 100%; +} + +.mode-impact { + margin: 0; + color: #9da8bb; + font-size: 0.74rem; + line-height: 1.45; +} + +.mode-control input, +.checkbox-label input, +th input, +td input { + min-width: 1.5rem; + min-height: 1.5rem; + width: auto; + margin: 0; + accent-color: #91a0ff; +} + +.source-actions, +.inline-confirm { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + align-items: center; +} + +.source-actions { + justify-content: space-between; +} + +.inline-confirm { + justify-content: flex-end; + width: 100%; + color: #f1c2cc; + font-size: 0.78rem; +} + +.button-link, +.detail-link, +.pagination a { + display: inline-flex; + min-height: 2.55rem; + align-items: center; + border: 1px solid #30384a; + border-radius: 10px; + padding: 0.68rem 0.95rem; + color: #dfe5f2; + background: #151b28; + font-size: 0.8rem; + font-weight: 700; + text-decoration: none; +} + +.button-link:hover, +.detail-link:hover, +.pagination a:hover { + border-color: #66728b; + background: #1b2333; +} + +.filter-bar { + display: grid; + grid-template-columns: minmax(220px, 1.5fr) repeat(2, minmax(150px, 0.65fr)) auto auto; + gap: 0.8rem; + align-items: end; + padding: 1rem; +} + +.filter-bar .checkbox-label { + min-height: 2.75rem; + white-space: nowrap; +} + +.bulk-bar { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + padding: 1rem 1.2rem; +} + +.bulk-confirm { + flex-basis: 100%; +} + +.bulk-bar > div:first-child { + display: grid; + gap: 0.25rem; + margin-right: auto; +} + +.bulk-bar small, +.muted-status { + color: #9ca7ba; + font-size: 0.72rem; +} + +.mode-control.compact { + padding: 0.6rem 0.75rem; +} + +.row-modes { + flex-wrap: wrap; + border: 0; + padding: 0; +} + +.mode-semantics { + display: grid; + grid-template-columns: minmax(180px, 0.45fr) minmax(0, 1.55fr); + gap: 1rem; + align-items: center; + padding: 1rem 1.2rem; +} + +.mode-semantics h2 { + margin: 0.3rem 0 0; +} + +.mode-semantics dl { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.6rem; + margin: 0; +} + +.mode-semantics dl div { + border-left: 2px solid #36415a; + padding-left: 0.65rem; +} + +.mode-semantics dt { + color: #dbe1ec; + font-size: 0.75rem; + font-weight: 750; +} + +.mode-semantics dd { + margin: 0.25rem 0 0; + color: #96a2b6; + font-size: 0.7rem; + line-height: 1.45; +} + +.empty-recovery, +.table-empty .button-link { + justify-content: center; + margin-top: 0.8rem; +} + +.row-modes label { + gap: 0.25rem; +} + +td strong, +td code { + display: block; +} + +.removed-row { + opacity: 0.68; +} + +.detail-link { + min-height: 2rem; + padding: 0.35rem 0.6rem; +} + +.pagination { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 1rem; + align-items: center; + padding: 1rem 1.2rem; + color: #9da8bc; + font-size: 0.74rem; +} + +.pagination a:last-child { + justify-self: end; +} + +.detail-panel { + overflow: hidden; +} + +.detail-body { + display: grid; + gap: 1rem; + padding: 1.3rem; +} + +.detail-list { + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); +} + +.detail-body h3 { + margin: 0.6rem 0 0; + color: #dce2ed; + font-size: 0.85rem; +} + +.detail-body pre { + overflow: auto; + max-height: 420px; + margin: 0; + border: 1px solid #293247; + border-radius: 10px; + padding: 1rem; + color: #cbd4e4; + background: #090e16; + font-size: 0.72rem; +} + +.privacy-note { + display: flex; + justify-content: space-between; + gap: 2rem; + align-items: center; + padding: 1.2rem 1.4rem; +} + +.privacy-note p:last-child { + max-width: 560px; + margin: 0; + color: #aab4c5; + line-height: 1.55; +} + +.approval-explainer, +.approval-filters { + display: flex; + justify-content: space-between; + gap: 2rem; + align-items: center; + padding: 1.2rem 1.4rem; +} + +.approval-explainer h2, +.approval-filters h2 { + margin: 0.3rem 0 0; +} + +.approval-explainer > p { + max-width: 650px; + margin: 0; + color: #aab4c5; + line-height: 1.55; +} + +.approval-filters label { + min-width: 180px; + margin-left: auto; +} + +.approval-list { + display: grid; +} + +.approval-row { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr) auto; + gap: 1.2rem; + align-items: center; + padding: 1.2rem 1.4rem; + border-bottom: 1px solid #202738; +} + +.approval-row-main { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + min-width: 0; +} + +.approval-row-main h3 { + margin: 0.55rem 0 0.25rem; + color: #eef1f7; + font-size: 0.95rem; +} + +.approval-row-main code { + overflow-wrap: anywhere; + color: #9da8bb; + font-size: 0.72rem; +} + +.approval-timing { + display: grid; + flex: 0 0 auto; + gap: 0.2rem; + color: #d4dbe7; + font-size: 0.75rem; + text-align: right; +} + +.approval-timing small { + color: #a8b3c7; +} + +.approval-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.6rem; + margin: 0; +} + +.approval-summary div { + min-width: 0; + border-left: 2px solid #303a51; + padding-left: 0.65rem; +} + +.approval-summary dt { + color: #8f9bb1; + font-size: 0.62rem; + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.approval-summary dd { + overflow-wrap: anywhere; + margin: 0.25rem 0 0; + color: #d7dde8; + font-size: 0.75rem; +} + +.approval-summary small { + display: block; + margin-top: 0.2rem; + color: #929eb2; + font-size: 0.66rem; +} + +.approval-detail-heading { + display: flex; + gap: 0.8rem; + align-items: center; +} + +.approval-detail-heading code { + overflow-wrap: anywhere; + color: #d5dbe8; +} + +.approval-detail-heading p { + margin: 0.25rem 0 0; + color: #9ca7ba; + font-size: 0.76rem; +} + +.approval-arguments, +.approval-decision { + display: grid; + gap: 0.8rem; + border: 1px solid #293247; + border-radius: 12px; + padding: 1rem; + background: #0d131d; +} + +.approval-arguments h3, +.approval-confirm h3 { + margin: 0; +} + +.approval-arguments p, +.approval-decision p, +.approval-confirm p { + margin: 0; + color: #a2adbf; + font-size: 0.78rem; + line-height: 1.55; +} + +.approval-arguments pre { + max-height: 360px; +} + +.approval-decision { + border-color: #465174; + background: #111728; +} + +.approval-confirm { + display: grid; + gap: 0.8rem; +} + +@media (max-width: 820px) { + .app-frame { + display: block; + } + + .sidebar { + position: static; + display: block; + height: auto; + border-right: 0; + border-bottom: 1px solid #202738; + } + + .brand, + .instance-card { + display: none; + } + + nav { + display: flex; + overflow-x: auto; + } + + nav a { + flex: 0 0 auto; + } + + .nav-count { + margin-left: 0; + } + + .placeholder-grid, + .token-layout, + .filter-bar { + grid-template-columns: 1fr; + } + + .filter-bar .checkbox-label { + min-height: auto; + } + + .privacy-note { + align-items: flex-start; + flex-direction: column; + } + + .approval-explainer, + .approval-filters { + align-items: flex-start; + flex-direction: column; + } + + .approval-filters label { + width: 100%; + margin-left: 0; + } + + .approval-row { + grid-template-columns: 1fr; + } + + .approval-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .mode-semantics { + grid-template-columns: 1fr; + } + + .mode-semantics dl { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .import-form, + .import-fields, + .credential-row { + grid-template-columns: 1fr; + } + + .wide-field { + grid-column: auto; + } +} + +@media (max-width: 640px) { + .section-heading { + align-items: flex-start; + flex-wrap: wrap; + gap: 0.85rem; + } + + .table-scroll { + overflow: visible; + } + + table, + tbody, + tr, + td { + display: block; + width: 100%; + } + + thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + } + + tbody { + padding: 0.65rem; + } + + tr { + margin-bottom: 0.65rem; + border: 1px solid #2c3548; + border-radius: 12px; + padding: 0.55rem 0; + background: #101620; + } + + td { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + border: 0; + padding: 0.48rem 0.75rem; + white-space: normal; + overflow-wrap: anywhere; + text-align: right; + } + + td::before { + flex: 0 0 5.5rem; + color: #9da9bd; + content: attr(data-label); + font-size: 0.67rem; + font-weight: 750; + letter-spacing: 0.06em; + text-align: left; + text-transform: uppercase; + } + + td code { + min-width: 0; + overflow-wrap: anywhere; + } + + td[data-label="Behavior"] { + align-items: stretch; + flex-direction: column; + text-align: left; + } + + td[data-label="Behavior"]::before { + flex-basis: auto; + } + + .row-modes { + flex-wrap: wrap; + } +} + +@media (max-width: 520px) { + .sidebar { + padding: 0.75rem; + } + + nav { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: visible; + } + + nav a { + min-width: 0; + gap: 0.45rem; + padding: 0.65rem; + } + + nav a:last-child { + grid-column: 1 / -1; + } + + .nav-marker { + display: none; + } + + .nav-label { + overflow-wrap: anywhere; + } + + .nav-count { + min-width: 1.45rem; + } + + .page-header { + display: block; + padding-inline: 0.85rem; + } + + .admin-actions { + align-items: flex-start; + flex-direction: column; + margin-top: 1rem; + white-space: normal; + } + + .auth-heading { + align-items: flex-start; + } + + .page-content { + padding-inline: 0.75rem; + } + + .centered-page { + padding-inline: 0.75rem; + } + + .empty-state, + .scope-card, + .token-form, + .reveal-card { + padding: 1.1rem; + } + + .source-card-heading, + .source-actions { + align-items: flex-start; + flex-direction: column; + } + + .source-stats { + grid-template-columns: 1fr; + } + + .approval-summary { + grid-template-columns: 1fr; + } + + .pagination { + grid-template-columns: 1fr; + } + + .pagination a, + .pagination a:last-child { + justify-self: stretch; + justify-content: center; + } + + .mode-semantics dl { + grid-template-columns: 1fr; + } +} diff --git a/web/static/robots.txt b/web/static/robots.txt new file mode 100644 index 000000000..b6dd6670c --- /dev/null +++ b/web/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 000000000..850d095d6 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,11 @@ +import adapter from "@sveltejs/adapter-static"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ fallback: "index.html", strict: true }), + }, +}; + +export default config; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 000000000..43447105a --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 000000000..5737c41dd --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,16 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import { svelteTesting } from "@testing-library/svelte/vite"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [sveltekit(), svelteTesting()], + build: { + license: { + fileName: "THIRD_PARTY_JAVASCRIPT_LICENSES.json", + }, + }, + test: { + environment: "jsdom", + include: ["src/**/*.test.ts"], + }, +});