diff --git a/.claude/skills/onboarding/SKILL.md b/.claude/skills/onboarding/SKILL.md
index 67c7f87c8..d37ff68e7 100644
--- a/.claude/skills/onboarding/SKILL.md
+++ b/.claude/skills/onboarding/SKILL.md
@@ -134,8 +134,10 @@ user selected GitHub:
5. If GitHub sign-in is selected, set the **Callback URL** (under "Identifying and authorizing
users"): `{deployed-web-app-url}/api/auth/callback/github`
- **CRITICAL**: The origin must exactly match the Homepage URL selected above.
-6. **Repository permissions**: Contents (Read & Write), Issues (Read & Write), Pull requests (Read &
- Write), Metadata (Read-only)
+6. **Repository permissions**: Contents (Read & Write), Pull requests (Read & Write), Metadata
+ (Read-only), and Issues (Read & Write) only if the GitHub bot is enabled. Pull requests
+ permission also authorizes creating and applying labels to session-created pull requests;
+ labeling does not require Issues permission.
7. If GitHub sign-in uses email/domain admission, set **Account permissions**: Email addresses
(Read-only)
8. Create app, note **App ID**
diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml
new file mode 100644
index 000000000..1c3644645
--- /dev/null
+++ b/.github/workflows/ci-python.yml
@@ -0,0 +1,220 @@
+name: CI (Python)
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - ".github/workflows/ci-python.yml"
+ - "packages/control-plane/src/image-builds/timeouts.ts"
+ - "packages/daytona-infra/**"
+ - "packages/e2b-infra/**"
+ - "packages/modal-infra/**"
+ - "packages/sandbox-runtime/**"
+ - "packages/shared/src/types/integrations.ts"
+ - "ruff.toml"
+ - "terraform/environments/production/modal.tf"
+ - "terraform/modules/modal-app/scripts/deploy.sh"
+ - "!**/*.md"
+ pull_request:
+ branches: [main]
+ paths:
+ - ".github/workflows/ci-python.yml"
+ - "packages/control-plane/src/image-builds/timeouts.ts"
+ - "packages/daytona-infra/**"
+ - "packages/e2b-infra/**"
+ - "packages/modal-infra/**"
+ - "packages/sandbox-runtime/**"
+ - "packages/shared/src/types/integrations.ts"
+ - "ruff.toml"
+ - "terraform/environments/production/modal.tf"
+ - "terraform/modules/modal-app/scripts/deploy.sh"
+ - "!**/*.md"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lint-python-sandbox-runtime:
+ name: Lint & Format (Python - sandbox-runtime)
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ defaults:
+ run:
+ working-directory: packages/sandbox-runtime
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: "pip"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ - name: Run Ruff linter
+ run: ruff check src/ tests/
+
+ - name: Run Ruff formatter check
+ run: ruff format --check src/ tests/
+
+ lint-python:
+ name: Lint & Format (Python - provider infra)
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: "pip"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e packages/sandbox-runtime
+ pip install -e "packages/modal-infra[dev]"
+
+ - name: Run Ruff linter
+ run: ruff check packages/modal-infra/ packages/e2b-infra/ packages/daytona-infra/
+
+ - name: Run Ruff formatter check
+ run: ruff format --check packages/modal-infra/ packages/e2b-infra/ packages/daytona-infra/
+
+ typecheck-python:
+ name: TypeCheck (Python)
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ defaults:
+ run:
+ working-directory: packages/modal-infra
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: "pip"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ../sandbox-runtime
+ pip install -e ".[dev]"
+
+ - name: Run MyPy
+ run: mypy src/
+ continue-on-error: true # Allow failures initially as types are added
+
+ typecheck-python-sandbox-runtime:
+ name: TypeCheck (Python - sandbox-runtime)
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ needs: [lint-python-sandbox-runtime]
+ defaults:
+ run:
+ working-directory: packages/sandbox-runtime
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: "pip"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ - name: Run MyPy
+ run: mypy src/
+ continue-on-error: true # Allow failures initially as types are added
+
+ test-python-sandbox-runtime:
+ name: Test (Python - sandbox-runtime)
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ needs: [lint-python-sandbox-runtime]
+ defaults:
+ run:
+ working-directory: packages/sandbox-runtime
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: "pip"
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: "22"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ - name: Run tests
+ run: pytest tests/ -v
+
+ - name: Run Node.js tests
+ run: node --test tests/*.test.mjs
+
+ test-python:
+ name: Test (Python - modal-infra)
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ needs: [lint-python]
+ defaults:
+ run:
+ working-directory: packages/modal-infra
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: "pip"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ../sandbox-runtime
+ pip install -e ".[dev]"
+
+ - name: Run tests
+ run: pytest tests/ -v
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 02b513517..d9f75ac73 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,10 +1,53 @@
-name: CI
+name: CI (TypeScript)
on:
push:
branches: [main]
+ paths:
+ - ".github/workflows/ci.yml"
+ - ".prettierignore"
+ - ".prettierrc"
+ - "eslint.config.js"
+ - "knip.json"
+ - "package-lock.json"
+ - "package.json"
+ - "packages/control-plane/**"
+ - "packages/github-bot/**"
+ - "packages/linear-bot/**"
+ - "packages/opencomputer-infra/**"
+ - "packages/sandbox-runtime/**"
+ - "packages/shared/**"
+ - "packages/slack-bot/**"
+ - "packages/web/**"
+ - "scripts/**"
+ - "terraform/d1/migrations/**"
+ - "vitest.workspace.ts"
+ - "!**/*.md"
pull_request:
branches: [main]
+ paths:
+ - ".github/workflows/ci.yml"
+ - ".prettierignore"
+ - ".prettierrc"
+ - "eslint.config.js"
+ - "knip.json"
+ - "package-lock.json"
+ - "package.json"
+ - "packages/control-plane/**"
+ - "packages/github-bot/**"
+ - "packages/linear-bot/**"
+ - "packages/opencomputer-infra/**"
+ - "packages/sandbox-runtime/**"
+ - "packages/shared/**"
+ - "packages/slack-bot/**"
+ - "packages/web/**"
+ - "scripts/**"
+ - "terraform/d1/migrations/**"
+ - "vitest.workspace.ts"
+ - "!**/*.md"
+
+permissions:
+ contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -37,6 +80,9 @@ jobs:
- name: Check Prettier formatting
run: npm run format:check
+ - name: Run Knip (unused code detection)
+ run: npm run knip -- --no-exit-code
+
typecheck-typescript:
name: TypeCheck (TypeScript)
runs-on: ubuntu-latest
@@ -83,117 +129,6 @@ jobs:
- name: Build web package
run: npm run build -w @open-inspect/web
- lint-python-sandbox-runtime:
- name: Lint & Format (Python - sandbox-runtime)
- runs-on: ubuntu-latest
- timeout-minutes: 5
- defaults:
- run:
- working-directory: packages/sandbox-runtime
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Python
- uses: actions/setup-python@v6
- with:
- python-version: "3.12"
- cache: "pip"
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -e ".[dev]"
-
- - name: Run Ruff linter
- run: ruff check src/ tests/
-
- - name: Run Ruff formatter check
- run: ruff format --check src/ tests/
-
- lint-python:
- name: Lint & Format (Python - modal-infra)
- runs-on: ubuntu-latest
- timeout-minutes: 5
- defaults:
- run:
- working-directory: packages/modal-infra
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Python
- uses: actions/setup-python@v6
- with:
- python-version: "3.12"
- cache: "pip"
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -e ../sandbox-runtime
- pip install -e ".[dev]"
-
- - name: Run Ruff linter
- run: ruff check src/
-
- - name: Run Ruff formatter check
- run: ruff format --check src/
-
- typecheck-python:
- name: TypeCheck (Python)
- runs-on: ubuntu-latest
- timeout-minutes: 5
- defaults:
- run:
- working-directory: packages/modal-infra
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Python
- uses: actions/setup-python@v6
- with:
- python-version: "3.12"
- cache: "pip"
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -e ../sandbox-runtime
- pip install -e ".[dev]"
-
- - name: Run MyPy
- run: mypy src/
- continue-on-error: true # Allow failures initially as types are added
-
- typecheck-python-sandbox-runtime:
- name: TypeCheck (Python - sandbox-runtime)
- runs-on: ubuntu-latest
- timeout-minutes: 5
- needs: [lint-python-sandbox-runtime]
- defaults:
- run:
- working-directory: packages/sandbox-runtime
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Python
- uses: actions/setup-python@v6
- with:
- python-version: "3.12"
- cache: "pip"
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -e ".[dev]"
-
- - name: Run MyPy
- run: mypy src/
- continue-on-error: true # Allow failures initially as types are added
-
test-cp-unit:
name: Test (control-plane unit)
runs-on: ubuntu-latest
@@ -244,9 +179,6 @@ jobs:
- name: Build shared package
run: npm run build -w @open-inspect/shared
- - name: Install workerd runtime dependency
- run: sudo apt-get update && sudo apt-get install -y libc++1
-
- name: Run control-plane integration tests
run: npm run test:integration -w @open-inspect/control-plane -- --shard=${{ matrix.shard }}
@@ -301,56 +233,3 @@ jobs:
- name: Run linear-bot tests
run: npm test -w @open-inspect/linear-bot
-
- test-python-sandbox-runtime:
- name: Test (Python - sandbox-runtime)
- runs-on: ubuntu-latest
- timeout-minutes: 5
- needs: [lint-python-sandbox-runtime]
- defaults:
- run:
- working-directory: packages/sandbox-runtime
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Python
- uses: actions/setup-python@v6
- with:
- python-version: "3.12"
- cache: "pip"
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -e ".[dev]"
-
- - name: Run tests
- run: pytest tests/ -v
-
- test-python:
- name: Test (Python - modal-infra)
- runs-on: ubuntu-latest
- timeout-minutes: 5
- needs: [lint-python]
- defaults:
- run:
- working-directory: packages/modal-infra
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Python
- uses: actions/setup-python@v6
- with:
- python-version: "3.12"
- cache: "pip"
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -e ../sandbox-runtime
- pip install -e ".[dev]"
-
- - name: Run tests
- run: pytest tests/ -v
diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml
index dc18b839c..0caa4dfd7 100644
--- a/.github/workflows/terraform.yml
+++ b/.github/workflows/terraform.yml
@@ -98,8 +98,13 @@ jobs:
run: terraform validate -no-color
working-directory: ${{ env.TF_WORKING_DIR }}
+ - name: Terraform Tests
+ id: test
+ run: terraform test -filter=tests/auth_provider_configuration.tftest.hcl
+ working-directory: ${{ env.TF_WORKING_DIR }}
+
- name: Post Validation Results
- if: github.event_name == 'pull_request'
+ if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v8
with:
script: |
@@ -115,6 +120,7 @@ jobs:
| Format | ${{ steps.fmt.outcome == 'success' && '✅' || '⚠️' }} |
| Init | ${{ steps.init.outcome == 'success' && '✅' || '❌' }} |
| Validate | ${{ steps.validate.outcome == 'success' && '✅' || '❌' }} |
+ | Tests | ${{ steps.test.outcome == 'success' && '✅' || '❌' }} |
${planNote}
*Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
@@ -174,6 +180,20 @@ jobs:
-backend-config='endpoints={s3="https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com"}'
working-directory: ${{ env.TF_WORKING_DIR }}
+ - name: Stage SchedulerDO deletion migration
+ run: |
+ set -euo pipefail
+ # Read state and parse it as separate steps: piping straight into jq
+ # turns a failed state read into an empty tag, which would silently
+ # skip the deletion migration and ship a worker whose class was never
+ # retired. An absent or empty state is still a valid "not v2" answer.
+ state="$(terraform state pull)"
+ current_tag="$(printf '%s' "$state" | jq -r '.resources[]? | select(.module == "module.control_plane_worker" and .type == "cloudflare_worker_version" and .name == "this") | .instances[0].attributes.migration_tag // empty')"
+ if [ "$current_tag" = "v2" ]; then
+ printf '%s\n' '{"control_plane_migration_tag":"v3","control_plane_migration_old_tag":"v2","control_plane_deleted_classes":["SchedulerDO"]}' > scheduler-do-retirement.auto.tfvars.json
+ fi
+ working-directory: ${{ env.TF_WORKING_DIR }}
+
- name: Terraform Plan
id: plan
run: |
@@ -207,6 +227,7 @@ jobs:
TF_VAR_anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
TF_VAR_token_encryption_key: ${{ secrets.TOKEN_ENCRYPTION_KEY }}
TF_VAR_repo_secrets_encryption_key: ${{ secrets.REPO_SECRETS_ENCRYPTION_KEY }}
+ TF_VAR_provider_accounts_encryption_key: ${{ secrets.PROVIDER_ACCOUNTS_ENCRYPTION_KEY }}
TF_VAR_nextauth_secret: ${{ secrets.NEXTAUTH_SECRET }}
TF_VAR_modal_api_secret: ${{ secrets.MODAL_API_SECRET }}
TF_VAR_allowed_users: ${{ secrets.ALLOWED_USERS }}
@@ -218,7 +239,6 @@ jobs:
TF_VAR_github_webhook_secret: ${{ secrets.GH_WEBHOOK_SECRET }}
TF_VAR_github_bot_username: ${{ secrets.GH_BOT_USERNAME }}
TF_VAR_app_name: "${{ secrets.APP_NAME || 'Open-Inspect' }}"
- TF_VAR_app_short_name: ${{ secrets.APP_SHORT_NAME }}
TF_VAR_app_icon_url: ${{ secrets.APP_ICON_URL }}
TF_VAR_enable_linear_bot: "${{ secrets.ENABLE_LINEAR_BOT || 'false' }}"
TF_VAR_linear_client_id: ${{ secrets.LINEAR_CLIENT_ID }}
@@ -250,6 +270,8 @@ jobs:
TF_VAR_e2b_api_url: "${{ secrets.E2B_API_URL || 'https://api.e2b.app' }}"
TF_VAR_e2b_sandbox_timeout_seconds: "${{ secrets.E2B_SANDBOX_TIMEOUT_SECONDS || '7200' }}"
TF_VAR_e2b_auto_pause: "${{ secrets.E2B_AUTO_PAUSE || 'true' }}"
+ TF_VAR_e2b_template_cpu: "${{ secrets.E2B_TEMPLATE_CPU || '2' }}"
+ TF_VAR_e2b_template_memory_mb: "${{ secrets.E2B_TEMPLATE_MEMORY_MB || '4096' }}"
- name: Post Plan Results
uses: actions/github-script@v8
@@ -335,6 +357,20 @@ jobs:
-backend-config='endpoints={s3="https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com"}'
working-directory: ${{ env.TF_WORKING_DIR }}
+ - name: Stage SchedulerDO deletion migration
+ run: |
+ set -euo pipefail
+ # Read state and parse it as separate steps: piping straight into jq
+ # turns a failed state read into an empty tag, which would silently
+ # skip the deletion migration and ship a worker whose class was never
+ # retired. An absent or empty state is still a valid "not v2" answer.
+ state="$(terraform state pull)"
+ current_tag="$(printf '%s' "$state" | jq -r '.resources[]? | select(.module == "module.control_plane_worker" and .type == "cloudflare_worker_version" and .name == "this") | .instances[0].attributes.migration_tag // empty')"
+ if [ "$current_tag" = "v2" ]; then
+ printf '%s\n' '{"control_plane_migration_tag":"v3","control_plane_migration_old_tag":"v2","control_plane_deleted_classes":["SchedulerDO"]}' > scheduler-do-retirement.auto.tfvars.json
+ fi
+ working-directory: ${{ env.TF_WORKING_DIR }}
+
- name: Terraform Apply
run: terraform apply -auto-approve
working-directory: ${{ env.TF_WORKING_DIR }}
@@ -362,6 +398,7 @@ jobs:
TF_VAR_anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
TF_VAR_token_encryption_key: ${{ secrets.TOKEN_ENCRYPTION_KEY }}
TF_VAR_repo_secrets_encryption_key: ${{ secrets.REPO_SECRETS_ENCRYPTION_KEY }}
+ TF_VAR_provider_accounts_encryption_key: ${{ secrets.PROVIDER_ACCOUNTS_ENCRYPTION_KEY }}
TF_VAR_nextauth_secret: ${{ secrets.NEXTAUTH_SECRET }}
TF_VAR_modal_api_secret: ${{ secrets.MODAL_API_SECRET }}
TF_VAR_allowed_users: ${{ secrets.ALLOWED_USERS }}
@@ -373,7 +410,6 @@ jobs:
TF_VAR_github_webhook_secret: ${{ secrets.GH_WEBHOOK_SECRET }}
TF_VAR_github_bot_username: ${{ secrets.GH_BOT_USERNAME }}
TF_VAR_app_name: "${{ secrets.APP_NAME || 'Open-Inspect' }}"
- TF_VAR_app_short_name: ${{ secrets.APP_SHORT_NAME }}
TF_VAR_app_icon_url: ${{ secrets.APP_ICON_URL }}
TF_VAR_enable_linear_bot: "${{ secrets.ENABLE_LINEAR_BOT || 'false' }}"
TF_VAR_linear_client_id: ${{ secrets.LINEAR_CLIENT_ID }}
@@ -405,6 +441,8 @@ jobs:
TF_VAR_e2b_api_url: "${{ secrets.E2B_API_URL || 'https://api.e2b.app' }}"
TF_VAR_e2b_sandbox_timeout_seconds: "${{ secrets.E2B_SANDBOX_TIMEOUT_SECONDS || '7200' }}"
TF_VAR_e2b_auto_pause: "${{ secrets.E2B_AUTO_PAUSE || 'true' }}"
+ TF_VAR_e2b_template_cpu: "${{ secrets.E2B_TEMPLATE_CPU || '2' }}"
+ TF_VAR_e2b_template_memory_mb: "${{ secrets.E2B_TEMPLATE_MEMORY_MB || '4096' }}"
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80139ad7f..3a5b0e317 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,156 @@
New features, integrations, and notable improvements to Open-Inspect — newest first.
+## August 22, 2026
+
+**Unified model and reasoning selection.** New-session and follow-up composers now combine model and
+reasoning-effort selection in one responsive control, with nested desktop menus and an in-place
+mobile drill-down.
+
+## August 21, 2026
+
+**Configurable keyboard shortcuts.** Set per-user shortcuts for sending prompts, opening search or a
+new session, and toggling the sidebar. Settings can record, validate, reset, and persist bindings.
+
+**Recent automation activity.** Automation rows now show the latest execution outcomes at a glance,
+alongside clearer compact schedules, responsive mobile actions, and confirmation before deletion.
+
+**OpenCode Zen GLM 5.2.** Adds `opencode/glm-5.2` to the opt-in OpenCode Zen catalog.
+
+## August 20, 2026
+
+**Managed provider accounts.** Connect and manage multiple ChatGPT and SuperGrok subscription
+accounts from Settings using device authorization, choose accounts for sessions and automations, and
+configure defaults for unattended runs. These installation-wide accounts and defaults are available
+to every admitted user, while sandboxes receive short-lived access without exposing stored refresh
+credentials.
+
+**Prebuilt images for E2B.** E2B can now build, snapshot, reuse, and delete repository and
+environment images, with reliable startup from prebuilt snapshots and configurable template CPU and
+memory.
+
+**Actionable sandbox failures.** Session status now shows the provider's startup or recovery error
+when available and preserves it across reloads, while clearing stale details when a retry begins.
+
+**Automatic retirement of stale sandbox snapshots.** Session snapshots now record their runtime
+version and restore only when compatible. Incompatible or unknown snapshots trigger a fresh sandbox
+instead of repeatedly reviving a broken runtime, which may discard uncommitted filesystem state.
+
+## August 16, 2026
+
+**Session attention inbox.** The sidebar now groups session trees into Needs attention, In progress,
+and Recent, prioritizing unread terminal outcomes. Each section has independent pagination and retry
+behavior while preserving parent and child-session grouping.
+
+**Managed skill autocomplete.** Type `/skill-name` or `$skill-name` in new-session and follow-up
+prompts to search applicable skills, with keyboard and pointer selection. Existing sessions suggest
+from their pinned skill manifest so completions stay reproducible.
+
+## August 15, 2026
+
+**Managed skills.** Create and edit reusable Agent Skills in Settings, assign them globally or to
+specific repositories and environments, and organize personal profiles. When starting a session,
+choose all applicable skills, none, or a profile; Open-Inspect pins and securely installs the exact
+revisions before the agent starts, while the session sidebar records each skill's revision and
+assignment source so existing sessions stay reproducible as the shared catalog changes.
+
+**Multiple pull requests per session.** The `create-pull-request` tool now opens one PR per head
+branch instead of one per repository: agents can create stacked PRs (each level passing the previous
+branch as `baseBranch`), open a fresh PR after the previous one merges, and calling the tool again
+from the same branch updates that branch's open PR with the latest commits instead of failing. Every
+PR is tracked with full lifecycle state and listed in the session sidebar with its live status, and
+stale artifacts heal when the provider reports a PR already merged. Sessions holding several PRs get
+a Pull requests sidebar section with one sync control for all of them, and the View PR action
+becomes a picker naming each PR by number and head branch.
+
+**Claude Sonnet 5 and Grok 4.6.** Adds `anthropic/claude-sonnet-5` to the model picker and
+integrations with adaptive thinking and reasoning efforts from low through max, and `xai/grok-4.6`
+to the opt-in xAI / SuperGrok catalog with low, medium, and high efforts.
+
+**Kimi K3 and GLM 5.3.** Adds `opencode/kimi-k3` to the opt-in OpenCode Zen catalog and
+`zai-coding-plan/glm-5.3` to the opt-in Z.AI Coding Plan catalog.
+
+**Cancel queued prompts.** Pending web prompts can now be removed before they start processing,
+freeing queue capacity and restoring the removed text to an empty composer after server
+confirmation.
+
+## August 14, 2026
+
+**OpenCode runtime upgraded to 1.18.18.** Newly built images across all sandbox providers now use a
+release that fixes a message-ID wraparound which prevented older sessions from recognizing new
+prompts after August 14. Sessions restored from pre-upgrade snapshots retain their existing runtime.
+
+**Automatic abandoned-draft cleanup.** Warmed sessions that never receive a prompt are archived
+after an eight-hour grace period, while sessions that have started work or contain messages or
+queued prompts are left untouched.
+
+**More adaptable session controls.** Desktop users can hide the session details sidebar, with the
+preference preserved across visits. Mobile headers now show separate connection and sandbox states,
+including lifecycle details and provider-dashboard access when available.
+
+**Search and paginate automations.** The automation list now supports URL-backed name search and
+stable cursor pagination, with responsive rows and distinct empty, no-results, and error states.
+
+## August 13, 2026
+
+**Cleaner, more responsive session timelines.** Completed turns collapse intermediate activity
+behind a “Worked for” disclosure while keeping the prompt and final response visible. Long text and
+expanded tool details now wrap or scroll within mobile layouts without widening the page.
+
+## August 12, 2026
+
+**Queue web follow-up prompts.** Submit up to ten follow-ups while a session is processing, with
+durable FIFO ordering, state synchronized across tabs and reloads, and idempotent retries that avoid
+duplicate work.
+
+**Correct child-session attribution.** Child sessions and parent-to-child follow-ups now use the
+active prompt author's identity and SCM credentials instead of always inheriting the parent session
+owner, including prompts started from bot integrations.
+
+## August 11, 2026
+
+**Clear Slack speaker attribution.** Slack-started sessions and follow-ups now label the current
+message with the sender's display name and stable Slack ID, preserving who gave an instruction in
+multi-person threads and across repository clarification.
+
+## August 9, 2026
+
+**Browser-based sandbox desktops.** Opt in to a full VNC desktop for sessions, available from the
+session sidebar through authenticated noVNC access. Desktop settings can be configured globally or
+overridden per environment and repository, and work across supported sandbox providers.
+
+**Follow up with child sessions.** Parent agents can queue additional instructions for direct child
+sessions with `send-child-prompt`, including resuming completed or failed children while preserving
+lineage, ownership, concurrency, and cancellation safeguards.
+
+## August 8, 2026
+
+**Context-aware Slack channel automations.** Runs triggered from Slack threads can now include the
+root and recent earlier replies, with bounded, safely attributed context. Text-bearing file-share
+messages can trigger runs too, while history failures fall back without blocking the automation.
+
+**Clearer, more resilient session timelines.** Session pages now server-render from a canonical
+snapshot, keep existing content visible through WebSocket reconnects, and show when OpenCode
+compacts context to continue a long-running session.
+
+## August 7, 2026
+
+**Labels for session-created pull requests.** Configure a label for pull and merge requests, with a
+global default and per-repository overrides. The policy applies to the actual target repository in
+multi-repo sessions, supports GitHub and GitLab, and creates missing GitHub labels when permitted.
+
+## August 5, 2026
+
+**Draft pull request policy.** Configure session-created pull requests to open as drafts by default,
+globally or per repository. The policy applies to the actual target repository in multi-repo
+sessions and works across GitHub and GitLab deployments.
+
+## August 3, 2026
+
+**Unread session outcomes.** Per-user unread indicators now highlight sessions and child sessions
+with new terminal results. Viewing meaningful output in an active tab marks it read automatically,
+with an explicit Mark as read action also available.
+
## August 1, 2026
**Grok models with your SuperGrok subscription.** Use Grok 4.5 or Grok Build 0.1 through managed xAI
@@ -468,8 +618,8 @@ from a dedicated settings page, and have them injected into the sandbox environm
**Structured JSON logging.** Wide events and correlation IDs across the control plane, Modal
infrastructure, and Slack bot.
-**D1 migration system.** A proper migration system replaces the single `schema.sql`, with a
-standalone script to migrate the session index and repository metadata from KV to D1.
+**D1 migration system.** A proper migration system replaces the single `schema.sql`, with session
+index and repository metadata storage moved from KV to D1.
_Also:_ `.openinspect/setup.sh` repository setup support, an archived-chats section in settings, and
bridge-timeout hardening with an inactivity-based SSE timeout.
diff --git a/README.md b/README.md
index 16fed10e6..91c5863eb 100644
--- a/README.md
+++ b/README.md
@@ -139,6 +139,9 @@ To understand the architecture and core concepts, read
To set up recurring scheduled tasks, see **[docs/AUTOMATIONS.md](docs/AUTOMATIONS.md)**.
+To create and use reusable agent instructions, see
+**[docs/MANAGED_SKILLS.md](docs/MANAGED_SKILLS.md)**.
+
## Key Features
### Fast Startup
@@ -163,6 +166,15 @@ One session can work across several repositories in a single sandbox:
- See [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md#environments) for the model and
[docs/IMAGE_PREBUILD.md](docs/IMAGE_PREBUILD.md) for environment prebuilds
+### Managed Skills
+
+Create reusable instructions and supporting files that agents receive when a session starts:
+
+- Assign shared skills globally or to selected repositories and environments
+- Save personal profiles for frequently used skill sets
+- Pin exact skill revisions to each session for repeatable behavior
+- See [docs/MANAGED_SKILLS.md](docs/MANAGED_SKILLS.md) for the user guide
+
### Multiplayer Sessions
Multiple users can collaborate in the same session:
@@ -187,13 +199,13 @@ await configureGitIdentity({
Choose the AI model that fits your task, with per-session reasoning effort controls:
-| Provider | Models |
-| ---------------- | ----------------------------------------------------------------- |
-| Anthropic | Claude Haiku 4.5, Sonnet 4.5/4.6, Opus 4.5/4.6/4.7/4.8/5, Fable 5 |
-| OpenAI | GPT 5.4, GPT 5.5, 5.3 Codex, 5.3 Codex Spark |
-| xAI / SuperGrok | Grok models (opt-in) |
-| OpenCode Zen | Kimi K2.5/K2.6, MiniMax M2.5, Qwen3.7 Max, GLM 5/5.1 (opt-in) |
-| Z.AI Coding Plan | GLM 5.2 (opt-in) |
+| Provider | Models |
+| ---------------- | -------------------------------------------------------------------- |
+| Anthropic | Claude Haiku 4.5, Sonnet 4.5/4.6/5, Opus 4.5/4.6/4.7/4.8/5, Fable 5 |
+| OpenAI | GPT 5.4, GPT 5.5, 5.3 Codex, 5.3 Codex Spark |
+| xAI / SuperGrok | Grok models (opt-in) |
+| OpenCode Zen | Kimi K2.5/K2.6/K3, MiniMax M2.5, Qwen3.7 Max, GLM 5/5.1/5.2 (opt-in) |
+| Z.AI Coding Plan | GLM 5.2/5.3 (opt-in) |
OpenAI models work with your existing ChatGPT subscription via OAuth — no separate API key needed.
Grok models work with an eligible SuperGrok subscription through control-plane-managed OAuth. See
@@ -250,6 +262,7 @@ Agents can decompose work into parallel child sessions:
- `spawn-child` creates a child session in its own sandbox and returns immediately
- Parent continues working while children run in parallel on separate branches
+- `send-child-prompt` queues follow-up instructions in an existing direct child session
- `get-child-status` and `cancel-child` coordinate child sessions
- Depth limits and per-repo guardrails enforced
diff --git a/docs/AUTOMATIONS.md b/docs/AUTOMATIONS.md
index 5c0865c29..3334dbf1d 100644
--- a/docs/AUTOMATIONS.md
+++ b/docs/AUTOMATIONS.md
@@ -231,9 +231,17 @@ ingested once the flag is on.
A Slack automation must define at least a **Slack Channel** condition; the rest are optional
filters.
-Slack Message automations ingest text only. Slack file uploads have a `file_share` subtype and are
-ignored, so image-only messages do not trigger a run and attachments are not added to prompts. Use
-an interactive bot DM or `@mention` for image input.
+Slack Message automations ingest text only. A message posted with an attachment (a `file_share`
+message) is matched on its text like any other, but the attachment is not added to the prompt, so an
+image-only message with no text triggers nothing. Use an interactive bot DM or `@mention` for image
+input.
+
+When the triggering message is a reply, the thread it belongs to is passed to the agent alongside it
+— up to 20 earlier messages total, preserving the thread's opening message alongside the most recent
+replies — so a reply is read in context instead of on its own. The thread is fetched only after a
+run is admitted, so unmatched messages, follow-ups that steer an existing session, and skipped or
+duplicate firings cost nothing. Conditions still match against the triggering message's text only,
+never the thread.
- **Slack Channel** (required) — the channels to watch. Pick channels by name in the web form;
channel IDs (for example `C0123ABCD`) also work as a fallback when channel listing is unavailable.
diff --git a/docs/AVAILABLE_MODELS.md b/docs/AVAILABLE_MODELS.md
index 8ff2ef191..cf9d975cc 100644
--- a/docs/AVAILABLE_MODELS.md
+++ b/docs/AVAILABLE_MODELS.md
@@ -2,9 +2,14 @@
Open-Inspect exposes these models in the model picker and integration preferences. The default
enabled set includes Anthropic and OpenAI models. xAI / SuperGrok, OpenCode Zen, Z.AI Coding Plan,
-and DeepSeek models are available but must be enabled in **Settings > Models**. SuperGrok requires
-managed xAI OAuth credentials; Z.AI Coding Plan requires `ZHIPU_API_KEY`; DeepSeek requires
-`DEEPSEEK_API_KEY`.
+and DeepSeek models are available but must be enabled in **Settings > Models**. OpenAI and SuperGrok
+subscriptions are configured in **Settings > Provider Accounts**; Z.AI Coding Plan requires
+`ZHIPU_API_KEY`; DeepSeek requires `DEEPSEEK_API_KEY`.
+
+OpenAI and xAI session selectors offer provider policy, any active connected account, and API-key
+mode. Automation editors can resolve defaults on each run or pin an account/API-key choice.
+Unattended Slack, GitHub, Linear, and unpinned automation launches follow the provider's configured
+unattended mode.
## Anthropic
@@ -12,7 +17,8 @@ managed xAI OAuth credentials; Z.AI Coding Plan requires `ZHIPU_API_KEY`; DeepSe
| ----------------------------- | ----------------- | ---------------------------------- | ----------------------------- | -------------- |
| `anthropic/claude-haiku-4-5` | Claude Haiku 4.5 | Fast and efficient | high, max | max |
| `anthropic/claude-sonnet-4-5` | Claude Sonnet 4.5 | Balanced performance | high, max | max |
-| `anthropic/claude-sonnet-4-6` | Claude Sonnet 4.6 | Latest balanced, fast coding | low, medium, high, max | high |
+| `anthropic/claude-sonnet-4-6` | Claude Sonnet 4.6 | Balanced, fast coding | low, medium, high, max | high |
+| `anthropic/claude-sonnet-5` | Claude Sonnet 5 | Latest Sonnet, adaptive thinking | low, medium, high, xhigh, max | high |
| `anthropic/claude-opus-4-5` | Claude Opus 4.5 | Most capable | high, max | max |
| `anthropic/claude-opus-4-6` | Claude Opus 4.6 | Most capable, adaptive thinking | low, medium, high, max | high |
| `anthropic/claude-opus-4-7` | Claude Opus 4.7 | Most capable, adaptive thinking | low, medium, high, xhigh, max | high |
@@ -22,8 +28,8 @@ managed xAI OAuth credentials; Z.AI Coding Plan requires `ZHIPU_API_KEY`; DeepSe
## OpenAI
-OpenAI models require ChatGPT OAuth credentials. See [Using OpenAI Models](OPENAI_MODELS.md) for
-setup instructions.
+OpenAI models support connected ChatGPT provider accounts or `OPENAI_API_KEY` mode. See
+[Using OpenAI Models](OPENAI_MODELS.md) for account setup and coexistence details.
| Model ID | Display name | Description | Reasoning efforts | Default effort |
| ---------------------------- | ------------------- | -------------------------------------------- | ------------------------------ | -------------- |
@@ -37,12 +43,14 @@ setup instructions.
## xAI / SuperGrok
-Grok models require a SuperGrok OAuth refresh token and are disabled by default. See
-[Using Grok with a SuperGrok Subscription](GROK_MODELS.md) for setup and rollout instructions.
+Grok models support connected SuperGrok provider accounts or `XAI_API_KEY` mode and are disabled by
+default. See [Using Grok with a SuperGrok Subscription](GROK_MODELS.md) for setup and rollout
+instructions.
| Model ID | Display name | Description | Reasoning efforts | Default effort |
| -------------------- | -------------- | ----------------------------------------------- | ----------------- | -------------- |
-| `xai/grok-4.5` | Grok 4.5 | Latest Grok for chat, coding, and agentic tools | low, medium, high | high |
+| `xai/grok-4.5` | Grok 4.5 | Grok for chat, coding, and agentic tools | low, medium, high | high |
+| `xai/grok-4.6` | Grok 4.6 | Latest Grok for chat, coding, and agentic tools | low, medium, high | high |
| `xai/grok-build-0.1` | Grok Build 0.1 | Coding model for SuperGrok subscribers | Not configurable | N/A |
## OpenCode Zen
@@ -51,10 +59,12 @@ Grok models require a SuperGrok OAuth refresh token and are disabled by default.
| ----------------------- | ------------ | ------------- | ----------------- | -------------- |
| `opencode/kimi-k2.5` | Kimi K2.5 | Moonshot AI | Not supported | N/A |
| `opencode/kimi-k2.6` | Kimi K2.6 | Moonshot AI | Not supported | N/A |
+| `opencode/kimi-k3` | Kimi K3 | Moonshot AI | Not supported | N/A |
| `opencode/minimax-m2.5` | MiniMax M2.5 | MiniMax | Not supported | N/A |
| `opencode/qwen3.7-max` | Qwen3.7 Max | Alibaba Cloud | Not supported | N/A |
| `opencode/glm-5` | GLM 5 | Z.ai 744B MoE | Not supported | N/A |
| `opencode/glm-5.1` | GLM 5.1 | Z.ai | Not supported | N/A |
+| `opencode/glm-5.2` | GLM 5.2 | Z.ai | Not supported | N/A |
## Z.AI Coding Plan
@@ -63,6 +73,7 @@ Z.AI Coding Plan models require `ZHIPU_API_KEY` as a global or repository secret
| Model ID | Display name | Description | Reasoning efforts | Default effort |
| ------------------------- | ------------ | ---------------- | ----------------- | -------------- |
| `zai-coding-plan/glm-5.2` | GLM 5.2 | Z.AI Coding Plan | Not supported | N/A |
+| `zai-coding-plan/glm-5.3` | GLM 5.3 | Z.AI Coding Plan | Not supported | N/A |
## DeepSeek
diff --git a/docs/E2B_SANDBOX_PROVIDER.md b/docs/E2B_SANDBOX_PROVIDER.md
index 2d4219133..d85db03ce 100644
--- a/docs/E2B_SANDBOX_PROVIDER.md
+++ b/docs/E2B_SANDBOX_PROVIDER.md
@@ -25,6 +25,8 @@ e2b_template_id = "open-inspect-sandbox" # template name to build/use
# e2b_api_url = "https://api.e2b.app" # REST API base URL
# e2b_sandbox_timeout_seconds = 7200 # sandbox TTL (default 2h)
# e2b_auto_pause = true # pause (recoverable), not kill, on TTL lapse
+# e2b_template_cpu = 2 # template vCPU count
+# e2b_template_memory_mb = 4096 # template memory (MB, even number)
```
For GitHub Actions-based deployment, configure the matching repository secrets:
@@ -36,6 +38,8 @@ E2B_TEMPLATE_ID
E2B_API_URL # optional
E2B_SANDBOX_TIMEOUT_SECONDS # optional
E2B_AUTO_PAUSE # optional
+E2B_TEMPLATE_CPU # optional
+E2B_TEMPLATE_MEMORY_MB # optional
```
The E2B provider also needs the normal Open-Inspect values such as Cloudflare, GitHub App,
@@ -81,22 +85,31 @@ export E2B_TEMPLATE_ID=open-inspect-sandbox
uv run python build-template.py
```
-Optional build knobs: `E2B_TEMPLATE_CPU` (default `2`), `E2B_TEMPLATE_MEM` MB (default `1024`) —
-these apply to **manual** builds; Terraform-managed templates use the module's fixed defaults of **2
-vCPU / 1024 MB**. See [`packages/e2b-infra/README.md`](../packages/e2b-infra/README.md) for details
-on the template tooling and the launcher.
+Optional build knobs: `E2B_TEMPLATE_CPU` (default `2`), `E2B_TEMPLATE_MEMORY_MB` (default `4096`) —
+these apply to **manual** builds; Terraform-managed templates are sized by the `e2b_template_cpu` /
+`e2b_template_memory_mb` variables (same defaults). See
+[`packages/e2b-infra/README.md`](../packages/e2b-infra/README.md) for details on the template
+tooling.
## Runtime Behavior
-The E2B provider creates fresh sandboxes from the configured template. E2B runs the template's start
-command once at build and resumes it per create, so it never sees per-session env. The launcher
-(`oi-launch`) works around this:
+The E2B provider creates fresh sandboxes from the configured template, delivering env and starting
+the runtime the same way Open-Inspect does on every other provider:
-1. waits for the control plane to drop the per-session env file (`/tmp/oi-session.env`) over envd
-2. `exec`s the supervisor (`python -m sandbox_runtime.entrypoint`) with that env
+1. the per-sandbox env — `CONTROL_PLANE_URL`, `SESSION_CONFIG`, the sandbox auth token, user secrets
+ — is passed as create-time `envVars` on `POST /sandboxes`; envd applies it to every process it
+ starts
+2. the control plane starts the supervisor (`python -m sandbox_runtime.entrypoint`) via envd,
+ detached, with stdout/stderr in `/tmp/oi-supervisor.log`; the template itself runs nothing (its
+ start command is inert, and a prebuilt image's snapshot resume never re-runs it anyway)
3. the supervisor clones or syncs the selected repositories, starts OpenCode and code-server, and
connects the Open-Inspect bridge back to the control plane
-4. agent events stream back through the control plane
+4. agent events stream back through the control plane; readiness is the bridge phoning home, and the
+ shared connecting timeout fails the session otherwise
+
+Prebuilt repo images boot identically — the image (a snapshot template baked by the image-build
+workflow after running `.openinspect/setup.sh` once) is purely a filesystem; the entrypoint is
+started fresh on every spawn, mirroring how Modal reboots a repo image's entrypoint.
## Lifecycle: Pause and Resume
@@ -111,7 +124,7 @@ therefore drives the lifecycle through the shared lifecycle manager, treating E2
- The next prompt **resumes** the paused sandbox in place (workspace state preserved); if E2B has
since dropped it, the control plane spawns a fresh sandbox.
- Only sandboxes that fail before becoming usable — a spawn that never connects, or one whose
- session-env write fails — are **killed**, to avoid orphaning them.
+ entrypoint could not be started — are **killed**, to avoid orphaning them.
Paused E2B sandboxes are not billed and are retained indefinitely, so pausing is the default
recoverable stop. `E2B_AUTO_PAUSE` controls the **TTL action** (pause vs kill when the timeout
@@ -154,8 +167,18 @@ After `terraform apply`, verify:
tell me about this repository
```
-If a session starts but never produces agent output, check the control-plane Worker logs and the E2B
-sandbox logs for runtime startup, bridge connection, and OpenCode health events.
+If a session starts but never produces agent output, check the control-plane Worker logs for runtime
+startup, bridge connection, and OpenCode health events. E2B's platform logs never contain process
+output (envd reports byte counts only); the in-sandbox forensics file is `/tmp/oi-supervisor.log`
+(reachable via the session's code-server terminal while the sandbox is alive).
+
+## Upgrading from the launcher-based template
+
+Earlier versions delivered session env as a file (`/tmp/oi-session.env`) consumed by a launcher
+baked into the template (`oi-launch`). One `terraform apply` upgrades in place: the control plane
+deploys first and boots every sandbox by direct exec, then the template rebuild removes the
+launcher. **Existing prebuilt images keep working without a rebuild** — their baked launcher is
+simply never fed and never runs; the runtime they bundle boots by direct exec like everything else.
## Common Issues
diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md
index 15384d677..ab06e0ae6 100644
--- a/docs/GETTING_STARTED.md
+++ b/docs/GETTING_STARTED.md
@@ -309,7 +309,8 @@ GitHub OAuth sign-in, but its client pair is optional when Google is the only si
5. Set **Repository permissions**:
- Contents: **Read & Write**
- Issues: **Read & Write** _(required if enabling GitHub bot)_
- - Pull requests: **Read & Write**
+ - Pull requests: **Read & Write** _(also authorizes creating and applying labels to
+ session-created pull requests)_
- Metadata: **Read-only**
6. If using `ALLOWED_GITHUB_ORGS`/`allowed_github_orgs`, set **Organization permissions**:
- Members: **Read-only**
@@ -565,8 +566,9 @@ anthropic_api_key = "sk-ant-..."
# Security Secrets (from Step 5)
token_encryption_key = "your-generated-value"
repo_secrets_encryption_key = "your-generated-value"
-modal_api_secret = "your-generated-value"
-nextauth_secret = "your-generated-value"
+# provider_accounts_encryption_key = "existing-key" # Optional override; Terraform generates one
+modal_api_secret = "your-generated-value"
+nextauth_secret = "your-generated-value"
# Configuration
# IMPORTANT: deployment_name must be globally unique for Vercel URLs
@@ -579,7 +581,6 @@ project_root = "../../../"
# messages (Slack/Linear), PR body footer, and outbound HTTP User-Agent.
# app_name = "Open-Inspect"
# Short brand label shown only in the sidebar header.
-# app_short_name = "Inspect"
# Optional URL (absolute or root-relative) to a custom logo/favicon override.
# Leave empty to keep the built-in favicon and default in-app icon.
# app_icon_url = ""
@@ -922,66 +923,68 @@ Enable automatic deployments when you push to main by adding GitHub Secrets.
Go to your fork's Settings → Secrets and variables → Actions, and add:
-| Secret Name | Value |
-| -------------------------------- | ------------------------------------------------------------------------------------------- |
-| `CLOUDFLARE_API_TOKEN` | Your Cloudflare API token |
-| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID |
-| `CLOUDFLARE_WORKER_SUBDOMAIN` | Your workers.dev subdomain |
-| `DEPLOYMENT_NAME` | Your deployment name |
-| `R2_ACCESS_KEY_ID` | R2 access key ID |
-| `R2_SECRET_ACCESS_KEY` | R2 secret access key |
-| `WEB_PLATFORM` | `vercel` or `cloudflare` |
-| `VERCEL_API_TOKEN` | Vercel API token _(only if `web_platform = "vercel"`)_ |
-| `VERCEL_TEAM_ID` | Vercel team/account ID _(only if `web_platform = "vercel"`)_ |
-| `VERCEL_PROJECT_ID` | Vercel project ID _(only if `web_platform = "vercel"`)_ |
-| `MODAL_TOKEN_ID` | Modal token ID |
-| `MODAL_TOKEN_SECRET` | Modal token secret |
-| `MODAL_WORKSPACE` | Modal workspace name |
-| `MODAL_ENVIRONMENT` | Modal environment name (defaults to `main`) |
-| `MODAL_ENVIRONMENT_WEB_SUFFIX` | Modal environment web suffix for endpoint URLs; lowercase letters, digits, dashes, or empty |
-| `SANDBOX_PROVIDER` | `modal`, `daytona`, or `vercel` |
-| `DAYTONA_API_URL` | Daytona API URL _(only if `sandbox_provider = "daytona"`)_ |
-| `DAYTONA_API_KEY` | Daytona API key _(only if `sandbox_provider = "daytona"`)_ |
-| `DAYTONA_BASE_SNAPSHOT` | Daytona base snapshot name _(only if `sandbox_provider = "daytona"`)_ |
-| `DAYTONA_TARGET` | Optional Daytona target name |
-| `VERCEL_SANDBOX_TOKEN` | Vercel API token _(only if `sandbox_provider = "vercel"`)_ |
-| `VERCEL_SANDBOX_PROJECT_ID` | Vercel project ID for sandbox sessions _(only if `sandbox_provider = "vercel"`)_ |
-| `VERCEL_SANDBOX_TEAM_ID` | Optional Vercel team/account ID for sandbox sessions |
-| `VERCEL_BASE_SNAPSHOT_ID` | Optional manual Vercel base-runtime snapshot; skips Terraform-managed snapshot builds |
-| `VERCEL_SANDBOX_RUNTIME` | Optional Vercel Sandbox runtime (defaults to `node24`) |
-| `VERCEL_SNAPSHOT_EXPIRATION_MS` | Optional Vercel runtime snapshot expiration in milliseconds (`0` means no expiration) |
-| `VERCEL_SANDBOX_API_BASE_URL` | Optional advanced Vercel Sandbox API base URL override |
-| `GH_OAUTH_CLIENT_ID` | Optional GitHub sign-in client ID; set with `GH_OAUTH_CLIENT_SECRET` |
-| `GH_OAUTH_CLIENT_SECRET` | Optional GitHub sign-in client secret; set with `GH_OAUTH_CLIENT_ID` |
-| `GOOGLE_CLIENT_ID` | Optional Google sign-in client ID; set with `GOOGLE_CLIENT_SECRET` |
-| `GOOGLE_CLIENT_SECRET` | Optional Google sign-in client secret; set with `GOOGLE_CLIENT_ID` |
-| `GH_APP_ID` | Required GitHub App repository-access ID |
-| `GH_APP_PRIVATE_KEY` | Required GitHub App repository-access private key (PKCS#8 format) |
-| `GH_APP_INSTALLATION_ID` | Required GitHub App repository-access installation ID |
-| `ENABLE_SLACK_BOT` | `true` to deploy Slack bot, `false` to skip (default: `true`) |
-| `SLACK_BOT_TOKEN` | Slack bot token (required if enabled) |
-| `SLACK_SIGNING_SECRET` | Slack signing secret (required if enabled) |
-| `ENABLE_LINEAR_BOT` | `true` to deploy Linear bot, `false` to skip (default: `false`) |
-| `LINEAR_CLIENT_ID` | Linear OAuth application client ID (required if Linear enabled) |
-| `LINEAR_CLIENT_SECRET` | Linear OAuth application client secret (required if Linear enabled) |
-| `LINEAR_WEBHOOK_SECRET` | Linear webhook signing secret (required if Linear enabled) |
-| `ANTHROPIC_API_KEY` | Anthropic API key |
-| `DEEPSEEK_API_KEY` | DeepSeek API key (optional, required only for DeepSeek models) |
-| `TOKEN_ENCRYPTION_KEY` | Generated encryption key (OAuth tokens) |
-| `REPO_SECRETS_ENCRYPTION_KEY` | Generated encryption key (repo secrets) |
-| `MODAL_API_SECRET` | Generated Modal API secret |
-| `NEXTAUTH_SECRET` | Generated browser-auth secret (legacy Actions secret name) |
-| `ALLOWED_USERS` | Comma-separated GitHub usernames (or empty for all users) |
-| `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (or empty for all domains) |
-| `ALLOWED_EMAILS` | Comma-separated exact email addresses (for individual users on shared domains) |
-| `ALLOWED_GITHUB_ORGS` | Comma-separated GitHub orgs whose active members can sign in |
-| `ENABLE_DURABLE_OBJECT_BINDINGS` | Optional Terraform CI flag for Durable Object phase 1 (defaults to `true`) |
-| `ENABLE_GITHUB_BOT` | `true` to deploy GitHub bot worker (or empty to skip) |
-| `GH_WEBHOOK_SECRET` | GitHub webhook secret (required if GitHub bot enabled) |
-| `GH_BOT_USERNAME` | GitHub App bot username, e.g., `my-app[bot]` (required if GitHub bot enabled) |
-| `APP_NAME` | Optional display name for whitelabeling (default: `Open-Inspect`) |
-| `APP_SHORT_NAME` | Optional short label for sidebar header (default: `Inspect`) |
-| `APP_ICON_URL` | Optional URL to a custom logo/favicon (default: built-in icon) |
+| Secret Name | Value |
+| ---------------------------------- | ------------------------------------------------------------------------------------------- |
+| `CLOUDFLARE_API_TOKEN` | Your Cloudflare API token |
+| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID |
+| `CLOUDFLARE_WORKER_SUBDOMAIN` | Your workers.dev subdomain |
+| `DEPLOYMENT_NAME` | Your deployment name |
+| `R2_ACCESS_KEY_ID` | R2 access key ID |
+| `R2_SECRET_ACCESS_KEY` | R2 secret access key |
+| `WEB_PLATFORM` | `vercel` or `cloudflare` |
+| `VERCEL_API_TOKEN` | Vercel API token _(only if `web_platform = "vercel"`)_ |
+| `VERCEL_TEAM_ID` | Vercel team/account ID _(only if `web_platform = "vercel"`)_ |
+| `VERCEL_PROJECT_ID` | Vercel project ID _(only if `web_platform = "vercel"`)_ |
+| `MODAL_TOKEN_ID` | Modal token ID |
+| `MODAL_TOKEN_SECRET` | Modal token secret |
+| `MODAL_WORKSPACE` | Modal workspace name |
+| `MODAL_ENVIRONMENT` | Modal environment name (defaults to `main`) |
+| `MODAL_ENVIRONMENT_WEB_SUFFIX` | Modal environment web suffix for endpoint URLs; lowercase letters, digits, dashes, or empty |
+| `SANDBOX_PROVIDER` | `modal`, `daytona`, or `vercel` |
+| `DAYTONA_API_URL` | Daytona API URL _(only if `sandbox_provider = "daytona"`)_ |
+| `DAYTONA_API_KEY` | Daytona API key _(only if `sandbox_provider = "daytona"`)_ |
+| `DAYTONA_BASE_SNAPSHOT` | Daytona base snapshot name _(only if `sandbox_provider = "daytona"`)_ |
+| `DAYTONA_TARGET` | Optional Daytona target name |
+| `VERCEL_SANDBOX_TOKEN` | Vercel API token _(only if `sandbox_provider = "vercel"`)_ |
+| `VERCEL_SANDBOX_PROJECT_ID` | Vercel project ID for sandbox sessions _(only if `sandbox_provider = "vercel"`)_ |
+| `VERCEL_SANDBOX_TEAM_ID` | Optional Vercel team/account ID for sandbox sessions |
+| `VERCEL_BASE_SNAPSHOT_ID` | Optional manual Vercel base-runtime snapshot; skips Terraform-managed snapshot builds |
+| `VERCEL_SANDBOX_RUNTIME` | Optional Vercel Sandbox runtime (defaults to `node24`) |
+| `VERCEL_SNAPSHOT_EXPIRATION_MS` | Optional Vercel runtime snapshot expiration in milliseconds (`0` means no expiration) |
+| `VERCEL_SANDBOX_API_BASE_URL` | Optional advanced Vercel Sandbox API base URL override |
+| `GH_OAUTH_CLIENT_ID` | Optional GitHub sign-in client ID; set with `GH_OAUTH_CLIENT_SECRET` |
+| `GH_OAUTH_CLIENT_SECRET` | Optional GitHub sign-in client secret; set with `GH_OAUTH_CLIENT_ID` |
+| `GOOGLE_CLIENT_ID` | Optional Google sign-in client ID; set with `GOOGLE_CLIENT_SECRET` |
+| `GOOGLE_CLIENT_SECRET` | Optional Google sign-in client secret; set with `GOOGLE_CLIENT_ID` |
+| `GH_APP_ID` | Required GitHub App repository-access ID |
+| `GH_APP_PRIVATE_KEY` | Required GitHub App repository-access private key (PKCS#8 format) |
+| `GH_APP_INSTALLATION_ID` | Required GitHub App repository-access installation ID |
+| `ENABLE_SLACK_BOT` | `true` to deploy Slack bot, `false` to skip (default: `true`) |
+| `SLACK_BOT_TOKEN` | Slack bot token (required if enabled) |
+| `SLACK_SIGNING_SECRET` | Slack signing secret (required if enabled) |
+| `ENABLE_LINEAR_BOT` | `true` to deploy Linear bot, `false` to skip (default: `false`) |
+| `LINEAR_CLIENT_ID` | Linear OAuth application client ID (required if Linear enabled) |
+| `LINEAR_CLIENT_SECRET` | Linear OAuth application client secret (required if Linear enabled) |
+| `LINEAR_WEBHOOK_SECRET` | Linear webhook signing secret (required if Linear enabled) |
+| `ANTHROPIC_API_KEY` | Anthropic API key |
+| `OPENAI_API_KEY` | Optional OpenAI API key used when a session selects API-key authentication |
+| `XAI_API_KEY` | Optional xAI API key used when a session selects API-key authentication |
+| `DEEPSEEK_API_KEY` | DeepSeek API key (optional, required only for DeepSeek models) |
+| `TOKEN_ENCRYPTION_KEY` | Generated encryption key (OAuth tokens) |
+| `REPO_SECRETS_ENCRYPTION_KEY` | Generated encryption key (repo secrets) |
+| `PROVIDER_ACCOUNTS_ENCRYPTION_KEY` | Optional existing provider-account key override; Terraform generates one when omitted |
+| `MODAL_API_SECRET` | Generated Modal API secret |
+| `NEXTAUTH_SECRET` | Generated browser-auth secret (legacy Actions secret name) |
+| `ALLOWED_USERS` | Comma-separated GitHub usernames (or empty for all users) |
+| `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (or empty for all domains) |
+| `ALLOWED_EMAILS` | Comma-separated exact email addresses (for individual users on shared domains) |
+| `ALLOWED_GITHUB_ORGS` | Comma-separated GitHub orgs whose active members can sign in |
+| `ENABLE_DURABLE_OBJECT_BINDINGS` | Optional Terraform CI flag for Durable Object phase 1 (defaults to `true`) |
+| `ENABLE_GITHUB_BOT` | `true` to deploy GitHub bot worker (or empty to skip) |
+| `GH_WEBHOOK_SECRET` | GitHub webhook secret (required if GitHub bot enabled) |
+| `GH_BOT_USERNAME` | GitHub App bot username, e.g., `my-app[bot]` (required if GitHub bot enabled) |
+| `APP_NAME` | Optional display name for whitelabeling (default: `Open-Inspect`) |
+| `APP_ICON_URL` | Optional URL to a custom logo/favicon (default: built-in icon) |
When enabling or upgrading the Linear bot, also enable **Client credentials tokens** on the OAuth
application in **Linear Settings → API → Applications**. This provider-side setting is not managed
@@ -1019,12 +1022,24 @@ Once configured, the GitHub Actions workflow will:
- Run `terraform plan` on pull requests (with PR comment)
- Run `terraform apply` when merged to main
+Terraform generates and persists `PROVIDER_ACCOUNTS_ENCRYPTION_KEY` when no override is configured.
+Existing local Terraform installations retain an existing key through the
+`provider_accounts_encryption_key` input; Actions deployments retain it through the
+`PROVIDER_ACCOUNTS_ENCRYPTION_KEY` repository or production-environment secret. Changing the key
+makes stored provider credentials unreadable. Preserve backups of the remote Terraform state because
+it is the recovery source for automatically generated keys.
+
---
## Updating Your Deployment
To update after pulling changes from upstream:
+Terraform generates the provider-account credential key for installations without an existing
+override. For local applies, keep any existing `provider_accounts_encryption_key` input unchanged.
+For Actions deployments, keep any existing `PROVIDER_ACCOUNTS_ENCRYPTION_KEY` repository or
+production-environment secret unchanged so stored provider credentials remain readable.
+
```bash
# Pull latest changes
git pull upstream main
@@ -1037,6 +1052,15 @@ cd terraform/environments/production
terraform apply
```
+### Configure Provider Accounts
+
+Open **Settings > Provider Accounts** to add and verify accounts. Setting a provider default changes
+only sessions created afterward; existing sessions remain pinned to legacy scoped OAuth, a specific
+account, or API-key mode. Legacy credentials may coexist during rollout, and the settings page lists
+their locations. Remove them only after dependent legacy-bound sessions are no longer needed.
+Rebuild all sandbox runtime images, templates, and provider snapshots so new sessions use the
+generic broker.
+
---
## Troubleshooting
@@ -1220,10 +1244,6 @@ Add these to your `terraform.tfvars`:
# - Outbound HTTP User-Agent headers (GitHub, GitLab API)
app_name = "Acme Bot"
-# Optional short label for the sidebar header. Set this when app_name is too
-# wide for the sidebar.
-app_short_name = "Acme"
-
# Optional URL to a custom logo image (SVG/PNG). When set, replaces the icon in
# the command menu and favicon. Leave empty to keep the built-in favicon.
# Use an absolute URL or a root-relative path served from packages/web/public/.
@@ -1231,8 +1251,8 @@ app_icon_url = "/branding/acme-logo.svg" # or "https://cdn.example.com/logo.sv
```
After changing any of these values, run `terraform apply` and (for Vercel) redeploy the web app so
-the new build picks up the `NEXT_PUBLIC_APP_NAME`, `NEXT_PUBLIC_APP_SHORT_NAME`, and
-`NEXT_PUBLIC_APP_ICON_URL` env vars (Cloudflare's web deploy is rebuilt automatically by Terraform).
+the new build picks up the `NEXT_PUBLIC_APP_NAME` and `NEXT_PUBLIC_APP_ICON_URL` env vars
+(Cloudflare's web deploy is rebuilt automatically by Terraform).
> **Note**: `NEXT_PUBLIC_*` vars are inlined into the client bundle at build time, so changes
> require a fresh web build. The bot/control-plane workers read `APP_NAME` at request time, so they
diff --git a/docs/GROK_MODELS.md b/docs/GROK_MODELS.md
index bb7e8aef4..8890655ff 100644
--- a/docs/GROK_MODELS.md
+++ b/docs/GROK_MODELS.md
@@ -13,6 +13,7 @@ the durable OAuth refresh token and gives each sandbox only a short-lived access
| Model ID | Display name | Reasoning efforts | Default effort |
| -------------------- | -------------- | ----------------- | -------------- |
| `xai/grok-4.5` | Grok 4.5 | low, medium, high | high |
+| `xai/grok-4.6` | Grok 4.6 | low, medium, high | high |
| `xai/grok-build-0.1` | Grok Build 0.1 | Not configurable | N/A |
Grok Build performs reasoning internally but does not accept a configurable reasoning effort.
@@ -24,88 +25,88 @@ The **xAI / SuperGrok** group is disabled by default. An administrator must enab
## Setup
-### Step 1: Obtain an xAI OAuth Refresh Token
+### Step 1: Connect SuperGrok
-Use OpenCode 1.17.18 or newer on a trusted local machine:
+1. Open **Settings > Provider Accounts**.
+2. Choose **Add account > SuperGrok**.
+3. Open the xAI device authorization page and approve access with the displayed code.
+4. Keep the dialog open until Open-Inspect confirms the connection.
-1. Install and launch [OpenCode](https://opencode.ai).
-2. Run `/connect setup`.
-3. Select **xAI Grok OAuth (SuperGrok Subscription)** and complete the browser login. For a remote
- machine, select the headless/device-code xAI option instead.
-4. Open OpenCode's credential file:
- ```bash
- cat ~/.local/share/opencode/auth.json
- ```
-5. In the `xai` entry, copy the `refresh` value.
+Open-Inspect creates the account as **SuperGrok account** by default. Use **Rename** afterward if
+you need to distinguish multiple subscriptions.
-Treat this value like a password. Do not copy the short-lived `access` value into Open-Inspect and
-do not commit either value to a repository.
+The refresh token is returned directly to the control plane and encrypted there. It is never shown
+in the browser or copied through the sandbox. Provider accounts are installation-wide and available
+to every admitted user in the deployment.
-xAI refresh tokens rotate. After transferring the token, do not keep using the same xAI credential
-entry in local OpenCode: a local refresh can rotate the token before Open-Inspect persists it.
-Remove the local `xai` entry or reserve that login exclusively for Open-Inspect. If another client
-rotates the token, repeat this step and replace the stored secret.
+### Step 2: Configure Defaults
-### Step 2: Store the Refresh Token
+Choose an xAI **Default account** in **Settings > Provider Accounts**. Set **Unattended mode** to
+**Use default account** for Slack, GitHub, Linear, and unpinned automation runs to use SuperGrok, or
+choose **Use API key** to retain the metered API-key path for unattended launches.
-In the Open-Inspect web app, open **Settings > Secrets** and add:
-
-| Secret name | Value |
-| ------------------------- | ------------------------------------- |
-| `XAI_OAUTH_REFRESH_TOKEN` | The `xai.refresh` value from OpenCode |
-
-Choose the scope based on who should share the subscription:
-
-| Scope | Sessions that use it |
-| ----------- | ---------------------------------------------------------------------------------- |
-| Global | Any session without a more specific managed xAI credential |
-| Repository | Sessions launched from that repository; overrides global |
-| Environment | Sessions launched from that environment; overrides global and ignores repo secrets |
-
-For an ad-hoc multi-repository session, managed OAuth credentials come from the primary repository
-only, then fall back to global. A secondary repository cannot become the token rotation source.
+Defaults affect newly created sessions only. Existing sessions keep their pinned provider-account,
+API-key, or `legacy_scoped_oauth` mode. When a new session has no explicit selection or xAI default,
+it also persists `legacy_scoped_oauth`: a resolved legacy refresh token uses the managed broker,
+while its absence leaves `XAI_API_KEY` available as the compatibility fallback.
### Step 3: Enable and Select Grok
1. Open **Settings > Models**.
-2. Enable **Grok 4.5** or **Grok Build 0.1** under **xAI / SuperGrok**.
-3. Create a new session or restart an existing sandbox.
-4. Select the enabled Grok model and the desired reasoning effort.
+2. Enable **Grok 4.6**, **Grok 4.5**, or **Grok Build 0.1** under **xAI / SuperGrok**.
+3. Create a new session.
+4. Select the enabled Grok model and desired reasoning effort.
+5. Use **xAI authentication** to follow provider policy, select a specific account, or choose **Use
+ API key**.
+
+Automation editors expose xAI authentication independently of the configured model. Leave it on
+**Use defaults when each run starts**, or pin a specific account or API-key mode for future runs.
---
## How Authentication Works
-The OAuth refresh token stays in the encrypted control-plane secret store:
+The provider-account OAuth refresh token stays in the encrypted credential store:
-1. At sandbox creation, Open-Inspect removes xAI OAuth credentials from the generic environment and
- injects only the non-secret `XAI_OAUTH_MANAGED=1` marker.
-2. The sandbox runtime writes an xAI OAuth sentinel to OpenCode's `auth.json` and installs the xAI
+1. Session creation pins a concrete xAI provider account, API-key mode, or legacy scoped-OAuth mode.
+2. In account mode, Open-Inspect removes `XAI_API_KEY` and legacy xAI OAuth fields from the sandbox
+ environment and injects only the non-secret `XAI_OAUTH_MANAGED=1` marker.
+3. The sandbox runtime writes an xAI OAuth sentinel to OpenCode's `auth.json` and installs the xAI
auth proxy plugin.
-3. The plugin calls the sandbox-authenticated `/sessions/:id/xai-token-refresh` broker.
-4. The control plane returns a short-lived access token, caches it, and writes any rotated refresh
- token back to the same global, repository, or environment scope it came from.
-5. The plugin replaces OpenCode's dummy authorization header with the short-lived bearer token.
+4. The plugin calls `POST /sessions/:id/provider-auth/xai/access-token` with the session's sandbox
+ credential.
+5. The control plane returns a short-lived access token and atomically persists rotated account
+ credentials.
+6. The plugin replaces OpenCode's dummy authorization header with the short-lived bearer token.
+
+For a `legacy_scoped_oauth` binding, the generic broker delegates to the legacy scoped refresh path.
+If the resolved secrets contain no legacy xAI refresh token, sandbox preparation leaves
+`XAI_API_KEY` available as the compatibility fallback instead.
-The sandbox never receives `XAI_OAUTH_REFRESH_TOKEN`. Broker responses use
-`Cache-Control: no-store`, and the endpoint rejects user and service credentials in favor of the
-matching session's sandbox token.
+The sandbox never receives the refresh token. Broker responses use `Cache-Control: no-store`, and
+the endpoint rejects user and service credentials in favor of the matching session's sandbox token.
---
## Deployment and Rollout
-The xAI proxy plugin is part of `packages/sandbox-runtime`. After upgrading Open-Inspect, rebuild
-the sandbox runtime image or provider snapshot before testing Grok. Existing images do not gain the
-plugin merely because the control plane was deployed.
+The provider-account xAI proxy plugin is part of `packages/sandbox-runtime`. Before rollout, rebuild
+**every** sandbox runtime image, template, and provider snapshot. Existing images do not gain the
+generic provider-auth endpoint merely because the control plane was deployed.
Before production rollout, run a staging session with an eligible SuperGrok account and verify:
- The selected Grok model is available to the account.
- A prompt succeeds at each enabled reasoning effort.
- A second prompt reuses or refreshes the brokered access token.
-- A new sandbox can authenticate after token refresh without replacing the stored secret manually.
-- Sandbox environment inspection does not reveal `XAI_OAUTH_REFRESH_TOKEN`.
+- A new sandbox can authenticate after token refresh without reconnecting the account manually.
+- Sandbox environment inspection reveals neither the refresh token nor `XAI_API_KEY` in account
+ mode.
+
+Legacy scoped OAuth continues to work alongside provider accounts. Set an xAI default when new
+sessions should use the connected account; existing sessions keep their pinned authentication. The
+settings page lists legacy key locations so operators can remove them after legacy-bound sessions
+are no longer needed. See [Using OpenAI Models](OPENAI_MODELS.md#deployment-and-coexistence).
---
@@ -113,21 +114,20 @@ Before production rollout, run a staging session with an eligible SuperGrok acco
### Grok does not appear in the model selector
-Enable **Grok 4.5** or **Grok Build 0.1** under **Settings > Models**. The xAI group is opt-in and
-is not part of the default enabled model set.
+Enable **Grok 4.6**, **Grok 4.5**, or **Grok Build 0.1** under **Settings > Models**. The xAI group
+is opt-in and is not part of the default enabled model set.
-### `XAI_OAUTH_REFRESH_TOKEN not configured`
+### Session uses API-key mode unexpectedly
-Check the session target's secret scope. Environment sessions do not inherit repository secrets, and
-multi-repository sessions use only the primary repository for managed OAuth credentials. Restart the
-sandbox after changing secrets.
+Inspect the session or automation authentication selector and verify the xAI default and unattended
+mode. Without a default or explicit choice, new sessions preserve legacy scoped behavior.
### `xAI token refresh failed: unauthorized` or `invalid_grant`
-The refresh token was revoked, expired, or already rotated elsewhere. Repeat the local OpenCode
-login and replace `XAI_OAUTH_REFRESH_TOKEN` in the same secret scope.
+The refresh token was revoked, expired, or already rotated elsewhere. Use **Reconnect** on the
+provider account and complete xAI device authorization again.
-### `Model not found: xai/grok-4.5` or `xai/grok-build-0.1`
+### `Model not found: xai/grok-4.6`, `xai/grok-4.5`, or `xai/grok-build-0.1`
Rebuild the sandbox image so it includes the xAI auth proxy plugin and confirm the deployment uses
OpenCode 1.17.18 or newer.
diff --git a/docs/HOW_IT_WORKS.md b/docs/HOW_IT_WORKS.md
index f8eb6be7d..e23f31e99 100644
--- a/docs/HOW_IT_WORKS.md
+++ b/docs/HOW_IT_WORKS.md
@@ -399,6 +399,31 @@ This lets you send follow-up thoughts while the agent works. Prompts are process
You can also stop the current execution if the agent is going down the wrong path.
+### Parent-to-Child Follow-Ups
+
+An agent that created a child with `spawn-child` can continue that same child session with
+`send-child-prompt`. The follow-up enters the child's normal durable queue:
+
+```text
+Child prompt 1 (processing) ──▶ Parent follow-up (queued) ──▶ Child continues
+```
+
+The follow-up does not interrupt active work. Completed and failed children can resume, restoring
+their compatible sandbox snapshot when available. Cancelled children remain terminal, and archived
+children must be explicitly unarchived before they can accept prompts.
+
+The parent token is never exchanged for the child's sandbox token. The control plane authenticates
+the parent session, verifies the direct parent-child relationship in D1, verifies it again in the
+child Durable Object, and attributes the queued prompt to the child owner with source `agent`.
+
+`send-child-prompt` returns after the prompt is durably queued. The parent calls `get-child-status`
+when it needs the follow-up result. An earlier completed response is labeled as such while newer
+child work is still running.
+
+The runtime tool is installed when a sandbox starts from a runtime image that includes it. A parent
+restored from a snapshot created before this capability shipped keeps the older captured runtime and
+will not see `send-child-prompt` until it starts in a fresh sandbox built from the newer runtime.
+
---
## The Agent
@@ -550,7 +575,7 @@ was built for internal use where all employees have access to company repositori
| User OAuth Token | Create PRs, identify users | Repos the user has access to |
| Sandbox Auth Token | Authenticate sandbox → control plane calls | Single session |
| WebSocket Token | Authenticate client connections | Single session |
-| Managed LLM Token | Short-lived OpenAI or xAI model access | Provider account + secret scope |
+| Managed LLM Token | Short-lived OpenAI or xAI model access | Pinned session provider account |
Fresh and prebuilt-image sandboxes fetch git credentials on demand through the control plane instead
of relying on a token embedded in the environment or remote URL. Snapshot restores may still receive
@@ -576,10 +601,35 @@ per-environment scope. A session receives global secrets plus its **session targ
- Injected into sandboxes at startup
- Never exposed to clients (only key names are visible)
-Managed OpenAI and xAI OAuth refresh tokens are a stricter case: they remain control-plane-only and
-are replaced with non-secret provider markers before sandbox creation. The sandbox uses its session
-auth token to request short-lived model access from a provider-specific broker. Refresh-token
-rotation is persisted back to the global, repository, or environment scope that supplied it. See
+OpenAI and xAI subscription credentials are installation-wide provider accounts. Account rows store
+display, status, and optional external identity separately from credentials encrypted with
+`PROVIDER_ACCOUNTS_ENCRYPTION_KEY`. Each provider has an optional default account and an unattended
+mode that chooses the default account or API-key mode for Slack, GitHub, Linear, and unpinned
+automation runs.
+
+The web groups OpenAI and xAI accounts from shared static provider IDs and display metadata; there
+is no provider-catalog endpoint. The control-plane adapter registry remains authoritative when an
+account is connected, selected, defaulted, or consumed.
+
+Session creation resolves every subscription provider once and persists an immutable provider
+account, API-key, or legacy scoped-OAuth auth row in D1, the sole authority for session provider
+auth. The session Durable Object remains authoritative for lifecycle and sandbox-token
+authentication but does not replicate provider-account bindings. An interactive session can follow
+provider policy, select an active account, or choose API-key mode. Automations can pin the same
+choices or resolve current defaults each run. Child sessions copy their parent's D1 auth rows, and
+later default changes do not move existing sessions between accounts.
+
+In account mode, the sandbox receives only a managed marker. Provider API keys and legacy OAuth
+fields for that provider are suppressed. The runtime plugin calls the sandbox-authenticated
+`POST /sessions/:id/provider-auth/:provider/access-token` endpoint; the control plane reads the
+trusted D1 session binding using the sandbox-authenticated session ID, refreshes the encrypted
+account credential, and returns short-lived access with `Cache-Control: no-store`. Sandbox startup
+also reads the complete D1 auth snapshot and fails closed if it is unavailable or incomplete.
+
+Legacy scoped OAuth and provider accounts can coexist. Existing sessions remain pinned to legacy
+scoped OAuth. New sessions use an explicit choice, then a provider-account default, and otherwise
+retain legacy scoped OAuth or API-key behavior. Setting a default affects only future sessions;
+operators may remove legacy keys after legacy-bound sessions are no longer needed. See
[Using OpenAI Models](./OPENAI_MODELS.md) and
[Using Grok with a SuperGrok Subscription](./GROK_MODELS.md).
@@ -588,7 +638,8 @@ rotation is persisted back to the global, repository, or environment scope that
>
> **Opt-in model providers**: DeepSeek models require `DEEPSEEK_API_KEY`, and Z.AI Coding Plan
> models require `ZHIPU_API_KEY`, as a global secret with any sandbox provider. SuperGrok models
-> require managed xAI OAuth credentials and must be enabled under **Settings > Models**.
+> require an xAI provider account or `XAI_API_KEY` mode and must be enabled under **Settings >
+> Models**.
See [Secrets Management](./SECRETS.md) for setup instructions.
@@ -603,4 +654,5 @@ See [Secrets Management](./SECRETS.md) for setup instructions.
## What's Next
- **[Getting Started](./GETTING_STARTED.md)**: Deploy your own instance
+- **[Managed Skills](./MANAGED_SKILLS.md)**: Create and select reusable agent instructions
- **[Debugging Playbook](./DEBUGGING_PLAYBOOK.md)**: Troubleshoot issues with structured logs
diff --git a/docs/MANAGED_SKILLS.md b/docs/MANAGED_SKILLS.md
new file mode 100644
index 000000000..d1d61f4d6
--- /dev/null
+++ b/docs/MANAGED_SKILLS.md
@@ -0,0 +1,328 @@
+# Managed Skills
+
+Managed skills give agents reusable instructions and supporting files. Use them to standardize
+workflows such as deployments, code reviews, incident response, or project-specific conventions
+without repeating the same guidance in every prompt.
+
+A skill can apply to every session or only to selected repositories and environments. Before
+starting a session, you can use all matching skills, none of them, or a personal profile containing
+the ones you prefer.
+
+> Managed skills are trusted content, not a permission boundary. A skill can direct the agent to use
+> tools, credentials, and network access already available in the session. Review instructions and
+> scripts before enabling them.
+
+---
+
+## Quick Start
+
+1. Go to **Settings > Skills**.
+2. On **Shared skills**, click **New skill**.
+3. Enter a canonical name, description, and instructions.
+4. Choose the repositories or environments where the skill should apply. New skills apply to **All
+ sessions (global)** by default.
+5. Click **Validate**, review the generated `SKILL.md`, then click **Create skill**.
+6. Start a new session. Leave the skill selector on **All applicable** to include every skill that
+ matches the selected target.
+
+Open the session's right sidebar and expand **Managed skills** to see exactly which skills and
+revisions were included.
+
+---
+
+## Skills, Assignments, and Profiles
+
+| Concept | Purpose | Who can use it |
+| -------------------- | -------------------------------------------------------------- | ---------------------------------------- |
+| **Shared skill** | Stores reusable instructions and optional supporting files | Everyone using the Open-Inspect instance |
+| **Assignment** | Controls which session targets a shared skill applies to | Everyone using the shared skill |
+| **Personal profile** | Saves a preferred subset of shared skills for session creation | Only the profile's owner |
+
+Profiles do not change a skill's assignments. If a profile contains a disabled skill or one that
+does not apply to the selected target, Open-Inspect ignores that entry.
+
+Shared skills are installation-wide. Any signed-in user can create, edit, enable, disable, assign,
+or delete them. Coordinate changes with other users of your Open-Inspect instance.
+
+---
+
+## Creating a Shared Skill
+
+Open **Settings > Skills > Shared skills**, then select **New skill**.
+
+### Skill content
+
+| Field | What to enter |
+| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| **Canonical name** | Required. A unique name using lowercase letters, numbers, and single hyphens, such as `deploy-service`. The name cannot be changed later. |
+| **Description** | Required. A short explanation of when and why the agent should use the skill. |
+| **Instructions** | The workflow, rules, examples, and expected outcomes in Markdown. This may be empty, but instructions are recommended for most skills. |
+
+Write instructions that make the skill's trigger and outcome clear. For example:
+
+```markdown
+## When to use
+
+Use this skill when deploying the API service to staging.
+
+## Workflow
+
+1. Run the pre-deployment checks in `scripts/preflight.sh`.
+2. Summarize any failed checks and stop before deployment.
+3. Deploy using the repository's documented staging command.
+4. Report the deployed revision and health-check result.
+```
+
+### Optional fields
+
+- **License** records licensing information for the skill.
+- **Compatibility** describes environment or tool requirements.
+- **Metadata** accepts a JSON object whose keys and values are strings, for example
+ `{"team":"platform"}`.
+
+Open-Inspect generates the skill's `SKILL.md` from these fields. You do not need to create it as a
+supporting file.
+
+### Supporting files
+
+Select **Add file** to include text-based references, templates, assets, source files, or scripts.
+Use relative paths such as:
+
+```text
+references/runbook.md
+assets/review-template.md
+scripts/preflight.sh
+```
+
+Supporting files must be UTF-8 text; binary uploads and archive imports are not supported. Mark a
+file **Executable** only when its path is under `scripts/`.
+
+You can author files directly in the editor or import them from a repository. Managed skills cannot
+be imported from a marketplace, a local directory, or an archive.
+
+### Validate before saving
+
+Click **Validate** to preview and check the skill without saving it. The result shows:
+
+- The generated `SKILL.md`
+- Total content size
+- A SHA-256 digest identifying the content
+
+Validation is optional. **Create skill** and **Save new revision** perform the same checks when you
+save.
+
+---
+
+## Importing a Skill from a Repository
+
+Skills usually live in Git already. **Settings > Skills > Shared skills > Import from repository**
+reads a skill directory — a `SKILL.md` file plus its supporting files — from a connected repository
+instead of retyping it.
+
+### Choosing a source
+
+| Field | What to enter |
+| ---------------- | ------------------------------------------------------------------------------------------------------------ |
+| **Repository** | Any repository the Open-Inspect app installation can read. Repositories it cannot reach are not importable. |
+| **Ref** | Optional branch, tag, or commit. The repository's default branch is used when this is empty. |
+| **Subdirectory** | Optional path to the skill inside the repository. Leave empty when the repository root holds the `SKILL.md`. |
+| **Name** | Optional canonical name. Defaults to the `name` in `SKILL.md`, then to the last segment of the source path. |
+
+To import one skill from a repository that holds several, name its subdirectory. If the path you
+chose has no `SKILL.md`, the error lists the subdirectories that do. Importing several skills at
+once is not supported; repeat the import for each.
+
+### How `SKILL.md` maps onto a managed skill
+
+`name`, `description`, `license`, `compatibility`, and `metadata` become the matching fields, and
+everything after the frontmatter becomes the instructions. Every other frontmatter key — for example
+`allowed-tools` or `version` — has no managed-skill field and is reported in the preview rather than
+dropped silently. All other files in the directory are imported as supporting files.
+
+Open-Inspect regenerates `SKILL.md` from the mapped fields alone. The stored file is therefore
+neither byte-identical to the upstream one nor a superset of it: unmapped frontmatter keys are
+reported in the preview and then left behind.
+
+### Reviewing before saving
+
+Nothing is stored until you review the preview, which shows the resolved commit, every file and its
+size, the total size, the content digest, the generated `SKILL.md`, and any mapping warnings.
+Assignments are chosen exactly as in the create flow. Confirming re-reads the source and refuses to
+save if the repository changed since the preview — preview again to see what changed.
+
+An import is rejected as a whole, never partially, and reports what failed: an unreachable
+repository, a missing ref or `SKILL.md`, unreadable frontmatter, a binary or oversized file, an
+executable outside `scripts/`, a symlink or submodule, or a name that is already taken. Every
+constraint under [Limits and File Rules](#limits-and-file-rules) applies exactly as it does to
+skills written in the editor.
+
+### Provenance and re-importing
+
+An imported skill records its source repository, requested ref, resolved commit, subdirectory, and a
+digest of the imported bytes. That digest covers the content read upstream and is deliberately
+different from the revision digest, which covers the complete stored revision tree, including the
+regenerated `SKILL.md` and supporting files. A moving ref is always pinned to the commit it resolved
+to.
+
+Open **Imported source** on the skill to pull the source again. Re-import reads the recorded
+repository and subdirectory; only the ref can be changed. Changed content is saved as a new
+revision, and unchanged content adds nothing — the recorded commit keeps pointing at the commit that
+produced the stored bytes. Nothing syncs on its own: upstream changes reach the catalog only when
+someone re-imports, and existing sessions are never affected.
+
+Editing an imported skill by hand is allowed and does not erase its source, so a later re-import
+replaces those edits with the source's content.
+
+> Importing makes it easy to pull third-party instructions and scripts into an installation-wide,
+> on-by-default catalog. Review the preview — especially anything under `scripts/` — before
+> confirming.
+
+---
+
+## Assigning a Skill
+
+Assignments determine when a skill is available. A skill applies when any one of its assignments
+matches the session target.
+
+| Assignment | Applies to |
+| ------------------------- | ------------------------------------------------------ |
+| **All sessions (global)** | Every session, including sessions without a repository |
+| **Repository** | Sessions containing the selected repository |
+| **Environment** | Sessions launched from the selected environment |
+
+You can select several repositories and environments. An environment session can match a global
+assignment, the environment assignment, and assignments for repositories contained in that
+environment.
+
+If you remove all assignments, the skill remains in the catalog but is not applicable to any new
+session until it is assigned again. Assignments do not override the skill's enabled or disabled
+state.
+
+---
+
+## Editing and Managing Skills
+
+Select a skill under **Settings > Skills > Shared skills** to edit its content, supporting files, or
+assignments.
+
+- Select **Save new revision** to save your changes. Content changes create a revision; changing
+ assignments alone updates the scope without creating a content revision. The canonical name cannot
+ be edited.
+- Use the switch beside a skill to enable or disable it. Disabled skills are excluded from new
+ sessions.
+- Select **Delete** to remove a skill from the catalog. There is no restore action in the web app.
+- If another user updates the skill while you are editing it, your save is rejected so that you do
+ not overwrite their changes. Reload the latest revision and apply your changes again.
+
+Edits, assignment changes, disabling, and deletion affect future sessions only. Existing sessions
+keep the exact skill revisions selected when they were created.
+
+---
+
+## Creating a Personal Profile
+
+Profiles make it easy to select the same subset of shared skills repeatedly. They are private to
+your account and do not affect other users.
+
+1. Go to **Settings > Skills > My profiles**.
+2. Click **New profile**.
+3. Enter a unique profile name.
+4. Select up to 20 shared skills.
+5. Click **Save profile**.
+
+A profile is a filter, not an override. At session creation, Open-Inspect includes only profile
+skills that are both enabled and assigned to the selected target. Disabled skills remain visible in
+the profile editor with a `(disabled)` suffix.
+
+Select an existing profile to rename it or change its included skills. Select **Delete** to remove a
+profile; deleting a profile does not delete its shared skills.
+
+---
+
+## Choosing Skills for a Session
+
+After selecting no repository, a repository, a repository set, or an environment on the new-session
+page, open the skill selector beside the model and reasoning controls.
+
+| Selection | Result |
+| -------------------- | -------------------------------------------------------------------------------------- |
+| **All applicable** | Includes every enabled skill whose assignment matches the target. This is the default. |
+| **None** | Starts the session without managed skills. |
+| **Personal profile** | Includes the enabled, applicable skills saved in that profile. |
+
+The number beside the selector previews how many skills will be included. If a profile shows **N
+ignored**, those entries are disabled or are not assigned to the selected target.
+
+Automations and integrations that do not offer a skill selector use **All applicable**. Child
+sessions created by an agent inherit the parent's exact set of skills.
+
+There is no separate installation step. Before the agent starts, Open-Inspect validates and installs
+the selected skills automatically. If selected content cannot be fetched, validated, or installed,
+the session fails to start. Name collisions are handled separately as described below.
+
+---
+
+## Inspecting Skills in a Session
+
+Expand **Managed skills** in the session's right sidebar. The section shows:
+
+- The selection used: **All applicable**, **None**, or a profile name
+- Each skill's canonical name and description
+- The pinned revision and abbreviated content digest
+- Why the skill matched, such as **Global**, **Repository**, or **Environment**
+
+Skills are pinned when the session is created. Restarting or restoring that session continues to use
+the same revisions; it does not pick up newer edits. Start a new session to use the latest catalog.
+
+---
+
+## Limits and File Rules
+
+| Constraint | Limit |
+| ---------------------------------- | --------------------: |
+| Canonical name | 64 characters |
+| Description | 1,024 characters |
+| License | 200 characters |
+| Compatibility | 500 characters |
+| Metadata key | 100 characters |
+| Metadata value | 500 characters |
+| Supporting files | 99 per skill revision |
+| Individual file | 256 KiB |
+| Complete skill revision | 1 MiB |
+| Skills in a profile or session | 20 |
+| Managed skill content in a session | 5 MiB |
+
+Supporting-file paths must:
+
+- Be relative paths using `/`, not absolute paths or backslashes
+- Avoid empty segments, `.` segments, and `..` segments
+- Be no more than 10 path segments or 240 UTF-8 bytes
+- Not contain control characters
+- Be unique and not conflict with another file or directory path
+- Not use `SKILL.md`, which Open-Inspect generates
+
+Canonical names must be unique. The names `agent-browser`, `record-video`, `upload-screenshot`,
+`visual-verification`, and `customize-opencode` are reserved by the sandbox runtime.
+
+---
+
+## Troubleshooting
+
+### A profile says that skills were ignored
+
+Open the profile under **Settings > Skills > My profiles** and check whether the entries are
+disabled. Then open each shared skill and confirm that its assignments match the session's
+repository or environment.
+
+### Changes are missing from an existing session
+
+Managed skills are pinned at session creation. Start a new session to receive a newer revision,
+changed assignments, or newly enabled skills.
+
+### A managed skill is missing because of a name collision
+
+When a managed skill has the same name as a repository, user, or bundled skill available in the
+sandbox, Open-Inspect keeps the discovered skill and drops the colliding managed skill. Other
+managed skills are still installed and the session continues to start. Rename or remove the
+discovered skill, or create a new managed skill with a different canonical name and update its
+assignments and profiles.
diff --git a/docs/OPENAI_MODELS.md b/docs/OPENAI_MODELS.md
index 8821f6542..1cc7b57bd 100644
--- a/docs/OPENAI_MODELS.md
+++ b/docs/OPENAI_MODELS.md
@@ -3,7 +3,8 @@
Open-Inspect supports OpenAI Codex models in addition to Anthropic Claude models. This guide covers
how to configure your deployment to use them.
-> **Note**: This setup process is temporary and will be streamlined in a future release.
+OpenAI subscriptions are managed as installation-wide provider accounts. Sessions and automations
+can use the installation default, select a specific account, or explicitly use API-key mode.
---
@@ -26,51 +27,67 @@ high for Codex models).
## Setup
-### Step 1: Obtain OpenAI OAuth Credentials
+### Step 1: Connect ChatGPT
-You'll use [OpenCode](https://opencode.ai) locally to authenticate with OpenAI and retrieve the
-required tokens.
+1. Open **Settings > Provider Accounts**.
+2. Choose **Add account > ChatGPT**. Device authorization starts automatically.
+3. Use **Open ChatGPT Settings** and enable device code authorization for Codex.
+4. Use **Open Device Authorization**, then enter the code shown by Open-Inspect when OpenAI asks for
+ it.
+5. Keep the dialog open while Open-Inspect waits for authorization. The new account appears after
+ OpenAI confirms the connection.
-1. Install OpenCode if you haven't already
-2. Launch OpenCode:
- ```bash
- opencode
- ```
-3. Inside OpenCode, run `/connect setup`
-4. Select **ChatGPT** and complete the OAuth login flow in your browser
-5. After authenticating, open the credentials file:
- ```bash
- cat ~/.local/share/opencode/auth.json
- ```
-6. From the `openai` section, copy the values for:
- - `refresh` — the refresh token
- - `accountId` — your ChatGPT account ID
+Open-Inspect creates the account as **ChatGPT account** by default. Use **Rename** afterward if you
+want a different display name. Provider accounts are shared by all admitted users in this
+single-tenant deployment; they are not repository-scoped or private to their creator.
-### Step 2: Add Secrets to Your Deployment
+### Step 2: Configure Defaults
-1. Go to your Open-Inspect web app's **Settings** page
-2. Add the following repository secrets:
+In the OpenAI section of **Settings > Provider Accounts**:
- | Secret Name | Value |
- | ---------------------------- | ------------------------------- |
- | `OPENAI_OAUTH_REFRESH_TOKEN` | The `refresh` token from Step 1 |
- | `OPENAI_OAUTH_ACCOUNT_ID` | The `accountId` from Step 1 |
+1. Choose the **Default account** used when an interactive session follows provider policy.
+2. Choose **Unattended mode**:
+ - **Use default account** makes Slack, GitHub, Linear, and unpinned automation runs use the
+ subscription account.
+ - **Use API key** keeps unattended launches on the existing API-key path.
-### Step 3: Select an OpenAI Model
+Defaults are resolved when a session starts. Changing them does not move a running session to a
+different paid account.
-When creating a new session, choose any OpenAI model from the model dropdown. Sessions using OpenAI
-models will automatically use your configured credentials.
+### Step 3: Select Authentication
+
+Choose an OpenAI model when creating a session and use the **OpenAI authentication** selector to
+choose provider policy, a specific connected account, or **Use API key**. Account mode overrides
+`OPENAI_API_KEY` for that session.
+
+Automation editors expose the same choices for every subscription provider. **Use defaults when each
+run starts** resolves current policy for every run; selecting an account or API-key mode pins that
+choice for future runs.
---
## How It Works
-Your refresh token is stored securely in the control plane and is never exposed to sandboxes. When a
-sandbox needs to make an OpenAI API call, it requests a short-lived access token from the control
-plane, which handles token refresh and rotation automatically. Only the temporary access token is
-present inside the sandbox.
+The OpenAI device authorization result is encrypted with `PROVIDER_ACCOUNTS_ENCRYPTION_KEY` in the
+control plane and is never exposed to the browser or sandboxes. A session stores the selected
+account ID, not credential material. When the sandbox needs OpenAI access, its runtime plugin calls
+the sandbox-authenticated `POST /sessions/:id/provider-auth/openai/access-token` endpoint. The
+control plane refreshes and rotates the account credential and returns only short-lived access
+material.
+
+Children inherit their parent's pinned provider authentication. Disabling or archiving an account
+blocks future broker calls, but an access token already issued to a running sandbox remains usable
+until it expires.
+
+## Deployment and Coexistence
-Credentials are scoped per repository, so different repos can use different OpenAI accounts.
+Legacy scoped OAuth and provider accounts can coexist. Existing sessions retain their legacy
+binding. Add and verify provider accounts at any time, then set a provider default when new sessions
+should use that account. Defaults never move existing sessions. The settings page lists remaining
+legacy OAuth key locations; remove them only after dependent legacy-bound sessions are no longer
+needed. Older manually provisioned credentials continue to work, but new ChatGPT accounts should use
+the first-party device authorization flow in Settings. Do not copy the same rotating refresh token
+into both credential systems.
---
@@ -83,11 +100,12 @@ Open-Inspect.
### Session fails to start with an OpenAI model
-Verify that both `OPENAI_OAUTH_REFRESH_TOKEN` and `OPENAI_OAUTH_ACCOUNT_ID` are set in your
-repository secrets (Settings page). The refresh token may have expired — repeat Step 1 to obtain
-fresh credentials.
+Confirm that the selected/default OpenAI account is active and the account is verified. If the
+session explicitly uses API-key mode, confirm `OPENAI_API_KEY` is available in its secret scope.
### "Token refresh failed" errors
-The OAuth refresh token may have been revoked or expired. Re-authenticate by repeating Step 1 and
-updating the secrets in your Settings page.
+The OAuth grant may have been revoked, expired, or rotated elsewhere. Use **Reconnect** on the
+existing account and complete the same device authorization flow. Reconnect preserves the account's
+display name and must authenticate the same OpenAI account identity; connect a new provider account
+if the identity changed.
diff --git a/docs/SECRETS.md b/docs/SECRETS.md
index ee96cdc8d..7c9effc83 100644
--- a/docs/SECRETS.md
+++ b/docs/SECRETS.md
@@ -148,9 +148,34 @@ If you try to save a reserved key, the UI will show a validation error.
- Secrets are decrypted at sandbox creation time and injected as environment variables
- System variables (set by the control plane) always take precedence over user-defined secrets
-Managed OpenAI and xAI OAuth credentials are exceptions to generic environment injection. Their
-refresh and cached access tokens stay in the control plane; the sandbox receives only a non-secret
-provider marker and requests short-lived access through its session-authenticated broker.
+OpenAI and xAI subscription credentials belong in **Settings > Provider Accounts**, not generic
+Secrets. Their refresh and cached access tokens are encrypted with
+`PROVIDER_ACCOUNTS_ENCRYPTION_KEY`, remain control-plane-only, and are never returned to the browser
+or injected into sandboxes. A session pins an account ID, API-key mode, or legacy scoped-OAuth mode
+and requests short-lived access through `POST /sessions/:id/provider-auth/:provider/access-token`.
+
+Provider-account mode removes that provider's canonical API key from the sandbox environment so the
+runtime cannot bypass the selected subscription. API-key mode continues to use ordinary global,
+repository, or environment secrets.
+
+### Legacy managed OAuth coexistence
+
+Legacy scoped OpenAI/xAI OAuth remains supported for sessions pinned to it. Provider-account
+defaults affect only sessions created afterward. **Settings > Provider Accounts** lists legacy key
+locations across global, repository, and environment scopes:
+
+```text
+OPENAI_OAUTH_REFRESH_TOKEN
+OPENAI_OAUTH_ACCESS_TOKEN
+OPENAI_OAUTH_ACCESS_TOKEN_EXPIRES_AT
+OPENAI_OAUTH_ACCOUNT_ID
+XAI_OAUTH_REFRESH_TOKEN
+XAI_OAUTH_ACCESS_TOKEN
+XAI_OAUTH_ACCESS_TOKEN_EXPIRES_AT
+```
+
+Do not reuse the same rotating refresh token in both systems. Operators may remove legacy keys once
+the legacy-bound sessions that depend on them are no longer needed.
### Secrets and prebuilt images
@@ -171,17 +196,16 @@ from it, even after you rotate the secret. Two guidelines:
## Common Examples
-| Key | Scope | Purpose |
-| ---------------------------- | ------ | ------------------------------------------------------------ |
-| `ANTHROPIC_API_KEY` | Global | Claude API access (required for Daytona or Vercel sandboxes) |
-| `DEEPSEEK_API_KEY` | Global | DeepSeek API access |
-| `ZHIPU_API_KEY` | Global | Z.AI Coding Plan GLM access |
-| `OPENAI_OAUTH_REFRESH_TOKEN` | Repo | OpenAI Codex access ([setup guide](OPENAI_MODELS.md)) |
-| `OPENAI_OAUTH_ACCOUNT_ID` | Repo | OpenAI Codex access ([setup guide](OPENAI_MODELS.md)) |
-| `XAI_OAUTH_REFRESH_TOKEN` | Any | SuperGrok access ([setup guide](GROK_MODELS.md)) |
-| `DATABASE_URL` | Repo | Database connection string |
-| `AWS_ACCESS_KEY_ID` | Repo | AWS credentials for a specific project |
-| `STRIPE_SECRET_KEY` | Repo | Stripe API key for a specific project |
+| Key | Scope | Purpose |
+| ------------------- | ------ | ------------------------------------------------------------ |
+| `ANTHROPIC_API_KEY` | Global | Claude API access (required for Daytona or Vercel sandboxes) |
+| `OPENAI_API_KEY` | Global | OpenAI API access when a session selects API-key mode |
+| `XAI_API_KEY` | Global | xAI API access when a session selects API-key mode |
+| `DEEPSEEK_API_KEY` | Global | DeepSeek API access |
+| `ZHIPU_API_KEY` | Global | Z.AI Coding Plan GLM access |
+| `DATABASE_URL` | Repo | Database connection string |
+| `AWS_ACCESS_KEY_ID` | Repo | AWS credentials for a specific project |
+| `STRIPE_SECRET_KEY` | Repo | Stripe API key for a specific project |
---
@@ -189,10 +213,12 @@ from it, even after you rotate the secret. Two guidelines:
### "Model not found" errors
-If you see "Model not found" errors, add the API key for your selected model provider as a global
-secret in Settings. For Claude on Daytona or Vercel, add `ANTHROPIC_API_KEY`. For DeepSeek, add
-`DEEPSEEK_API_KEY`. For Z.AI Coding Plan, add `ZHIPU_API_KEY`. For SuperGrok, follow the
-[managed OAuth setup guide](GROK_MODELS.md) instead of injecting the refresh token into a sandbox.
+If you see "Model not found" errors, verify the selected provider authentication mode first. For
+provider-account mode, verify the account and model entitlement. For API-key mode, add the required
+key to the session's secret scope. OpenAI uses `OPENAI_API_KEY`; xAI uses `XAI_API_KEY`; Claude on
+Daytona or Vercel uses `ANTHROPIC_API_KEY`; DeepSeek uses `DEEPSEEK_API_KEY`; Z.AI Coding Plan uses
+`ZHIPU_API_KEY`. For subscription authentication, follow the provider-account setup guidance in
+[OpenAI models](OPENAI_MODELS.md) or [Grok models](GROK_MODELS.md).
### Secret not appearing in sandbox
diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md
index cf8154875..a1818d20e 100644
--- a/docs/SETUP_GUIDE.md
+++ b/docs/SETUP_GUIDE.md
@@ -91,8 +91,6 @@ SERVICE_AUTH_SECRET=your_web_service_secret
# inlined into the client bundle at build time — restart `npm run dev`
# after changing them.
NEXT_PUBLIC_APP_NAME=Open-Inspect
-# Short label for the sidebar header.
-NEXT_PUBLIC_APP_SHORT_NAME=Inspect
NEXT_PUBLIC_APP_ICON_URL=
```
@@ -194,6 +192,13 @@ pytest tests/ -v
## Path C: Full Self-Hosted Deployment
+Follow the full deployment guide and generate `token_encryption_key` and
+`repo_secrets_encryption_key`. Terraform generates and persists the independent provider-account
+credential key unless an existing `provider_accounts_encryption_key` override is supplied. After
+deployment, connect subscriptions in **Settings > Provider Accounts**, configure defaults and
+unattended modes, and rebuild every runtime image. Legacy scoped OAuth can coexist with provider
+accounts; defaults affect only sessions created afterward.
+
For full infrastructure setup, use:
- [docs/GETTING_STARTED.md](./GETTING_STARTED.md)
@@ -206,6 +211,8 @@ Critical notes before deploy:
- For Modal deployments, eagerly build the Sandbox image with
`uv run python deploy.py --build-sandbox-image`, then deploy with `uv run modal deploy deploy.py`
(not `src/app.py`).
+- Existing sessions keep their pinned authentication. Remove legacy OAuth keys only after dependent
+ legacy-bound sessions are no longer needed.
## Common Issues and Fixes
@@ -242,6 +249,7 @@ configured/deployed.
- Linear integration usage: [docs/integrations/LINEAR.md](./integrations/LINEAR.md)
- Debugging and observability: [docs/DEBUGGING_PLAYBOOK.md](./DEBUGGING_PLAYBOOK.md)
- Available models: [docs/AVAILABLE_MODELS.md](./AVAILABLE_MODELS.md)
+- Managed skills: [docs/MANAGED_SKILLS.md](./MANAGED_SKILLS.md)
- OpenAI model setup: [docs/OPENAI_MODELS.md](./OPENAI_MODELS.md)
- SuperGrok model setup: [docs/GROK_MODELS.md](./GROK_MODELS.md)
- Contribution workflow: [CONTRIBUTING.md](../CONTRIBUTING.md)
diff --git a/docs/adr/0003-session-snapshot-handoff.md b/docs/adr/0003-session-snapshot-handoff.md
new file mode 100644
index 000000000..8b1cbd656
--- /dev/null
+++ b/docs/adr/0003-session-snapshot-handoff.md
@@ -0,0 +1,62 @@
+# ADR 0003: Session Snapshot Handoff
+
+## Status
+
+Accepted
+
+## Context
+
+Session hydration needs server-rendered state, stable timeline pagination, reconnect convergence,
+and authenticated access to sandbox credentials. A retained revision log can provide incremental
+resume, but it duplicates every session mutation into a second projection, adds per-socket revision
+state, and requires retention, gap recovery, and dual-protocol fan-out.
+
+The event replay is already bounded. Reconnect bandwidth is therefore predictable, while the
+complexity and correctness cost of maintaining a second mutation log applies to every write.
+
+## Decision
+
+1. **The canonical database is the synchronization source of truth**
+ - `GET /sessions/:id` returns a secret-free canonical snapshot for SSR.
+ - Every WebSocket subscribe or reconnect receives one authoritative `subscribed` snapshot.
+ - After subscription, existing semantic messages update the live view.
+
+2. **The snapshot-to-stream handoff is synchronous**
+ - Complete authentication and all asynchronous enrichment first.
+ - Perform the final canonical SQLite snapshot read.
+ - Send the snapshot and register/persist the socket without an `await` between those operations.
+ - A mutation is therefore either included in the snapshot or delivered after registration on the
+ ordered WebSocket stream.
+
+3. **Timeline identity is independent from synchronization revisions**
+ - Events retain stable `eventId` and `timelineSequence` envelopes for deterministic pagination.
+ - No session-view revision, retained delta table, or per-socket applied revision is stored.
+
+4. **Sandbox credentials stay outside the canonical snapshot contract**
+ - Clients fetch credentials from authenticated `GET /sessions/:id/sandbox-access`.
+ - `sandbox_access_changed` invalidates that access query.
+ - The resource is limited to interactive sandbox services; integration credentials remain in
+ their own domain-specific flows.
+ - Credentials are never sent in snapshots or semantic WebSocket messages.
+
+## Consequences
+
+### Positive
+
+- Session mutations have one durable representation instead of a canonical write plus a view delta.
+- Reconnect correctness depends on one small handoff invariant rather than revision retention and
+ catch-up state machines.
+- The control plane, shared protocol, and web reducer have fewer synchronization branches.
+- Stable event pagination and secret-free SSR remain intact.
+
+### Negative
+
+- Every reconnect transfers a bounded full snapshot instead of only missed revisions.
+- SSR state can be briefly older than the authoritative socket snapshot.
+- Rare state that lacks a semantic live message converges on reconnect rather than immediately.
+
+## Follow-Up Rules
+
+- Do not add a retained session-view delta log without measured reconnect-bandwidth evidence.
+- Do not add asynchronous work between the final snapshot read and socket registration.
+- Prefer an existing semantic message or a narrow invalidation signal for new live state.
diff --git a/docs/integrations/SLACK.md b/docs/integrations/SLACK.md
index fdd75f3cf..dea726480 100644
--- a/docs/integrations/SLACK.md
+++ b/docs/integrations/SLACK.md
@@ -298,10 +298,24 @@ The feature is **disabled by default** and gated by the `SLACK_TRIGGERS_ENABLED`
When the flag is off, the bot ignores channel messages and forwards nothing; authoring a Slack
automation in the web app is still allowed, but it will not run until the flag is enabled.
-Slack Message automations currently ingest the message's own text only. File uploads, including
-image-only `file_share` messages, do not start these automations; attachments on automation thread
-replies are not forwarded to the session; and the body of a forwarded message is not read. Use an
-interactive DM or `@mention` when the agent needs an image or a forwarded message.
+Slack Message automations ingest message text only. A message that carries an attachment does start
+an automation, but on its text alone — the attachment itself is not forwarded, so an image-only
+message with no text starts nothing. Attachments on automation thread replies are likewise not
+forwarded to the session, and the body of a forwarded message is not read. Use an interactive DM or
+`@mention` when the agent needs an image or a forwarded message.
+
+When the triggering message is a **reply**, the agent also receives the thread it was posted in, so
+it can read the reply in context rather than as an isolated sentence. The thread is read only once a
+run has actually been admitted — never for messages that match no automation, for follow-ups that
+continue an existing session, or for firings dropped as concurrent or duplicate — and once per
+message however many automations match it. Top-level messages have no thread to read.
+
+The context contains up to 20 earlier messages total; on long threads, the opening message is
+preserved alongside the most recent replies. Each message is truncated to 1,024 characters, and its
+speaker record identifies people, apps, and the bot's own earlier turns without relying on a display
+name alone. It is passed as JSON and labelled untrusted: Slack text is written by people who may not
+be asking the agent anything, so it is presented as a record of the conversation rather than as
+instructions. If Slack cannot be read, the run starts with no thread history rather than failing.
### Slack app setup
diff --git a/docs/plans/managed-skills-interactive.html b/docs/plans/managed-skills-interactive.html
new file mode 100644
index 000000000..03bc2be9b
--- /dev/null
+++ b/docs/plans/managed-skills-interactive.html
@@ -0,0 +1,1855 @@
+
+
+
+
+
+
+ Managed Skills | Interactive Design Walkthrough
+
+
+
+
+
+
+
+
+
+ No chapter matches that search.
+
+
+
+
01
+
+ Start here
+
The frame
+
+
Mark complete
+
+
+ V1 is intentionally scoped to the product that exists today: one trusted installation,
+ canonical users, environments, repositories, sessions, and provider-neutral sandboxes.
+ It does not pretend that a team authorization boundary already exists.
+
+
+
+
+ Product thesis
+ Central management without central ambiguity
+
+ People author a portable skill once, assign it to useful targets, and choose how
+ much of the applicable catalog enters each session. The system records exactly what
+ ran.
+
+
+
+ Tenancy stance
+ Installation-wide, not fake teams
+
+ "Team-managed" means shared by admitted users in V1. Real team ACLs must later cover
+ sessions, repos, environments, secrets, integrations, and skills together.
+
+
+
+ Goal
+ Author in platform
+ Create and modify standard skill trees, with creator and revision attribution.
+
+
+ Goal
+ Target precisely
+ Assign globally, to exact repositories, or to exact saved environments.
+
+
+ Goal
+ Reproduce sessions
+ Resolve once, pin immutable content, and show provenance in session details.
+
+
+
+
+
+
+
+ Format
+ Portable Agent Skills directory with required SKILL.md.
+
+
+ Versioning
+ Immutable internal revisions now; history, diffs, tags, and rollback later.
+
+
+ Storage
+ D1 for bounded UTF-8 trees in V1; R2 for larger or binary packages later.
+
+
+ Delivery
+
+ Session-authenticated fetch from the sandbox, never environment-variable payloads.
+
+
+
+ Failure
+
+ Fail startup if selected skills cannot be authenticated, verified, or installed.
+
+
+
+
+
+
+
+
+
02
+
+ Domain language
+
Four clean concepts
+
+
Mark complete
+
+
+ Content, applicability, user preference, and execution provenance are separate records.
+ This prevents profiles from becoming copies and prevents later edits from mutating a
+ session already in flight.
+
+
+
+
+
01 / CONTENT Skill
+
A stable catalog identity pointing at current immutable content.
+
+
+
02 / APPLICABILITY Assignment
+
A global, repository, or environment rule that can match a target.
+
+
+
03 / PREFERENCE Profile
+
A user's reusable explicit selection from the shared catalog.
+
+
+
04 / EXECUTION Manifest
+
The exact revisions, sources, profile, and digest pinned to one session.
+
+
+
+
+
+
+ Immutable underneath
+ Every successful save becomes a revision
+
+ V1 exposes only current content, but the data plane references revision IDs and
+ SHA-256 digests. History becomes a product feature later without changing runtime
+ semantics.
+
+
+
+ Mutable above
+ Assignments and profiles remain lightweight
+
+ They reference stable skill identities. A session turns those moving references into
+ one permanent manifest before its Durable Object is initialized.
+
+
+
+
+
+
+
+
03
+
+ User journey
+
Author to session
+
+
Mark complete
+
+
+ The experience is split between shared catalog management and personal session choice.
+ The canonical name is immutable; content changes affect only sessions created after
+ save.
+
+
+
+
+ Skills catalog
+ Skill editor
+ Profiles
+ New session
+
+
+
+
+ Scan the catalog
+
+ See canonical name, description, state, assignments, creator, last editor,
+ update time, and validation.
+
+
+
+ Control lifecycle
+
+ Disable to stop new resolution or soft-delete while retaining revisions required
+ by pinned sessions.
+
+
+
+
+
+
+
+ Content
+ Structured frontmatter, Markdown body, and supporting UTF-8 file tree.
+
+
+ Assignments
+ Global toggle plus repository and environment multi-selects.
+
+
+ Details
+ Creator, digest, timestamps, validation, disable, and delete.
+
+
+
+
+
+ Profiles are personal explicit sets, not copied content. A selected skill still
+ loads only when an assignment makes it applicable to the current target.
+
+
+
+
+
+ All applicable
+ Default for people, bots, integrations, and old clients.
+
+
+ No managed skills
+ Explicit opt-out from centrally managed skills.
+
+
+ Named profile
+ Intersect applicable skills with the user's saved set.
+
+
+
+
+
+
+
+
+
04
+
+ Try the rules
+
Resolution, made visible
+
+
Mark complete
+
+
+ Assignments form the applicable set. The user's selection filters it. The result is
+ bounded, sorted, generation-checked, and persisted with the session snapshot.
+
+
+
+
+
Interactive resolver
+
+ Session target
+
+ No repository
+ Repository: acme/api
+ Repository: acme/web
+ Environment: Production
+
+
+
+ Skill selection
+
+ All applicable
+ Profile: Backend focus
+ Profile: Review only
+ No managed skills
+
+
+
+ Example catalog only. The real resolver also records every matching assignment
+ source.
+
+
+
+
Pinned result
+
0 skills selected
+
+
+
+
+
+
+
+ Consistency
+ Generation check
+
+ Read generation, resolve, read again, and retry if any catalog/profile mutation
+ occurred.
+
+
+
+ Limits
+ No truncation
+ More than 20 skills or 5 MiB fails preview and creation with a specific error.
+
+
+ Child sessions
+ Copy the parent
+
+ Agent-spawned children inherit the exact pinned manifest and selection provenance.
+
+
+
+
+
+
+
+
+
+
06
+
+ Data plane
+
Before OpenCode starts
+
+
Mark complete
+
+
+ Runtime fetch is the only reliable ordering point across providers and restores. Managed
+ content is staged in OpenCode's global skills directory, outside repository checkouts.
+
+
+
+
+
BOOT Prepare repos
+
Clone, sync, run hooks, and assemble multi-repo OpenCode configuration.
+
+
+
FETCH Get manifest
+
Authenticate as this sandbox and retrieve only pinned revisions.
+
+
+
VERIFY Validate twice
+
Recheck names, paths, limits, UTF-8, source collisions, and every digest.
+
+
+
INSTALL Journaled swap
+
Repair prior journals, install the complete tree, then launch.
+
+
+
+
+
+
+ Filesystem ownership
+ ~/.config/opencode/skills
+
+ The platform owns this global directory inside its sandboxes. Repository and bundled
+ skills retain their existing project locations. The collision scan excludes the
+ managed destination itself so a restored snapshot does not collide with its own
+ tree.
+
+
+
+
+
+
+
+
07
+
+ Threat model
+
Instructions are executable
+
+
Mark complete
+
+
+ Markdown can direct an agent to run scripts, inspect injected credentials, or exfiltrate
+ data through tools it already has. Skills are supply-chain content, not harmless notes.
+
+
+
+ What V1 enforces
+
+ Verified authorship, safe YAML, bounded UTF-8 files, normalized relative paths, no
+ links or special files, per-file and package digests, session-bound download auth,
+ independent runtime validation, and fail-closed materialization.
+
+
+
+ What skills never grant
+
+ allowed-tools is rejected. Skill use never grants MCP access, secrets,
+ shell permissions, network access, or external SaaS authorization. Those remain
+ separate enforcement planes.
+
+
+
+ Accepted V1 trust risk
+
+ All admitted users may edit installation-wide content under the current single-tenant
+ trust model. Deployments that do not trust all admitted users keep the feature
+ disabled until editor roles or an allowlist exist.
+
+
+
+ Failure posture
+
+ Invalid edits preserve the current revision. Missing content, auth failure, hash
+ mismatch, or exhausted startup retries fail the sandbox before OpenCode starts. Name
+ collisions keep the discovered skill, drop the managed entry, and emit a warning.
+ Activation callback failure alone retries best-effort after local success.
+
+
+
+
+
+
+ Per skill
+ 1 MiB / 100 files
+
+ Each file is at most 256 KiB and paths are at most 240 bytes deep across 10
+ segments.
+
+
+
+ Per session
+ 5 MiB / 20 skills
+ Resolution returns an error instead of dropping entries to fit.
+
+
+ Lifecycle
+ No mid-session refresh
+ Updates prepare future sessions; already-pinned sessions remain reproducible.
+
+
+
+
+
+
+
08
+
+ Delivery plan
+
Earn the runtime
+
+
Mark complete
+
+
+ Build and observe the catalog and resolver before allowing content into production
+ sandboxes. Runtime injection is the third phase, not the first demo.
+
+
+
+
+ PHASE 0
+ Format and hardening
+
+ Shared limits, parser, digest fixtures, collision detection, provider deployment
+ hashes.
+
+
+
+ PHASE 1
+ Catalog and authoring
+
+ D1, stores, CRUD APIs, settings editor, authorship, internal revisions. Injection
+ stays off.
+
+
+
+ PHASE 2
+ Assignments and preview
+
+ Scopes, personal profiles, warmed-session key, manifest persistence, staging
+ comparison.
+
+
+
+ PHASE 3
+ Sandbox delivery
+ Authenticated fetch, materializer, internal rollout, opt-in, default.
+
+
+ PHASE 4
+ Governance
+
+ History, review, Git import, R2, ACLs, signed packages, evals, canaries, revocation.
+
+
+
+
+
+
+
+
09
+
+ External evidence
+
Patterns, not imitation
+
+
Mark complete
+
+
+ The portable file format is standard. Assignment, profiles, transport, version pinning,
+ and precedence are Open-Inspect product decisions informed by adjacent systems.
+
+
+
+
+
+
+ Canonical detail
+ Continue into the implementation-grade plan
+
+ The Markdown document includes complete logical DDL, exact digest byte grammar, API
+ payloads, failure matrix, testing matrix, alternatives, file map, and future version
+ control design.
+
+ Read managed-skills.md
+
+
+
+
+
+
+
+
diff --git a/docs/plans/managed-skills.md b/docs/plans/managed-skills.md
new file mode 100644
index 000000000..2be04e646
--- /dev/null
+++ b/docs/plans/managed-skills.md
@@ -0,0 +1,1141 @@
+# Managed Skills
+
+## Status
+
+Proposed design for V1. This document intentionally distinguishes product-visible version control,
+which is deferred, from immutable internal revisions, which are required for reproducible sessions.
+
+## Summary
+
+Open-Inspect should let admitted users create and edit reusable agent skills in the web application,
+associate them with the installation, environments, and repositories, and choose a personal skill
+profile when creating a session. The control plane resolves those inputs once, pins the exact skill
+revisions to the session, and makes a content-addressed manifest available only to that session's
+sandbox. The sandbox validates and atomically installs the manifest before OpenCode starts.
+
+V1 introduces four separate concepts:
+
+- **Skill**: a shared, installation-wide Agent Skills package and its current content.
+- **Assignment**: a rule that makes a skill applicable globally or to a repository or environment.
+- **Profile**: a user's reusable selection from the skills applicable to a session target.
+- **Session manifest**: the immutable revisions actually selected for one session, including why
+ each skill applied.
+
+The separation matters. Editing a skill does not mutate running or already-created sessions;
+assignments do not duplicate content; and profiles express user preference without changing the
+team's shared catalog.
+
+Open-Inspect is currently a single-tenant product. It has canonical users but no internal team,
+workspace, membership, role, or tenant authorization model. In V1, "team-managed" therefore means
+shared by everyone admitted to one Open-Inspect installation. Adding a cosmetic `team_id` only to
+skills would imply an isolation boundary that does not exist. A future multi-tenant design must add
+tenancy consistently across repositories, environments, sessions, secrets, integrations, and skills.
+
+## Decisions
+
+| Area | V1 decision |
+| ---------------- | ---------------------------------------------------------------------------------------- |
+| Format | Adopt the portable Agent Skills directory format with a required `SKILL.md`. |
+| Ownership | Skills are installation-wide; `created_by` and `updated_by` reference canonical users. |
+| Authoring | Web editor for `SKILL.md` and supporting UTF-8 text files. |
+| Scope | Explicit global, repository, and environment assignments. |
+| User choice | Built-in All and None choices plus personal named profiles. |
+| Resolution | Union applicable assignments, then apply the chosen profile as a selection filter. |
+| Session behavior | Resolve and pin exact internal revisions when the session is created. |
+| Storage | D1 metadata, revisions, and text files for V1; design permits later R2 packages. |
+| Delivery | Sandbox-authenticated manifest endpoint; no skill content in environment variables. |
+| Installation | Atomically materialize into OpenCode's global skills directory before startup. |
+| Conflicts | Prefer discovered skills and drop colliding managed entries with a warning. |
+| Failure policy | Fail sandbox startup if a selected manifest cannot be authenticated or installed. |
+| Versioning | Keep immutable revisions internally; defer history, diffs, tags, rollback, and Git sync. |
+| Access control | All admitted users can read and modify skills in V1; preserve clear future ACL seams. |
+
+## Motivation
+
+Repositories can already carry `.opencode/skills`, and the sandbox runtime bundles several system
+skills. Those approaches are useful but do not solve central management:
+
+- A shared workflow must be copied into every repository and updated independently.
+- Repository skills cannot easily be associated with an environment containing multiple repos.
+- Users cannot opt out of irrelevant shared skills without modifying repository content.
+- The platform cannot show who created or last changed a skill.
+- There is no session record of the centrally managed content an agent received.
+- Bots and repository-less sessions have no repository in which to store a shared skill.
+
+Skills are also more than prompt snippets. A standard skill can include scripts, references, and
+assets. Installing one changes the instructions and executable content available to the agent, so
+the feature needs integrity checks, deterministic resolution, startup ordering, and provenance.
+
+## Current Architecture
+
+### Identity and tenancy
+
+Canonical people are stored in `users` and `user_identities`; browser and integration principals
+resolve to a canonical user ID. Sessions store that ID. The product's admission policy controls who
+may enter the installation, but admitted users are trusted members of one organization. There are no
+team membership or role checks.
+
+This design uses canonical user IDs for authorship. Authorship is audit metadata, not ownership or
+authorization. V1 must not implement "only the creator may edit" because that would be an accidental
+and inadequate ACL model.
+
+### Environments and repositories
+
+An environment is a globally named, ordered set of repositories. Creating a session from an
+environment snapshots its repositories into `session_repositories` and retains `environment_id` as
+provenance. This is the precedent for resolving skills: mutable configuration is converted into an
+immutable session input at session creation.
+
+Repository owners can contain `/`; repository names cannot. Any skill schema and API that stores a
+repository target must use separate `repo_owner` and `repo_name` columns and the shared repository
+identity helpers.
+
+### Existing skills and startup
+
+The runtime currently:
+
+1. Boots and synchronizes repositories.
+2. For multi-repository sessions, merges member `.opencode` trees into `/workspace/.opencode` in
+ repository order.
+3. Calls `OpenCodeServer._prepare_opencode_filesystem()`.
+4. Copies bundled runtime skills to the active worktree's `.opencode/skills` directory.
+5. Starts `opencode serve`.
+
+The safe insertion point for managed skills is step 3, after repository boot but before the OpenCode
+process launches. There is no reliable paused interval after `modal.Sandbox.create()` in which the
+control plane can copy files; the runtime itself must fetch and install them.
+
+Snapshots preserve the full filesystem. Installation must therefore reconcile stale files on every
+fresh boot and restore, not just copy new files with `dirs_exist_ok=True`.
+
+## Goals
+
+- Let users create, view, edit, disable, and soft-delete shared skills in the platform.
+- Track the canonical user who created the skill and who created each internal revision.
+- Support a complete portable skill directory, within explicit V1 size and file-type limits.
+- Associate skills globally and with one or more repositories or environments.
+- Let each user select All, None, or a personal named profile when starting a session.
+- Give bot-created and automated sessions deterministic default behavior.
+- Pin exact skill content to a session before sandbox startup.
+- Materialize skills before OpenCode discovers them.
+- Make the effective skill set and its provenance inspectable from the session.
+- Preserve a clean path to user-facing version history, approval flows, Git import, and ACLs.
+
+## Non-Goals
+
+- Internal multi-team tenancy or role-based access control.
+- Public or cross-installation skill marketplaces.
+- Git-backed export or bidirectional synchronization. (User-initiated Git import shipped after V1;
+ see Phase 4.)
+- User-facing version history, diffs, branches, tags, promotion channels, or rollback.
+- Binary assets or arbitrary archive upload.
+- Skill dependencies, package managers, hooks, MCP server creation, or secret declarations.
+- Treating skill instructions as security policy.
+- Updating skills during a running session.
+- Automatically importing skills already committed in repositories.
+- Evals, approvals, canary rollout, or usage-based quality scoring.
+- Supporting agent hosts other than OpenCode in V1.
+
+## Terminology
+
+### Skill
+
+A stable catalog entry with a globally unique Agent Skills `name`, display metadata, lifecycle
+state, assignments, and a pointer to its current immutable revision.
+
+### Revision
+
+An immutable, content-addressed set of files produced on every successful content save. Revisions
+are an implementation detail in V1, but session manifests reference them. Metadata-only edits such
+as assignment changes do not create a content revision.
+
+### Assignment
+
+A statement that a skill applies to one target:
+
+- `global`: every session, including repository-less sessions.
+- `repository`: any session containing the exact repository.
+- `environment`: only a session launched through that exact environment.
+
+### Profile
+
+A personal, reusable explicit set of skill IDs. A profile is not a copy of skill content and does
+not make an otherwise unassigned skill applicable. At resolution time it filters the applicable set.
+Personal profiles are visible and editable only by their owner in V1.
+
+### Manifest
+
+The canonical, immutable list of selected skill revisions for one session, plus content hashes, file
+metadata, assignment provenance, profile choice, and resolver version.
+
+## Product Experience
+
+### Skills settings
+
+Add a **Skills** category to Settings. The list view shows:
+
+- display name and canonical skill name;
+- description;
+- enabled or disabled state;
+- Global, repository, and environment assignment summaries;
+- creator and last editor;
+- last updated time;
+- validation state.
+
+The create/edit view has three tabs:
+
+1. **Content**: structured fields for `name`, `description`, optional `license` and `compatibility`,
+ a Markdown editor for the `SKILL.md` body, and a supporting-file tree.
+2. **Assignments**: global toggle plus repository and environment multi-selects.
+3. **Details**: creator, current revision digest, timestamps, validation output, disable, and delete
+ actions.
+
+The structured fields render the `SKILL.md` frontmatter rather than maintaining an independent
+description that can drift. The server reparses the rendered file and returns field-level errors. An
+advanced raw `SKILL.md` mode may be added later, but V1 should not provide two simultaneous sources
+of truth.
+
+The canonical skill name is immutable after creation and read-only in the edit view. Renaming would
+change invocation identity and complicate repository collisions, profiles, and historical
+provenance; V1 users create a replacement skill instead.
+
+Supporting files use path-based create, rename, edit, and delete operations. The UI should suggest
+the conventional `scripts/`, `references/`, and `assets/` directories without requiring them.
+`SKILL.md` cannot be renamed or deleted.
+
+Saving content creates a new internal revision and makes it current for newly created sessions.
+Existing sessions retain their pinned revision. The UI states this explicitly.
+
+### Profiles settings
+
+Add a **Skill Profiles** section under Skills or the user settings area. Users can:
+
+- create, rename, edit, and delete personal profiles;
+- select an explicit set of shared skills;
+- see each skill's assignments and whether it is currently disabled;
+- see that a selected skill will only load when it also applies to the session target.
+
+V1 profiles are personal because the requirement is user customization and the product has no
+team-role model. Shared/admin-managed profiles can be added once ownership and ACL semantics exist.
+
+### Session creation
+
+Add a skill selector alongside target, model, and reasoning effort:
+
+- **All applicable skills** is the default.
+- **No managed skills** opts out of all managed skills.
+- Personal profiles appear by name.
+
+The selector displays a preview count after a target is selected. Changing the selection must
+invalidate the web client's warmed pending session, just like changing the target or model. The
+create-session request carries a discriminated choice, never an ambiguous nullable profile ID:
+
+```ts
+type SessionSkillSelection =
+ | { mode: "all" }
+ | { mode: "none" }
+ | { mode: "profile"; profileId: string };
+```
+
+Bot, automation, Slack, Linear, and GitHub-created sessions use `{ mode: "all" }` unless their
+server-side configuration gains an explicit profile later. A caller cannot select another user's
+profile.
+
+### Session visibility
+
+The session details UI shows the pinned managed skills with:
+
+- skill name and description;
+- revision number and shortened digest;
+- profile choice;
+- assignment reasons such as Global, Environment: Production, or Repository: owner/name;
+- installation status or startup error.
+
+This is read-only provenance. Editing a catalog skill from this view affects only future sessions.
+
+## Skill Format
+
+V1 adopts the [Agent Skills specification](https://agentskills.io/specification):
+
+```text
+skill-name/
+|-- SKILL.md
+|-- scripts/ # optional
+|-- references/ # optional
+`-- assets/ # optional
+```
+
+`SKILL.md` contains YAML frontmatter followed by Markdown. V1 accepts the portable fields:
+
+- `name` (required);
+- `description` (required);
+- `license` (optional);
+- `compatibility` (optional);
+- `metadata` (optional string-to-string map).
+
+The standard's experimental `allowed-tools` field is rejected in V1. OpenCode does not document it
+as a recognized skill field, and natural-language or skill metadata must not bypass OpenCode
+permissions or sandbox controls. Host-specific Claude or Codex fields are also rejected so a skill
+does not appear portable while silently behaving differently. The editor exposes `metadata` as a
+string-to-string map. Shared validation enforces the standard's 1,024-character description limit
+and 500-character compatibility limit.
+
+Parse YAML with a safe schema. Reject custom tags, duplicate keys, and aliases rather than allowing
+parser-dependent expansion or ambiguity. Render canonical frontmatter from the structured fields
+before validation and hashing.
+
+The standard name constraints become the canonical catalog constraints:
+
+```text
+^[a-z0-9]+(-[a-z0-9]+)*$
+```
+
+Names are 1 to 64 characters, match the skill directory, and are globally unique case-insensitively.
+Runtime-bundled skill names are reserved and cannot be used by managed skills. The UI should
+encourage an organization prefix for generic names, such as `acme-deploy`, to reduce collisions with
+repository-authored skills.
+
+### V1 content limits
+
+Define each value once as a shared constant and use it in schemas, the UI, control plane, and
+runtime:
+
+| Limit | Proposed value |
+| --------------------------------- | -------------: |
+| Files per skill | 100 |
+| Bytes per file | 256 KiB |
+| Total bytes per revision | 1 MiB |
+| Path length | 240 bytes |
+| Path depth below skill root | 10 segments |
+| Managed skills per session | unbounded |
+| Total managed content per session | 5 MiB |
+
+Only valid UTF-8 text files are accepted. This supports Markdown, source code, scripts, JSON, YAML,
+and text templates while keeping D1 storage and JSON delivery bounded. Binary assets and archive
+upload move to content-addressed R2 packages in a later phase.
+
+A session's manifest is bounded by total content bytes, not by skill count. V1 also capped the count
+at 20; that was removed because assignments are additive and a global assignment applies to every
+session, so one per-session count limit gated the entire installation — exceeding it failed every
+session create and automation run rather than the one oversized session. Byte limits do not have
+that property. If manifest delivery becomes the bottleneck, change the transmission (paginate or
+stream the installation fetch, move file bodies to content-addressed storage) rather than
+reintroducing a count cap.
+
+The aggregate session limits are enforced by resolution preview and session creation. Resolution
+returns a specific error and never truncates a profile or silently drops skills.
+
+No single statement may bind a parameter per skill over an unbounded list. Such statements fail
+outright rather than degrading, and the count cap was previously masking them. Chunked reads still
+bind one parameter per skill within a chunk; what matters is that no one statement is handed the
+whole list. Prefer keying off an ID the database already holds — the session installation query
+filters `skill_revision_files` by a subquery on `session_skill_revisions`, so a wider manifest costs
+no additional parameters. Where the list genuinely originates outside the database — profile
+membership and skill assignments, both of which arrive in the request body — chunk by the engine's
+bound-parameter ceiling (`MAX_D1_QUERY_PARAMETERS`). A JSON-array parameter with `json_each` would
+also work on SQLite but is not portable to the second `SqlDatabase` engine.
+
+The same applies to aggregates on the read side, which are easier to miss because they fail on a
+byte ceiling rather than a parameter count. `SkillProfileStore.list` used to build a profile's whole
+membership into one `json_group_array` value, capped at 2 MB or roughly fifty thousand ids; nothing
+bounds profile width, and that ceiling was only out of reach because profile writes give out first
+at about 33,000 members. Making the write cheaper would have moved the write cliff past the read one
+and left profiles that could be saved and never loaded. It reads membership as its own query and
+groups in memory instead. When a limit is removed or a write is made cheaper, check what the read
+path was quietly relying on that limit to keep small.
+
+### Bounds that remain implicit
+
+Removing the count cap exposed three resources scaling with skill count rather than content. None
+was enforced, so each surfaced as an engine error rather than a validation message. What each cost
+and what it costs now:
+
+| Resource | Was | Now | Cliff |
+| ---------------------------- | ------------------------------ | -------------------------------------------- | ------------- |
+| Installation payload | Whole manifest in one response | `MANAGED_SKILLS_PAGE_SIZE` per response | none |
+| Session manifest persistence | One INSERT per skill | 10 `session_skill_revisions` rows per INSERT | 9,041 skills |
+| Profile membership writes | One INSERT per skill | 50 `skill_profile_items` rows per INSERT | 33,251 skills |
+
+The payload was the binding constraint at roughly 2,400 skills, assuming the worst realistic shape:
+`MAX_SKILL_FILES` near-empty files per skill, where roughly 134 bytes plus the path length of
+framing per file counts against the runtime's `MAX_MANAGED_SKILL_RESPONSE_BYTES` but contributes
+nothing to the 5 MiB content aggregate. Because resolution checks only content bytes, such a
+manifest was accepted and persisted, then failed closed at sandbox boot — the one case where an
+accepted manifest was not installable. That is a transport shape rather than a storage bound, so the
+runtime pages the fetch instead of resolution rejecting the manifest: a fixed number of skills per
+response keeps every response far below the ceiling however wide the manifest is, and the runtime
+writes each page into the staging tree as it arrives, so peak memory is one page rather than the
+whole installation. Duplicate names and the content aggregate accumulate across pages, because they
+are properties of the installation and not of a response.
+
+The two write paths are bounded by D1's 1,000 queries per Worker invocation. Both pack rows into
+multi-row `INSERT`s sized to the 100-parameter ceiling (`bulkInsertStatements`), which divides the
+statement count by rows-per-statement and keeps the write inside its caller's atomic `batch()`.
+Multi-row `VALUES` is standard SQL, so neither path needs an engine branch. `bindManifestCopy` is
+set-based (`INSERT … SELECT`) and stays fully count-independent.
+
+Packing lowers the constant; it does not remove the linear term, and the budget is spent by the
+whole invocation rather than by the write alone. The cliffs above are the minimum end-to-end cost
+for `N` skills, so any additional per-request work lowers them further:
+
+| Path | Reads | Writes | Total |
+| -------------- | ------------------------------------------------------- | ----------------------------------------- | --------- |
+| Session create | 2 generation + 1 catalog + ⌈N/100⌉ assignment hydration | 1 session + 1 manifest + ⌈N/10⌉ revisions | 5 + 0.11N |
+| Profile create | ⌈N/100⌉ `validateSkillIds` | 1 profile + ⌈N/50⌉ items + 1 generation | 2 + 0.03N |
+
+Session create excludes repository and provider-auth statements and assumes resolution does not
+retry; a generation change retries the read phase up to `MAX_CATALOG_READ_ATTEMPTS` times, and
+profile-mode selection adds two more reads. Profile create excludes authentication and routing.
+Making either genuinely count-independent needs a set-based bulk write behind the database boundary
+— `json_each`-style expansion of a single parameter — which the `SqlDatabase` port cannot express
+today because it is types-only and erased at build time, leaving no runtime dispatch point for an
+engine branch.
+
+Paths must be normalized relative POSIX paths. Reject absolute paths, empty segments, `.`, `..`,
+backslashes, NUL/control characters, duplicate normalized paths, symlinks, hard links, and reserved
+platform paths. An `executable` bit may be set only for regular files under `scripts/`; it is stored
+in the manifest and applied after writing.
+
+## Assignment and Resolution Semantics
+
+Resolution happens once in the control plane after repository/environment resolution and canonical
+user resolution, but before the D1 session row and Durable Object are initialized.
+
+### Applicable set
+
+For an enabled skill, assignments are additive. It is applicable when any assignment matches:
+
+- a global assignment always matches;
+- a repository assignment matches any member of a scalar, list, or environment session;
+- an environment assignment matches only when `environment_id` is that environment;
+- no assignment means the skill is catalog-only and never automatically selected.
+
+An environment session can match its environment assignment and assignments for any member
+repository. The manifest retains every matching reason, not only the first. A repository-less
+session can match only global assignments.
+
+### Profile filter
+
+After building the applicable set:
+
+- `all` selects every applicable skill;
+- `none` selects none;
+- `profile` intersects the applicable set with the profile's explicit skill IDs.
+
+Disabled or soft-deleted skills are excluded for new sessions even if referenced by a profile. A
+profile reference to an inapplicable skill is ignored and reported in the preview; it is not an
+error. This lets one profile work across several repositories without making its skills global.
+
+The result is sorted by canonical skill name before hashing and persistence. Profiles do not define
+prompt order because OpenCode exposes skills independently and loads them on demand.
+
+### Conflicts
+
+OpenCode requires discovered skill names to be unique but does not document a collision precedence
+rule. The platform must not rely on filesystem location or copy order to select one.
+
+At catalog write time, reject collisions with managed and bundled runtime names. At sandbox boot,
+enumerate every effective discovery location supported by the pinned OpenCode version, currently the
+project and global `.opencode/skills`, `.claude/skills`, and `.agents/skills` locations. Perform the
+scan after multi-repository assembly, include bundled sources directly, and exclude the
+platform-owned managed destination because a snapshot may contain this session's previous complete
+tree. Reconcile that destination by manifest digest instead. If a selected managed skill has the
+same canonical name as another discovered skill, keep the discovered skill, remove the managed entry
+from staging, and log a warning naming the managed skill and every discovered path. Continue
+installing all non-colliding managed skills without merging or overwriting directories.
+
+Repository-to-repository skill conflicts already predate this feature and remain governed by the
+current multi-repository assembly behavior. Normalizing that behavior is a separate change.
+
+### Resolver pseudocode
+
+```ts
+const applicable = await listEnabledSkillsMatching({ repositories, environmentId });
+const selectedIds =
+ selection.mode === "all"
+ ? new Set(applicable.map((skill) => skill.id))
+ : selection.mode === "none"
+ ? new Set()
+ : await loadOwnedProfileSkillIds(userId, selection.profileId);
+
+const resolved = applicable
+ .filter((skill) => selectedIds.has(skill.id))
+ .sort((a, b) => compareUtf8Bytes([a.name, a.id], [b.name, b.id]))
+ .map((skill) => ({
+ skillId: skill.id,
+ revisionId: skill.currentRevisionId,
+ name: skill.name,
+ revisionSha256: skill.revisionSha256,
+ assignmentSources: skill.matchingAssignments,
+ }));
+```
+
+Use a singleton catalog-generation row to make the read consistent. Every skill, assignment, and
+profile mutation increments the generation in the same D1 batch as its data changes. Resolution
+reads the generation, loads all inputs, then reads the generation again. A mismatch retries from the
+start with a bounded retry count. An equal before/after value proves the resolved set existed as one
+database state. The immutable revisions and final manifest rows are then persisted in the same D1
+batch as the session and repository snapshot so later catalog edits cannot alter the result.
+
+Assignment generation updates are enforced by database triggers, rather than only by store methods,
+because environment deletion can remove assignments through a foreign-key cascade. An environment
+name update also increments generation when environment assignments reference it, since that display
+name is copied into manifest provenance. The resolver filters candidate skills with a SQL `EXISTS`
+over the bounded target repositories, then batches assignment hydration and retains the small
+in-memory matching pass so every matching source is preserved without constructing JSON in SQL.
+
+Agent-spawned child sessions copy the parent's pinned manifest and selection provenance verbatim;
+they do not re-resolve mutable assignments or lose the parent's personal profile. This keeps a
+subtask in the same effective environment even when its checkout target is one member of the
+parent's multi-repository or environment session.
+
+## Data Model
+
+The following is logical DDL. Exact names and constraints should be finalized in the migration.
+Managed-skill timestamps use Unix milliseconds and control-plane writes use `Date.now()`
+consistently; the repository contains older tables with mixed timestamp units.
+
+```sql
+CREATE TABLE skills_catalog_state (
+ singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
+ generation INTEGER NOT NULL
+);
+
+CREATE TABLE skills (
+ id TEXT PRIMARY KEY, -- skill_
+ name TEXT NOT NULL,
+ current_revision_id TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ deleted_at INTEGER,
+ created_by TEXT NOT NULL,
+ updated_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ FOREIGN KEY(id, current_revision_id) REFERENCES skill_revisions(skill_id, id)
+ ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED
+);
+CREATE UNIQUE INDEX idx_skills_name ON skills(lower(name));
+
+CREATE TABLE skill_revisions (
+ id TEXT PRIMARY KEY, -- skillrev_
+ skill_id TEXT NOT NULL,
+ revision_number INTEGER NOT NULL,
+ revision_sha256 TEXT NOT NULL,
+ description TEXT NOT NULL,
+ license TEXT,
+ compatibility TEXT,
+ metadata_json TEXT NOT NULL DEFAULT '{}',
+ total_bytes INTEGER NOT NULL,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ UNIQUE(skill_id, revision_number),
+ UNIQUE(skill_id, id),
+ FOREIGN KEY(skill_id) REFERENCES skills(id)
+ DEFERRABLE INITIALLY DEFERRED
+);
+
+CREATE TABLE skill_revision_files (
+ revision_id TEXT NOT NULL,
+ path TEXT NOT NULL,
+ content BLOB NOT NULL,
+ content_sha256 TEXT NOT NULL,
+ size_bytes INTEGER NOT NULL,
+ executable INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY(revision_id, path),
+ FOREIGN KEY(revision_id) REFERENCES skill_revisions(id) ON DELETE CASCADE
+);
+
+CREATE TABLE skill_assignments (
+ id TEXT PRIMARY KEY,
+ skill_id TEXT NOT NULL,
+ scope_type TEXT NOT NULL CHECK(scope_type IN ('global', 'repository', 'environment')),
+ repo_owner TEXT,
+ repo_name TEXT,
+ environment_id TEXT,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ FOREIGN KEY(skill_id) REFERENCES skills(id) ON DELETE CASCADE,
+ CHECK(
+ (scope_type = 'global' AND repo_owner IS NULL AND repo_name IS NULL AND environment_id IS NULL)
+ OR (scope_type = 'repository' AND repo_owner IS NOT NULL AND repo_name IS NOT NULL
+ AND environment_id IS NULL)
+ OR (scope_type = 'environment' AND repo_owner IS NULL AND repo_name IS NULL
+ AND environment_id IS NOT NULL)
+ )
+);
+
+CREATE TABLE skill_profiles (
+ id TEXT PRIMARY KEY, -- skillprof_
+ user_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ UNIQUE(user_id, name)
+);
+
+CREATE TABLE skill_profile_items (
+ profile_id TEXT NOT NULL,
+ skill_id TEXT NOT NULL,
+ PRIMARY KEY(profile_id, skill_id),
+ FOREIGN KEY(profile_id) REFERENCES skill_profiles(id) ON DELETE CASCADE,
+ FOREIGN KEY(skill_id) REFERENCES skills(id)
+);
+
+CREATE TABLE session_skill_manifests (
+ session_id TEXT PRIMARY KEY,
+ selection_mode TEXT NOT NULL CHECK(selection_mode IN ('all', 'none', 'profile')),
+ profile_id TEXT,
+ profile_name TEXT,
+ resolver_version INTEGER NOT NULL,
+ manifest_sha256 TEXT NOT NULL,
+ resolved_at INTEGER NOT NULL,
+ FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
+ CHECK(
+ (selection_mode = 'profile' AND profile_id IS NOT NULL AND profile_name IS NOT NULL)
+ OR (selection_mode IN ('all', 'none') AND profile_id IS NULL AND profile_name IS NULL)
+ )
+);
+
+CREATE TABLE session_skill_revisions (
+ session_id TEXT NOT NULL,
+ position INTEGER NOT NULL,
+ skill_id TEXT NOT NULL,
+ revision_id TEXT NOT NULL,
+ skill_name TEXT NOT NULL,
+ revision_sha256 TEXT NOT NULL,
+ assignment_sources TEXT NOT NULL, -- validated JSON
+ PRIMARY KEY(session_id, skill_id),
+ UNIQUE(session_id, position),
+ FOREIGN KEY(session_id) REFERENCES session_skill_manifests(session_id) ON DELETE CASCADE,
+ FOREIGN KEY(skill_id) REFERENCES skills(id) ON DELETE RESTRICT,
+ FOREIGN KEY(revision_id) REFERENCES skill_revisions(id) ON DELETE RESTRICT
+);
+```
+
+Add uniqueness indexes for each assignment shape because SQLite treats `NULL` values as distinct:
+
+- one global row per skill;
+- one case-normalized row per `(skill_id, repo_owner, repo_name)`;
+- one row per `(skill_id, environment_id)`.
+
+The composite deferred foreign key guarantees that `current_revision_id` exists and belongs to the
+same skill while permitting atomic creation of the mutually linked first skill and revision.
+`assignment_sources` snapshots assignment ID, target identity, and the display label used at
+resolution. In particular, environment sources retain both `environmentId` and the historical
+`environmentName` so later rename or deletion does not change session provenance.
+
+`created_by` and `updated_by` logically reference `users.id`, but existing identity retention and
+deletion policy should determine whether they use foreign keys. API responses resolve current user
+display data and retain the opaque ID if the user record no longer exists.
+
+Skill deletion is soft deletion. Revisions referenced by session manifests must remain available for
+sandbox creation, restore, and provenance. A later garbage collector may remove unreferenced
+revisions after a retention period, but it must never remove a revision referenced by a live or
+retained session.
+
+Although version history is not exposed in V1, immutable `skill_revisions` avoid a future breaking
+migration and prevent a save racing with sandbox startup. A save equal to the current revision is a
+no-op. Content equal to an older revision creates a new monotonic revision in V1; moving the current
+pointer backward is reserved for the future rollback experience.
+
+### Future tenant boundary
+
+Do not add a nullable or constant fake `team_id` in V1. Keep all skill queries behind `SkillStore`
+and profile queries behind `SkillProfileStore`, and pass an explicit authorization context to route
+handlers. When real teams are introduced, add a non-null tenant key and backfill the installation's
+resources into a default tenant. That migration must scope sessions, environments, repository
+access, secrets, integrations, images, and skills together.
+
+Future ACL operations should distinguish `read`, `use`, `edit`, `review`, `publish`, `assign`, and
+`admin`. Permission to use a skill must not grant access to MCP servers, secrets, tools, or network
+destinations mentioned by the skill.
+
+## API Design
+
+Define Zod schemas and response types in `@open-inspect/shared`. The web BFF proxies authenticated
+browser requests and forwards no caller-asserted user IDs.
+
+### Catalog APIs
+
+| Method | Path | Purpose |
+| -------- | ----------------- | --------------------------------------------------------------------------- |
+| `GET` | `/skills` | List active or disabled skills, assignments, authors, and current metadata. |
+| `POST` | `/skills` | Create a skill, first revision, and initial assignments atomically. |
+| `GET` | `/skills/:id` | Read metadata, current files, assignments, and provenance. |
+| `PATCH` | `/skills/:id` | Change enabled state or assignments without changing content. |
+| `PUT` | `/skills/:id` | Atomically edit content, enabled state, and assignments with `If-Match`. |
+| `DELETE` | `/skills/:id` | Soft-delete and remove it from future resolution. |
+| `POST` | `/skills/preview` | Validate unsaved files without creating a revision. |
+
+Use one content update containing the complete desired file tree. Full replacement makes deletion
+unambiguous and allows the server to validate and hash one atomic revision. Reject `If-Match`
+mismatches using the current revision ID to prevent one editor silently overwriting another.
+`SKILL.md` must retain the skill's immutable canonical name.
+
+### Profile APIs
+
+| Method | Path | Purpose |
+| -------- | ------------------------- | ----------------------------------------------------- |
+| `GET` | `/skill-profiles` | List the authenticated user's profiles. |
+| `POST` | `/skill-profiles` | Create a personal profile and its explicit skill set. |
+| `PATCH` | `/skill-profiles/:id` | Rename or replace selected skill IDs. |
+| `DELETE` | `/skill-profiles/:id` | Delete an owned profile. |
+| `POST` | `/skills/resolve-preview` | Preview effective skills for a target and selection. |
+
+Profile routes derive `user_id` from the principal. They return not found for another user's
+profile, avoiding an ownership oracle that will complicate future authorization.
+
+### Session API
+
+Extend `CreateSessionRequest` with optional `skillSelection`; omission normalizes to
+`{ mode: "all" }` for old clients. This is concrete backward compatibility for bot and web callers
+that may deploy separately.
+
+`POST /sessions` resolves skills and writes the session, repository snapshot, skill manifest, and
+manifest entries in the existing D1-before-Durable-Object initialization path. If any selected
+current revision is missing or invalid, session creation fails rather than pinning a partial set.
+The same D1 batch owns all session snapshot rows. The separate agent-child spawn path copies the
+parent's rows instead of running the resolver again.
+
+Add `GET /sessions/:id/skills` for authenticated human-readable provenance.
+
+### Sandbox API
+
+Add a sandbox-authenticated endpoint:
+
+```text
+GET /sessions/:id/sandbox-skills[?limit=<1..200>&cursor=]
+```
+
+The session-specific sandbox bearer token, validated by the Session Durable Object, must
+authenticate the request and bind it to exactly the same session ID. Internal HMAC service
+authentication and human principals are rejected on this sandbox-only route. The response is a
+narrow installation DTO containing the pinned manifest digest and bounded UTF-8 files:
+
+```json
+{
+ "schemaVersion": 1,
+ "manifestSha256": "...",
+ "skills": [
+ {
+ "name": "acme-deploy",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "content": "---\nname: acme-deploy\n...",
+ "sha256": "...",
+ "sizeBytes": 42,
+ "executable": false
+ }
+ ]
+ }
+ ],
+ "nextCursor": null
+}
+```
+
+`limit` is optional. Without it the response is the whole installation and `nextCursor` is null,
+which is the only shape sandbox runtimes predating paging understand — they ignore the field, so
+this route must keep serving unpaged requests for as long as older snapshots can be restored. With
+it, the response holds at most `limit` skills and `nextCursor` carries the last position returned,
+or null at the end. Pinned revisions are immutable, so position is a stable cursor and every page of
+one installation reports the same `manifestSha256`; a runtime must reject a page whose digest
+differs from the first page's.
+
+Only an unpaged response carries an `ETag`. The digest covers the whole manifest, so it cannot
+identify a page.
+
+All integer fields in digest encodings are unsigned big-endian. `str(value)` means a 32-bit byte
+length followed by the exact UTF-8 bytes. A SHA-256 field contributes its raw 32 bytes, not hex.
+
+A revision encoding is the ASCII domain separator `OPEN_INSPECT_SKILL_REVISION_V1`, NUL, a 32-bit
+file count, then each file sorted by UTF-8 path bytes. Each file contributes `str(path)`, one
+executable byte (`0` or `1`), a 64-bit content length, and exact content bytes.
+
+A manifest encoding is the ASCII domain separator `OPEN_INSPECT_SKILL_MANIFEST_V1`, NUL, a 32-bit
+resolver version, one selection byte (`0` All, `1` None, `2` Profile), and, for Profile only,
+`str(profileId)` and `str(profileName)`. It then contains a 32-bit skill count and skill entries
+sorted by `(UTF-8 canonical name bytes, UTF-8 skill ID bytes)`. Each entry contributes
+`str(skillId)`, `str(revisionId)`, `str(name)`, the raw revision digest, and a 32-bit assignment
+count. Assignment sources are sorted by the UTF-8 byte tuple
+`(type, assignmentId, repoOwner, repoName, environmentId, environmentName)` and contribute each of
+those six values with `str`, using the empty string for fields not applicable to that source type.
+
+The control plane owns the canonical provenance digest. The sandbox independently verifies every
+delivered file's path, size, content hash, permissions, and generated `SKILL.md` identity.
+Selection, revision metadata, and assignment provenance remain available from
+`GET /sessions/:id/skills`. The unpaged `ETag` described above exists for diagnostics and future
+caching.
+
+The response is intentionally not placed in `SESSION_CONFIG`, environment variables, or the Modal
+create request. Content can exceed environment limits, executable instructions should not appear in
+provider control logs, and a fetch endpoint works consistently across sandbox providers and
+restores.
+
+## Sandbox Materialization
+
+Add a provider-neutral managed-skills component to `packages/sandbox-runtime`. It uses the existing
+control-plane URL, session ID, and sandbox authentication token.
+
+### Startup sequence
+
+1. Complete repository boot and multi-repository `.opencode` assembly.
+2. Request the pinned session manifest from the control plane.
+3. Revalidate schema, names, paths, counts, sizes, UTF-8, and every file SHA-256 hash.
+4. Scan all skill locations discovered by the pinned OpenCode version except the managed destination
+ and drop colliding managed entries with a structured warning.
+5. Build the complete managed tree in a temporary directory on the same filesystem.
+6. Set executable bits only where the manifest permits; remove other write/execute bits as
+ appropriate.
+7. Install the managed tree with a journaled directory swap.
+8. Start `opencode serve` only after materialization succeeds.
+
+Use `OpenCodeServer._resolve_opencode_global_config_dir() / "skills"`, normally
+`~/.config/opencode/skills`, for managed skills. This avoids changing a repository checkout and
+works for single-repository, multi-repository, and repository-less sessions. The platform owns this
+directory in its sandboxes; repository and bundled skills retain their existing project locations.
+
+On snapshot restore, fetch and reinstall the session's same pinned manifest before OpenCode starts.
+Replacing the complete managed directory removes stale files from prior snapshots and revisions.
+Never refresh skills during a running OpenCode process or an OpenCode process restart.
+
+The supervisor runs the async materializer once after repository boot and before the initial
+`OpenCodeServer.start()`. Process-level OpenCode restarts reuse the installed tree without requiring
+the control plane. Do not perform async HTTP by blocking the event loop.
+
+### Atomicity
+
+The destination and staging directory must share a filesystem. Write each file with exclusive
+creation, verify its final hash, and fsync where supported. A normal POSIX rename cannot replace a
+non-empty directory atomically, so use `renameat2(RENAME_EXCHANGE)` where the image and filesystem
+support it. The portable fallback writes a single intent marker, renames the current directory to a
+backup, renames staging to current, and removes the backup and marker. Every sandbox startup repairs
+an interrupted journal before reading or installing skills. OpenCode is not running during this
+sequence, so the fallback may have a transient missing destination but never exposes a partial tree
+to the agent. Tests must cover a crash after each transition.
+
+Because each session pins its own manifest, a last-known-good manifest from another session is not a
+valid fallback. A restored snapshot may reuse its matching installed tree only after comparing the
+stored and expected manifest digests.
+
+## Security and Trust Model
+
+Skills are untrusted executable supply-chain content even when their primary file is Markdown. The
+agent can follow hidden instructions, run bundled scripts, read injected secrets, or send data over
+the network using already-authorized tools.
+
+V1 controls are:
+
+- Require an authenticated admitted user for all authoring operations.
+- Record creator and revision author from the verified principal, never the request body.
+- Validate content server-side on preview and save and independently in the sandbox.
+- Reject binary files, links, traversal, special files, invalid names, and oversized packages.
+- Compute per-file and whole-revision SHA-256 digests.
+- Authenticate sandbox downloads and bind the token to one session.
+- Never include platform secrets in skill content or API responses.
+- Do not interpret `allowed-tools` as an authorization grant.
+- Keep OpenCode permissions, sandboxing, network policy, MCP authorization, and secret injection
+ separate from skills.
+- Fail closed when selected content cannot be verified or installed.
+- Escape skill metadata in the UI and logs; never render authored Markdown as unsanitized HTML.
+- Bound each request with file, revision, and session limits; V1 adds no feature-specific request
+ throttle for admitted users.
+
+Allowing every admitted user to modify installation-wide executable content follows the product's
+current single-tenant trust model and is an explicit V1 risk acceptance, not an authorization
+boundary. Deployments that do not trust every admitted user should keep the feature disabled until
+operation-specific roles or an interim editor allowlist are designed.
+
+The UI should show a persistent warning that skills may contain executable scripts and agent
+instructions. Users must be able to inspect every file before saving. V1 does not claim malware or
+prompt-injection detection; heuristic scanning would create false assurance. Future publication
+workflows should add code review, secret scanning, static analysis, and evaluations.
+
+Soft deletion stops future selection but does not stop an already-running session. A future
+emergency-revocation feature may terminate or restart affected sessions, but that policy must be
+explicit because mutating their skills in place would break reproducibility.
+
+## Failure Handling
+
+| Failure | Behavior |
+| ------------------------------------- | -------------------------------------------------------------- |
+| Invalid content save | Return field/path errors; keep the current revision unchanged. |
+| Concurrent edit | Return `409 Conflict` with the new current revision ID. |
+| Profile deleted before session create | Return `404`; do not silently use All. |
+| Skill disabled during resolution | Omit it from new sessions. |
+| Revision changes after resolution | Session uses the pinned old revision. |
+| Missing pinned revision | Fail session initialization and mark the session failed. |
+| Sandbox download auth failure | Fail startup; do not start OpenCode without selected skills. |
+| Control-plane timeout | Retry with bounded exponential backoff, then fail startup. |
+| Manifest or file hash mismatch | Delete staging content and fail startup. |
+| Name collision | Drop the managed entry, warn with discovered paths, continue. |
+| Snapshot contains stale managed files | Replace the complete managed directory before startup. |
+
+Use a named TypeScript timeout constant in milliseconds and a Python timeout constant in seconds.
+Define each default once. Sandbox download retries must fit inside the existing OpenCode startup
+budget and surface a specific boot phase in session diagnostics.
+
+## Observability and Audit
+
+Emit structured events and metrics for:
+
+- skill create, content revision, assignment update, enable/disable, and soft delete;
+- profile create, update, and delete;
+- manifest resolution count, duration, selection mode, and digest;
+- bundle response bytes and duration;
+- sandbox fetch, validation, collision scan, and installation duration;
+- materialization failures grouped by stable error code;
+- skill count and total bytes per manifest.
+
+Do not log full skill content. Logs may contain IDs, canonical names, hashes, paths, sizes, user
+IDs, and assignment types. Audit records should retain actor, action, target ID, previous/current
+revision IDs, assignment changes, and timestamp. If a general audit-event facility is not introduced
+in V1, the immutable revision author plus `created_by`/`updated_by` is the minimum; assignment
+changes will not have complete history and this limitation should be documented.
+
+## Testing Strategy
+
+### Shared contracts
+
+- Valid and invalid skill names and frontmatter.
+- File path normalization, duplicates, size/count/depth boundaries, and executable constraints.
+- Assignment discriminated unions, including nested repository owners.
+- Session skill selection and backward-compatible default parsing.
+- Manifest canonicalization and stable digest fixtures shared with Python.
+
+Build `@open-inspect/shared` before dependent packages.
+
+### Control plane unit tests
+
+- Atomic skill creation and content replacement.
+- Content-identical revision reuse.
+- Optimistic concurrency conflict.
+- Global, repository, environment, multi-repository, and repository-less matching.
+- Profile ownership and intersection behavior.
+- Disabled and deleted skill behavior.
+- Stable ordering and assignment provenance.
+- Catalog-generation retry under concurrent skill, assignment, and profile writes.
+- Session pinning across later edits and deletion.
+- Agent-spawned children copy the parent's exact manifest and provenance.
+- Sandbox endpoint principal and session binding.
+- Content and manifest hashing.
+
+### Control plane integration tests
+
+- Apply the D1 migration and include all new tables in shared cleanup.
+- CRUD through authenticated routes against real D1.
+- Session creation writes repositories and skill manifest consistently.
+- Environment edits after session creation do not alter the manifest.
+- Another sandbox cannot fetch a session's manifest.
+- Revisions referenced by sessions survive catalog deletion.
+- Existing create-session callers without `skillSelection` receive All behavior.
+
+### Sandbox runtime tests
+
+- Fetch and install occur before the OpenCode subprocess launches.
+- Single-repository, multi-repository, and repository-less paths.
+- Full companion-file tree and executable modes.
+- Traversal, symlink-equivalent paths, invalid UTF-8, oversize, and digest rejection.
+- Atomic replacement and cleanup after an interrupted staging write.
+- Stale files disappear on snapshot restore.
+- A matching installed digest can take the validated fast path.
+- Managed/repository and managed/bundled name collisions drop only the managed entries and warn.
+- Download retry, timeout, and authentication behavior.
+- Python and TypeScript canonical digest fixtures produce identical values.
+
+### Web tests
+
+- Settings navigation and loading/error/empty states.
+- Create, edit, validation, disable, and delete flows.
+- Supporting-file operations and unsaved-change protection.
+- Assignment selectors for repositories and environments.
+- Profile ownership and selection.
+- Effective-set preview.
+- Session warming invalidation when skill selection changes.
+- Creator/revision/session provenance rendering.
+
+## Rollout Plan
+
+### Phase 0: Format and runtime hardening
+
+- Publish shared format and limit constants.
+- Extract reusable skill parsing and manifest canonicalization.
+- Add collision detection covering current bundled and repository skills.
+- Fix sandbox-runtime deployment hashes so changes to bundled `SKILL.md` and companion files trigger
+ provider updates; some provider Terraform hashes currently include only source-code extensions.
+
+### Phase 1: Catalog and authoring
+
+- Add D1 tables, stores, shared schemas, control-plane routes, web BFF routes, and settings UI.
+- Support internal immutable revisions while showing only the current one.
+- Record authorship and validate all content.
+
+### Phase 2: Assignments, profiles, and preview
+
+- Add global/repository/environment assignment editing.
+- Add personal profiles and target-aware resolution preview.
+- Extend session creation and warmed-session keys.
+- Persist manifests but do not yet deliver them to production sandboxes.
+- Compare preview/resolution output in staging and inspect collision rates and manifest sizes.
+
+### Phase 3: Sandbox delivery
+
+- Add the sandbox endpoint and runtime materializer across every supported provider.
+- Enable for internal sessions first, then opt-in installations, then by default.
+- Monitor boot failure rate, download latency, collision warnings, and bytes per manifest.
+
+### Phase 4: Governance and distribution
+
+- Expose revision history, diff, rollback, and immutable release labels.
+- Add draft/review/publish/promotion lifecycle and evaluations.
+- Add Git import/export with source, commit SHA, and content digest provenance. User-initiated
+ import shipped, recording provider, repository identity, requested and resolved ref, commit SHA,
+ subdirectory, and a digest of the imported bytes; export and bulk import of skill-collection
+ repositories remain open.
+- Move large or binary packages to a dedicated R2 bucket.
+- Add shared profiles, real team ownership, and operation-specific ACLs.
+- Add signed packages, approval gates, emergency revocation, and staged rollout channels.
+
+## Implementation Map
+
+Expected code areas include:
+
+| Tier | Files or modules |
+| ------------ | ----------------------------------------------------------------------------------- |
+| Shared | `packages/shared/src/types/skills.ts`, session request schemas and exports |
+| D1 | New migration under `terraform/d1/migrations/`, integration cleanup |
+| Stores | `packages/control-plane/src/db/skills.ts`, `skill-profiles.ts` |
+| Resolution | `packages/control-plane/src/session/skill-resolution.ts`, session initialization |
+| Routes | Human CRUD/profile/preview routes and sandbox installation route |
+| Router/types | Route registration, `Env` only if storage bindings later change |
+| Web BFF | `/api/skills`, `/api/skill-profiles`, and preview proxies |
+| Web UI | Settings category, skill editor, profile editor, session selector, provenance panel |
+| Runtime | New `managed_skills.py`, `entrypoint.py`, `opencode_server.py` integration |
+| Providers | No content wire field; verify all images contain the updated runtime |
+| Terraform | Runtime source-hash coverage; dedicated R2 binding only in a later phase |
+
+No Modal-specific skill copying should be introduced. The control-plane endpoint and sandbox-runtime
+materializer keep the feature provider-neutral.
+
+## Alternatives Considered
+
+### Store skills only in Git repositories
+
+This gives familiar review and history but cannot supply repository-less sessions, requires
+duplication for global workflows, makes environment-wide assignment awkward, and does not provide
+personal profiles. Git import/export remains a valuable later source, not the only registry.
+
+### Mutate one current skill without internal revisions
+
+This is simpler schema-wise but creates a race between session creation and sandbox download. It
+also makes restored sessions irreproducible and forces a larger migration when user-facing history
+arrives. Internal immutable revisions are worth retaining even when hidden from V1 users.
+
+### Put complete skills in `SESSION_CONFIG`
+
+This threads potentially large executable content through provider request JSON and environment
+variables, risks logging and size limits, and requires coordinated TypeScript/Python/provider wire
+changes. A session-authenticated fetch is bounded, auditable, and provider-neutral.
+
+### Push files into the sandbox after provider creation
+
+The runtime starts immediately and can race the copy. Restore paths differ by provider. Fetching in
+the runtime immediately before OpenCode gives one ordering invariant.
+
+### Install into each repository's `.opencode/skills`
+
+This dirties or requires excluding every checkout, duplicates content in multi-repository sessions,
+and does not fit repository-less sessions. OpenCode's global skill location is the appropriate
+managed location.
+
+### Let installation order choose name conflicts
+
+OpenCode does not document a precedence rule for all discovered locations. Silent overwrite can
+select different instructions than the UI preview and can merge companion files. Explicitly keeping
+the discovered skill, dropping the managed entry, and logging the decision is deterministic and
+diagnosable.
+
+### Make profiles copied bundles
+
+Copying content into profiles creates drift, multiplies storage, obscures authorship, and makes
+security fixes hard to propagate. Profiles should reference stable skill identities; sessions pin
+the resulting revisions.
+
+### Use R2 packages in V1
+
+R2 is the better long-term home for binary and large content-addressed bundles, but bounded UTF-8
+skill trees fit D1 and avoid new infrastructure, archive parsing, and lifecycle policy in V1. The
+revision/file abstraction allows storage to move behind the store without changing assignments or
+manifests.
+
+### Introduce teams only for skills
+
+This would not isolate repository access, environment secrets, sessions, integrations, or sandbox
+downloads and would give users a false security expectation. True tenancy must be a product-wide
+architecture change.
+
+## Future Version Control
+
+The internal revision model supports a future version-control experience without changing session
+semantics. A later design should add:
+
+- revision list, author, commit message, and file-by-file diff;
+- rollback by moving `current_revision_id` to an existing immutable revision;
+- draft versus published revisions;
+- mutable channels such as staging and production that resolve to immutable revisions;
+- source repository URL, branch/tag, commit SHA, subdirectory, and import digest;
+- export as a standard skill directory or archive;
+- protected promotion and approval actions;
+- eval results linked to a revision;
+- deterministic canary assignment and emergency revocation.
+
+A session must always store the resolved revision ID and digest, never a moving channel such as
+`latest`. Git sync must distinguish source commit, release version, and downloaded content digest.
+
+## Product Validation
+
+V1 deliberately chooses admitted-user editing, personal-only profiles, All for unconfigured bot and
+automation sessions, bounded UTF-8 text packages, discovered-skill precedence on name collision, and
+immediate publication of each successful save. Deleted revisions are retained for at least as long
+as any referencing session. Authorship survives user offboarding and does not make the skill part of
+the departing user's data.
+
+Before implementation, customer discovery should validate that the proposed 1 MiB per-skill and 5
+MiB per-session limits cover initial use cases and that immediate publication is acceptable. If
+binary templates, shared profiles, approval before publication, or a narrower editor population are
+required for launch, this document must return to Draft because those changes affect storage,
+ownership, and workflow rather than being incidental UI additions.
+
+## Research Findings
+
+The design follows recurring patterns from current agent products and configuration systems:
+
+| Source | Relevant lesson |
+| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [Agent Skills specification](https://agentskills.io/specification) | A portable skill is a directory with `SKILL.md`, optional scripts/references/assets, constrained metadata, and progressive disclosure. |
+| [OpenCode skills](https://opencode.ai/docs/skills/) | OpenCode discovers project and global skill locations, loads bodies on demand, supports skill permissions, and requires unique names. |
+| [Claude Code skills](https://code.claude.com/docs/en/skills) | Enterprise, personal, project, and plugin scopes need explicit precedence; plugin namespacing prevents collisions. |
+| [Anthropic Skills API](https://platform.claude.com/docs/en/build-with-claude/skills-guide) | Workspace skills use immutable versions and allow requests to pin a concrete version. |
+| [Codex skills](https://developers.openai.com/codex/build-skills) | Repository, user, admin, and system scopes are distinct; progressive disclosure prevents loading every full skill at startup. |
+| [GitHub Copilot skills](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills) | Skills are explicitly folders of instructions, scripts, and resources and can come from project and personal scopes. |
+| [GitHub CLI skill management](https://cli.github.com/manual/gh_skill) | Distribution benefits from source refs, content digests, validation, and explicit pinning. |
+| [LangSmith prompt management](https://docs.langchain.com/langsmith/manage-prompts) | Immutable commits plus movable environment tags support diff, promotion, and rollback; pinning the resolved commit for run provenance is an Open-Inspect inference. |
+| [PromptLayer registry](https://docs.promptlayer.com/features/prompt-registry) | Prompt changes benefit from release labels, approval controls, evaluations, and production attribution. |
+| [OPA bundles](https://www.openpolicyagent.org/docs/management-bundles) | OPA demonstrates authenticated distribution, optional signature/hash verification, activation reporting, and retaining the prior bundle after failed verified activation. |
+| [SLSA build provenance](https://slsa.dev/spec/v1.2/build-provenance) | Artifact digests, resolved dependencies, builder identity, and invocation context are useful provenance primitives. |
+
+These sources do not define Open-Inspect's product semantics. In particular, the Agent Skills
+standard does not specify assignments, profiles, package transport, versions, provenance, or name
+precedence. Those are platform decisions documented here.
+
+Research was reviewed on August 14, 2026. External products evolve, so implementation should test
+against the OpenCode version pinned in the sandbox image rather than assuming current online docs
+match every deployed runtime.
+
+## Related Open-Inspect Documentation
+
+- [How It Works](../HOW_IT_WORKS.md)
+- [Image Pre-Building](../IMAGE_PREBUILD.md)
+- [Secrets](../SECRETS.md)
+- [Session Snapshot Handoff ADR](../adr/0003-session-snapshot-handoff.md)
+- [Modal Infrastructure](../../packages/modal-infra/README.md)
diff --git a/docs/plans/task-activity-nesting.md b/docs/plans/task-activity-nesting.md
index 78f014af3..617847719 100644
--- a/docs/plans/task-activity-nesting.md
+++ b/docs/plans/task-activity-nesting.md
@@ -77,8 +77,8 @@ The timeline grouping pass will:
3. Associate child events only through `(messageId, taskCallId)`.
4. Keep each Task at its own stable persisted position.
5. Apply the existing consecutive same-tool grouping inside each Task.
-6. Render Task groups expanded initially, with a left guide and nested activity. Users can collapse
- a Task to reduce noise or expand its existing arguments/output details.
+6. Render Task groups collapsed initially to reduce timeline noise. Users can expand a Task to see
+ its nested activity and existing arguments/output details.
Legacy and malformed correlations degrade safely: an event without a matching Task remains in the
normal top-level flow.
diff --git a/eslint.config.js b/eslint.config.js
index a60aa709d..c4124529a 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -75,7 +75,6 @@ export default tseslint.config(
"generateInternalToken",
"verifyCallbackSignature",
"verifyCallbackFromControlPlane",
- "verifyInternalToken",
],
message: "Import auth-owned names from @open-inspect/shared/auth.",
},
@@ -140,6 +139,72 @@ export default tseslint.config(
},
},
+ // Session boundary rules, in the same family as the env.DB ban above. Two
+ // bans, both via the base no-restricted-imports rule so they stack with the
+ // repo-wide @typescript-eslint/no-restricted-imports paths config:
+ // - the composition root (session/components.ts) is the platform adapter's
+ // private wiring: only durable-object.ts may import it — services take
+ // their dependencies as constructor inputs, never by reaching into the
+ // root;
+ // - the platform adapter (session/durable-object.ts) is the Cloudflare
+ // edge of the session: only the worker entrypoint may import it, so
+ // nothing the factory builds can hold a reference back to the DO.
+ // Flat-config gotcha: a later object's config for the same rule REPLACES
+ // the earlier one for files both match, so this general block carries both
+ // bans and each exempted file re-declares the ban that still applies to it.
+ {
+ files: ["packages/control-plane/src/**/*.ts"],
+ ignores: [
+ "packages/control-plane/src/session/durable-object.ts",
+ "packages/control-plane/src/index.ts",
+ "packages/control-plane/src/**/*.test.ts",
+ ],
+ rules: {
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ // Last-segment match: covers any relative depth (./, ../, ../../)
+ // and extension-bearing specifiers. The basename is unique in
+ // this package, so anchoring on it is precise.
+ regex: "(?:^|/)components(?:\\.[cm]?[jt]sx?)?$",
+ message:
+ "Only the platform adapter (session/durable-object.ts) may import the composition root. Take dependencies as constructor inputs instead.",
+ },
+ {
+ regex: "(?:^|/)durable-object(?:\\.[cm]?[jt]sx?)?$",
+ message:
+ "Only the worker entrypoint (src/index.ts) may import the platform adapter. Depend on the session collaborators, not the Durable Object.",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ // The worker entrypoint may import the adapter (it exports the DO class to
+ // the runtime) but not the composition root.
+ {
+ files: ["packages/control-plane/src/index.ts"],
+ rules: {
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ // Last-segment match: covers any relative depth (./, ../, ../../)
+ // and extension-bearing specifiers. The basename is unique in
+ // this package, so anchoring on it is precise.
+ regex: "(?:^|/)components(?:\\.[cm]?[jt]sx?)?$",
+ message:
+ "Only the platform adapter (session/durable-object.ts) may import the composition root. Take dependencies as constructor inputs instead.",
+ },
+ ],
+ },
+ ],
+ },
+ },
+
// React-specific configuration for web package
{
files: ["packages/web/**/*.{ts,tsx}"],
diff --git a/knip.json b/knip.json
new file mode 100644
index 000000000..46e3ae62d
--- /dev/null
+++ b/knip.json
@@ -0,0 +1,62 @@
+{
+ "$schema": "https://unpkg.com/knip@6/schema.json",
+ "ignoreDependencies": ["wrangler", "cloudflare", "postcss-load-config", "vitest"],
+ "ignoreBinaries": ["ruff", "ssh-keygen"],
+ "workspaces": {
+ ".": {
+ "entry": ["scripts/cf-logs.ts", "scripts/merge-split-users.ts", "vitest.workspace.ts"],
+ "project": ["scripts/**/*.ts"],
+ "ignoreDependencies": ["database", "survivor", "loser"]
+ },
+ "packages/shared": {
+ "project": ["src/**/*.ts"],
+ "ignoreIssues": {
+ "src/types/repositories.ts": ["duplicates"]
+ }
+ },
+ "packages/control-plane": {
+ "entry": ["test/integration/**/*.test.ts"],
+ "project": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"]
+ },
+ "packages/github-bot": {
+ "entry": ["test/**/*.test.ts"],
+ "project": ["src/**/*.ts", "test/**/*.ts"]
+ },
+ "packages/linear-bot": {
+ "project": ["src/**/*.ts"]
+ },
+ "packages/slack-bot": {
+ "project": ["src/**/*.ts"]
+ },
+ "packages/opencomputer-infra": {
+ "project": ["src/**/*.ts"]
+ },
+ "packages/sandbox-runtime": {
+ "entry": [
+ "src/sandbox_runtime/bin/upload-media.js",
+ "src/sandbox_runtime/plugins/codex-auth-plugin.js",
+ "src/sandbox_runtime/plugins/inspect-plugin.js",
+ "src/sandbox_runtime/plugins/xai-auth-plugin.js",
+ "src/sandbox_runtime/tools/cancel-child.js",
+ "src/sandbox_runtime/tools/get-child-status.js",
+ "src/sandbox_runtime/tools/send-child-prompt.js",
+ "src/sandbox_runtime/tools/slack-notify.js",
+ "src/sandbox_runtime/tools/spawn-child.js",
+ "src/sandbox_runtime/ttyd_proxy/server.ts"
+ ]
+ },
+ "packages/web": {
+ "next": true,
+ "entry": [
+ "open-next.config.ts",
+ "public/hljs-themes/atom-one-dark.css",
+ "public/hljs-themes/atom-one-light.css",
+ "public/hljs-themes/github-dark.css",
+ "public/hljs-themes/github.css",
+ "src/**/*.test.ts",
+ "src/**/*.test.tsx"
+ ],
+ "project": ["src/**/*.{ts,tsx,css}"]
+ }
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index eef6919b2..19752e3a0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"eslint-plugin-react-hooks": "^5.1.0",
"globals": "^15.14.0",
"husky": "^9.1.7",
+ "knip": "^6.32.1",
"lint-staged": "^16.2.7",
"prettier": "^3.4.2",
"typescript": "^5.7.2",
@@ -33,7 +34,7 @@
"version": "0.9.31",
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
"integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/@adobe/css-tools": {
@@ -89,7 +90,7 @@
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
@@ -106,7 +107,7 @@
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9",
@@ -120,7 +121,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
@@ -130,7 +131,7 @@
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/@ast-grep/napi": {
@@ -1348,7 +1349,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1358,7 +1359,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1368,7 +1369,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
@@ -1394,7 +1395,7 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
@@ -1408,7 +1409,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -1445,7 +1446,7 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
@@ -1617,7 +1618,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -1637,7 +1638,7 @@
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -1661,7 +1662,7 @@
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz",
"integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -1689,7 +1690,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -1712,7 +1713,7 @@
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz",
"integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -1737,7 +1738,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "github",
@@ -1833,6 +1834,7 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1841,9 +1843,9 @@
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
- "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1854,6 +1856,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1867,6 +1870,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1883,6 +1887,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1899,6 +1904,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1915,6 +1921,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1931,6 +1938,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1947,6 +1955,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1963,6 +1972,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1979,6 +1989,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1995,6 +2006,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2011,6 +2023,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2027,6 +2040,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2043,6 +2057,7 @@
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2059,6 +2074,7 @@
"cpu": [
"mips64el"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2075,6 +2091,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2091,6 +2108,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2107,6 +2125,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2123,6 +2142,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2139,6 +2159,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2155,6 +2176,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2171,6 +2193,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2187,6 +2210,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2203,6 +2227,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2219,6 +2244,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2235,6 +2261,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2251,6 +2278,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2267,6 +2295,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2437,7 +2466,7 @@
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
@@ -3158,7 +3187,7 @@
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -3169,7 +3198,7 @@
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -3194,21 +3223,25 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
- "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz",
+ "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
- "@tybys/wasm-util": "^0.10.2"
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1"
+ "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4",
+ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
}
},
"node_modules/@next/env": {
@@ -3256,9 +3289,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3275,9 +3305,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3294,9 +3321,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3313,9 +3337,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3390,7 +3411,7 @@
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
@@ -3606,130 +3627,747 @@
"node": ">=14"
}
},
- "node_modules/@oxc-project/types": {
- "version": "0.133.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
- "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
- "devOptional": true,
+ "node_modules/@oxc-parser/binding-android-arm-eabi": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.143.0.tgz",
+ "integrity": "sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
"license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/Boshen"
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
}
},
- "node_modules/@pierre/diffs": {
- "version": "1.2.12",
- "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.12.tgz",
- "integrity": "sha512-pY/gmgWL03WnagqCyCnBi3QtRXUv4hCIY6FYqd5b1ZGaoI6a4Bsji8j+yRl2RfzPh/8Hf19rCl1GE80G6a1cLQ==",
- "license": "apache-2.0",
- "dependencies": {
- "@pierre/theme": "1.1.0",
- "@pierre/theming": "0.0.2",
- "@shikijs/transformers": "^3.0.0 || ^4.0.0",
- "diff": "9.0.0",
- "hast-util-to-html": "9.0.5",
- "lru_map": "0.4.1",
- "shiki": "^3.0.0 || ^4.0.0"
- },
- "peerDependencies": {
- "react": "^18.3.1 || ^19.0.0",
- "react-dom": "^18.3.1 || ^19.0.0"
+ "node_modules/@oxc-parser/binding-android-arm64": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.143.0.tgz",
+ "integrity": "sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
}
},
- "node_modules/@pierre/diffs/node_modules/diff": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
- "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
- "license": "BSD-3-Clause",
+ "node_modules/@oxc-parser/binding-darwin-arm64": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.143.0.tgz",
+ "integrity": "sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
"engines": {
- "node": ">=0.3.1"
+ "node": "^20.19.0 || >=22.12.0"
}
},
- "node_modules/@pierre/theme": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-1.1.0.tgz",
- "integrity": "sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==",
+ "node_modules/@oxc-parser/binding-darwin-x64": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.143.0.tgz",
+ "integrity": "sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
"engines": {
- "vscode": "^1.0.0"
+ "node": "^20.19.0 || >=22.12.0"
}
},
- "node_modules/@pierre/theming": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/@pierre/theming/-/theming-0.0.2.tgz",
- "integrity": "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==",
- "license": "apache-2.0",
- "peerDependencies": {
- "@pierre/theme": "^1.1.0",
- "@shikijs/themes": "^3.0.0 || ^4.0.0",
- "react": "^18.3.1 || ^19.0.0",
- "react-dom": "^18.3.1 || ^19.0.0",
- "shiki": "^3.0.0 || ^4.0.0"
- },
- "peerDependenciesMeta": {
- "@pierre/theme": {
- "optional": true
- },
- "@shikijs/themes": {
- "optional": true
- },
- "react": {
- "optional": true
- },
- "react-dom": {
- "optional": true
- },
- "shiki": {
- "optional": true
- }
+ "node_modules/@oxc-parser/binding-freebsd-x64": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.143.0.tgz",
+ "integrity": "sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
}
},
- "node_modules/@poppinss/colors": {
- "version": "4.1.6",
- "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
- "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+ "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.143.0.tgz",
+ "integrity": "sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==",
+ "cpu": [
+ "arm"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "kleur": "^4.1.5"
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
}
},
- "node_modules/@poppinss/dumper": {
- "version": "0.6.5",
- "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
- "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+ "node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.143.0.tgz",
+ "integrity": "sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==",
+ "cpu": [
+ "arm"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "@poppinss/colors": "^4.1.5",
- "@sindresorhus/is": "^7.0.2",
- "supports-color": "^10.0.0"
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
}
},
- "node_modules/@poppinss/dumper/node_modules/supports-color": {
- "version": "10.2.2",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
- "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "node_modules/@oxc-parser/binding-linux-arm64-gnu": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.143.0.tgz",
+ "integrity": "sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
"license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
"engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/chalk/supports-color?sponsor=1"
+ "node": "^20.19.0 || >=22.12.0"
}
},
- "node_modules/@poppinss/exception": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
- "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+ "node_modules/@oxc-parser/binding-linux-arm64-musl": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.143.0.tgz",
+ "integrity": "sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
- "license": "MIT"
- },
- "node_modules/@radix-ui/number": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz",
- "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.143.0.tgz",
+ "integrity": "sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.143.0.tgz",
+ "integrity": "sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-riscv64-musl": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.143.0.tgz",
+ "integrity": "sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-s390x-gnu": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.143.0.tgz",
+ "integrity": "sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-x64-gnu": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.143.0.tgz",
+ "integrity": "sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-x64-musl": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.143.0.tgz",
+ "integrity": "sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-openharmony-arm64": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.143.0.tgz",
+ "integrity": "sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-arm64-msvc": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.143.0.tgz",
+ "integrity": "sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-ia32-msvc": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.143.0.tgz",
+ "integrity": "sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-x64-msvc": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.143.0.tgz",
+ "integrity": "sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.133.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
+ "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
+ "devOptional": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-android-arm-eabi": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz",
+ "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-android-arm64": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz",
+ "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-darwin-arm64": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz",
+ "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-darwin-x64": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz",
+ "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-freebsd-x64": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz",
+ "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz",
+ "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz",
+ "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm64-gnu": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz",
+ "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm64-musl": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz",
+ "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz",
+ "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz",
+ "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-riscv64-musl": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz",
+ "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-s390x-gnu": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz",
+ "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-x64-gnu": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz",
+ "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-x64-musl": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz",
+ "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-openharmony-arm64": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz",
+ "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-wasm32-wasi": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz",
+ "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.2",
+ "@emnapi/runtime": "1.11.2",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
+ "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz",
+ "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-win32-x64-msvc": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz",
+ "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@pierre/diffs": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.12.tgz",
+ "integrity": "sha512-pY/gmgWL03WnagqCyCnBi3QtRXUv4hCIY6FYqd5b1ZGaoI6a4Bsji8j+yRl2RfzPh/8Hf19rCl1GE80G6a1cLQ==",
+ "license": "apache-2.0",
+ "dependencies": {
+ "@pierre/theme": "1.1.0",
+ "@pierre/theming": "0.0.2",
+ "@shikijs/transformers": "^3.0.0 || ^4.0.0",
+ "diff": "9.0.0",
+ "hast-util-to-html": "9.0.5",
+ "lru_map": "0.4.1",
+ "shiki": "^3.0.0 || ^4.0.0"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1 || ^19.0.0",
+ "react-dom": "^18.3.1 || ^19.0.0"
+ }
+ },
+ "node_modules/@pierre/diffs/node_modules/diff": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
+ "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/@pierre/theme": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-1.1.0.tgz",
+ "integrity": "sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==",
+ "license": "MIT",
+ "engines": {
+ "vscode": "^1.0.0"
+ }
+ },
+ "node_modules/@pierre/theming": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@pierre/theming/-/theming-0.0.2.tgz",
+ "integrity": "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==",
+ "license": "apache-2.0",
+ "peerDependencies": {
+ "@pierre/theme": "^1.1.0",
+ "@shikijs/themes": "^3.0.0 || ^4.0.0",
+ "react": "^18.3.1 || ^19.0.0",
+ "react-dom": "^18.3.1 || ^19.0.0",
+ "shiki": "^3.0.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@pierre/theme": {
+ "optional": true
+ },
+ "@shikijs/themes": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "shiki": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@poppinss/colors": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
+ "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^4.1.5"
+ }
+ },
+ "node_modules/@poppinss/dumper": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
+ "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@sindresorhus/is": "^7.0.2",
+ "supports-color": "^10.0.0"
+ }
+ },
+ "node_modules/@poppinss/dumper/node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/@poppinss/exception": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
+ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz",
+ "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==",
+ "license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.4",
@@ -4658,6 +5296,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4674,6 +5313,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4690,6 +5330,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4706,6 +5347,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4722,6 +5364,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4738,6 +5381,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4754,6 +5398,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4770,6 +5415,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4786,6 +5432,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4802,6 +5449,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4818,6 +5466,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4834,6 +5483,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4850,6 +5500,7 @@
"cpu": [
"wasm32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4865,6 +5516,7 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4878,6 +5530,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -4894,6 +5547,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5696,9 +6350,10 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
- "version": "0.10.2",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
- "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -6165,7 +6820,7 @@
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
@@ -6335,7 +6990,7 @@
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -6358,7 +7013,7 @@
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -6686,7 +7341,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
"integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
@@ -6698,7 +7353,7 @@
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -6709,7 +7364,7 @@
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/async-function": {
@@ -7100,7 +7755,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
@@ -7234,7 +7889,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/bytes": {
@@ -7772,7 +8427,7 @@
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
@@ -7805,7 +8460,7 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
"integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.0.1",
@@ -7948,7 +8603,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
@@ -8033,7 +8688,7 @@
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/decimal.js-light": {
@@ -8318,7 +8973,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
@@ -8565,7 +9220,7 @@
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
- "devOptional": true,
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -9122,6 +9777,16 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/fd-package-json": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
+ "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "walk-up-path": "^4.0.0"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -9300,6 +9965,22 @@
"node": ">= 0.6"
}
},
+ "node_modules/formatly": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz",
+ "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fd-package-json": "^2.0.0"
+ },
+ "bin": {
+ "formatly": "bin/index.mjs"
+ },
+ "engines": {
+ "node": ">=18.3.0"
+ }
+ },
"node_modules/formdata-node": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
@@ -9521,6 +10202,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-tsconfig": {
+ "version": "4.14.1",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz",
+ "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
"node_modules/glob": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz",
@@ -9632,7 +10326,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -9835,7 +10529,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
@@ -9848,7 +10542,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/html-url-attributes": {
@@ -9896,7 +10590,7 @@
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
@@ -9910,7 +10604,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@@ -10423,7 +11117,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/is-promise": {
@@ -10609,7 +11303,7 @@
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
@@ -10619,7 +11313,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
@@ -10634,7 +11328,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
@@ -10695,9 +11389,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
- "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@@ -10721,7 +11415,7 @@
"version": "28.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@acemir/cssom": "^0.9.31",
@@ -10815,6 +11509,68 @@
"node": ">=6"
}
},
+ "node_modules/knip": {
+ "version": "6.32.2",
+ "resolved": "https://registry.npmjs.org/knip/-/knip-6.32.2.tgz",
+ "integrity": "sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/webpro"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/knip"
+ }
+ ],
+ "license": "ISC",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "formatly": "^0.3.0",
+ "get-tsconfig": "4.14.1",
+ "jiti": "^2.7.0",
+ "oxc-parser": "^0.143.0",
+ "oxc-resolver": "11.24.2",
+ "picomatch": "^4.0.5",
+ "smol-toml": "^1.7.1",
+ "strip-json-comments": "5.0.3",
+ "tinyglobby": "^0.2.17",
+ "unbash": "^4.0.9",
+ "yaml": "^2.9.0",
+ "zod": "^4.4.3"
+ },
+ "bin": {
+ "knip": "bin/knip.js",
+ "knip-bun": "bin/knip-bun.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/knip/node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/knip/node_modules/strip-json-comments": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
+ "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/kysely": {
"version": "0.29.4",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz",
@@ -10875,6 +11631,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -10895,6 +11652,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -10915,6 +11673,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -10935,6 +11694,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -10955,6 +11715,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -10975,6 +11736,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -10995,6 +11757,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11015,6 +11778,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11035,6 +11799,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11055,6 +11820,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11075,6 +11841,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11269,7 +12036,7 @@
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
- "devOptional": true,
+ "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -11309,7 +12076,7 @@
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
@@ -11321,7 +12088,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
@@ -11337,7 +12104,7 @@
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -11651,7 +12418,7 @@
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
- "devOptional": true,
+ "dev": true,
"license": "CC0-1.0"
},
"node_modules/media-typer": {
@@ -12624,9 +13391,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -12643,9 +13407,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -12662,9 +13423,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -12681,9 +13439,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -12700,9 +13455,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -12719,9 +13471,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -12738,9 +13487,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -12757,9 +13503,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -12776,9 +13519,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -12801,9 +13541,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -12826,9 +13563,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -12851,9 +13585,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -12876,9 +13607,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -12901,9 +13629,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -12926,9 +13651,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -12951,9 +13673,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -13432,6 +14151,84 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/oxc-parser": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.143.0.tgz",
+ "integrity": "sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "^0.143.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxc-parser/binding-android-arm-eabi": "0.143.0",
+ "@oxc-parser/binding-android-arm64": "0.143.0",
+ "@oxc-parser/binding-darwin-arm64": "0.143.0",
+ "@oxc-parser/binding-darwin-x64": "0.143.0",
+ "@oxc-parser/binding-freebsd-x64": "0.143.0",
+ "@oxc-parser/binding-linux-arm-gnueabihf": "0.143.0",
+ "@oxc-parser/binding-linux-arm-musleabihf": "0.143.0",
+ "@oxc-parser/binding-linux-arm64-gnu": "0.143.0",
+ "@oxc-parser/binding-linux-arm64-musl": "0.143.0",
+ "@oxc-parser/binding-linux-ppc64-gnu": "0.143.0",
+ "@oxc-parser/binding-linux-riscv64-gnu": "0.143.0",
+ "@oxc-parser/binding-linux-riscv64-musl": "0.143.0",
+ "@oxc-parser/binding-linux-s390x-gnu": "0.143.0",
+ "@oxc-parser/binding-linux-x64-gnu": "0.143.0",
+ "@oxc-parser/binding-linux-x64-musl": "0.143.0",
+ "@oxc-parser/binding-openharmony-arm64": "0.143.0",
+ "@oxc-parser/binding-win32-arm64-msvc": "0.143.0",
+ "@oxc-parser/binding-win32-ia32-msvc": "0.143.0",
+ "@oxc-parser/binding-win32-x64-msvc": "0.143.0"
+ }
+ },
+ "node_modules/oxc-parser/node_modules/@oxc-project/types": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
+ "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/oxc-resolver": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz",
+ "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxc-resolver/binding-android-arm-eabi": "11.24.2",
+ "@oxc-resolver/binding-android-arm64": "11.24.2",
+ "@oxc-resolver/binding-darwin-arm64": "11.24.2",
+ "@oxc-resolver/binding-darwin-x64": "11.24.2",
+ "@oxc-resolver/binding-freebsd-x64": "11.24.2",
+ "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2",
+ "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2",
+ "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2",
+ "@oxc-resolver/binding-linux-arm64-musl": "11.24.2",
+ "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2",
+ "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2",
+ "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2",
+ "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2",
+ "@oxc-resolver/binding-linux-x64-gnu": "11.24.2",
+ "@oxc-resolver/binding-linux-x64-musl": "11.24.2",
+ "@oxc-resolver/binding-openharmony-arm64": "11.24.2",
+ "@oxc-resolver/binding-wasm32-wasi": "11.24.2",
+ "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2",
+ "@oxc-resolver/binding-win32-x64-msvc": "11.24.2"
+ }
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -13513,7 +14310,7 @@
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
@@ -13612,9 +14409,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -13956,7 +14753,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -14441,7 +15238,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -14487,6 +15284,16 @@
"node": ">=4"
}
},
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
@@ -14678,7 +15485,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
@@ -15044,6 +15851,19 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/smol-toml": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
+ "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyyynthia"
+ }
+ },
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@@ -15058,7 +15878,7 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -15077,7 +15897,7 @@
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
@@ -15416,7 +16236,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@@ -15454,7 +16274,7 @@
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/tailwind-merge": {
@@ -15560,7 +16380,7 @@
"version": "5.16.9",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.16.9.tgz",
"integrity": "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.2",
@@ -15579,7 +16399,7 @@
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/thenify": {
@@ -15656,7 +16476,7 @@
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz",
"integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.3"
@@ -15669,7 +16489,7 @@
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz",
"integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/to-regex-range": {
@@ -15698,7 +16518,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
@@ -15711,7 +16531,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
@@ -15934,6 +16754,16 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
+ "node_modules/unbash": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.10.tgz",
+ "integrity": "sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -15954,10 +16784,10 @@
}
},
"node_modules/undici": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
- "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
- "devOptional": true,
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -16428,7 +17258,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
@@ -16437,6 +17267,16 @@
"node": ">=18"
}
},
+ "node_modules/walk-up-path": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
+ "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
@@ -16450,7 +17290,7 @@
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
@@ -16460,7 +17300,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
@@ -16470,7 +17310,7 @@
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
@@ -16752,7 +17592,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
@@ -16778,7 +17618,7 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/y18n": {
@@ -16795,7 +17635,6 @@
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
- "devOptional": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
@@ -16917,7 +17756,8 @@
"@cloudflare/workers-types": "^4.20241230.0",
"@open-inspect/shared": "file:../shared",
"better-auth": "1.6.25",
- "zod": "^4.1.13"
+ "yaml": "^2.9.0",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.16.18",
@@ -17016,7 +17856,6 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
- "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/typography": "^0.5.19",
diff --git a/package.json b/package.json
index 19a6de900..aef1af3ae 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"test:coverage": "npm run test:coverage --workspaces --if-present",
"test:integration": "npm run test:integration --workspaces --if-present",
"typecheck": "npm run build -w @open-inspect/shared && npm run typecheck --workspaces --if-present",
+ "knip": "knip",
"build": "npm run build -w @open-inspect/shared && npm run build --workspaces --if-present",
"build:opencomputer-template": "npm run build-template -w @open-inspect/opencomputer-infra --",
"prepare": "node -e \"if (process.env.CI) process.exit(0)\" && husky"
@@ -30,6 +31,7 @@
"eslint-plugin-react-hooks": "^5.1.0",
"globals": "^15.14.0",
"husky": "^9.1.7",
+ "knip": "^6.32.1",
"lint-staged": "^16.2.7",
"prettier": "^3.4.2",
"typescript": "^5.7.2",
@@ -41,7 +43,7 @@
},
"overrides": {
"minimatch": "^10.2.5",
- "undici": "^7.28.0"
+ "undici": "^7.29.0"
},
"lint-staged": {
"*.{ts,tsx}": [
@@ -51,11 +53,7 @@
"*.{js,jsx,json,md,yaml,yml,css}": [
"prettier --write"
],
- "packages/modal-infra/**/*.py": [
- "ruff check --fix",
- "ruff format"
- ],
- "packages/sandbox-runtime/**/*.py": [
+ "packages/{daytona-infra,e2b-infra,modal-infra,sandbox-runtime}/**/*.py": [
"ruff check --fix",
"ruff format"
]
diff --git a/packages/control-plane/README.md b/packages/control-plane/README.md
index 32b4ed1f4..a0b8b3cf2 100644
--- a/packages/control-plane/README.md
+++ b/packages/control-plane/README.md
@@ -59,8 +59,9 @@ The control plane provides:
| ------------------------------- | --------- | ------------------------------ |
| `/sessions` | GET | List user's sessions |
| `/sessions` | POST | Create new session |
-| `/sessions/:id` | GET | Get session state |
+| `/sessions/:id` | GET | Get canonical session snapshot |
| `/sessions/:id` | DELETE | Delete session |
+| `/sessions/:id/sandbox-access` | GET | Get sandbox connection details |
| `/sessions/:id/prompt` | POST | Enqueue prompt |
| `/sessions/:id/stop` | POST | Stop execution |
| `/sessions/:id/ws` | WebSocket | Real-time connection |
@@ -86,6 +87,12 @@ The control plane provides:
When `headBranch` is omitted, control-plane resolves it from session state and finally falls back to
the generated `open-inspect/` branch.
+A session can hold multiple pull requests per repository — one open PR per head branch. Calling the
+endpoint again for a head branch that already carries an open PR force-pushes the branch and reuses
+that PR instead of creating a duplicate; the response marks this with `updated: true`. A merged or
+closed PR releases its head branch for a fresh PR. The success response is
+`{ prNumber, prUrl, state, headBranch, baseBranch, updated }`.
+
### SCM Credentials
`POST /sessions/:id/scm-credentials` is a sandbox-authenticated endpoint used by the in-sandbox git
@@ -344,19 +351,43 @@ runtime constructs Better Auth and the immutable provider list from the same con
`GET /internal/auth/sign-in-providers` exposes only those identifiers to signed `service:web`
requests so the React `/login` route can render them server-side.
-## Token Encryption
+## Credential Encryption
-GitHub OAuth tokens are encrypted at rest using AES-256-GCM:
+Three independent key domains protect stored credentials. Rotation guidance differs — never treat
+them as interchangeable during an incident:
-```typescript
-import { encryptToken, decryptToken } from "./auth/crypto";
+- **`TOKEN_ENCRYPTION_KEY`** — AES-256-GCM for the SCM enrichment tokens in `user_scm_tokens`:
-// Encrypt before storing
-const encrypted = await encryptToken(accessToken, env.TOKEN_ENCRYPTION_KEY);
+ ```typescript
+ import { encryptToken, decryptToken } from "./auth/crypto";
-// Decrypt when needed
-const token = await decryptToken(encrypted, env.TOKEN_ENCRYPTION_KEY);
-```
+ // Encrypt before storing
+ const encrypted = await encryptToken(accessToken, env.TOKEN_ENCRYPTION_KEY);
+
+ // Decrypt when needed
+ const token = await decryptToken(encrypted, env.TOKEN_ENCRYPTION_KEY);
+ ```
+
+ Rotating it invalidates stored SCM tokens; affected users re-link their SCM connection.
+
+- **`BROWSER_AUTH_SECRET`** — Better Auth's secret. It signs browser session cookies **and**
+ encrypts the sign-in OAuth credential columns on `user_identities` (`access_token`,
+ `refresh_token`, `id_token`, written at web sign-in and read via `auth.api.getAccessToken`).
+ Rotating it signs every browser session out and orphans those stored credentials — they
+ re-populate at each user's next sign-in. It does not affect `user_scm_tokens`.
+
+- **`PROVIDER_ACCOUNTS_ENCRYPTION_KEY`** — dedicated AES-256-GCM key for subscription-provider
+ account credentials. Provider account mode stores only account references on sessions and brokers
+ short-lived access through `POST /sessions/:id/provider-auth/:provider/access-token`. Rotation
+ requires an explicit migration that can decrypt every credential with the old key and re-encrypt
+ it with the new key while both are available, then verifies the migrated data before changing the
+ Worker binding. Reconnecting accounts does not migrate already encrypted rows. If the old key is
+ lost, remove affected defaults and archive/recreate the accounts; sessions bound to the lost
+ credentials are unrecoverable and must be recreated.
+
+Legacy scoped OpenAI/xAI OAuth and provider accounts can coexist. Explicit choices and provider
+defaults apply to newly created sessions; sessions without either retain legacy scoped behavior.
+Existing sessions remain pinned to their stored authentication mode.
## Security Model
@@ -406,6 +437,10 @@ All secrets are configured via Terraform. Required secrets include:
Optional variables:
+- `provider_accounts_encryption_key` - Existing Base64 AES-256-GCM key override for provider account
+ credentials. When blank, Terraform generates a key and persists it in state. In both cases,
+ Terraform supplies the required `PROVIDER_ACCOUNTS_ENCRYPTION_KEY` Worker secret binding.
+
- `SCM_PROVIDER` - Source control provider for this deployment (`github`, `bitbucket`, or `gitlab`,
default: `github`). `bitbucket` returns explicit `501 Not Implemented` responses until
implemented.
diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json
index 5d764fd7c..eb530b0d4 100644
--- a/packages/control-plane/package.json
+++ b/packages/control-plane/package.json
@@ -17,7 +17,8 @@
"@cloudflare/workers-types": "^4.20241230.0",
"@open-inspect/shared": "file:../shared",
"better-auth": "1.6.25",
- "zod": "^4.1.13"
+ "yaml": "^2.9.0",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.16.18",
diff --git a/packages/control-plane/src/auth/authenticate.test.ts b/packages/control-plane/src/auth/authenticate.test.ts
index 59d9ea631..a28eef733 100644
--- a/packages/control-plane/src/auth/authenticate.test.ts
+++ b/packages/control-plane/src/auth/authenticate.test.ts
@@ -11,6 +11,7 @@ import { generateInternalToken } from "@open-inspect/shared/auth";
import { authenticate, isAuthError, SERVICE_REQUEST_MAX_BODY_BYTES } from "./authenticate";
import type { RequestContext } from "../routes/shared";
import type { Env } from "../types";
+import { TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support";
const SECRETS = {
SERVICE_AUTH_SECRET_WEB: "web-secret",
@@ -36,6 +37,7 @@ function createCtx(identityRow: Record | null = null): RequestC
return {
trace_id: "trace-test",
request_id: "req-test",
+ executionCtx: TEST_BACKGROUND_TASK_CONTEXT,
metrics: { summarize: () => ({}) },
db: { prepare: vi.fn(() => statement), batch: vi.fn(), exec: vi.fn() },
} as unknown as RequestContext;
@@ -324,6 +326,7 @@ describe("authenticate — compound browser credentials", () => {
}
it("requires the web sig1 channel and Better Auth session for a browser resource", async () => {
+ const userId = "0123456789abcdef0123456789abcdef";
const request = await signedRequest({
service: "web",
method: "GET",
@@ -333,8 +336,8 @@ describe("authenticate — compound browser credentials", () => {
},
});
const ctx = createUserAuthContext({
- session: { id: "session-1", userId: "user-1" },
- user: { id: "user-1" },
+ session: { id: "session-1", userId },
+ user: { id: userId },
});
const result = await authenticate(request, createEnv(), ctx, {
@@ -343,7 +346,7 @@ describe("authenticate — compound browser credentials", () => {
expect(isAuthError(result)).toBe(false);
if (isAuthError(result)) return;
- expect(result.principal).toEqual({ kind: "user", userId: "user-1" });
+ expect(result.principal).toEqual({ kind: "user", userId });
expect(result.authentication).toEqual({
mechanism: "browser_session",
credentialId: "session-1",
diff --git a/packages/control-plane/src/auth/authenticate.ts b/packages/control-plane/src/auth/authenticate.ts
index ec753ef0a..c237dbdd3 100644
--- a/packages/control-plane/src/auth/authenticate.ts
+++ b/packages/control-plane/src/auth/authenticate.ts
@@ -20,7 +20,7 @@ import type { Env } from "../types";
const logger = createLogger("auth");
-export { isAuthError, type AuthError, type AuthResult } from "./result";
+export { isAuthError, type AuthResult } from "./result";
export { SERVICE_REQUEST_MAX_BODY_BYTES } from "./service/request-authenticator";
export interface AuthenticationRequirement {
diff --git a/packages/control-plane/src/auth/claimed-provider-credential-exchange.test.ts b/packages/control-plane/src/auth/claimed-provider-credential-exchange.test.ts
new file mode 100644
index 000000000..868c9288a
--- /dev/null
+++ b/packages/control-plane/src/auth/claimed-provider-credential-exchange.test.ts
@@ -0,0 +1,364 @@
+import { describe, expect, it, vi } from "vitest";
+import type { ProviderCredentialState } from "../db/provider-account-credentials";
+import {
+ ModelProviderAccountAdapterRegistry,
+ ProviderRefreshError,
+ type ModelProviderAccountAdapter,
+} from "./model-provider-account-adapters";
+import {
+ ClaimedProviderCredentialExchange,
+ type ClaimedProviderCredentialExchangeStore,
+} from "./claimed-provider-credential-exchange";
+
+type Credential = { refreshToken: string };
+
+const NOW = 1_000;
+
+function state(): ProviderCredentialState {
+ return {
+ payload: { refreshToken: "stored" },
+ credentialSchemaVersion: 1,
+ credentialVersion: 3,
+ exchangeGeneration: 0,
+ exchangeState: "idle",
+ exchangeOwner: null,
+ exchangeStartedAt: null,
+ accessTokenExpiresAt: null,
+ updatedAt: 1,
+ };
+}
+
+function adapter(
+ refresh: ModelProviderAccountAdapter["refresh"] = vi.fn().mockResolvedValue({
+ credential: { refreshToken: "rotated" },
+ accessToken: "access",
+ accessTokenExpiresAt: 4_000,
+ })
+): ModelProviderAccountAdapter {
+ return {
+ provider: "openai",
+ credentialSchemaVersion: 1,
+ refreshBufferMs: 1,
+ parseConnectInput: vi.fn() as never,
+ connect: vi.fn() as never,
+ parseCredential: vi.fn((value) => value as Credential),
+ refresh,
+ cachedAccess: vi.fn(() => null),
+ validateReconnectInputIdentity: vi.fn() as never,
+ runtimeMetadata: vi.fn(() => ({})),
+ validateExternalIdentity: vi.fn(),
+ };
+}
+
+function setup(providerAdapter = adapter()) {
+ const calls: string[] = [];
+ const complete = vi.fn(async () => {
+ calls.push("complete");
+ return true;
+ });
+ const store: ClaimedProviderCredentialExchangeStore = {
+ tryBeginExchange: vi.fn(async () => {
+ calls.push("claim");
+ return { acquired: true, generation: 7 };
+ }),
+ clearSafeFailure: vi.fn(async () => {
+ calls.push("clear");
+ return true;
+ }),
+ };
+ const terminalFailure = vi.fn(async () => {
+ calls.push("terminal");
+ return true;
+ });
+ const registry = new ModelProviderAccountAdapterRegistry([providerAdapter]);
+ const exchange = new ClaimedProviderCredentialExchange(store, terminalFailure);
+ return {
+ calls,
+ complete,
+ exchange,
+ providerAdapter: registry.get("openai")!,
+ store,
+ terminalFailure,
+ };
+}
+
+describe("ClaimedProviderCredentialExchange", () => {
+ it("claims before parsing and refresh, then dispatches one fenced completion", async () => {
+ const { calls, complete, exchange, providerAdapter } = setup();
+ vi.mocked(providerAdapter.parseCredential).mockImplementation((value) => {
+ calls.push("parse");
+ return value as Credential;
+ });
+ vi.mocked(providerAdapter.refresh).mockImplementation(async () => {
+ calls.push("refresh");
+ return {
+ credential: { refreshToken: "rotated" },
+ accessToken: "access",
+ accessTokenExpiresAt: 4_000,
+ };
+ });
+
+ await expect(
+ exchange.run({
+ providerAccountId: "account-1",
+ provider: "openai",
+ state: state(),
+ expectedAccountStatus: "active",
+ adapter: providerAdapter,
+ owner: "owner-1",
+ now: () => NOW,
+ complete,
+ })
+ ).resolves.toMatchObject({ kind: "completed", refreshed: { accessToken: "access" } });
+
+ expect(calls).toEqual(["claim", "parse", "refresh", "complete"]);
+ expect(complete).toHaveBeenCalledWith(
+ expect.objectContaining({
+ write: expect.objectContaining({
+ providerAccountId: "account-1",
+ expectedCredentialVersion: 3,
+ exchangeGeneration: 7,
+ exchangeOwner: "owner-1",
+ expectedAccountStatus: "active",
+ payload: { refreshToken: "rotated" },
+ }),
+ })
+ );
+ });
+
+ it("does not parse or refresh when the durable claim is unavailable", async () => {
+ const { complete, exchange, providerAdapter, store } = setup();
+ vi.mocked(store.tryBeginExchange).mockResolvedValue({ acquired: false });
+
+ await expect(
+ exchange.run({
+ providerAccountId: "account-1",
+ provider: "openai",
+ state: state(),
+ expectedAccountStatus: "active",
+ adapter: providerAdapter,
+ owner: "owner-1",
+ now: () => NOW,
+ complete,
+ })
+ ).resolves.toEqual({ kind: "claim_unavailable" });
+ expect(providerAdapter.parseCredential).not.toHaveBeenCalled();
+ expect(providerAdapter.refresh).not.toHaveBeenCalled();
+ });
+
+ it("clears the fenced claim after retry-safe refresh failure", async () => {
+ const failure = new ProviderRefreshError("retry", "retry_safe");
+ const { complete, exchange, providerAdapter, store, terminalFailure } = setup(
+ adapter(vi.fn().mockRejectedValue(failure))
+ );
+
+ await expect(
+ exchange.run({
+ providerAccountId: "account-1",
+ provider: "openai",
+ state: state(),
+ expectedAccountStatus: "active",
+ adapter: providerAdapter,
+ owner: "owner-1",
+ now: () => NOW,
+ complete,
+ })
+ ).rejects.toMatchObject({ phase: "refresh", cause: failure });
+ expect(store.clearSafeFailure).toHaveBeenCalledWith("account-1", 3, 7, "owner-1", NOW);
+ expect(terminalFailure).not.toHaveBeenCalled();
+ });
+
+ it("preserves a retry-safe refresh failure when clearing the claim also fails", async () => {
+ const failure = new ProviderRefreshError("retry", "retry_safe");
+ const { complete, exchange, providerAdapter, store } = setup(
+ adapter(vi.fn().mockRejectedValue(failure))
+ );
+ vi.mocked(store.clearSafeFailure).mockRejectedValue(new Error("D1 unavailable"));
+
+ await expect(
+ exchange.run({
+ providerAccountId: "account-1",
+ provider: "openai",
+ state: state(),
+ expectedAccountStatus: "active",
+ adapter: providerAdapter,
+ owner: "owner-1",
+ now: () => NOW,
+ complete,
+ })
+ ).rejects.toMatchObject({ phase: "refresh", cause: failure });
+ });
+
+ it.each(["ambiguous", "unauthorized"] as const)(
+ "atomically fences the claim and requires reconnect after a %s refresh failure",
+ async (classification) => {
+ const failure = new ProviderRefreshError(classification, classification);
+ const { complete, exchange, providerAdapter, store, terminalFailure } = setup(
+ adapter(vi.fn().mockRejectedValue(failure))
+ );
+
+ await expect(
+ exchange.run({
+ providerAccountId: "account-1",
+ provider: "openai",
+ state: state(),
+ expectedAccountStatus: "active",
+ adapter: providerAdapter,
+ owner: "owner-1",
+ now: () => NOW,
+ complete,
+ })
+ ).rejects.toMatchObject({
+ phase: "refresh",
+ terminalFence: "committed",
+ });
+ expect(terminalFailure).toHaveBeenCalledWith({
+ providerAccountId: "account-1",
+ credentialVersion: 3,
+ exchangeGeneration: 7,
+ exchangeOwner: "owner-1",
+ now: NOW,
+ });
+ expect(store.clearSafeFailure).not.toHaveBeenCalled();
+ }
+ );
+
+ it("reports when terminal fencing loses the refresh claim", async () => {
+ const failure = new ProviderRefreshError("ambiguous", "ambiguous");
+ const { complete, exchange, providerAdapter, terminalFailure } = setup(
+ adapter(vi.fn().mockRejectedValue(failure))
+ );
+ terminalFailure.mockResolvedValue(false);
+
+ await expect(
+ exchange.run({
+ providerAccountId: "account-1",
+ provider: "openai",
+ state: state(),
+ expectedAccountStatus: "active",
+ adapter: providerAdapter,
+ owner: "owner-1",
+ now: () => NOW,
+ complete,
+ })
+ ).rejects.toMatchObject({
+ phase: "refresh",
+ cause: failure,
+ terminalFence: "lost",
+ });
+ });
+
+ it("clears the claim when credential parsing fails before refresh dispatch", async () => {
+ const { complete, exchange, providerAdapter, store } = setup();
+ vi.mocked(providerAdapter.parseCredential).mockImplementation(() => {
+ throw new Error("invalid credential");
+ });
+
+ await expect(
+ exchange.run({
+ providerAccountId: "account-1",
+ provider: "openai",
+ state: state(),
+ expectedAccountStatus: "active",
+ adapter: providerAdapter,
+ owner: "owner-1",
+ now: () => NOW,
+ complete,
+ })
+ ).rejects.toMatchObject({ phase: "parse" });
+ expect(store.clearSafeFailure).toHaveBeenCalledWith("account-1", 3, 7, "owner-1", NOW);
+ });
+
+ it("preserves a parse failure when clearing the claim also fails", async () => {
+ const { complete, exchange, providerAdapter, store } = setup();
+ const parseFailure = new Error("invalid credential");
+ vi.mocked(providerAdapter.parseCredential).mockImplementation(() => {
+ throw parseFailure;
+ });
+ vi.mocked(store.clearSafeFailure).mockRejectedValue(new Error("D1 unavailable"));
+
+ await expect(
+ exchange.run({
+ providerAccountId: "account-1",
+ provider: "openai",
+ state: state(),
+ expectedAccountStatus: "active",
+ adapter: providerAdapter,
+ owner: "owner-1",
+ now: () => NOW,
+ complete,
+ })
+ ).rejects.toMatchObject({ phase: "parse", cause: parseFailure });
+ });
+
+ it("atomically fences when the caller's completion cannot commit", async () => {
+ const { exchange, providerAdapter, terminalFailure } = setup();
+ const complete = vi.fn().mockResolvedValue(false);
+
+ await expect(
+ exchange.run({
+ providerAccountId: "account-1",
+ provider: "openai",
+ state: state(),
+ expectedAccountStatus: "active",
+ adapter: providerAdapter,
+ owner: "owner-1",
+ now: () => NOW,
+ complete,
+ })
+ ).rejects.toMatchObject({ phase: "completion" });
+ expect(complete).toHaveBeenCalledWith(
+ expect.objectContaining({
+ write: expect.objectContaining({
+ expectedCredentialVersion: 3,
+ exchangeGeneration: 7,
+ exchangeOwner: "owner-1",
+ expectedAccountStatus: "active",
+ }),
+ })
+ );
+ expect(terminalFailure).toHaveBeenCalledWith({
+ providerAccountId: "account-1",
+ credentialVersion: 3,
+ exchangeGeneration: 7,
+ exchangeOwner: "owner-1",
+ now: NOW,
+ });
+ });
+
+ it("reports when terminal fencing loses a failed completion claim", async () => {
+ const { exchange, providerAdapter, terminalFailure } = setup();
+ terminalFailure.mockResolvedValue(false);
+
+ await expect(
+ exchange.run({
+ providerAccountId: "account-1",
+ provider: "openai",
+ state: state(),
+ expectedAccountStatus: "active",
+ adapter: providerAdapter,
+ owner: "owner-1",
+ now: () => NOW,
+ complete: vi.fn().mockResolvedValue(false),
+ })
+ ).rejects.toMatchObject({ phase: "completion", terminalFence: "lost" });
+ });
+
+ it("preserves a failed completion when terminal fencing also fails", async () => {
+ const { exchange, providerAdapter, terminalFailure } = setup();
+ terminalFailure.mockRejectedValue(new Error("D1 unavailable"));
+
+ await expect(
+ exchange.run({
+ providerAccountId: "account-1",
+ provider: "openai",
+ state: state(),
+ expectedAccountStatus: "active",
+ adapter: providerAdapter,
+ owner: "owner-1",
+ now: () => NOW,
+ complete: vi.fn().mockResolvedValue(false),
+ })
+ ).rejects.toMatchObject({ phase: "completion", terminalFence: "lost" });
+ });
+});
diff --git a/packages/control-plane/src/auth/claimed-provider-credential-exchange.ts b/packages/control-plane/src/auth/claimed-provider-credential-exchange.ts
new file mode 100644
index 000000000..9c16fa55d
--- /dev/null
+++ b/packages/control-plane/src/auth/claimed-provider-credential-exchange.ts
@@ -0,0 +1,178 @@
+import type {
+ CompleteProviderExchangeInput,
+ ProviderCredentialExchangeAccountStatus,
+ ProviderCredentialState,
+ ProviderCredentialStore,
+} from "../db/provider-account-credentials";
+import type { FenceProviderCredentialExchangeInput } from "../db/model-provider-account-atomic-writer";
+import type { ModelProviderId } from "../model-provider-accounts/provider-auth-contracts";
+import {
+ ProviderRefreshError,
+ type ModelProviderAccountAdapter,
+ type ProviderRefreshResult,
+} from "./model-provider-account-adapters";
+
+type ErasedProviderAccountAdapter = ModelProviderAccountAdapter;
+
+export type ClaimedProviderCredentialExchangeStore = Pick<
+ ProviderCredentialStore,
+ "tryBeginExchange" | "clearSafeFailure"
+>;
+
+type ProviderCredentialTerminalFailure = (
+ input: FenceProviderCredentialExchangeInput
+) => Promise;
+
+interface CompletionContext {
+ write: CompleteProviderExchangeInput & { now: number };
+ refreshed: ProviderRefreshResult;
+}
+
+export interface ClaimedProviderCredentialExchangeRequest {
+ providerAccountId: string;
+ provider: ModelProviderId;
+ state: ProviderCredentialState;
+ expectedAccountStatus: ProviderCredentialExchangeAccountStatus;
+ adapter: ErasedProviderAccountAdapter;
+ owner: string;
+ now: () => number;
+ complete: (context: CompletionContext) => Promise;
+}
+
+export type ClaimedProviderCredentialExchangeResult =
+ | { kind: "claim_unavailable" }
+ | {
+ kind: "completed";
+ refreshed: ProviderRefreshResult;
+ };
+
+export class ClaimedProviderCredentialExchangeError extends Error {
+ constructor(
+ readonly phase: "parse" | "refresh" | "completion",
+ cause: unknown,
+ readonly terminalFence: "not_attempted" | "committed" | "lost" = "not_attempted"
+ ) {
+ super(`Provider credential exchange failed during ${phase}`, { cause });
+ }
+}
+
+export class ClaimedProviderCredentialExchange {
+ constructor(
+ private readonly store: ClaimedProviderCredentialExchangeStore,
+ private readonly terminalFailure: ProviderCredentialTerminalFailure
+ ) {}
+
+ async run(
+ request: ClaimedProviderCredentialExchangeRequest
+ ): Promise {
+ const claim = await this.store.tryBeginExchange(
+ request.providerAccountId,
+ request.state.credentialVersion,
+ request.owner,
+ request.expectedAccountStatus,
+ request.now()
+ );
+ if (!claim.acquired) return { kind: "claim_unavailable" };
+
+ let credential: unknown;
+ try {
+ credential = request.adapter.parseCredential(
+ request.state.payload,
+ request.state.credentialSchemaVersion
+ );
+ } catch (cause) {
+ await this.clearAfterFailure(request, claim.generation);
+ throw new ClaimedProviderCredentialExchangeError("parse", cause);
+ }
+
+ let refreshed: ProviderRefreshResult;
+ try {
+ refreshed = await request.adapter.refresh(credential, request.now());
+ } catch (cause) {
+ if (cause instanceof ProviderRefreshError && cause.classification === "retry_safe") {
+ await this.clearAfterFailure(request, claim.generation);
+ throw new ClaimedProviderCredentialExchangeError("refresh", cause);
+ }
+ const fenced = await this.failTerminally(request, claim.generation);
+ throw new ClaimedProviderCredentialExchangeError(
+ "refresh",
+ cause,
+ fenced ? "committed" : "lost"
+ );
+ }
+
+ const write: CompleteProviderExchangeInput & { now: number } = {
+ providerAccountId: request.providerAccountId,
+ provider: request.provider,
+ credentialSchemaVersion: request.adapter.credentialSchemaVersion,
+ payload: refreshed.credential,
+ accessTokenExpiresAt: refreshed.accessTokenExpiresAt,
+ expectedCredentialVersion: request.state.credentialVersion,
+ exchangeGeneration: claim.generation,
+ exchangeOwner: request.owner,
+ expectedAccountStatus: request.expectedAccountStatus,
+ now: request.now(),
+ };
+ try {
+ const completed = await request.complete({ write, refreshed });
+ if (!completed) {
+ throw new ClaimedProviderCredentialExchangeError(
+ "completion",
+ new Error("Provider credential exchange lost its durable claim")
+ );
+ }
+ } catch (cause) {
+ const fenced = await this.failTerminally(request, claim.generation);
+ const underlying =
+ cause instanceof ClaimedProviderCredentialExchangeError ? cause.cause : cause;
+ throw new ClaimedProviderCredentialExchangeError(
+ "completion",
+ underlying,
+ fenced ? "committed" : "lost"
+ );
+ }
+
+ return { kind: "completed", refreshed };
+ }
+
+ private clear(request: ClaimedProviderCredentialExchangeRequest, generation: number) {
+ return this.store.clearSafeFailure(
+ request.providerAccountId,
+ request.state.credentialVersion,
+ generation,
+ request.owner,
+ request.now()
+ );
+ }
+
+ private async clearAfterFailure(
+ request: ClaimedProviderCredentialExchangeRequest,
+ generation: number
+ ): Promise {
+ try {
+ await this.clear(request, generation);
+ } catch {
+ // The original classified failure is authoritative. A failed clear leaves
+ // the durable lease in flight until the stale-exchange path reconciles it.
+ }
+ }
+
+ private async failTerminally(
+ request: ClaimedProviderCredentialExchangeRequest,
+ generation: number
+ ): Promise {
+ try {
+ return await this.terminalFailure({
+ providerAccountId: request.providerAccountId,
+ credentialVersion: request.state.credentialVersion,
+ exchangeGeneration: generation,
+ exchangeOwner: request.owner,
+ now: request.now(),
+ });
+ } catch {
+ // Preserve the original exchange failure and force the caller through
+ // authoritative reconciliation when terminal fencing cannot be observed.
+ return false;
+ }
+ }
+}
diff --git a/packages/control-plane/src/auth/github-app.ts b/packages/control-plane/src/auth/github-app.ts
index 1d8f3db74..eb6c422d5 100644
--- a/packages/control-plane/src/auth/github-app.ts
+++ b/packages/control-plane/src/auth/github-app.ts
@@ -17,7 +17,7 @@ import { z } from "zod";
import { base64UrlEncode } from "./encoding";
/** Timeout for individual GitHub API requests (ms). */
-export const GITHUB_FETCH_TIMEOUT_MS = 60_000;
+const GITHUB_FETCH_TIMEOUT_MS = 60_000;
/** Cache installation tokens for this duration at most (ms). */
export const INSTALLATION_TOKEN_CACHE_MAX_AGE_MS = 50 * 60 * 1000;
@@ -26,7 +26,7 @@ export const INSTALLATION_TOKEN_CACHE_MAX_AGE_MS = 50 * 60 * 1000;
export const INSTALLATION_TOKEN_MIN_REMAINING_MS = 5 * 60 * 1000;
/** Upper bound for KV cache TTL (seconds). */
-export const INSTALLATION_TOKEN_CACHE_MAX_TTL_SECONDS = 3600;
+const INSTALLATION_TOKEN_CACHE_MAX_TTL_SECONDS = 3600;
const INSTALLATION_TOKEN_CACHE_KEY_PREFIX = "github:installation-token:v1";
@@ -73,7 +73,7 @@ export function fetchWithTimeout(
}
/** Per-page timing record returned from listInstallationRepositories. */
-export interface GitHubPageTiming {
+interface GitHubPageTiming {
page: number;
fetchMs: number;
repoCount: number;
@@ -205,7 +205,7 @@ async function importPrivateKeyCached(pem: string): Promise {
* @param privateKey - PEM-encoded private key
* @returns Signed JWT valid for 10 minutes
*/
-export async function generateAppJwt(appId: string, privateKey: string): Promise {
+async function generateAppJwt(appId: string, privateKey: string): Promise {
const now = Math.floor(Date.now() / 1000);
// JWT header
diff --git a/packages/control-plane/src/auth/github.test.ts b/packages/control-plane/src/auth/github.test.ts
index 4bd17cd80..13dd89807 100644
--- a/packages/control-plane/src/auth/github.test.ts
+++ b/packages/control-plane/src/auth/github.test.ts
@@ -1,21 +1,20 @@
import { describe, expect, it, vi, afterEach } from "vitest";
-import { exchangeCodeForToken, refreshAccessToken } from "./github";
-import type { GitHubOAuthConfig } from "./github";
-import type { GitHubTokenResponse } from "../types";
+import { GITHUB_OAUTH_REQUEST_TIMEOUT_MS, refreshAccessToken } from "./github";
+import type { GitHubOAuthConfig, GitHubTokenResponse } from "./github";
describe("github auth", () => {
const originalFetch = globalThis.fetch;
const config: GitHubOAuthConfig = {
clientId: "client-id",
clientSecret: "client-secret",
- encryptionKey: "unused",
};
afterEach(() => {
globalThis.fetch = originalFetch;
+ vi.restoreAllMocks();
});
- describe("exchangeCodeForToken", () => {
+ describe("refreshAccessToken", () => {
it("parses a valid token response", async () => {
const tokenResponse: GitHubTokenResponse = {
access_token: "gho_token",
@@ -29,7 +28,7 @@ describe("github auth", () => {
json: () => Promise.resolve(tokenResponse),
} as unknown as Response);
- await expect(exchangeCodeForToken("code", config)).resolves.toEqual(tokenResponse);
+ await expect(refreshAccessToken("old-refresh", config)).resolves.toEqual(tokenResponse);
});
it("parses a valid token response with optional fields omitted", async () => {
@@ -43,7 +42,7 @@ describe("github auth", () => {
json: () => Promise.resolve(tokenResponse),
} as unknown as Response);
- await expect(exchangeCodeForToken("code", config)).resolves.toEqual(tokenResponse);
+ await expect(refreshAccessToken("old-refresh", config)).resolves.toEqual(tokenResponse);
});
it("rejects a malformed token response", async () => {
@@ -51,7 +50,7 @@ describe("github auth", () => {
json: () => Promise.resolve({ access_token: "gho_token", token_type: "bearer" }),
} as unknown as Response);
- await expect(exchangeCodeForToken("code", config)).rejects.toThrow(
+ await expect(refreshAccessToken("old-refresh", config)).rejects.toThrow(
"Invalid GitHub token response"
);
});
@@ -65,36 +64,32 @@ describe("github auth", () => {
}),
} as unknown as Response);
- await expect(exchangeCodeForToken("code", config)).rejects.toThrow(
+ await expect(refreshAccessToken("old-refresh", config)).rejects.toThrow(
"The code passed is incorrect or expired."
);
});
- });
-
- describe("refreshAccessToken", () => {
- it("parses a valid refresh response", async () => {
- const tokenResponse: GitHubTokenResponse = {
- access_token: "gho_new",
- token_type: "bearer",
- scope: "repo,user",
- refresh_token: "ghr_new",
- expires_in: 28800,
- };
-
- globalThis.fetch = vi.fn().mockResolvedValue({
- json: () => Promise.resolve(tokenResponse),
- } as unknown as Response);
- await expect(refreshAccessToken("old-refresh", config)).resolves.toEqual(tokenResponse);
- });
+ it("aborts a stalled token refresh at the request deadline", async () => {
+ const timeout = new AbortController();
+ const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeout.signal);
+ globalThis.fetch = vi.fn().mockImplementation(
+ (_url, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), {
+ once: true,
+ });
+ })
+ );
- it("rejects a malformed refresh response", async () => {
- globalThis.fetch = vi.fn().mockResolvedValue({
- json: () => Promise.resolve({ access_token: "gho_new", token_type: "bearer" }),
- } as unknown as Response);
+ const refreshPromise = refreshAccessToken("old-refresh", config);
+ const timeoutError = new DOMException("deadline exceeded", "TimeoutError");
+ timeout.abort(timeoutError);
- await expect(refreshAccessToken("old-refresh", config)).rejects.toThrow(
- "Invalid GitHub token response"
+ await expect(refreshPromise).rejects.toBe(timeoutError);
+ expect(timeoutSpy).toHaveBeenCalledWith(GITHUB_OAUTH_REQUEST_TIMEOUT_MS);
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ "https://github.com/login/oauth/access_token",
+ expect.objectContaining({ signal: timeout.signal })
);
});
});
diff --git a/packages/control-plane/src/auth/github.ts b/packages/control-plane/src/auth/github.ts
index bb9e7cf88..193984888 100644
--- a/packages/control-plane/src/auth/github.ts
+++ b/packages/control-plane/src/auth/github.ts
@@ -1,42 +1,22 @@
-/**
- * GitHub authentication utilities.
- */
-
-import { formatGitHubNoreplyEmail } from "@open-inspect/shared/types/github-identity";
-import { DEFAULT_APP_NAME } from "@open-inspect/shared/app-name";
import { z } from "zod";
-import { decryptToken, encryptToken } from "./crypto";
-import { githubTokenResponseSchema, type GitHubUser, type GitHubTokenResponse } from "../types";
+
+export const GITHUB_OAUTH_REQUEST_TIMEOUT_MS = 10_000;
const githubOAuthErrorSchema = z.object({
error: z.string().optional(),
error_description: z.string().optional(),
});
-/**
- * The `/user` fields this service depends on. `id` and `login` are the identity
- * keys and are validated strictly — a malformed 200 (e.g. no `id`) must fail
- * closed rather than mint a subject on the literal string "undefined". Display
- * fields are lenient (absent → null) so a valid-but-partial response still
- * resolves an identity.
- */
-const githubUserSchema = z.object({
- id: z.number(),
- login: z.string().min(1),
- name: z
- .string()
- .nullish()
- .transform((value) => value ?? null),
- email: z
- .string()
- .nullish()
- .transform((value) => value ?? null),
- avatar_url: z
- .string()
- .nullish()
- .transform((value) => value ?? ""),
+const githubTokenResponseSchema = z.object({
+ access_token: z.string(),
+ token_type: z.string(),
+ scope: z.string(),
+ refresh_token: z.string().optional(),
+ expires_in: z.number().optional(),
});
+export type GitHubTokenResponse = z.infer;
+
async function parseGitHubTokenResponse(response: Response): Promise {
const data: unknown = await response.json();
const errorResult = githubOAuthErrorSchema.safeParse(data);
@@ -52,46 +32,9 @@ async function parseGitHubTokenResponse(response: Response): Promise {
- const response = await fetch("https://github.com/login/oauth/access_token", {
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- client_id: config.clientId,
- client_secret: config.clientSecret,
- code,
- }),
- });
-
- return parseGitHubTokenResponse(response);
}
/**
@@ -113,181 +56,8 @@ export async function refreshAccessToken(
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
+ signal: AbortSignal.timeout(GITHUB_OAUTH_REQUEST_TIMEOUT_MS),
});
return parseGitHubTokenResponse(response);
}
-
-/** Error from a GitHub API call, carrying the HTTP status for callers that map it. */
-export class GitHubUserApiError extends Error {
- constructor(readonly status: number) {
- super(`GitHub API error: ${status}`);
- this.name = "GitHubUserApiError";
- }
-}
-
-/**
- * Get current user info from GitHub.
- *
- * @throws {GitHubUserApiError} on non-2xx responses, with `status` set.
- * @throws {Error} on a 2xx response whose body is not a valid user — fail
- * closed rather than let a malformed identity through.
- */
-export async function getGitHubUser(
- accessToken: string,
- userAgent: string = DEFAULT_APP_NAME,
- signal?: AbortSignal
-): Promise {
- const response = await fetch("https://api.github.com/user", {
- headers: {
- Authorization: `Bearer ${accessToken}`,
- Accept: "application/vnd.github.v3+json",
- "User-Agent": userAgent,
- },
- signal,
- });
-
- if (!response.ok) {
- throw new GitHubUserApiError(response.status);
- }
-
- const parsed = githubUserSchema.safeParse(await response.json().catch(() => null));
- if (!parsed.success) {
- throw new Error("Malformed GitHub user response");
- }
- return parsed.data;
-}
-
-const githubUserEmailsSchema = z.array(
- z.object({
- email: z.string().min(1),
- primary: z.boolean(),
- verified: z.boolean(),
- })
-);
-
-/**
- * Get user's email addresses from GitHub.
- *
- * @throws {GitHubUserApiError} on non-2xx responses, with `status` set — most
- * often 403 when the GitHub App is missing the "Email addresses" permission,
- * or 404 when an OAuth token lacks the email scope.
- * @throws {Error} on a 2xx response whose body is not a valid email list —
- * email evidence is an identity-linking key, so a malformed body must fail
- * closed, never read as "no email".
- */
-export async function getGitHubUserEmails(
- accessToken: string,
- userAgent: string = DEFAULT_APP_NAME,
- signal?: AbortSignal
-): Promise> {
- const response = await fetch("https://api.github.com/user/emails", {
- headers: {
- Authorization: `Bearer ${accessToken}`,
- Accept: "application/vnd.github.v3+json",
- "User-Agent": userAgent,
- },
- signal,
- });
-
- if (!response.ok) {
- throw new GitHubUserApiError(response.status);
- }
-
- const parsed = githubUserEmailsSchema.safeParse(await response.json().catch(() => null));
- if (!parsed.success) {
- throw new Error("Malformed GitHub user emails response");
- }
- return parsed.data;
-}
-
-/**
- * Store encrypted GitHub tokens.
- */
-export async function encryptGitHubTokens(
- tokens: GitHubTokenResponse,
- encryptionKey: string
-): Promise {
- const accessTokenEncrypted = await encryptToken(tokens.access_token, encryptionKey);
-
- const refreshTokenEncrypted = tokens.refresh_token
- ? await encryptToken(tokens.refresh_token, encryptionKey)
- : null;
-
- const expiresAt = tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null;
-
- return {
- accessTokenEncrypted,
- refreshTokenEncrypted,
- expiresAt,
- scope: tokens.scope,
- };
-}
-
-/**
- * Get valid access token, refreshing if necessary.
- */
-export async function getValidAccessToken(
- stored: StoredGitHubToken,
- config: GitHubOAuthConfig
-): Promise<{ accessToken: string; refreshed: boolean; newStored?: StoredGitHubToken }> {
- const now = Date.now();
- const bufferMs = 5 * 60 * 1000; // 5 minutes
-
- // Check if token needs refresh
- if (stored.expiresAt && stored.expiresAt - now < bufferMs) {
- if (!stored.refreshTokenEncrypted) {
- throw new Error("Token expired and no refresh token available");
- }
-
- const refreshToken = await decryptToken(stored.refreshTokenEncrypted, config.encryptionKey);
-
- const newTokens = await refreshAccessToken(refreshToken, config);
- const newStored = await encryptGitHubTokens(newTokens, config.encryptionKey);
-
- return {
- accessToken: newTokens.access_token,
- refreshed: true,
- newStored,
- };
- }
-
- // Token is still valid
- const accessToken = await decryptToken(stored.accessTokenEncrypted, config.encryptionKey);
-
- return {
- accessToken,
- refreshed: false,
- };
-}
-
-/**
- * Generate noreply email for users with private email.
- */
-export function generateNoreplyEmail(githubUser: GitHubUser): string {
- return formatGitHubNoreplyEmail(githubUser);
-}
-
-/**
- * Get best email for git commit attribution.
- */
-export function getCommitEmail(
- githubUser: GitHubUser,
- emails?: Array<{ email: string; primary: boolean; verified: boolean }>
-): string {
- // Use public email if available
- if (githubUser.email) {
- return githubUser.email;
- }
-
- // Use primary verified email from list
- if (emails) {
- const primary = emails.find((e) => e.primary && e.verified);
- if (primary) {
- return primary.email;
- }
- }
-
- // Fall back to noreply
- return generateNoreplyEmail(githubUser);
-}
diff --git a/packages/control-plane/src/auth/identity-enforcement.test.ts b/packages/control-plane/src/auth/identity-enforcement.test.ts
index fe6ec0b8a..74a724738 100644
--- a/packages/control-plane/src/auth/identity-enforcement.test.ts
+++ b/packages/control-plane/src/auth/identity-enforcement.test.ts
@@ -10,6 +10,7 @@ import {
import type { Principal, ResolvedIdentity } from "./principal";
import type { UserStore } from "../db/user-store";
import type { RequestContext } from "../routes/shared";
+import { TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support";
const USER_PRINCIPAL: Principal = {
kind: "user",
@@ -34,6 +35,7 @@ function createCtx(principal?: Principal): RequestContext {
trace_id: "trace-test",
request_id: "req-test",
principal,
+ executionCtx: TEST_BACKGROUND_TASK_CONTEXT,
} as RequestContext;
}
diff --git a/packages/control-plane/src/auth/identity-enforcement.ts b/packages/control-plane/src/auth/identity-enforcement.ts
index 63b34e883..7ae8620c1 100644
--- a/packages/control-plane/src/auth/identity-enforcement.ts
+++ b/packages/control-plane/src/auth/identity-enforcement.ts
@@ -10,7 +10,7 @@
*/
import type { AutomationEventSource } from "@open-inspect/shared/triggers";
-import type { SpawnSource } from "@open-inspect/shared";
+import type { SpawnSource } from "@open-inspect/shared/types/sessions";
import type { ServiceName } from "@open-inspect/shared/service-auth";
import { createLogger } from "./../logger";
import { CALLBACK_DESTINATIONS } from "./service/callback-signing";
@@ -95,7 +95,7 @@ export interface DerivedIdentity {
* `applyIdentityEnforcement` gate; the type says so, sparing call sites a
* null check the gate already performed.
*/
-export type EnforcedIdentity = R extends RequiresUserRoute
+type EnforcedIdentity = R extends RequiresUserRoute
? DerivedIdentity & { participantUserId: string }
: DerivedIdentity;
diff --git a/packages/control-plane/src/auth/model-provider-account-adapters.test.ts b/packages/control-plane/src/auth/model-provider-account-adapters.test.ts
new file mode 100644
index 000000000..901877d84
--- /dev/null
+++ b/packages/control-plane/src/auth/model-provider-account-adapters.test.ts
@@ -0,0 +1,166 @@
+import { describe, expect, expectTypeOf, it, vi } from "vitest";
+import { OpenAIModelProviderAccountAdapter } from "./model-provider-account-openai-adapter";
+import { XaiModelProviderAccountAdapter } from "./model-provider-account-xai-adapter";
+import { modelProviderAccountAdapterRegistry } from "./model-provider-account-default-adapters";
+import { ProviderIdentityError } from "./model-provider-account-adapters";
+import type { ModelProviderAccountAdapterRegistry } from "./model-provider-account-adapters";
+import { OpenAITokenRefreshError } from "./openai";
+
+describe("model provider account adapters", () => {
+ it("registers OpenAI and xAI", () => {
+ expect(modelProviderAccountAdapterRegistry.get("openai")).toBeInstanceOf(
+ OpenAIModelProviderAccountAdapter
+ );
+ expect(modelProviderAccountAdapterRegistry.get("xai")).toBeInstanceOf(
+ XaiModelProviderAccountAdapter
+ );
+ });
+
+ it("requires complete adapters at the registry boundary", () => {
+ type RegistryAdapters = ConstructorParameters[0];
+ expectTypeOf<
+ readonly [{ readonly provider: "openai" }]
+ >().not.toMatchTypeOf();
+ });
+
+ it("uses the canonical provider request schemas", () => {
+ const openai = new OpenAIModelProviderAccountAdapter();
+ const xai = new XaiModelProviderAccountAdapter();
+
+ expect(() =>
+ openai.parseConnectInput({
+ provider: "openai",
+ refreshToken: "refresh-token",
+ })
+ ).toThrow();
+ expect(() =>
+ openai.parseConnectInput({
+ provider: "openai",
+ refreshToken: "x".repeat(65_537),
+ accountId: "acct-1",
+ })
+ ).toThrow();
+ expect(() =>
+ xai.parseConnectInput({
+ provider: "xai",
+ refreshToken: "refresh-token",
+ accountId: "unexpected",
+ })
+ ).toThrow();
+ });
+
+ it("requires OpenAI to return a replacement refresh token", async () => {
+ const adapter = new OpenAIModelProviderAccountAdapter(
+ vi.fn().mockResolvedValue({ id_token: "id", access_token: "access" })
+ );
+
+ await expect(adapter.refresh({ refreshToken: "old" })).rejects.toMatchObject({
+ classification: "ambiguous",
+ });
+ });
+
+ it("rejects a claimed OpenAI account ID when trusted extraction fails", async () => {
+ const adapter = new OpenAIModelProviderAccountAdapter(
+ vi.fn().mockResolvedValue({
+ id_token: "not-a-jwt",
+ access_token: "access",
+ refresh_token: "replacement",
+ })
+ );
+
+ await expect(
+ adapter.connect({
+ provider: "openai",
+ refreshToken: "old",
+ accountId: "claimed-account",
+ })
+ ).rejects.toBeInstanceOf(ProviderIdentityError);
+ });
+
+ it("distinguishes a definitive OpenAI invalid_grant from an ambiguous failure", async () => {
+ const unauthorized = new OpenAIModelProviderAccountAdapter(
+ vi.fn().mockRejectedValue(new OpenAITokenRefreshError("failed", 400, "invalid_grant"))
+ );
+ const ambiguous = new OpenAIModelProviderAccountAdapter(
+ vi.fn().mockRejectedValue(new OpenAITokenRefreshError("failed", 500))
+ );
+
+ await expect(unauthorized.refresh({ refreshToken: "old" })).rejects.toMatchObject({
+ classification: "unauthorized",
+ });
+ await expect(ambiguous.refresh({ refreshToken: "old" })).rejects.toMatchObject({
+ classification: "ambiguous",
+ });
+ });
+
+ it("retains the xAI refresh token when replacement is omitted", async () => {
+ const adapter = new XaiModelProviderAccountAdapter(
+ vi.fn().mockResolvedValue({ access_token: "access", expires_in: 120 })
+ );
+
+ const result = await adapter.refresh({ refreshToken: "old" }, 1_000);
+
+ expect(result.credential).toEqual({
+ refreshToken: "old",
+ accessToken: "access",
+ accessTokenExpiresAt: 121_000,
+ });
+ });
+
+ it("uses a bounded default expiry when a provider omits expiry", async () => {
+ const adapter = new XaiModelProviderAccountAdapter(
+ vi.fn().mockResolvedValue({ access_token: "access" })
+ );
+
+ const result = await adapter.refresh({ refreshToken: "refresh" }, 10_000);
+
+ expect(result.accessTokenExpiresAt).toBe(3_610_000);
+ expect(result.credential.accessTokenExpiresAt).toBe(3_610_000);
+ });
+
+ it("only exposes allowlisted runtime metadata", () => {
+ const openai = new OpenAIModelProviderAccountAdapter();
+ const xai = new XaiModelProviderAccountAdapter();
+
+ expect(
+ openai.runtimeMetadata(
+ { refreshToken: "secret", accountId: "credential-account" },
+ "stored-account"
+ )
+ ).toEqual({ accountId: "credential-account" });
+ expect(openai.runtimeMetadata({ refreshToken: "secret" }, "stored-account")).toEqual({
+ accountId: "stored-account",
+ });
+ expect(xai.runtimeMetadata({ refreshToken: "secret" }, null)).toEqual({});
+ });
+
+ it("validates persisted OpenAI device state and its schema version before polling", async () => {
+ const capability = modelProviderAccountAdapterRegistry.requireDeviceAuthorization("openai");
+
+ await expect(async () =>
+ capability.pollPersisted({ deviceAuthId: "device" }, 1, 5_000)
+ ).rejects.toThrow();
+ await expect(async () =>
+ capability.pollPersisted({ deviceAuthId: "device", userCode: "CODE" }, 2, 5_000)
+ ).rejects.toThrow(/version/i);
+ await expect(async () =>
+ capability.pollPersisted(
+ { deviceAuthId: "device", userCode: "CODE", unexpected: true },
+ 1,
+ 5_000
+ )
+ ).rejects.toThrow();
+ });
+
+ it("registers and validates persisted xAI device authorization state", async () => {
+ const capability = modelProviderAccountAdapterRegistry.requireDeviceAuthorization("xai");
+
+ await expect(async () => capability.pollPersisted({}, 1, 5_000)).rejects.toThrow();
+ await expect(async () =>
+ capability.pollPersisted({ deviceCode: "device" }, 2, 5_000)
+ ).rejects.toThrow(/version/i);
+ await expect(async () =>
+ capability.pollPersisted({ deviceCode: "device", unexpected: true }, 1, 5_000)
+ ).rejects.toThrow();
+ });
+});
diff --git a/packages/control-plane/src/auth/model-provider-account-adapters.ts b/packages/control-plane/src/auth/model-provider-account-adapters.ts
new file mode 100644
index 000000000..05abe5dea
--- /dev/null
+++ b/packages/control-plane/src/auth/model-provider-account-adapters.ts
@@ -0,0 +1,135 @@
+import type { ModelProviderId } from "../model-provider-accounts/provider-auth-contracts";
+
+export const DEFAULT_PROVIDER_ACCESS_TOKEN_LIFETIME_MS = 60 * 60 * 1000;
+export const DEFAULT_PROVIDER_REFRESH_BUFFER_MS = 5 * 60 * 1000;
+
+export interface ProviderConnectionResult {
+ credential: TCredential;
+ externalAccountId?: string;
+ accessTokenExpiresAt?: number;
+}
+
+export interface ProviderDeviceAuthorizationStart {
+ providerState: TProviderState;
+ userCode: string;
+ verificationUrl: string;
+ intervalMs: number;
+ expiresInMs?: number;
+}
+
+export type ProviderDeviceAuthorizationPollResult =
+ | { status: "pending"; intervalMs?: number }
+ | { status: "connected"; connection: ProviderConnectionResult }
+ | { status: "denied" | "expired" | "failed" };
+
+export interface ProviderDeviceAuthorizationCapability {
+ readonly stateSchemaVersion: number;
+ start(): Promise>;
+ parseState(payload: unknown, schemaVersion: number): TProviderState;
+ poll(
+ providerState: TProviderState,
+ intervalMs: number
+ ): Promise>;
+}
+
+interface ErasedProviderDeviceAuthorizationCapability {
+ readonly stateSchemaVersion: number;
+ start(): Promise>;
+ pollPersisted(
+ payload: unknown,
+ schemaVersion: number,
+ intervalMs: number
+ ): Promise>;
+}
+
+export interface ProviderRefreshResult {
+ credential: TCredential;
+ accessToken: string;
+ accessTokenExpiresAt: number;
+ externalAccountId?: string;
+}
+
+export interface CachedProviderAccess {
+ accessToken: string;
+ accessTokenExpiresAt: number;
+}
+
+export interface ModelProviderAccountAdapter {
+ readonly provider: ModelProviderId;
+ readonly credentialSchemaVersion: number;
+ readonly refreshBufferMs: number;
+ readonly deviceAuthorization?: ProviderDeviceAuthorizationCapability;
+ parseConnectInput(input: unknown): TConnectInput;
+ connect(input: TConnectInput): Promise>;
+ parseCredential(payload: unknown, schemaVersion: number): TCredential;
+ refresh(credential: TCredential, now?: number): Promise>;
+ cachedAccess(credential: TCredential): CachedProviderAccess | null;
+ validateReconnectInputIdentity(
+ input: TConnectInput,
+ expectedExternalAccountId: string | null
+ ): void;
+ runtimeMetadata(
+ credential: TCredential,
+ externalAccountId: string | null
+ ): Record;
+ validateExternalIdentity(actual: string | undefined, expected: string | null): void;
+}
+
+export type ProviderRefreshFailureClassification = "unauthorized" | "ambiguous" | "retry_safe";
+
+export class ProviderRefreshError extends Error {
+ constructor(
+ message: string,
+ readonly classification: ProviderRefreshFailureClassification,
+ options?: ErrorOptions
+ ) {
+ super(message, options);
+ }
+}
+
+export class ProviderCredentialError extends Error {}
+export class ProviderIdentityError extends Error {}
+
+type ErasedAdapter = ModelProviderAccountAdapter;
+
+export class ModelProviderAccountAdapterRegistry {
+ private readonly adapters = new Map();
+
+ constructor(adapters: readonly ErasedAdapter[]) {
+ for (const adapter of adapters) {
+ if (this.adapters.has(adapter.provider)) {
+ throw new Error(`Duplicate model provider account adapter: ${adapter.provider}`);
+ }
+ this.adapters.set(adapter.provider, adapter);
+ }
+ }
+
+ get(provider: ModelProviderId): ErasedAdapter | undefined {
+ return this.adapters.get(provider);
+ }
+
+ require(provider: ModelProviderId): ErasedAdapter {
+ const adapter = this.get(provider);
+ if (!adapter) throw new Error(`Model provider account adapter unavailable: ${provider}`);
+ return adapter;
+ }
+
+ requireDeviceAuthorization(
+ provider: ModelProviderId
+ ): ErasedProviderDeviceAuthorizationCapability {
+ const capability = this.require(provider).deviceAuthorization;
+ if (!capability) throw new Error(`Device authorization unavailable: ${provider}`);
+ return eraseDeviceAuthorizationCapability(capability);
+ }
+}
+
+function eraseDeviceAuthorizationCapability(
+ capability: ProviderDeviceAuthorizationCapability
+): ErasedProviderDeviceAuthorizationCapability {
+ return {
+ stateSchemaVersion: capability.stateSchemaVersion,
+ start: () => capability.start(),
+ pollPersisted: (payload, schemaVersion, intervalMs) =>
+ capability.poll(capability.parseState(payload, schemaVersion), intervalMs),
+ };
+}
diff --git a/packages/control-plane/src/auth/model-provider-account-broker.test.ts b/packages/control-plane/src/auth/model-provider-account-broker.test.ts
new file mode 100644
index 000000000..47e664c28
--- /dev/null
+++ b/packages/control-plane/src/auth/model-provider-account-broker.test.ts
@@ -0,0 +1,497 @@
+import { describe, expect, it, vi } from "vitest";
+import type { ModelProviderAccount } from "../db/model-provider-accounts";
+import type { ProviderCredentialState } from "../db/provider-account-credentials";
+import {
+ ModelProviderAccountAdapterRegistry,
+ ProviderIdentityError,
+ ProviderRefreshError,
+ type ModelProviderAccountAdapter,
+} from "./model-provider-account-adapters";
+import {
+ ModelProviderAccountBroker,
+ ModelProviderAccountBrokerError,
+ type ModelProviderAccountBrokerStores,
+} from "./model-provider-account-broker";
+
+type Credential = {
+ refreshToken: string;
+ accessToken?: string;
+ accessTokenExpiresAt?: number;
+};
+
+const NOW = 1_000_000;
+
+function account(overrides: Partial = {}): ModelProviderAccount {
+ return {
+ id: "account-1",
+ provider: "openai",
+ displayName: "Primary",
+ externalAccountId: "external-1",
+ status: "active",
+ createdBy: null,
+ updatedBy: null,
+ lastVerifiedAt: null,
+ lastUsedAt: null,
+ createdAt: 1,
+ updatedAt: 1,
+ archivedAt: null,
+ ...overrides,
+ };
+}
+
+function state(
+ overrides: Partial> = {}
+): ProviderCredentialState {
+ return {
+ payload: { refreshToken: "refresh" },
+ credentialSchemaVersion: 1,
+ credentialVersion: 1,
+ exchangeGeneration: 0,
+ exchangeState: "idle",
+ exchangeOwner: null,
+ exchangeStartedAt: null,
+ accessTokenExpiresAt: null,
+ updatedAt: 1,
+ ...overrides,
+ };
+}
+
+function adapter(
+ refresh: ModelProviderAccountAdapter["refresh"] = vi.fn()
+): ModelProviderAccountAdapter {
+ return {
+ provider: "openai",
+ credentialSchemaVersion: 1,
+ refreshBufferMs: 300_000,
+ parseConnectInput: vi.fn() as never,
+ connect: vi.fn() as never,
+ parseCredential: (value, version) => {
+ if (version !== 1 || !value || typeof value !== "object" || !("refreshToken" in value)) {
+ throw new Error("invalid credential");
+ }
+ return value as Credential;
+ },
+ refresh,
+ cachedAccess: (credential) =>
+ credential.accessToken && credential.accessTokenExpiresAt
+ ? {
+ accessToken: credential.accessToken,
+ accessTokenExpiresAt: credential.accessTokenExpiresAt,
+ }
+ : null,
+ validateReconnectInputIdentity: vi.fn() as never,
+ runtimeMetadata: (_credential, externalAccountId): Record =>
+ externalAccountId ? { accountId: externalAccountId } : {},
+ validateExternalIdentity: (actual, expected) => {
+ if (!actual || !expected || actual !== expected) {
+ throw new ProviderIdentityError("OpenAI account identity did not match");
+ }
+ },
+ };
+}
+
+function setup(
+ options: {
+ providerAccount?: ModelProviderAccount | null;
+ credentialStates?: Array | null>;
+ refresh?: ModelProviderAccountAdapter["refresh"];
+ tryBegin?: ModelProviderAccountBrokerStores["credentials"]["tryBeginExchange"];
+ complete?: ModelProviderAccountBrokerStores["credentials"]["completeExchange"];
+ terminalFailure?: ModelProviderAccountBrokerStores["atomicWriter"]["fenceExchangeAndRequireReconnect"];
+ now?: () => number;
+ sleep?: (ms: number) => Promise;
+ useDefaultPolling?: boolean;
+ exchangeTimeoutMs?: number;
+ pollDelayMs?: number;
+ } = {}
+) {
+ const states = [...(options.credentialStates ?? [state()])];
+ let lastState = states.at(-1) ?? null;
+ const stores: ModelProviderAccountBrokerStores = {
+ accounts: {
+ getById: vi
+ .fn()
+ .mockResolvedValue(
+ options.providerAccount === undefined ? account() : options.providerAccount
+ ),
+ touchLastUsed: vi.fn().mockResolvedValue(true),
+ },
+ credentials: {
+ readCredentialState: vi.fn().mockImplementation(async () => {
+ if (states.length) lastState = states.shift() ?? null;
+ return lastState;
+ }),
+ tryBeginExchange:
+ options.tryBegin ?? vi.fn().mockResolvedValue({ acquired: true, generation: 1 }),
+ completeExchange: options.complete ?? vi.fn().mockResolvedValue(true),
+ clearSafeFailure: vi.fn().mockResolvedValue(true),
+ },
+ atomicWriter: {
+ fenceExchangeAndRequireReconnect: options.terminalFailure ?? vi.fn().mockResolvedValue(true),
+ },
+ };
+ const refresh =
+ options.refresh ??
+ vi.fn().mockResolvedValue({
+ credential: {
+ refreshToken: "replacement",
+ accessToken: "new-access",
+ accessTokenExpiresAt: NOW + 3_600_000,
+ },
+ accessToken: "new-access",
+ accessTokenExpiresAt: NOW + 3_600_000,
+ externalAccountId: "external-1",
+ });
+ const registry = new ModelProviderAccountAdapterRegistry([adapter(refresh)]);
+ const broker = new ModelProviderAccountBroker(stores, registry, {
+ now: options.now ?? (() => NOW),
+ sleep: options.sleep ?? (() => Promise.resolve()),
+ createOwner: () => "owner-1",
+ ...(options.useDefaultPolling ? {} : { maxPollAttempts: 3 }),
+ exchangeTimeoutMs: options.exchangeTimeoutMs ?? 10_000,
+ pollDelayMs: options.pollDelayMs,
+ });
+ return { broker, stores, refresh };
+}
+
+describe("ModelProviderAccountBroker", () => {
+ it("reuses a valid cached access token", async () => {
+ const { broker, stores, refresh } = setup({
+ credentialStates: [
+ state({
+ payload: {
+ refreshToken: "refresh",
+ accessToken: "cached",
+ accessTokenExpiresAt: NOW + 600_000,
+ },
+ accessTokenExpiresAt: NOW + 600_000,
+ }),
+ ],
+ });
+
+ await expect(broker.getAccess("account-1", "openai")).resolves.toMatchObject({
+ accessToken: "cached",
+ providerMetadata: { accountId: "external-1" },
+ });
+ expect(refresh).not.toHaveBeenCalled();
+ expect(stores.credentials.tryBeginExchange).not.toHaveBeenCalled();
+ });
+
+ it("coalesces refreshes for the same account and credential version locally", async () => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => (release = resolve));
+ const refresh = vi.fn().mockImplementation(async () => {
+ await gate;
+ return {
+ credential: {
+ refreshToken: "next",
+ accessToken: "access",
+ accessTokenExpiresAt: NOW + 600_000,
+ },
+ accessToken: "access",
+ accessTokenExpiresAt: NOW + 600_000,
+ externalAccountId: "external-1",
+ };
+ });
+ const { broker, stores } = setup({ refresh, credentialStates: [state(), state()] });
+
+ const first = broker.getAccess("account-1", "openai");
+ const second = broker.getAccess("account-1", "openai");
+ await vi.waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
+ release();
+
+ await expect(Promise.all([first, second])).resolves.toHaveLength(2);
+ expect(stores.credentials.tryBeginExchange).toHaveBeenCalledTimes(1);
+ });
+
+ it("polls when another process owns the durable claim and returns its token", async () => {
+ const winner = state({
+ payload: { refreshToken: "next", accessToken: "winner", accessTokenExpiresAt: NOW + 600_000 },
+ credentialVersion: 2,
+ accessTokenExpiresAt: NOW + 600_000,
+ });
+ const { broker, refresh, stores } = setup({
+ credentialStates: [state(), winner],
+ tryBegin: vi.fn().mockResolvedValue({ acquired: false }),
+ });
+
+ await expect(broker.getAccess("account-1", "openai")).resolves.toMatchObject({
+ accessToken: "winner",
+ });
+ expect(refresh).not.toHaveBeenCalled();
+ expect(stores.credentials.readCredentialState).toHaveBeenCalledTimes(2);
+ });
+
+ it("waits for a cross-isolate exchange through its claim deadline", async () => {
+ const inFlight = state({
+ exchangeState: "in_flight",
+ exchangeGeneration: 1,
+ exchangeOwner: "other-isolate",
+ exchangeStartedAt: NOW,
+ });
+ const winner = state({
+ payload: { refreshToken: "next", accessToken: "winner", accessTokenExpiresAt: NOW + 600_000 },
+ credentialVersion: 2,
+ exchangeGeneration: 1,
+ accessTokenExpiresAt: NOW + 600_000,
+ });
+ const { broker, stores } = setup({
+ credentialStates: [inFlight, inFlight, inFlight, inFlight, inFlight, inFlight, winner],
+ useDefaultPolling: true,
+ exchangeTimeoutMs: 1_000,
+ pollDelayMs: 100,
+ });
+
+ await expect(broker.getAccess("account-1", "openai")).resolves.toMatchObject({
+ accessToken: "winner",
+ });
+ expect(stores.credentials.readCredentialState).toHaveBeenCalledTimes(7);
+ });
+
+ it("fences a stale exchange before marking reconnect required", async () => {
+ const calls: string[] = [];
+ const terminalFailure = vi.fn().mockImplementation(async () => {
+ calls.push("terminal");
+ return true;
+ });
+ const { broker } = setup({
+ credentialStates: [
+ state({
+ exchangeState: "in_flight",
+ exchangeGeneration: 4,
+ exchangeOwner: "dead-owner",
+ exchangeStartedAt: NOW - 20_000,
+ }),
+ ],
+ terminalFailure,
+ });
+
+ await expect(broker.getAccess("account-1", "openai")).rejects.toMatchObject({
+ code: "reconnect_required",
+ });
+ expect(terminalFailure).toHaveBeenCalledWith({
+ providerAccountId: "account-1",
+ credentialVersion: 1,
+ exchangeGeneration: 4,
+ exchangeOwner: "dead-owner",
+ now: NOW,
+ });
+ expect(calls).toEqual(["terminal"]);
+ });
+
+ it("uses a late completion when stale fencing loses the race", async () => {
+ const stale = state({
+ exchangeState: "in_flight",
+ exchangeGeneration: 4,
+ exchangeOwner: "slow-owner",
+ exchangeStartedAt: NOW - 20_000,
+ });
+ const completed = state({
+ payload: { refreshToken: "next", accessToken: "late", accessTokenExpiresAt: NOW + 600_000 },
+ credentialVersion: 2,
+ exchangeGeneration: 4,
+ accessTokenExpiresAt: NOW + 600_000,
+ });
+ const { broker, refresh } = setup({
+ credentialStates: [stale, completed],
+ terminalFailure: vi.fn().mockResolvedValue(false),
+ });
+
+ await expect(broker.getAccess("account-1", "openai")).resolves.toMatchObject({
+ accessToken: "late",
+ });
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it("reconciles durable state when stale fencing rejects ambiguously", async () => {
+ const stale = state({
+ exchangeState: "in_flight",
+ exchangeGeneration: 4,
+ exchangeOwner: "slow-owner",
+ exchangeStartedAt: NOW - 20_000,
+ });
+ const completed = state({
+ payload: { refreshToken: "next", accessToken: "late", accessTokenExpiresAt: NOW + 600_000 },
+ credentialVersion: 2,
+ exchangeGeneration: 4,
+ accessTokenExpiresAt: NOW + 600_000,
+ });
+ const { broker, refresh } = setup({
+ credentialStates: [stale, completed],
+ terminalFailure: vi.fn().mockRejectedValue(new Error("D1 response lost")),
+ });
+
+ await expect(broker.getAccess("account-1", "openai")).resolves.toMatchObject({
+ accessToken: "late",
+ });
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it("preserves a stale-fence storage error when durable state is unchanged", async () => {
+ const stale = state({
+ exchangeState: "in_flight",
+ exchangeGeneration: 4,
+ exchangeOwner: "slow-owner",
+ exchangeStartedAt: NOW - 20_000,
+ });
+ const failure = new Error("D1 response lost");
+ const { broker, refresh } = setup({
+ credentialStates: [stale, stale],
+ terminalFailure: vi.fn().mockRejectedValue(failure),
+ });
+
+ await expect(broker.getAccess("account-1", "openai")).rejects.toBe(failure);
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it("observes reconnect state when another caller wins stale fencing", async () => {
+ const stale = state({
+ exchangeState: "in_flight",
+ exchangeGeneration: 4,
+ exchangeOwner: "slow-owner",
+ exchangeStartedAt: NOW - 20_000,
+ });
+ const { broker, stores, refresh } = setup({
+ credentialStates: [stale],
+ terminalFailure: vi.fn().mockResolvedValue(false),
+ });
+ vi.mocked(stores.accounts.getById)
+ .mockResolvedValueOnce(account())
+ .mockResolvedValue(account({ status: "reconnect_required" }));
+
+ await expect(broker.getAccess("account-1", "openai")).rejects.toMatchObject({
+ code: "reconnect_required",
+ });
+ expect(refresh).not.toHaveBeenCalled();
+ });
+
+ it.each(["unauthorized", "ambiguous"] as const)(
+ "marks %s refresh failures reconnect required",
+ async (classification) => {
+ const terminalFailure = vi.fn().mockResolvedValue(true);
+ const { broker } = setup({
+ refresh: vi.fn().mockRejectedValue(new ProviderRefreshError("failed", classification)),
+ terminalFailure,
+ });
+
+ await expect(broker.getAccess("account-1", "openai")).rejects.toMatchObject({
+ code: "reconnect_required",
+ });
+ expect(terminalFailure).toHaveBeenCalledWith(
+ expect.objectContaining({ providerAccountId: "account-1", credentialVersion: 1 })
+ );
+ }
+ );
+
+ it("uses the authoritative lifecycle when terminal fencing loses its claim", async () => {
+ const { broker, stores } = setup({
+ refresh: vi.fn().mockRejectedValue(new ProviderRefreshError("failed", "ambiguous")),
+ terminalFailure: vi.fn().mockResolvedValue(false),
+ });
+ vi.mocked(stores.accounts.getById)
+ .mockResolvedValueOnce(account())
+ .mockResolvedValue(account({ status: "disabled" }));
+
+ await expect(broker.getAccess("account-1", "openai")).rejects.toMatchObject({
+ code: "account_inactive",
+ });
+ expect(stores.credentials.readCredentialState).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses a concurrent credential replacement when terminal fencing loses its claim", async () => {
+ const completed = state({
+ payload: {
+ refreshToken: "next",
+ accessToken: "winner",
+ accessTokenExpiresAt: NOW + 600_000,
+ },
+ credentialVersion: 2,
+ accessTokenExpiresAt: NOW + 600_000,
+ });
+ const { broker } = setup({
+ credentialStates: [state(), completed],
+ refresh: vi.fn().mockRejectedValue(new ProviderRefreshError("failed", "ambiguous")),
+ terminalFailure: vi.fn().mockResolvedValue(false),
+ });
+
+ await expect(broker.getAccess("account-1", "openai")).resolves.toMatchObject({
+ accessToken: "winner",
+ });
+ });
+
+ it("does not claim reconnect when terminal fencing loses an unchanged claim", async () => {
+ const { broker } = setup({
+ credentialStates: [state(), state()],
+ refresh: vi.fn().mockRejectedValue(new ProviderRefreshError("failed", "ambiguous")),
+ terminalFailure: vi.fn().mockResolvedValue(false),
+ });
+
+ await expect(broker.getAccess("account-1", "openai")).rejects.toMatchObject({
+ code: "exchange_busy",
+ });
+ });
+
+ it.each([undefined, "different-account"])(
+ "rejects refreshed OpenAI identity %s before persisting rotated credentials",
+ async (externalAccountId) => {
+ const terminalFailure = vi.fn().mockResolvedValue(true);
+ const { broker, stores } = setup({
+ refresh: vi.fn().mockResolvedValue({
+ credential: {
+ refreshToken: "replacement",
+ accessToken: "new-access",
+ accessTokenExpiresAt: NOW + 3_600_000,
+ },
+ accessToken: "new-access",
+ accessTokenExpiresAt: NOW + 3_600_000,
+ externalAccountId,
+ }),
+ terminalFailure,
+ });
+
+ await expect(broker.getAccess("account-1", "openai")).rejects.toMatchObject({
+ code: "reconnect_required",
+ });
+ expect(stores.credentials.completeExchange).not.toHaveBeenCalled();
+ expect(terminalFailure).toHaveBeenCalledTimes(1);
+ }
+ );
+
+ it("never returns a token when completion persistence fails", async () => {
+ const terminalFailure = vi.fn().mockResolvedValue(true);
+ const { broker } = setup({
+ complete: vi.fn().mockRejectedValue(new Error("D1 unavailable")),
+ terminalFailure,
+ });
+
+ await expect(broker.getAccess("account-1", "openai")).rejects.toMatchObject({
+ code: "reconnect_required",
+ });
+ expect(terminalFailure).toHaveBeenCalled();
+ });
+
+ it.each([
+ [account({ status: "disabled" }), "account_inactive"],
+ [account({ status: "reconnect_required" }), "account_inactive"],
+ [account({ archivedAt: NOW }), "account_archived"],
+ [account({ provider: "xai" }), "provider_mismatch"],
+ ] as const)(
+ "rejects inactive, archived, and mismatched accounts",
+ async (providerAccount, code) => {
+ const { broker, stores } = setup({ providerAccount });
+
+ await expect(broker.getAccess("account-1", "openai")).rejects.toMatchObject({ code });
+ expect(stores.credentials.readCredentialState).not.toHaveBeenCalled();
+ }
+ );
+
+ it("does not fall back to a different account", async () => {
+ const { broker, stores } = setup({ providerAccount: null });
+
+ await expect(broker.getAccess("missing", "openai")).rejects.toBeInstanceOf(
+ ModelProviderAccountBrokerError
+ );
+ expect(stores.accounts.getById).toHaveBeenCalledTimes(1);
+ expect(stores.credentials.readCredentialState).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/control-plane/src/auth/model-provider-account-broker.ts b/packages/control-plane/src/auth/model-provider-account-broker.ts
new file mode 100644
index 000000000..ab91e90ae
--- /dev/null
+++ b/packages/control-plane/src/auth/model-provider-account-broker.ts
@@ -0,0 +1,387 @@
+import type {
+ ModelProviderAccount,
+ ModelProviderAccountStore,
+} from "../db/model-provider-accounts";
+import type {
+ ProviderCredentialState,
+ ProviderCredentialStore,
+} from "../db/provider-account-credentials";
+import type { ModelProviderAccountAtomicWriter } from "../db/model-provider-account-atomic-writer";
+import type { ModelProviderId } from "../model-provider-accounts/provider-auth-contracts";
+import { ProviderRefreshError } from "./model-provider-account-adapters";
+import type {
+ ModelProviderAccountAdapter,
+ ModelProviderAccountAdapterRegistry,
+} from "./model-provider-account-adapters";
+import {
+ ClaimedProviderCredentialExchange,
+ ClaimedProviderCredentialExchangeError,
+} from "./claimed-provider-credential-exchange";
+import { providerAccountIneligibility } from "../model-provider-accounts/account-lifecycle-policy";
+
+type ErasedProviderAccountAdapter = ModelProviderAccountAdapter;
+
+const LAST_USED_WRITE_INTERVAL_MS = 15 * 60 * 1000;
+const DEFAULT_EXCHANGE_TIMEOUT_MS = 15_000;
+const DEFAULT_POLL_DELAY_MS = 100;
+
+export interface ProviderAccess {
+ accessToken: string;
+ expiresIn?: number;
+ externalAccountId?: string;
+ providerMetadata?: Record;
+}
+
+export type ModelProviderAccountBrokerErrorCode =
+ | "account_not_found"
+ | "account_inactive"
+ | "account_archived"
+ | "provider_mismatch"
+ | "provider_unavailable"
+ | "credential_not_found"
+ | "credential_invalid"
+ | "exchange_busy"
+ | "reconnect_required"
+ | "upstream_retry_safe";
+
+export class ModelProviderAccountBrokerError extends Error {
+ constructor(
+ readonly code: ModelProviderAccountBrokerErrorCode,
+ message: string,
+ options?: ErrorOptions
+ ) {
+ super(message, options);
+ }
+}
+
+export interface ModelProviderAccountBrokerStores {
+ accounts: Pick;
+ credentials: Pick<
+ ProviderCredentialStore,
+ "readCredentialState" | "tryBeginExchange" | "completeExchange" | "clearSafeFailure"
+ >;
+ atomicWriter: Pick;
+}
+
+interface BrokerOptions {
+ now?: () => number;
+ sleep?: (ms: number) => Promise;
+ createOwner?: () => string;
+ exchangeTimeoutMs?: number;
+ maxPollAttempts?: number;
+ pollDelayMs?: number;
+}
+
+export class ModelProviderAccountBroker {
+ private readonly inFlight = new Map>();
+ private readonly now: () => number;
+ private readonly sleep: (ms: number) => Promise;
+ private readonly createOwner: () => string;
+ private readonly exchangeTimeoutMs: number;
+ private readonly maxPollAttempts: number;
+ private readonly pollDelayMs: number;
+ private readonly exchange: ClaimedProviderCredentialExchange;
+
+ constructor(
+ private readonly stores: ModelProviderAccountBrokerStores,
+ private readonly registry: ModelProviderAccountAdapterRegistry,
+ options: BrokerOptions = {}
+ ) {
+ this.now = options.now ?? Date.now;
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
+ this.createOwner = options.createOwner ?? (() => crypto.randomUUID());
+ this.exchangeTimeoutMs = options.exchangeTimeoutMs ?? DEFAULT_EXCHANGE_TIMEOUT_MS;
+ this.pollDelayMs = options.pollDelayMs ?? DEFAULT_POLL_DELAY_MS;
+ this.maxPollAttempts =
+ options.maxPollAttempts ?? Math.ceil(this.exchangeTimeoutMs / this.pollDelayMs) + 1;
+ this.exchange = new ClaimedProviderCredentialExchange(
+ stores.credentials,
+ stores.atomicWriter.fenceExchangeAndRequireReconnect.bind(stores.atomicWriter)
+ );
+ }
+
+ async getAccess(accountId: string, expectedProvider: ModelProviderId): Promise {
+ const account = await this.requireUsableAccount(accountId, expectedProvider);
+ const adapter = this.registry.get(expectedProvider);
+ if (!adapter) {
+ throw new ModelProviderAccountBrokerError(
+ "provider_unavailable",
+ `Provider account adapter unavailable for ${expectedProvider}`
+ );
+ }
+ const state = await this.readState(accountId, expectedProvider);
+ const credential = this.parseCredential(adapter, state);
+ const cached = adapter.cachedAccess(credential);
+ if (cached && cached.accessTokenExpiresAt - this.now() > adapter.refreshBufferMs) {
+ await this.touchLastUsed(account);
+ return this.toAccess(account, adapter, credential, cached);
+ }
+
+ const key = `${accountId}:${state.credentialVersion}`;
+ const existing = this.inFlight.get(key);
+ if (existing) return existing;
+ const promise = this.refreshWithClaim(account, adapter, state).finally(() => {
+ if (this.inFlight.get(key) === promise) this.inFlight.delete(key);
+ });
+ this.inFlight.set(key, promise);
+ return promise;
+ }
+
+ private async refreshWithClaim(
+ account: ModelProviderAccount & { status: "active" },
+ adapter: ErasedProviderAccountAdapter,
+ initialState: ProviderCredentialState
+ ): Promise {
+ let state = initialState;
+ for (let attempt = 0; attempt < this.maxPollAttempts; attempt++) {
+ const credential = this.parseCredential(adapter, state);
+ const cached = adapter.cachedAccess(credential);
+ if (cached && cached.accessTokenExpiresAt - this.now() > adapter.refreshBufferMs) {
+ await this.touchLastUsed(account);
+ return this.toAccess(account, adapter, credential, cached);
+ }
+
+ if (state.exchangeState === "in_flight") {
+ const stale =
+ state.exchangeStartedAt === null ||
+ this.now() - state.exchangeStartedAt >= this.exchangeTimeoutMs;
+ if (stale) {
+ let fenced: boolean;
+ try {
+ fenced = await this.stores.atomicWriter.fenceExchangeAndRequireReconnect({
+ providerAccountId: account.id,
+ credentialVersion: state.credentialVersion,
+ exchangeGeneration: state.exchangeGeneration,
+ exchangeOwner: state.exchangeOwner ?? "",
+ now: this.now(),
+ });
+ } catch (cause) {
+ return this.reconcileLostTerminalFence(account, adapter, state, cause);
+ }
+ if (fenced) {
+ throw this.reconnectError(account.provider, "A credential exchange became stale");
+ }
+ return this.reconcileLostTerminalFence(account, adapter, state);
+ }
+ await this.sleep(this.pollDelayMs);
+ state = await this.readState(account.id, account.provider);
+ continue;
+ }
+
+ try {
+ const result = await this.exchange.run({
+ providerAccountId: account.id,
+ provider: account.provider,
+ state,
+ expectedAccountStatus: account.status,
+ adapter,
+ owner: this.createOwner(),
+ now: this.now,
+ complete: ({ write, refreshed }) => {
+ adapter.validateExternalIdentity(
+ refreshed.externalAccountId,
+ account.externalAccountId
+ );
+ return this.stores.credentials.completeExchange(write);
+ },
+ });
+ if (result.kind === "claim_unavailable") {
+ await this.sleep(this.pollDelayMs);
+ state = await this.readState(account.id, account.provider);
+ continue;
+ }
+ await this.touchLastUsed(account);
+ return this.toAccess(account, adapter, result.refreshed.credential, {
+ accessToken: result.refreshed.accessToken,
+ accessTokenExpiresAt: result.refreshed.accessTokenExpiresAt,
+ });
+ } catch (error) {
+ if (!(error instanceof ClaimedProviderCredentialExchangeError)) throw error;
+ if (error.phase === "parse") {
+ throw new ModelProviderAccountBrokerError(
+ "credential_invalid",
+ `Stored ${adapter.provider} credential is invalid`,
+ { cause: error.cause }
+ );
+ }
+ if (
+ error.phase === "refresh" &&
+ error.cause instanceof ProviderRefreshError &&
+ error.cause.classification === "retry_safe"
+ ) {
+ throw new ModelProviderAccountBrokerError(
+ "upstream_retry_safe",
+ `${account.provider} credential refresh failed safely`,
+ { cause: error.cause }
+ );
+ }
+ if (error.terminalFence === "lost") {
+ return this.reconcileLostTerminalFence(account, adapter, state);
+ }
+ const reread = await this.readState(account.id, account.provider);
+ if (reread.credentialVersion !== state.credentialVersion) {
+ return this.accessFromConcurrentUpdate(account, adapter, reread);
+ }
+ throw this.reconnectError(
+ account.provider,
+ error.phase === "completion"
+ ? "Refreshed credentials could not be persisted"
+ : "Credential refresh requires reconnection",
+ error.cause
+ );
+ }
+ }
+ throw new ModelProviderAccountBrokerError(
+ "exchange_busy",
+ `${account.provider} credential exchange did not complete`
+ );
+ }
+
+ private accessFromConcurrentUpdate(
+ account: ModelProviderAccount,
+ adapter: ErasedProviderAccountAdapter,
+ state: ProviderCredentialState
+ ): ProviderAccess {
+ const credential = this.parseCredential(adapter, state);
+ const cached = adapter.cachedAccess(credential);
+ if (!cached || cached.accessTokenExpiresAt - this.now() <= adapter.refreshBufferMs) {
+ throw this.reconnectError(
+ account.provider,
+ "Concurrent credential replacement has no usable access token"
+ );
+ }
+ return this.toAccess(account, adapter, credential, cached);
+ }
+
+ private parseCredential(
+ adapter: ErasedProviderAccountAdapter,
+ state: ProviderCredentialState
+ ): unknown {
+ try {
+ return adapter.parseCredential(state.payload, state.credentialSchemaVersion);
+ } catch (error) {
+ throw new ModelProviderAccountBrokerError(
+ "credential_invalid",
+ `Stored ${adapter.provider} credential is invalid`,
+ { cause: error }
+ );
+ }
+ }
+
+ private async requireUsableAccount(
+ accountId: string,
+ expectedProvider: ModelProviderId
+ ): Promise {
+ const account = await this.stores.accounts.getById(accountId);
+ if (!account) {
+ throw new ModelProviderAccountBrokerError("account_not_found", "Provider account not found");
+ }
+ if (account.provider !== expectedProvider) {
+ throw new ModelProviderAccountBrokerError(
+ "provider_mismatch",
+ `Provider account does not belong to ${expectedProvider}`
+ );
+ }
+ const ineligibility = providerAccountIneligibility(account, "active_use");
+ if (ineligibility === "archived") {
+ throw new ModelProviderAccountBrokerError("account_archived", "Provider account is archived");
+ }
+ if (ineligibility) {
+ throw new ModelProviderAccountBrokerError(
+ "account_inactive",
+ `Provider account is ${account.status}`
+ );
+ }
+ return { ...account, status: "active" };
+ }
+
+ private async reconcileLostTerminalFence(
+ previousAccount: ModelProviderAccount,
+ adapter: ErasedProviderAccountAdapter,
+ previousState: ProviderCredentialState,
+ fenceError?: unknown
+ ): Promise {
+ const account = await this.stores.accounts.getById(previousAccount.id);
+ if (!account) {
+ throw new ModelProviderAccountBrokerError("account_not_found", "Provider account not found");
+ }
+ if (account.provider !== previousAccount.provider) {
+ throw new ModelProviderAccountBrokerError(
+ "provider_mismatch",
+ `Provider account does not belong to ${previousAccount.provider}`
+ );
+ }
+ const ineligibility = providerAccountIneligibility(account, "active_use");
+ if (ineligibility === "archived") {
+ throw new ModelProviderAccountBrokerError("account_archived", "Provider account is archived");
+ }
+ if (ineligibility === "reconnect_required") {
+ throw this.reconnectError(account.provider, "Credential refresh requires reconnection");
+ }
+ if (ineligibility) {
+ throw new ModelProviderAccountBrokerError(
+ "account_inactive",
+ `Provider account is ${account.status}`
+ );
+ }
+
+ const state = await this.readState(account.id, account.provider);
+ if (state.credentialVersion !== previousState.credentialVersion) {
+ return this.accessFromConcurrentUpdate(account, adapter, state);
+ }
+ if (fenceError !== undefined) throw fenceError;
+ throw new ModelProviderAccountBrokerError(
+ "exchange_busy",
+ `${account.provider} credential exchange lost its durable claim`
+ );
+ }
+
+ private async readState(
+ accountId: string,
+ provider: ModelProviderId
+ ): Promise {
+ const state = await this.stores.credentials.readCredentialState(accountId, provider);
+ if (!state) {
+ throw new ModelProviderAccountBrokerError(
+ "credential_not_found",
+ "Provider account credential not found"
+ );
+ }
+ return state;
+ }
+
+ private toAccess(
+ account: ModelProviderAccount,
+ adapter: ErasedProviderAccountAdapter,
+ credential: unknown,
+ cached: { accessToken: string; accessTokenExpiresAt: number }
+ ): ProviderAccess {
+ const expiresIn = Math.max(0, Math.floor((cached.accessTokenExpiresAt - this.now()) / 1000));
+ return {
+ accessToken: cached.accessToken,
+ expiresIn,
+ ...(account.externalAccountId ? { externalAccountId: account.externalAccountId } : {}),
+ providerMetadata: adapter.runtimeMetadata(credential, account.externalAccountId),
+ };
+ }
+
+ private async touchLastUsed(account: ModelProviderAccount): Promise {
+ try {
+ await this.stores.accounts.touchLastUsed(
+ account.id,
+ this.now() - LAST_USED_WRITE_INTERVAL_MS,
+ this.now()
+ );
+ } catch {
+ // Usage attribution must not invalidate already-persisted authentication state.
+ }
+ }
+
+ private reconnectError(provider: ModelProviderId, message: string, cause?: unknown) {
+ return new ModelProviderAccountBrokerError(
+ "reconnect_required",
+ `${provider}: ${message}`,
+ cause === undefined ? undefined : { cause }
+ );
+ }
+}
diff --git a/packages/control-plane/src/auth/model-provider-account-default-adapters.ts b/packages/control-plane/src/auth/model-provider-account-default-adapters.ts
new file mode 100644
index 000000000..16dc223a4
--- /dev/null
+++ b/packages/control-plane/src/auth/model-provider-account-default-adapters.ts
@@ -0,0 +1,8 @@
+import { ModelProviderAccountAdapterRegistry } from "./model-provider-account-adapters";
+import { OpenAIModelProviderAccountAdapter } from "./model-provider-account-openai-adapter";
+import { XaiModelProviderAccountAdapter } from "./model-provider-account-xai-adapter";
+
+export const modelProviderAccountAdapterRegistry = new ModelProviderAccountAdapterRegistry([
+ new OpenAIModelProviderAccountAdapter(),
+ new XaiModelProviderAccountAdapter(),
+]);
diff --git a/packages/control-plane/src/auth/model-provider-account-openai-adapter.ts b/packages/control-plane/src/auth/model-provider-account-openai-adapter.ts
new file mode 100644
index 000000000..9d9eee173
--- /dev/null
+++ b/packages/control-plane/src/auth/model-provider-account-openai-adapter.ts
@@ -0,0 +1,162 @@
+import { z } from "zod";
+import {
+ connectOpenAIModelProviderAccountRequestSchema,
+ reconnectOpenAIModelProviderAccountRequestSchema,
+ type ConnectModelProviderAccountRequest,
+ type ReconnectModelProviderAccountRequest,
+} from "@open-inspect/shared/types/provider-accounts";
+import {
+ extractOpenAIAccountId,
+ openAIAccessTokenLifetimeMs,
+ refreshOpenAIToken,
+ OpenAITokenRefreshError,
+} from "./openai";
+import {
+ DEFAULT_PROVIDER_REFRESH_BUFFER_MS,
+ ProviderCredentialError,
+ ProviderIdentityError,
+ ProviderRefreshError,
+ type ModelProviderAccountAdapter,
+ type ProviderDeviceAuthorizationCapability,
+ type ProviderConnectionResult,
+ type ProviderRefreshResult,
+} from "./model-provider-account-adapters";
+import { OpenAIProviderDeviceAuthorization } from "./model-provider-account-openai-device-authorization";
+
+const credentialSchema = z.object({
+ refreshToken: z.string().min(1),
+ accessToken: z.string().min(1).optional(),
+ accessTokenExpiresAt: z.number().int().positive().optional(),
+ accountId: z.string().min(1).optional(),
+});
+const connectInputSchema = z.union([
+ connectOpenAIModelProviderAccountRequestSchema,
+ reconnectOpenAIModelProviderAccountRequestSchema,
+]);
+
+export type OpenAIProviderCredential = z.infer;
+export type OpenAIProviderConnectInput =
+ | Extract
+ | Extract;
+
+type RefreshOpenAI = typeof refreshOpenAIToken;
+
+function isUnauthorized(error: OpenAITokenRefreshError): boolean {
+ return error.status === 401 || error.errorCode === "invalid_grant";
+}
+
+export class OpenAIModelProviderAccountAdapter implements ModelProviderAccountAdapter<
+ OpenAIProviderCredential,
+ OpenAIProviderConnectInput
+> {
+ readonly provider = "openai" as const;
+ readonly credentialSchemaVersion = 1;
+ readonly refreshBufferMs = DEFAULT_PROVIDER_REFRESH_BUFFER_MS;
+ constructor(
+ private readonly refreshToken: RefreshOpenAI = refreshOpenAIToken,
+ readonly deviceAuthorization: ProviderDeviceAuthorizationCapability<
+ OpenAIProviderCredential,
+ unknown
+ > = new OpenAIProviderDeviceAuthorization()
+ ) {}
+
+ parseConnectInput(input: unknown): OpenAIProviderConnectInput {
+ return connectInputSchema.parse(input);
+ }
+
+ async connect(
+ input: OpenAIProviderConnectInput
+ ): Promise> {
+ const result = await this.refresh({ refreshToken: input.refreshToken });
+ this.validateExternalIdentity(result.externalAccountId, input.accountId);
+ return {
+ credential: result.credential,
+ externalAccountId: result.externalAccountId,
+ accessTokenExpiresAt: result.accessTokenExpiresAt,
+ };
+ }
+
+ parseCredential(payload: unknown, schemaVersion: number): OpenAIProviderCredential {
+ if (schemaVersion !== this.credentialSchemaVersion) {
+ throw new ProviderCredentialError(
+ `Unsupported OpenAI credential schema version: ${schemaVersion}`
+ );
+ }
+ const result = credentialSchema.safeParse(payload);
+ if (!result.success) throw new ProviderCredentialError("Invalid OpenAI provider credential");
+ return result.data;
+ }
+
+ async refresh(
+ credential: OpenAIProviderCredential,
+ now = Date.now()
+ ): Promise> {
+ try {
+ const tokens = await this.refreshToken(credential.refreshToken);
+ if (!tokens.refresh_token) {
+ throw new ProviderRefreshError(
+ "OpenAI refresh did not return a replacement refresh token",
+ "ambiguous"
+ );
+ }
+ const accessTokenExpiresAt = now + openAIAccessTokenLifetimeMs(tokens.expires_in);
+ const accountId = extractOpenAIAccountId(tokens);
+ return {
+ credential: {
+ refreshToken: tokens.refresh_token,
+ accessToken: tokens.access_token,
+ accessTokenExpiresAt,
+ ...(accountId ? { accountId } : {}),
+ },
+ accessToken: tokens.access_token,
+ accessTokenExpiresAt,
+ externalAccountId: accountId,
+ };
+ } catch (error) {
+ if (error instanceof ProviderRefreshError) throw error;
+ if (error instanceof OpenAITokenRefreshError && isUnauthorized(error)) {
+ throw new ProviderRefreshError("OpenAI refresh was unauthorized", "unauthorized", {
+ cause: error,
+ });
+ }
+ throw new ProviderRefreshError("OpenAI refresh outcome was ambiguous", "ambiguous", {
+ cause: error,
+ });
+ }
+ }
+
+ cachedAccess(credential: OpenAIProviderCredential) {
+ return credential.accessToken && credential.accessTokenExpiresAt
+ ? {
+ accessToken: credential.accessToken,
+ accessTokenExpiresAt: credential.accessTokenExpiresAt,
+ }
+ : null;
+ }
+
+ validateReconnectInputIdentity(
+ input: OpenAIProviderConnectInput,
+ expectedExternalAccountId: string | null
+ ): void {
+ if (expectedExternalAccountId && input.accountId !== expectedExternalAccountId) {
+ throw new ProviderIdentityError("OpenAI account identity did not match");
+ }
+ }
+
+ runtimeMetadata(
+ credential: OpenAIProviderCredential,
+ externalAccountId: string | null
+ ): Record {
+ const accountId = credential.accountId ?? externalAccountId;
+ return accountId ? { accountId } : {};
+ }
+
+ validateExternalIdentity(actual: string | undefined, expected: string | null): void {
+ if (!actual) {
+ throw new ProviderIdentityError("OpenAI account identity could not be verified");
+ }
+ if (!expected || actual !== expected) {
+ throw new ProviderIdentityError("OpenAI account identity did not match");
+ }
+ }
+}
diff --git a/packages/control-plane/src/auth/model-provider-account-openai-device-authorization.ts b/packages/control-plane/src/auth/model-provider-account-openai-device-authorization.ts
new file mode 100644
index 000000000..3a348d821
--- /dev/null
+++ b/packages/control-plane/src/auth/model-provider-account-openai-device-authorization.ts
@@ -0,0 +1,91 @@
+import {
+ checkOpenAIDeviceAuthorization,
+ exchangeOpenAIAuthorizationCode,
+ extractOpenAIAccountId,
+ openAIAccessTokenLifetimeMs,
+ OPENAI_DEVICE_VERIFICATION_URL,
+ startOpenAIDeviceAuthorization,
+ type OpenAITokenResponse,
+} from "./openai";
+import type {
+ ProviderDeviceAuthorizationCapability,
+ ProviderDeviceAuthorizationPollResult,
+} from "./model-provider-account-adapters";
+import type { OpenAIProviderCredential } from "./model-provider-account-openai-adapter";
+import { z } from "zod";
+
+const openAIDeviceAuthorizationStateSchema = z.strictObject({
+ deviceAuthId: z.string().min(1).max(4096),
+ userCode: z.string().min(1).max(128),
+});
+
+export type OpenAIDeviceAuthorizationState = z.infer;
+
+type OpenAIDeviceDependencies = {
+ start: typeof startOpenAIDeviceAuthorization;
+ check: typeof checkOpenAIDeviceAuthorization;
+ exchange: typeof exchangeOpenAIAuthorizationCode;
+ now: () => number;
+};
+
+export class OpenAIProviderDeviceAuthorization implements ProviderDeviceAuthorizationCapability<
+ OpenAIProviderCredential,
+ OpenAIDeviceAuthorizationState
+> {
+ readonly stateSchemaVersion = 1;
+
+ constructor(
+ private readonly dependencies: OpenAIDeviceDependencies = {
+ start: startOpenAIDeviceAuthorization,
+ check: checkOpenAIDeviceAuthorization,
+ exchange: exchangeOpenAIAuthorizationCode,
+ now: () => Date.now(),
+ }
+ ) {}
+
+ async start() {
+ const started = await this.dependencies.start();
+ return {
+ providerState: {
+ deviceAuthId: started.deviceAuthId,
+ userCode: started.userCode,
+ },
+ userCode: started.userCode,
+ verificationUrl: OPENAI_DEVICE_VERIFICATION_URL,
+ intervalMs: started.intervalMs,
+ };
+ }
+
+ parseState(payload: unknown, schemaVersion: number): OpenAIDeviceAuthorizationState {
+ if (schemaVersion !== this.stateSchemaVersion) {
+ throw new Error(`Unsupported OpenAI device authorization state version: ${schemaVersion}`);
+ }
+ return openAIDeviceAuthorizationStateSchema.parse(payload);
+ }
+
+ async poll(
+ state: OpenAIDeviceAuthorizationState
+ ): Promise> {
+ const status = await this.dependencies.check(state.deviceAuthId, state.userCode);
+ if (status.status === "pending") return { status: "pending" };
+ const tokens = await this.dependencies.exchange(status.authorizationCode, status.codeVerifier);
+ return { status: "connected", connection: this.connection(tokens) };
+ }
+
+ private connection(tokens: OpenAITokenResponse) {
+ const externalAccountId = extractOpenAIAccountId(tokens);
+ if (!externalAccountId) throw new Error("OpenAI account identity could not be verified");
+ const accessTokenExpiresAt =
+ this.dependencies.now() + openAIAccessTokenLifetimeMs(tokens.expires_in);
+ return {
+ credential: {
+ refreshToken: tokens.refresh_token,
+ accessToken: tokens.access_token,
+ accessTokenExpiresAt,
+ accountId: externalAccountId,
+ },
+ externalAccountId,
+ accessTokenExpiresAt,
+ };
+ }
+}
diff --git a/packages/control-plane/src/auth/model-provider-account-xai-adapter.ts b/packages/control-plane/src/auth/model-provider-account-xai-adapter.ts
new file mode 100644
index 000000000..f18576a35
--- /dev/null
+++ b/packages/control-plane/src/auth/model-provider-account-xai-adapter.ts
@@ -0,0 +1,141 @@
+import { z } from "zod";
+import {
+ connectXaiModelProviderAccountRequestSchema,
+ reconnectXaiModelProviderAccountRequestSchema,
+ type ConnectModelProviderAccountRequest,
+ type ReconnectModelProviderAccountRequest,
+} from "@open-inspect/shared/types/provider-accounts";
+import { refreshXaiToken, XaiTokenRefreshError } from "./xai";
+import {
+ DEFAULT_PROVIDER_ACCESS_TOKEN_LIFETIME_MS,
+ DEFAULT_PROVIDER_REFRESH_BUFFER_MS,
+ ProviderCredentialError,
+ ProviderIdentityError,
+ ProviderRefreshError,
+ type ModelProviderAccountAdapter,
+ type ProviderConnectionResult,
+ type ProviderDeviceAuthorizationCapability,
+ type ProviderRefreshResult,
+} from "./model-provider-account-adapters";
+import { XaiProviderDeviceAuthorization } from "./model-provider-account-xai-device-authorization";
+
+const credentialSchema = z.object({
+ refreshToken: z.string().min(1),
+ accessToken: z.string().min(1).optional(),
+ accessTokenExpiresAt: z.number().int().positive().optional(),
+});
+const connectInputSchema = z.union([
+ connectXaiModelProviderAccountRequestSchema,
+ reconnectXaiModelProviderAccountRequestSchema,
+]);
+
+export type XaiProviderCredential = z.infer;
+export type XaiProviderConnectInput =
+ | Extract
+ | Extract;
+
+type RefreshXai = typeof refreshXaiToken;
+
+export class XaiModelProviderAccountAdapter implements ModelProviderAccountAdapter<
+ XaiProviderCredential,
+ XaiProviderConnectInput
+> {
+ readonly provider = "xai" as const;
+ readonly credentialSchemaVersion = 1;
+ readonly refreshBufferMs = DEFAULT_PROVIDER_REFRESH_BUFFER_MS;
+
+ constructor(
+ private readonly refreshToken: RefreshXai = refreshXaiToken,
+ readonly deviceAuthorization: ProviderDeviceAuthorizationCapability<
+ XaiProviderCredential,
+ unknown
+ > = new XaiProviderDeviceAuthorization()
+ ) {}
+
+ parseConnectInput(input: unknown): XaiProviderConnectInput {
+ return connectInputSchema.parse(input);
+ }
+
+ async connect(
+ input: XaiProviderConnectInput
+ ): Promise> {
+ const result = await this.refresh({ refreshToken: input.refreshToken });
+ return {
+ credential: result.credential,
+ accessTokenExpiresAt: result.accessTokenExpiresAt,
+ };
+ }
+
+ parseCredential(payload: unknown, schemaVersion: number): XaiProviderCredential {
+ if (schemaVersion !== this.credentialSchemaVersion) {
+ throw new ProviderCredentialError(
+ `Unsupported xAI credential schema version: ${schemaVersion}`
+ );
+ }
+ const result = credentialSchema.safeParse(payload);
+ if (!result.success) throw new ProviderCredentialError("Invalid xAI provider credential");
+ return result.data;
+ }
+
+ async refresh(
+ credential: XaiProviderCredential,
+ now = Date.now()
+ ): Promise> {
+ try {
+ const tokens = await this.refreshToken(credential.refreshToken);
+ const accessTokenExpiresAt =
+ now + (tokens.expires_in ?? DEFAULT_PROVIDER_ACCESS_TOKEN_LIFETIME_MS / 1000) * 1000;
+ return {
+ credential: {
+ refreshToken: tokens.refresh_token ?? credential.refreshToken,
+ accessToken: tokens.access_token,
+ accessTokenExpiresAt,
+ },
+ accessToken: tokens.access_token,
+ accessTokenExpiresAt,
+ };
+ } catch (error) {
+ if (error instanceof XaiTokenRefreshError) {
+ const unauthorized = error.reason === "invalid_grant" || error.reason === "unauthorized";
+ throw new ProviderRefreshError(
+ unauthorized ? "xAI refresh was unauthorized" : "xAI refresh outcome was ambiguous",
+ unauthorized ? "unauthorized" : "ambiguous",
+ { cause: error }
+ );
+ }
+ throw new ProviderRefreshError("xAI refresh outcome was ambiguous", "ambiguous", {
+ cause: error,
+ });
+ }
+ }
+
+ cachedAccess(credential: XaiProviderCredential) {
+ return credential.accessToken && credential.accessTokenExpiresAt
+ ? {
+ accessToken: credential.accessToken,
+ accessTokenExpiresAt: credential.accessTokenExpiresAt,
+ }
+ : null;
+ }
+
+ validateReconnectInputIdentity(
+ _input: XaiProviderConnectInput,
+ expectedExternalAccountId: string | null
+ ): void {
+ if (expectedExternalAccountId) {
+ throw new ProviderIdentityError(
+ "Identity-bound xAI accounts must reconnect through device authorization"
+ );
+ }
+ }
+
+ runtimeMetadata(_credential: XaiProviderCredential, _externalAccountId: string | null) {
+ return {};
+ }
+
+ validateExternalIdentity(actual: string | undefined, expected: string | null): void {
+ if (actual && expected && actual !== expected) {
+ throw new ProviderIdentityError("xAI account identity did not match");
+ }
+ }
+}
diff --git a/packages/control-plane/src/auth/model-provider-account-xai-device-authorization.test.ts b/packages/control-plane/src/auth/model-provider-account-xai-device-authorization.test.ts
new file mode 100644
index 000000000..bdd604214
--- /dev/null
+++ b/packages/control-plane/src/auth/model-provider-account-xai-device-authorization.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it, vi } from "vitest";
+import { XaiProviderDeviceAuthorization } from "./model-provider-account-xai-device-authorization";
+
+describe("XaiProviderDeviceAuthorization", () => {
+ it("creates a trusted connection from a successful device token response", async () => {
+ const authorization = new XaiProviderDeviceAuthorization({
+ start: vi.fn(),
+ check: vi.fn().mockResolvedValue({
+ status: "connected",
+ tokens: {
+ access_token: "access",
+ refresh_token: "refresh",
+ expires_in: 120,
+ },
+ }),
+ accountId: vi.fn().mockResolvedValue("xai-user"),
+ now: () => 1_000,
+ });
+
+ await expect(authorization.poll({ deviceCode: "device-secret" }, 5_000)).resolves.toEqual({
+ status: "connected",
+ connection: {
+ credential: {
+ refreshToken: "refresh",
+ accessToken: "access",
+ accessTokenExpiresAt: 121_000,
+ },
+ externalAccountId: "xai-user",
+ accessTokenExpiresAt: 121_000,
+ },
+ });
+ });
+
+ it("fails closed when xAI does not return a trusted identity", async () => {
+ const authorization = new XaiProviderDeviceAuthorization({
+ start: vi.fn(),
+ check: vi.fn().mockResolvedValue({
+ status: "connected",
+ tokens: { access_token: "opaque", refresh_token: "refresh" },
+ }),
+ accountId: vi.fn().mockRejectedValue(new Error("xAI user info returned invalid data")),
+ now: () => 1_000,
+ });
+
+ await expect(authorization.poll({ deviceCode: "device-secret" }, 5_000)).rejects.toThrow(
+ "user info returned invalid data"
+ );
+ });
+
+ it("uses the canonical provider access-token lifetime when xAI omits expiry", async () => {
+ const authorization = new XaiProviderDeviceAuthorization({
+ start: vi.fn(),
+ check: vi.fn().mockResolvedValue({
+ status: "connected",
+ tokens: { access_token: "access", refresh_token: "refresh" },
+ }),
+ accountId: vi.fn().mockResolvedValue("xai-user"),
+ now: () => 1_000,
+ });
+
+ await expect(authorization.poll({ deviceCode: "device-secret" }, 5_000)).resolves.toMatchObject(
+ {
+ connection: { accessTokenExpiresAt: 3_601_000 },
+ }
+ );
+ });
+});
diff --git a/packages/control-plane/src/auth/model-provider-account-xai-device-authorization.ts b/packages/control-plane/src/auth/model-provider-account-xai-device-authorization.ts
new file mode 100644
index 000000000..787824c94
--- /dev/null
+++ b/packages/control-plane/src/auth/model-provider-account-xai-device-authorization.ts
@@ -0,0 +1,82 @@
+import { z } from "zod";
+import {
+ DEFAULT_PROVIDER_ACCESS_TOKEN_LIFETIME_MS,
+ type ProviderDeviceAuthorizationCapability,
+ type ProviderDeviceAuthorizationPollResult,
+} from "./model-provider-account-adapters";
+import type { XaiProviderCredential } from "./model-provider-account-xai-adapter";
+import { checkXaiDeviceAuthorization, fetchXaiAccountId, startXaiDeviceAuthorization } from "./xai";
+
+const xaiDeviceAuthorizationStateSchema = z.strictObject({
+ deviceCode: z.string().min(1).max(4096),
+});
+
+export type XaiDeviceAuthorizationState = z.infer;
+
+type XaiDeviceDependencies = {
+ start: typeof startXaiDeviceAuthorization;
+ check: typeof checkXaiDeviceAuthorization;
+ accountId: typeof fetchXaiAccountId;
+ now: () => number;
+};
+
+export class XaiProviderDeviceAuthorization implements ProviderDeviceAuthorizationCapability<
+ XaiProviderCredential,
+ XaiDeviceAuthorizationState
+> {
+ readonly stateSchemaVersion = 1;
+
+ constructor(
+ private readonly dependencies: XaiDeviceDependencies = {
+ start: startXaiDeviceAuthorization,
+ check: checkXaiDeviceAuthorization,
+ accountId: fetchXaiAccountId,
+ now: () => Date.now(),
+ }
+ ) {}
+
+ async start() {
+ const started = await this.dependencies.start();
+ return {
+ providerState: {
+ deviceCode: started.deviceCode,
+ },
+ userCode: started.userCode,
+ verificationUrl: started.verificationUrl,
+ expiresInMs: started.expiresInMs,
+ intervalMs: started.intervalMs,
+ };
+ }
+
+ parseState(payload: unknown, schemaVersion: number): XaiDeviceAuthorizationState {
+ if (schemaVersion !== this.stateSchemaVersion) {
+ throw new Error(`Unsupported xAI device authorization state version: ${schemaVersion}`);
+ }
+ return xaiDeviceAuthorizationStateSchema.parse(payload);
+ }
+
+ async poll(
+ state: XaiDeviceAuthorizationState,
+ intervalMs: number
+ ): Promise> {
+ const result = await this.dependencies.check(state.deviceCode, intervalMs);
+ if (result.status !== "connected") return result;
+
+ const externalAccountId = await this.dependencies.accountId(result.tokens.access_token);
+ const accessTokenExpiresAt =
+ this.dependencies.now() +
+ (result.tokens.expires_in ?? DEFAULT_PROVIDER_ACCESS_TOKEN_LIFETIME_MS / 1000) * 1000;
+ return {
+ status: "connected",
+ connection: {
+ credential: {
+ refreshToken: result.tokens.refresh_token,
+ accessToken: result.tokens.access_token,
+ accessTokenExpiresAt,
+ },
+ externalAccountId,
+ accessTokenExpiresAt,
+ },
+ };
+ }
+}
diff --git a/packages/control-plane/src/auth/oauth-refresh-single-flight.test.ts b/packages/control-plane/src/auth/oauth-refresh-single-flight.test.ts
new file mode 100644
index 000000000..611d7266d
--- /dev/null
+++ b/packages/control-plane/src/auth/oauth-refresh-single-flight.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it, vi } from "vitest";
+import { OAuthRefreshSingleFlight } from "./oauth-refresh-single-flight";
+
+describe("OAuthRefreshSingleFlight", () => {
+ it("coalesces the same refresh-token version within a scope", async () => {
+ const coordinator = new OAuthRefreshSingleFlight();
+ let resolveRefresh!: (result: string) => void;
+ const refresh = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveRefresh = resolve;
+ })
+ );
+
+ const first = coordinator.run({ kind: "global" }, "refresh-v1", refresh);
+ const second = coordinator.run({ kind: "global" }, "refresh-v1", refresh);
+ resolveRefresh("access-v1");
+
+ await expect(Promise.all([first, second])).resolves.toEqual(["access-v1", "access-v1"]);
+ expect(refresh).toHaveBeenCalledOnce();
+ });
+
+ it("does not let an older refresh clear a newer token version", async () => {
+ const coordinator = new OAuthRefreshSingleFlight();
+ let resolveOld!: (result: string) => void;
+ let resolveNew!: (result: string) => void;
+ const oldRefresh = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveOld = resolve;
+ })
+ );
+ const newRefresh = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveNew = resolve;
+ })
+ );
+
+ const oldResult = coordinator.run({ kind: "global" }, "refresh-v1", oldRefresh);
+ const newResult = coordinator.run({ kind: "global" }, "refresh-v2", newRefresh);
+ resolveOld("access-v1");
+ await expect(oldResult).resolves.toBe("access-v1");
+
+ const coalescedNewResult = coordinator.run(
+ { kind: "global" },
+ "refresh-v2",
+ vi.fn().mockResolvedValue("unused")
+ );
+ resolveNew("access-v2");
+
+ await expect(Promise.all([newResult, coalescedNewResult])).resolves.toEqual([
+ "access-v2",
+ "access-v2",
+ ]);
+ expect(oldRefresh).toHaveBeenCalledOnce();
+ expect(newRefresh).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/control-plane/src/auth/oauth-refresh-single-flight.ts b/packages/control-plane/src/auth/oauth-refresh-single-flight.ts
new file mode 100644
index 000000000..2252040b0
--- /dev/null
+++ b/packages/control-plane/src/auth/oauth-refresh-single-flight.ts
@@ -0,0 +1,38 @@
+import type { OAuthSecretScope } from "../db/scoped-oauth-secrets";
+
+type InFlightRefresh = {
+ refreshToken: string;
+ promise: Promise;
+};
+
+/** Coalesces refresh-token rotation for one provider within a Worker isolate. */
+export class OAuthRefreshSingleFlight {
+ private readonly inFlight = new Map>();
+
+ run(
+ scope: OAuthSecretScope,
+ refreshToken: string,
+ refresh: () => Promise
+ ): Promise {
+ const key = this.scopeKey(scope);
+ const existing = this.inFlight.get(key);
+ if (existing?.refreshToken === refreshToken) return existing.promise;
+
+ const promise = refresh().finally(() => {
+ if (this.inFlight.get(key)?.promise === promise) this.inFlight.delete(key);
+ });
+ this.inFlight.set(key, { refreshToken, promise });
+ return promise;
+ }
+
+ private scopeKey(scope: OAuthSecretScope): string {
+ switch (scope.kind) {
+ case "environment":
+ return `environment:${scope.environmentId}`;
+ case "repo":
+ return `repo:${scope.repoId}`;
+ case "global":
+ return "global";
+ }
+ }
+}
diff --git a/packages/control-plane/src/auth/openai-token-broker.ts b/packages/control-plane/src/auth/openai-token-broker.ts
new file mode 100644
index 000000000..043d97597
--- /dev/null
+++ b/packages/control-plane/src/auth/openai-token-broker.ts
@@ -0,0 +1,237 @@
+import { extractOpenAIAccountId, OpenAITokenRefreshError, refreshOpenAIToken } from "./openai";
+import { ScopedOAuthSecretsStore, type OAuthSecretScope } from "../db/scoped-oauth-secrets";
+import type { SqlDatabase } from "../db/sql-database";
+import type { Logger } from "../logger";
+import { OAuthRefreshSingleFlight } from "./oauth-refresh-single-flight";
+
+const OPENAI_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
+const OPENAI_DEFAULT_TOKEN_LIFETIME_MS = 60 * 60 * 1000;
+const OPENAI_TOKEN_PERSIST_MAX_ATTEMPTS = 3;
+const OPENAI_TOKEN_PERSIST_RETRY_DELAY_MS = 100;
+const OPENAI_CONCURRENT_ROTATION_POLL_DELAYS_MS = [100, 250, 500, 1_000] as const;
+const OPENAI_TOKEN_PERSIST_FAILURE =
+ "OpenAI tokens rotated but could not be saved; reconnect OpenAI OAuth";
+
+type OpenAITokenState =
+ | { type: "cached"; accessToken: string; expiresIn: number; accountId?: string }
+ | { type: "refresh"; refreshToken: string; scope: OAuthSecretScope; accountId?: string };
+
+export type OpenAIToken = { accessToken: string; expiresIn?: number; accountId?: string };
+
+class OpenAITokenBrokerError extends Error {}
+export class OpenAITokenNotConfiguredError extends OpenAITokenBrokerError {}
+export class OpenAITokenStorageError extends OpenAITokenBrokerError {}
+export class OpenAITokenUnauthorizedError extends OpenAITokenBrokerError {}
+export class OpenAITokenUpstreamError extends OpenAITokenBrokerError {}
+
+// Requests handled by the same Worker isolate share this coordinator. D1 rereads
+// below cover concurrent rotations performed by other isolates and Durable Objects.
+const openAIRefreshCoordinator = new OAuthRefreshSingleFlight();
+
+/** Provider-level broker shared by session adapters and global OAuth consumers. */
+export class OpenAITokenBroker {
+ private readonly secrets: ScopedOAuthSecretsStore;
+
+ constructor(
+ db: SqlDatabase,
+ encryptionKey: string,
+ private readonly log: Logger
+ ) {
+ this.secrets = new ScopedOAuthSecretsStore(db, encryptionKey);
+ }
+
+ refreshGlobal(): Promise {
+ return this.refreshScopes([{ kind: "global" }]);
+ }
+
+ async refreshScopes(scopes: readonly OAuthSecretScope[]): Promise {
+ let tokenState: OpenAITokenState | null;
+ try {
+ tokenState = await this.readTokenState(scopes);
+ } catch (error) {
+ this.log.error("Failed to read OpenAI token state from secrets", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw new OpenAITokenStorageError("Failed to read token state", { cause: error });
+ }
+
+ if (!tokenState) {
+ throw new OpenAITokenNotConfiguredError("OPENAI_OAUTH_REFRESH_TOKEN not configured");
+ }
+
+ if (tokenState.type === "cached") {
+ return {
+ accessToken: tokenState.accessToken,
+ expiresIn: tokenState.expiresIn,
+ accountId: tokenState.accountId,
+ };
+ }
+
+ try {
+ return await this.refreshSingleFlight(tokenState);
+ } catch (error) {
+ if (error instanceof OpenAITokenBrokerError) throw error;
+ if (error instanceof OpenAITokenRefreshError && error.status === 401) {
+ return this.handleUnauthorizedRefresh(tokenState, scopes);
+ }
+
+ this.log.error("OpenAI token refresh failed", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw new OpenAITokenUpstreamError("OpenAI token refresh failed", { cause: error });
+ }
+ }
+
+ private stateFromSecrets(
+ secrets: Record,
+ scope: OAuthSecretScope
+ ): OpenAITokenState | null {
+ const refreshToken = secrets.OPENAI_OAUTH_REFRESH_TOKEN;
+ if (!refreshToken) return null;
+
+ const cachedToken = secrets.OPENAI_OAUTH_ACCESS_TOKEN;
+ const expiresAt = Number.parseInt(secrets.OPENAI_OAUTH_ACCESS_TOKEN_EXPIRES_AT || "0", 10);
+ const now = Date.now();
+
+ if (cachedToken && expiresAt - now > OPENAI_TOKEN_REFRESH_BUFFER_MS) {
+ return {
+ type: "cached",
+ accessToken: cachedToken,
+ expiresIn: Math.floor((expiresAt - now) / 1000),
+ accountId: secrets.OPENAI_OAUTH_ACCOUNT_ID,
+ };
+ }
+
+ return {
+ type: "refresh",
+ refreshToken,
+ scope,
+ accountId: secrets.OPENAI_OAUTH_ACCOUNT_ID,
+ };
+ }
+
+ private async readTokenState(
+ scopes: readonly OAuthSecretScope[]
+ ): Promise {
+ for (const scope of scopes) {
+ const state = this.stateFromSecrets(await this.secrets.read(scope), scope);
+ if (state) return state;
+ }
+ return null;
+ }
+
+ private async attemptRefresh(
+ tokenState: Extract
+ ): Promise {
+ const tokens = await refreshOpenAIToken(tokenState.refreshToken);
+ const accountId = extractOpenAIAccountId(tokens) ?? tokenState.accountId;
+ const expiresAt =
+ Date.now() +
+ (tokens.expires_in === undefined
+ ? OPENAI_DEFAULT_TOKEN_LIFETIME_MS
+ : tokens.expires_in * 1000);
+ const secretsToWrite: Record = {
+ OPENAI_OAUTH_REFRESH_TOKEN: tokens.refresh_token,
+ OPENAI_OAUTH_ACCESS_TOKEN: tokens.access_token,
+ OPENAI_OAUTH_ACCESS_TOKEN_EXPIRES_AT: String(expiresAt),
+ };
+ if (accountId) secretsToWrite.OPENAI_OAUTH_ACCOUNT_ID = accountId;
+
+ await this.persistRotatedTokens(tokenState.scope, secretsToWrite);
+
+ this.log.info("OpenAI tokens rotated and cached", {
+ scope: tokenState.scope.kind,
+ has_account_id: !!accountId,
+ });
+ return {
+ accessToken: tokens.access_token,
+ expiresIn: tokens.expires_in,
+ accountId,
+ };
+ }
+
+ private refreshSingleFlight(
+ tokenState: Extract
+ ): Promise {
+ return openAIRefreshCoordinator.run(tokenState.scope, tokenState.refreshToken, () =>
+ this.attemptRefresh(tokenState)
+ );
+ }
+
+ private async persistRotatedTokens(
+ scope: OAuthSecretScope,
+ secrets: Record
+ ): Promise {
+ for (let attempt = 1; attempt <= OPENAI_TOKEN_PERSIST_MAX_ATTEMPTS; attempt++) {
+ try {
+ await this.secrets.write(scope, secrets);
+ return;
+ } catch (error) {
+ const finalAttempt = attempt === OPENAI_TOKEN_PERSIST_MAX_ATTEMPTS;
+ const context = {
+ scope: scope.kind,
+ attempt,
+ max_attempts: OPENAI_TOKEN_PERSIST_MAX_ATTEMPTS,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ if (finalAttempt) {
+ this.log.error("Failed to store rotated OpenAI tokens", context);
+ throw new OpenAITokenStorageError(OPENAI_TOKEN_PERSIST_FAILURE, { cause: error });
+ }
+ this.log.warn("Failed to store rotated OpenAI tokens; retrying", context);
+ await new Promise((resolve) => setTimeout(resolve, OPENAI_TOKEN_PERSIST_RETRY_DELAY_MS));
+ }
+ }
+ }
+
+ private async handleUnauthorizedRefresh(
+ tokenState: Extract,
+ scopes: readonly OAuthSecretScope[]
+ ): Promise {
+ this.log.warn("OpenAI refresh got 401, checking for concurrent rotation", {
+ scope: tokenState.scope.kind,
+ });
+ let observedRefreshToken = tokenState.refreshToken;
+
+ for (const [pollIndex, delayMs] of OPENAI_CONCURRENT_ROTATION_POLL_DELAYS_MS.entries()) {
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+
+ let reread: OpenAITokenState | null;
+ try {
+ reread = await this.readTokenState(scopes);
+ } catch (error) {
+ this.log.error("Failed to reread OpenAI token state after 401", {
+ poll_attempt: pollIndex + 1,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ if (pollIndex === OPENAI_CONCURRENT_ROTATION_POLL_DELAYS_MS.length - 1) {
+ throw new OpenAITokenStorageError("Failed to read token state", { cause: error });
+ }
+ continue;
+ }
+ if (reread?.type === "cached") {
+ this.log.info("Using cached access token from concurrent rotation");
+ return {
+ accessToken: reread.accessToken,
+ expiresIn: reread.expiresIn,
+ accountId: reread.accountId,
+ };
+ }
+ if (reread?.type === "refresh" && reread.refreshToken !== observedRefreshToken) {
+ observedRefreshToken = reread.refreshToken;
+ this.log.info("Detected concurrent token rotation, retrying");
+ try {
+ return await this.refreshSingleFlight(reread);
+ } catch (error) {
+ if (error instanceof OpenAITokenBrokerError) throw error;
+ if (error instanceof OpenAITokenRefreshError && error.status === 401) continue;
+ this.log.error("OpenAI token refresh retry failed", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw new OpenAITokenUpstreamError("OpenAI token refresh failed", { cause: error });
+ }
+ }
+ }
+ throw new OpenAITokenUnauthorizedError("OpenAI token refresh failed: unauthorized");
+ }
+}
diff --git a/packages/control-plane/src/auth/openai.test.ts b/packages/control-plane/src/auth/openai.test.ts
index f4037669d..099d783d3 100644
--- a/packages/control-plane/src/auth/openai.test.ts
+++ b/packages/control-plane/src/auth/openai.test.ts
@@ -1,5 +1,14 @@
import { describe, it, expect, vi, afterEach } from "vitest";
-import { refreshOpenAIToken, extractOpenAIAccountId, OpenAITokenRefreshError } from "./openai";
+import {
+ checkOpenAIDeviceAuthorization,
+ exchangeOpenAIAuthorizationCode,
+ extractOpenAIAccountId,
+ openAIAccessTokenLifetimeMs,
+ OpenAIOAuthError,
+ OpenAITokenRefreshError,
+ refreshOpenAIToken,
+ startOpenAIDeviceAuthorization,
+} from "./openai";
import type { OpenAITokenResponse } from "./openai";
describe("openai", () => {
@@ -18,11 +27,7 @@ describe("openai", () => {
expires_in: 3600,
};
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: true,
- status: 200,
- text: () => Promise.resolve(JSON.stringify(mockTokens)),
- } as unknown as Response);
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(mockTokens)));
const result = await refreshOpenAIToken("rt_old");
@@ -32,6 +37,7 @@ describe("openai", () => {
const [url, init] = (globalThis.fetch as ReturnType).mock.calls[0];
expect(url).toBe("https://auth.openai.com/oauth/token");
expect(init.method).toBe("POST");
+ expect(init.signal).toBeInstanceOf(AbortSignal);
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
expect(init.body).toContain("grant_type=refresh_token");
expect(init.body).toContain("refresh_token=rt_old");
@@ -45,47 +51,36 @@ describe("openai", () => {
refresh_token: "rt_new",
};
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: true,
- status: 200,
- text: () => Promise.resolve(JSON.stringify(mockTokens)),
- } as unknown as Response);
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(mockTokens)));
await expect(refreshOpenAIToken("rt_old")).resolves.toEqual(mockTokens);
});
it("throws OpenAITokenRefreshError on malformed success response", async () => {
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: true,
- status: 200,
- text: () => Promise.resolve('{"access_token":"acc_123"}'),
- } as unknown as Response);
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response('{"access_token":"acc_123"}'));
const err = await refreshOpenAIToken("rt_old").catch((e) => e);
expect(err).toBeInstanceOf(OpenAITokenRefreshError);
expect(err.status).toBe(200);
- expect(err.body).toBe('{"access_token":"acc_123"}');
+ expect(err).not.toHaveProperty("body");
});
- it("throws OpenAITokenRefreshError on 401 with status and body", async () => {
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: false,
- status: 401,
- text: () => Promise.resolve('{"error":"invalid_grant"}'),
- } as unknown as Response);
+ it("throws OpenAITokenRefreshError on 401 without retaining the provider body", async () => {
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValue(new Response('{"error":"invalid_grant"}', { status: 401 }));
const err = await refreshOpenAIToken("rt_expired").catch((e) => e);
expect(err).toBeInstanceOf(OpenAITokenRefreshError);
expect(err.status).toBe(401);
- expect(err.body).toBe('{"error":"invalid_grant"}');
+ expect(err.errorCode).toBe("invalid_grant");
+ expect(err).not.toHaveProperty("body");
});
it("throws OpenAITokenRefreshError on 500", async () => {
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: false,
- status: 500,
- text: () => Promise.resolve("Internal Server Error"),
- } as unknown as Response);
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValue(new Response("Internal Server Error", { status: 500 }));
await expect(refreshOpenAIToken("rt_any")).rejects.toThrow(OpenAITokenRefreshError);
});
@@ -97,6 +92,192 @@ describe("openai", () => {
});
});
+ describe("device authorization", () => {
+ it("sends the exact start request and validates the interval", async () => {
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(
+ JSON.stringify({ device_auth_id: "device-1", user_code: "ABCD-EFGH", interval: "3" })
+ )
+ );
+
+ await expect(startOpenAIDeviceAuthorization()).resolves.toEqual({
+ deviceAuthId: "device-1",
+ userCode: "ABCD-EFGH",
+ intervalMs: 3000,
+ });
+ const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0];
+ expect(url).toBe("https://auth.openai.com/api/accounts/deviceauth/usercode");
+ expect(init).toMatchObject({
+ method: "POST",
+ headers: { "Content-Type": "application/json", "User-Agent": "Open-Inspect" },
+ });
+ expect(JSON.parse(String(init?.body))).toEqual({
+ client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
+ });
+ });
+
+ it("accepts and strips extra provider response fields", async () => {
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ device_auth_id: "device-1",
+ user_code: "ABCD-EFGH",
+ interval: 3,
+ provider_metadata: "ignored",
+ })
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ authorization_code: "authorization-secret",
+ code_verifier: "verifier",
+ provider_metadata: "ignored",
+ })
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ id_token: "id.jwt.token",
+ access_token: "access",
+ refresh_token: "refresh",
+ provider_metadata: "ignored",
+ })
+ )
+ );
+
+ await expect(startOpenAIDeviceAuthorization()).resolves.toEqual({
+ deviceAuthId: "device-1",
+ userCode: "ABCD-EFGH",
+ intervalMs: 3_000,
+ });
+ await expect(checkOpenAIDeviceAuthorization("device-1", "ABCD")).resolves.toEqual({
+ status: "authorized",
+ authorizationCode: "authorization-secret",
+ codeVerifier: "verifier",
+ });
+ await expect(
+ exchangeOpenAIAuthorizationCode("authorization-secret", "verifier")
+ ).resolves.toEqual({
+ id_token: "id.jwt.token",
+ access_token: "access",
+ refresh_token: "refresh",
+ });
+ });
+
+ it.each([403, 404])("maps provider %s to pending", async (status) => {
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response("pending", { status }));
+ await expect(checkOpenAIDeviceAuthorization("device-1", "ABCD")).resolves.toEqual({
+ status: "pending",
+ });
+ });
+
+ it("returns server-only authorization material and uses the fixed token exchange", async () => {
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ authorization_code: "authorization-secret",
+ code_verifier: "verifier",
+ })
+ )
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ id_token: "id.jwt.token",
+ access_token: "access",
+ refresh_token: "refresh",
+ expires_in: 3600,
+ })
+ )
+ );
+
+ await expect(checkOpenAIDeviceAuthorization("device-1", "ABCD")).resolves.toEqual({
+ status: "authorized",
+ authorizationCode: "authorization-secret",
+ codeVerifier: "verifier",
+ });
+ await exchangeOpenAIAuthorizationCode("authorization-secret", "verifier");
+ const [url, init] = vi.mocked(globalThis.fetch).mock.calls[1];
+ expect(url).toBe("https://auth.openai.com/oauth/token");
+ expect(new URLSearchParams(String(init?.body))).toEqual(
+ new URLSearchParams({
+ grant_type: "authorization_code",
+ code: "authorization-secret",
+ redirect_uri: "https://auth.openai.com/deviceauth/callback",
+ client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
+ code_verifier: "verifier",
+ })
+ );
+ });
+
+ it("accepts omitted ID tokens and numeric-string lifetimes", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ access_token: "access.jwt.token",
+ refresh_token: "refresh",
+ expires_in: "7776000",
+ })
+ )
+ );
+
+ await expect(
+ exchangeOpenAIAuthorizationCode("authorization-secret", "verifier")
+ ).resolves.toEqual({
+ access_token: "access.jwt.token",
+ refresh_token: "refresh",
+ expires_in: 7_776_000,
+ });
+ expect(openAIAccessTokenLifetimeMs(7_776_000)).toBe(7 * 24 * 60 * 60 * 1000);
+ });
+
+ it.each([0, -1, 1.5])("rejects invalid token lifetime %s", async (expiresIn) => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ id_token: "id.jwt.token",
+ access_token: "access",
+ refresh_token: "refresh",
+ expires_in: expiresIn,
+ })
+ )
+ );
+ await expect(
+ exchangeOpenAIAuthorizationCode("authorization-secret", "verifier")
+ ).rejects.toThrow("invalid data");
+ });
+
+ it.each([0, 61, "1.5"])("rejects invalid polling interval %s", async (interval) => {
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ device_auth_id: "device", user_code: "ABCD", interval }))
+ );
+ await expect(startOpenAIDeviceAuthorization()).rejects.toThrow("invalid data");
+ });
+
+ it("bounds malformed and oversized responses without exposing bodies", async () => {
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValueOnce(new Response(JSON.stringify({ user_code: "SECRET-CODE" })))
+ .mockResolvedValueOnce(
+ new Response("x", { headers: { "Content-Length": String(64 * 1024 + 1) } })
+ );
+ const malformed = await startOpenAIDeviceAuthorization().catch((error) => error);
+ expect(malformed).toBeInstanceOf(OpenAIOAuthError);
+ expect(malformed.message).not.toContain("SECRET-CODE");
+ await expect(startOpenAIDeviceAuthorization()).rejects.toThrow("oversized response");
+ });
+ });
+
describe("extractOpenAIAccountId", () => {
function makeJwt(payload: Record): string {
const header = btoa(JSON.stringify({ alg: "RS256", typ: "JWT" }));
@@ -128,7 +309,6 @@ describe("openai", () => {
it("extracts organizations[0].id from access_token", () => {
const tokens: OpenAITokenResponse = {
- id_token: makeJwt({}),
access_token: makeJwt({ organizations: [{ id: "org_abc" }] }),
refresh_token: "rt",
};
@@ -209,14 +389,28 @@ describe("openai", () => {
expect(extractOpenAIAccountId(tokens)).toBe("ab");
});
- it("converts numeric account ID to string", () => {
+ it("rejects non-string account IDs", () => {
const tokens: OpenAITokenResponse = {
id_token: makeJwt({ chatgpt_account_id: 12345 }),
access_token: makeJwt({}),
refresh_token: "rt",
};
- expect(extractOpenAIAccountId(tokens)).toBe("12345");
+ expect(extractOpenAIAccountId(tokens)).toBeUndefined();
+ });
+
+ it.each([
+ ["object", { nested: "account" }],
+ ["array", ["account"]],
+ ["blank string", " "],
+ ])("rejects %s account IDs", (_label, accountId) => {
+ const tokens: OpenAITokenResponse = {
+ id_token: makeJwt({ chatgpt_account_id: accountId }),
+ access_token: makeJwt({}),
+ refresh_token: "rt",
+ };
+
+ expect(extractOpenAIAccountId(tokens)).toBeUndefined();
});
});
});
diff --git a/packages/control-plane/src/auth/openai.ts b/packages/control-plane/src/auth/openai.ts
index 2e996541e..1a470c6a7 100644
--- a/packages/control-plane/src/auth/openai.ts
+++ b/packages/control-plane/src/auth/openai.ts
@@ -3,34 +3,185 @@
*/
import { z } from "zod";
+import {
+ fetchProvider,
+ parseProviderResponse,
+ readBoundedProviderBody,
+ type ProviderResponseErrorFactory,
+} from "./provider-response";
const OPENAI_TOKEN_URL = "https://auth.openai.com/oauth/token";
+const OPENAI_DEVICE_CODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
+const OPENAI_DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
+export const OPENAI_DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device";
+const OPENAI_DEVICE_REDIRECT_URL = "https://auth.openai.com/deviceauth/callback";
const OPENAI_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
+const DEFAULT_OPENAI_TOKEN_LIFETIME_SECONDS = 60 * 60;
+const MAX_OPENAI_TOKEN_LIFETIME_SECONDS = 7 * 24 * 60 * 60;
-export const openAITokenResponseSchema = z.object({
- id_token: z.string(),
- access_token: z.string(),
- refresh_token: z.string(),
- expires_in: z.number().optional(),
+const tokenLifetimeSchema = z
+ .union([z.number(), z.string().regex(/^\d+$/)])
+ .transform(Number)
+ .pipe(z.number().int().positive());
+
+const openAITokenResponseSchema = z.object({
+ id_token: z.string().min(1).max(16_384).optional(),
+ access_token: z.string().min(1).max(16_384),
+ refresh_token: z.string().min(1).max(16_384),
+ expires_in: tokenLifetimeSchema.optional(),
+});
+
+const deviceAuthorizationSchema = z.object({
+ device_auth_id: z.string().min(1).max(4096),
+ user_code: z.string().min(1).max(128),
+ interval: z
+ .union([z.number().int(), z.string().regex(/^\d+$/)])
+ .transform(Number)
+ .pipe(z.number().int().min(1).max(60)),
+});
+
+const deviceStatusSchema = z.object({
+ authorization_code: z.string().min(1).max(4096),
+ code_verifier: z.string().min(1).max(4096),
+});
+
+const openAIAccountIdSchema = z.string().trim().min(1);
+const openAIIdentityClaimsSchema = z.object({
+ chatgpt_account_id: openAIAccountIdSchema.optional(),
+ "https://api.openai.com/auth": z
+ .object({ chatgpt_account_id: openAIAccountIdSchema.optional() })
+ .optional(),
+ organizations: z.array(z.object({ id: openAIAccountIdSchema })).optional(),
});
export type OpenAITokenResponse = z.infer;
+export type OpenAIDeviceAuthorization = {
+ deviceAuthId: string;
+ userCode: string;
+ intervalMs: number;
+};
+export type OpenAIDeviceStatus =
+ | { status: "pending" }
+ | { status: "authorized"; authorizationCode: string; codeVerifier: string };
export class OpenAITokenRefreshError extends Error {
constructor(
message: string,
public readonly status: number,
- public readonly body: string
+ public readonly errorCode?: string
) {
super(message);
}
}
+export class OpenAIOAuthError extends Error {
+ constructor(
+ message: string,
+ public readonly status: number
+ ) {
+ super(message);
+ }
+}
+
+export function openAIAccessTokenLifetimeMs(expiresIn?: number): number {
+ return (
+ Math.min(
+ expiresIn ?? DEFAULT_OPENAI_TOKEN_LIFETIME_SECONDS,
+ MAX_OPENAI_TOKEN_LIFETIME_SECONDS
+ ) * 1000
+ );
+}
+
+function openAIResponseError(operation: string): ProviderResponseErrorFactory {
+ return (reason, status, invalidFields) => {
+ if (reason === "oversized") {
+ return new OpenAIOAuthError(`OpenAI ${operation} returned an oversized response`, 502);
+ }
+ if (reason === "http") {
+ return new OpenAIOAuthError(`OpenAI ${operation} failed`, status);
+ }
+ if (reason === "invalid_json") {
+ return new OpenAIOAuthError(`OpenAI ${operation} returned invalid JSON`, 502);
+ }
+ return new OpenAIOAuthError(
+ `OpenAI ${operation} returned invalid data (${invalidFields?.join(", ")})`,
+ 502
+ );
+ };
+}
+
+export async function startOpenAIDeviceAuthorization(): Promise {
+ const response = await fetchProvider(OPENAI_DEVICE_CODE_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "User-Agent": "Open-Inspect" },
+ body: JSON.stringify({ client_id: OPENAI_CLIENT_ID }),
+ });
+ const data = await parseProviderResponse(
+ response,
+ deviceAuthorizationSchema,
+ openAIResponseError("device authorization")
+ );
+ return {
+ deviceAuthId: data.device_auth_id,
+ userCode: data.user_code,
+ intervalMs: data.interval * 1000,
+ };
+}
+
+export async function checkOpenAIDeviceAuthorization(
+ deviceAuthId: string,
+ userCode: string
+): Promise {
+ const response = await fetchProvider(OPENAI_DEVICE_TOKEN_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "User-Agent": "Open-Inspect" },
+ body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
+ });
+ if (response.status === 403 || response.status === 404) {
+ await readBoundedProviderBody(response, () =>
+ openAIResponseError("device status check")("oversized", response.status)
+ );
+ return { status: "pending" };
+ }
+ const data = await parseProviderResponse(
+ response,
+ deviceStatusSchema,
+ openAIResponseError("device status check")
+ );
+ return {
+ status: "authorized",
+ authorizationCode: data.authorization_code,
+ codeVerifier: data.code_verifier,
+ };
+}
+
+export async function exchangeOpenAIAuthorizationCode(
+ authorizationCode: string,
+ codeVerifier: string
+): Promise {
+ const response = await fetchProvider(OPENAI_TOKEN_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code: authorizationCode,
+ redirect_uri: OPENAI_DEVICE_REDIRECT_URL,
+ client_id: OPENAI_CLIENT_ID,
+ code_verifier: codeVerifier,
+ }).toString(),
+ });
+ return parseProviderResponse(
+ response,
+ openAITokenResponseSchema,
+ openAIResponseError("token exchange")
+ );
+}
+
/**
* Refresh an OpenAI OAuth access token using a refresh token.
*/
export async function refreshOpenAIToken(refreshToken: string): Promise {
- const response = await fetch(OPENAI_TOKEN_URL, {
+ const response = await fetchProvider(OPENAI_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -43,26 +194,33 @@ export async function refreshOpenAIToken(refreshToken: string): Promise new OpenAITokenRefreshError("OpenAI token refresh returned an oversized response", 502)
);
- }
-
- const body = await response.text();
- const parsed: unknown = JSON.parse(body);
- const tokenResult = openAITokenResponseSchema.safeParse(parsed);
- if (!tokenResult.success) {
+ let errorCode: string | undefined;
+ try {
+ const parsed = z.object({ error: z.string() }).safeParse(JSON.parse(body));
+ if (parsed.success) errorCode = parsed.data.error;
+ } catch {
+ // Provider error bodies are intentionally discarded.
+ }
throw new OpenAITokenRefreshError(
- `OpenAI token refresh returned invalid response: ${response.status}`,
+ `OpenAI token refresh failed: ${response.status}`,
response.status,
- body
+ errorCode
);
}
- return tokenResult.data;
+ return parseProviderResponse(
+ response,
+ openAITokenResponseSchema,
+ (_reason, status) =>
+ new OpenAITokenRefreshError(
+ `OpenAI token refresh returned invalid response: ${status}`,
+ status
+ )
+ );
}
/**
@@ -78,15 +236,17 @@ export function extractOpenAIAccountId(tokens: OpenAITokenResponse): string | un
if (parts.length < 2) continue;
// JWTs use base64url encoding; atob() requires standard base64 with padding
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
- const payload = JSON.parse(atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "=")));
-
- // Try different claim locations
+ const parsed = openAIIdentityClaimsSchema.safeParse(
+ JSON.parse(atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "=")))
+ );
+ if (!parsed.success) continue;
+ const payload = parsed.data;
const accountId =
payload.chatgpt_account_id ??
payload["https://api.openai.com/auth"]?.chatgpt_account_id ??
payload.organizations?.[0]?.id;
- if (accountId) return String(accountId);
+ if (accountId) return accountId;
} catch {
// Malformed token, try next
}
diff --git a/packages/control-plane/src/auth/principal.ts b/packages/control-plane/src/auth/principal.ts
index 411fac7db..cc31cadec 100644
--- a/packages/control-plane/src/auth/principal.ts
+++ b/packages/control-plane/src/auth/principal.ts
@@ -10,7 +10,7 @@
import type { ServiceName } from "@open-inspect/shared/service-auth";
/** Actor namespaces bots may assert (`slack:U123` etc.). */
-export const ACTOR_NAMESPACES = ["slack", "github", "linear"] as const;
+const ACTOR_NAMESPACES = ["slack", "github", "linear"] as const;
export type ActorNamespace = (typeof ACTOR_NAMESPACES)[number];
export function isActorNamespace(value: string): value is ActorNamespace {
diff --git a/packages/control-plane/src/auth/provider-account-crypto.test.ts b/packages/control-plane/src/auth/provider-account-crypto.test.ts
new file mode 100644
index 000000000..ac0c6c7e9
--- /dev/null
+++ b/packages/control-plane/src/auth/provider-account-crypto.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vitest";
+import { generateEncryptionKey } from "./crypto";
+import {
+ decryptProviderAccountPayload,
+ decryptProviderAuthorizationPayload,
+ encryptProviderAccountPayload,
+ encryptProviderAuthorizationPayload,
+} from "./provider-account-crypto";
+
+const context = {
+ providerAccountId: "account-1",
+ provider: "openai",
+ credentialSchemaVersion: 2,
+} as const;
+
+describe("provider account crypto", () => {
+ it("round-trips a versioned credential payload", async () => {
+ const key = generateEncryptionKey();
+ const payload = { refreshToken: "refresh-secret", accessToken: "access-secret" };
+
+ const encrypted = await encryptProviderAccountPayload(payload, key, context);
+
+ expect(encrypted).toMatch(/^v1\./);
+ expect(encrypted).not.toContain("refresh-secret");
+ await expect(decryptProviderAccountPayload(encrypted, key, context)).resolves.toEqual(payload);
+ });
+
+ it.each([
+ ["account", { ...context, providerAccountId: "account-2" }],
+ ["provider", { ...context, provider: "xai" }],
+ ["schema version", { ...context, credentialSchemaVersion: 3 }],
+ ])("rejects ciphertext moved to another %s context", async (_target, otherContext) => {
+ const key = generateEncryptionKey();
+ const encrypted = await encryptProviderAccountPayload({ refreshToken: "secret" }, key, context);
+
+ await expect(decryptProviderAccountPayload(encrypted, key, otherContext)).rejects.toThrow();
+ });
+
+ it("rejects unknown encryption format versions", async () => {
+ await expect(
+ decryptProviderAccountPayload("v2.invalid.invalid", generateEncryptionKey(), context)
+ ).rejects.toThrow(/format version/i);
+ });
+
+ it("rejects payloads that cannot be JSON encoded", async () => {
+ await expect(
+ encryptProviderAccountPayload(undefined, generateEncryptionKey(), context)
+ ).rejects.toThrow(/JSON encoded/);
+ });
+
+ it("binds pending authorization state to its transaction and provider", async () => {
+ const key = generateEncryptionKey();
+ const authorizationContext = {
+ transactionId: "01".repeat(32),
+ provider: "openai",
+ stateSchemaVersion: 1,
+ };
+ const encrypted = await encryptProviderAuthorizationPayload(
+ { deviceAuthId: "server-secret", userCode: "ABCD" },
+ key,
+ authorizationContext
+ );
+ expect(encrypted).not.toContain("server-secret");
+ await expect(
+ decryptProviderAuthorizationPayload(encrypted, key, authorizationContext)
+ ).resolves.toEqual({ deviceAuthId: "server-secret", userCode: "ABCD" });
+ await expect(
+ decryptProviderAuthorizationPayload(encrypted, key, {
+ ...authorizationContext,
+ transactionId: "02".repeat(32),
+ })
+ ).rejects.toThrow();
+ await expect(
+ decryptProviderAuthorizationPayload(encrypted, key, {
+ ...authorizationContext,
+ stateSchemaVersion: 2,
+ })
+ ).rejects.toThrow();
+ await expect(
+ decryptProviderAuthorizationPayload(encrypted, key, {
+ ...authorizationContext,
+ provider: "xai",
+ })
+ ).rejects.toThrow();
+ });
+});
diff --git a/packages/control-plane/src/auth/provider-account-crypto.ts b/packages/control-plane/src/auth/provider-account-crypto.ts
new file mode 100644
index 000000000..938a37a01
--- /dev/null
+++ b/packages/control-plane/src/auth/provider-account-crypto.ts
@@ -0,0 +1,133 @@
+const ALGORITHM = "AES-GCM";
+const IV_LENGTH = 12;
+const FORMAT_VERSION = "v1";
+
+export interface ProviderAccountCryptoContext {
+ providerAccountId: string;
+ provider: string;
+ credentialSchemaVersion: number;
+}
+
+export interface ProviderAuthorizationCryptoContext {
+ transactionId: string;
+ provider: string;
+ stateSchemaVersion: number;
+}
+
+function decodeBase64(value: string): Uint8Array {
+ return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
+}
+
+function encodeBase64(value: Uint8Array): string {
+ let binary = "";
+ for (const byte of value) binary += String.fromCharCode(byte);
+ return btoa(binary);
+}
+
+async function importKey(keyBase64: string): Promise {
+ const key = decodeBase64(keyBase64);
+ if (key.byteLength !== 32) {
+ throw new Error("Provider accounts encryption key must contain exactly 32 bytes");
+ }
+ return crypto.subtle.importKey("raw", key, ALGORITHM, false, ["encrypt", "decrypt"]);
+}
+
+function additionalData(context: ProviderAccountCryptoContext): Uint8Array {
+ return new TextEncoder().encode(
+ JSON.stringify([
+ FORMAT_VERSION,
+ context.providerAccountId,
+ context.provider,
+ context.credentialSchemaVersion,
+ ])
+ );
+}
+
+function authorizationAdditionalData(context: ProviderAuthorizationCryptoContext): Uint8Array {
+ return new TextEncoder().encode(
+ JSON.stringify([
+ FORMAT_VERSION,
+ "device-authorization",
+ context.transactionId,
+ context.provider,
+ context.stateSchemaVersion,
+ ])
+ );
+}
+
+async function encryptPayload(
+ payload: unknown,
+ encryptionKey: string,
+ aad: Uint8Array
+): Promise {
+ const key = await importKey(encryptionKey);
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
+ const serialized = JSON.stringify(payload);
+ if (serialized === undefined) throw new Error("Provider payload must be JSON encoded");
+ const ciphertext = await crypto.subtle.encrypt(
+ { name: ALGORITHM, iv, additionalData: aad },
+ key,
+ new TextEncoder().encode(serialized)
+ );
+ return `${FORMAT_VERSION}.${encodeBase64(iv)}.${encodeBase64(new Uint8Array(ciphertext))}`;
+}
+
+async function decryptPayload(
+ encrypted: string,
+ encryptionKey: string,
+ aad: Uint8Array,
+ payloadName: string
+): Promise {
+ const [version, encodedIv, encodedCiphertext, extra] = encrypted.split(".");
+ if (version !== FORMAT_VERSION) {
+ throw new Error(`Unsupported ${payloadName} encryption format version: ${version}`);
+ }
+ if (!encodedIv || !encodedCiphertext || extra !== undefined) {
+ throw new Error(`Malformed ${payloadName} ciphertext`);
+ }
+ const iv = decodeBase64(encodedIv);
+ if (iv.byteLength !== IV_LENGTH) throw new Error(`Malformed ${payloadName} IV`);
+ const plaintext = await crypto.subtle.decrypt(
+ { name: ALGORITHM, iv, additionalData: aad },
+ await importKey(encryptionKey),
+ decodeBase64(encodedCiphertext)
+ );
+ return JSON.parse(new TextDecoder().decode(plaintext)) as T;
+}
+
+export function encryptProviderAccountPayload(
+ payload: unknown,
+ encryptionKey: string,
+ context: ProviderAccountCryptoContext
+): Promise {
+ return encryptPayload(payload, encryptionKey, additionalData(context));
+}
+
+export function encryptProviderAuthorizationPayload(
+ payload: unknown,
+ encryptionKey: string,
+ context: ProviderAuthorizationCryptoContext
+): Promise {
+ return encryptPayload(payload, encryptionKey, authorizationAdditionalData(context));
+}
+
+export async function decryptProviderAccountPayload(
+ encrypted: string,
+ encryptionKey: string,
+ context: ProviderAccountCryptoContext
+): Promise {
+ return decryptPayload(encrypted, encryptionKey, additionalData(context), "provider credential");
+}
+
+export async function decryptProviderAuthorizationPayload(
+ encrypted: string,
+ encryptionKey: string,
+ context: ProviderAuthorizationCryptoContext
+): Promise {
+ return decryptPayload(
+ encrypted,
+ encryptionKey,
+ authorizationAdditionalData(context),
+ "provider authorization"
+ );
+}
diff --git a/packages/control-plane/src/auth/provider-response.ts b/packages/control-plane/src/auth/provider-response.ts
new file mode 100644
index 000000000..79a3e9847
--- /dev/null
+++ b/packages/control-plane/src/auth/provider-response.ts
@@ -0,0 +1,78 @@
+import type { z } from "zod";
+import { PROVIDER_TOKEN_REFRESH_TIMEOUT_MS } from "./provider-token-timeouts";
+
+const PROVIDER_RESPONSE_MAX_BYTES = 64 * 1024;
+
+type ProviderResponseErrorReason = "oversized" | "http" | "invalid_json" | "invalid_data";
+
+export type ProviderResponseErrorFactory = (
+ reason: ProviderResponseErrorReason,
+ status: number,
+ invalidFields?: readonly string[]
+) => Error;
+
+export function fetchProvider(url: string, init: RequestInit): Promise {
+ return fetch(url, {
+ ...init,
+ signal: AbortSignal.timeout(PROVIDER_TOKEN_REFRESH_TIMEOUT_MS),
+ });
+}
+
+export async function readBoundedProviderBody(
+ response: Response,
+ oversizedError: () => Error
+): Promise {
+ const declaredLength = Number(response.headers.get("content-length"));
+ if (Number.isFinite(declaredLength) && declaredLength > PROVIDER_RESPONSE_MAX_BYTES) {
+ throw oversizedError();
+ }
+ const reader = response.body?.getReader();
+ if (!reader) return response.text();
+ const chunks: Uint8Array[] = [];
+ let size = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > PROVIDER_RESPONSE_MAX_BYTES) {
+ await reader.cancel();
+ throw oversizedError();
+ }
+ chunks.push(value);
+ }
+ const body = new Uint8Array(size);
+ let offset = 0;
+ for (const chunk of chunks) {
+ body.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return new TextDecoder().decode(body);
+}
+
+export async function parseProviderResponse(
+ response: Response,
+ schema: z.ZodType,
+ createError: ProviderResponseErrorFactory,
+ options: { acceptErrorStatus?: boolean } = {}
+): Promise {
+ const body = await readBoundedProviderBody(response, () =>
+ createError("oversized", response.status)
+ );
+ if (!response.ok && !options.acceptErrorStatus) {
+ throw createError("http", response.status);
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(body);
+ } catch {
+ throw createError("invalid_json", response.status);
+ }
+ const result = schema.safeParse(parsed);
+ if (!result.success) {
+ const fields = [
+ ...new Set(result.error.issues.map((issue) => String(issue.path[0] ?? "response"))),
+ ];
+ throw createError("invalid_data", response.status, fields);
+ }
+ return result.data;
+}
diff --git a/packages/control-plane/src/auth/provider-token-timeouts.ts b/packages/control-plane/src/auth/provider-token-timeouts.ts
new file mode 100644
index 000000000..5d4ba0c36
--- /dev/null
+++ b/packages/control-plane/src/auth/provider-token-timeouts.ts
@@ -0,0 +1 @@
+export const PROVIDER_TOKEN_REFRESH_TIMEOUT_MS = 10_000;
diff --git a/packages/control-plane/src/auth/service/request-authenticator.ts b/packages/control-plane/src/auth/service/request-authenticator.ts
index 183cd013d..44fbc043d 100644
--- a/packages/control-plane/src/auth/service/request-authenticator.ts
+++ b/packages/control-plane/src/auth/service/request-authenticator.ts
@@ -7,7 +7,7 @@ import {
verifyServiceSignature,
type ServiceName,
} from "@open-inspect/shared/service-auth";
-import { readBodyCapped } from "@open-inspect/shared";
+import { readBodyCapped } from "@open-inspect/shared/http-body";
import { TOKEN_VALIDITY_MS } from "@open-inspect/shared/auth";
import { UserStore } from "../../db/user-store";
import { createLogger } from "../../logger";
diff --git a/packages/control-plane/src/auth/user/better-auth.test.ts b/packages/control-plane/src/auth/user/better-auth.test.ts
index 6da01c7da..514c85a80 100644
--- a/packages/control-plane/src/auth/user/better-auth.test.ts
+++ b/packages/control-plane/src/auth/user/better-auth.test.ts
@@ -1,19 +1,27 @@
import { describe, expect, it } from "vitest";
-import { memoryAdapter } from "better-auth/adapters/memory";
+import type { SqlDatabase, SqlStatement } from "../../db/sql-database";
import { createUserAuth } from "./better-auth";
const PUBLIC_WEB_ORIGIN = "https://web.test.local";
const SECRET = "test-only-better-auth-secret-with-at-least-32-characters";
const UNUSED_PROFILE_RESOLVER = async () => null;
-const UNUSED_USER_PROJECTION = { project: async () => {} };
+
+/** Provider rejection happens before any query executes. */
+const UNREACHED_DATABASE: SqlDatabase = {
+ prepare(): SqlStatement {
+ throw new Error("Database access is not expected in this test");
+ },
+ batch(): never {
+ throw new Error("Database access is not expected in this test");
+ },
+};
describe("Better Auth provider execution", () => {
it("rejects a provider that is disabled before sign-in executes", async () => {
const auth = createUserAuth({
- database: memoryAdapter({}) as unknown as D1Database,
+ database: UNREACHED_DATABASE,
publicWebOrigin: PUBLIC_WEB_ORIGIN,
secret: SECRET,
- userProjection: UNUSED_USER_PROJECTION,
google: {
clientId: "google-client-id",
clientSecret: "google-client-secret",
diff --git a/packages/control-plane/src/auth/user/better-auth.ts b/packages/control-plane/src/auth/user/better-auth.ts
index b26b62778..ab082bd19 100644
--- a/packages/control-plane/src/auth/user/better-auth.ts
+++ b/packages/control-plane/src/auth/user/better-auth.ts
@@ -1,7 +1,8 @@
import { BROWSER_AUTH_CLIENT_IP_HEADER } from "@open-inspect/shared/browser-auth-routes";
import { betterAuth } from "better-auth";
+import { createCanonicalBetterAuthAdapter } from "../../db/better-auth-adapter";
+import type { SqlDatabase } from "../../db/sql-database";
import { generateId } from "../crypto";
-import type { CanonicalUserProjection } from "./canonical-user-projection";
import type { ProviderProfileResolver } from "./provider-profile";
const MS_PER_SECOND = 1000;
@@ -9,26 +10,32 @@ const MS_PER_SECOND = 1000;
export const SESSION_EXPIRES_IN_MS = 7 * 24 * 60 * 60 * MS_PER_SECOND;
export const SESSION_UPDATE_AGE_MS = 24 * 60 * 60 * MS_PER_SECOND;
+export interface SocialProviderAuthConfig {
+ readonly clientId: string;
+ readonly clientSecret: string;
+ readonly getUserInfo: ProviderProfileResolver;
+}
+
export interface UserAuthConfig {
- readonly database: D1Database;
+ readonly database: SqlDatabase;
readonly publicWebOrigin: string;
readonly secret: string;
- readonly userProjection: CanonicalUserProjection;
- readonly github?: {
- readonly clientId: string;
- readonly clientSecret: string;
- readonly getUserInfo: ProviderProfileResolver;
- };
- readonly google?: {
- readonly clientId: string;
- readonly clientSecret: string;
- readonly getUserInfo: ProviderProfileResolver;
- };
+ readonly github?: SocialProviderAuthConfig;
+ readonly google?: SocialProviderAuthConfig;
}
/**
* Creates the control plane's user-authentication authority.
*
+ * Better Auth persists directly into the canonical identity registry (issue
+ * #1290 consolidation): its user model IS `users` and its account model IS
+ * `user_identities`, via the field maps below and the canonical SQL adapter.
+ * With a single registry there is nothing to keep synchronized — a
+ * bot-created GitHub identity is an account, so `findOAuthUser`'s
+ * account-first lookup signs bot-first users into their canonical row
+ * natively. Sessions and OAuth-state verifications stay in Better Auth-owned
+ * tables (epoch-ms columns, same adapter).
+ *
* `publicWebOrigin` is deliberately the browser-visible web origin rather than
* the control-plane origin. The web transparently proxies this handler, so all
* redirects and host-only cookies remain scoped to the web application.
@@ -36,7 +43,7 @@ export interface UserAuthConfig {
export function createUserAuth(config: UserAuthConfig) {
return betterAuth({
baseURL: config.publicWebOrigin,
- database: config.database,
+ database: createCanonicalBetterAuthAdapter(config.database),
secret: config.secret,
trustedOrigins: [config.publicWebOrigin],
telemetry: { enabled: false },
@@ -82,7 +89,14 @@ export function createUserAuth(config: UserAuthConfig) {
: {}),
},
user: {
- modelName: "auth_users",
+ modelName: "users",
+ fields: {
+ name: "display_name",
+ emailVerified: "email_verified",
+ image: "avatar_url",
+ createdAt: "created_at",
+ updatedAt: "updated_at",
+ },
},
session: {
modelName: "auth_sessions",
@@ -90,25 +104,32 @@ export function createUserAuth(config: UserAuthConfig) {
updateAge: SESSION_UPDATE_AGE_MS / MS_PER_SECOND,
},
account: {
- modelName: "auth_accounts",
- accountLinking: {
- disableImplicitLinking: true,
+ modelName: "user_identities",
+ fields: {
+ accountId: "provider_user_id",
+ providerId: "provider",
+ userId: "user_id",
+ accessToken: "access_token",
+ refreshToken: "refresh_token",
+ idToken: "id_token",
+ accessTokenExpiresAt: "access_token_expires_at",
+ refreshTokenExpiresAt: "refresh_token_expires_at",
+ createdAt: "created_at",
+ updatedAt: "updated_at",
},
+ // Implicit linking is deliberately enabled (the Better Auth default):
+ // bot ingress links identities across providers by verified email on
+ // every request, and pre-cutover web sign-in did the same — refusing it
+ // at the web door locked out every canonical user without a sign-in
+ // identity (#1290). requireLocalEmailVerified stays at its default
+ // (true): `users.email_verified` — written by completed OAuth proof,
+ // attested bot ingress (EMAIL_ATTESTING_PROVIDERS in db/user-store.ts),
+ // or the one-time 0057 backlog verify — is the linking gate.
encryptOAuthTokens: true,
},
verification: {
modelName: "auth_verifications",
storeIdentifier: "hashed",
},
- databaseHooks: {
- user: {
- create: {
- after: (user) => config.userProjection.project(user),
- },
- update: {
- after: (user) => config.userProjection.project(user),
- },
- },
- },
});
}
diff --git a/packages/control-plane/src/auth/user/canonical-user-projection.ts b/packages/control-plane/src/auth/user/canonical-user-projection.ts
deleted file mode 100644
index 56e80a611..000000000
--- a/packages/control-plane/src/auth/user/canonical-user-projection.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export interface UserProjectionInput {
- readonly id: string;
- readonly name: string;
- readonly email: string;
- readonly image?: string | null;
- readonly createdAt: Date;
- readonly updatedAt: Date;
-}
-
-/**
- * Projects Better Auth's user into the application's actor model.
- *
- * The ids must remain identical: authorization always names `users.id`, while
- * Better Auth remains authoritative for authentication state.
- */
-export interface CanonicalUserProjection {
- project(user: UserProjectionInput): Promise;
-}
diff --git a/packages/control-plane/src/auth/user/providers/github-identity.ts b/packages/control-plane/src/auth/user/providers/github-identity.ts
index 18e424e77..1b337e85f 100644
--- a/packages/control-plane/src/auth/user/providers/github-identity.ts
+++ b/packages/control-plane/src/auth/user/providers/github-identity.ts
@@ -1,9 +1,9 @@
import { z } from "zod";
+import { SIGN_IN_PROVIDER_ISSUERS } from "@open-inspect/shared/sign-in-provider";
import { createLogger, type Logger } from "../../../logger";
import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS } from "./constants";
import { assertCanonicalIssuer, OAuthProviderError, type VerifiedProviderIdentity } from "./types";
-const GITHUB_ISSUER = "https://github.com";
const GITHUB_API_URL = "https://api.github.com";
const GITHUB_API_VERSION = "2022-11-28";
const GITHUB_EMAILS_PER_PAGE = 100;
@@ -50,7 +50,7 @@ export class GitHubProviderIdentityResolver {
private readonly config: GitHubProviderIdentityResolverConfig,
dependencies: GitHubProviderIdentityResolverDependencies = {}
) {
- assertCanonicalIssuer(config.issuer, GITHUB_ISSUER);
+ assertCanonicalIssuer(config.issuer, SIGN_IN_PROVIDER_ISSUERS.github);
this.fetchImpl = dependencies.fetch ?? globalThis.fetch.bind(globalThis);
this.requestTimeoutMs = dependencies.requestTimeoutMs ?? DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS;
this.logger = dependencies.logger ?? createLogger("github-provider-identity");
@@ -67,7 +67,7 @@ export class GitHubProviderIdentityResolver {
];
return {
provider: "github",
- issuer: GITHUB_ISSUER,
+ issuer: SIGN_IN_PROVIDER_ISSUERS.github,
subject: String(user.id),
login: user.login,
displayName: user.name ?? user.login,
diff --git a/packages/control-plane/src/auth/user/providers/github-profile.ts b/packages/control-plane/src/auth/user/providers/github-profile.ts
index 77bf1d6fd..05d660a6c 100644
--- a/packages/control-plane/src/auth/user/providers/github-profile.ts
+++ b/packages/control-plane/src/auth/user/providers/github-profile.ts
@@ -2,7 +2,7 @@ import type { AdmissionPolicy, GitHubAdmissionEvidence } from "../admission-poli
import type { ProviderProfile, ProviderTokens } from "../provider-profile";
import { OAuthProviderError, type VerifiedProviderIdentity } from "./types";
-export interface GitHubIdentityResolver {
+interface GitHubIdentityResolver {
resolveIdentity(accessToken: string): Promise>;
}
diff --git a/packages/control-plane/src/auth/user/providers/google-profile.ts b/packages/control-plane/src/auth/user/providers/google-profile.ts
index 7f5ad6efc..27beb4b71 100644
--- a/packages/control-plane/src/auth/user/providers/google-profile.ts
+++ b/packages/control-plane/src/auth/user/providers/google-profile.ts
@@ -1,13 +1,12 @@
import { verifyGoogleIdToken } from "better-auth/social-providers";
+import { SIGN_IN_PROVIDER_ISSUERS } from "@open-inspect/shared/sign-in-provider";
import { z } from "zod";
import type { AdmissionPolicy, GoogleAdmissionEvidence } from "../admission-policy";
import type { ProviderProfile, ProviderTokens } from "../provider-profile";
import { OAuthProviderError } from "./types";
-const GOOGLE_ISSUER = "https://accounts.google.com";
-
const googleClaimsSchema = z.object({
- iss: z.union([z.literal(GOOGLE_ISSUER), z.literal("accounts.google.com")]),
+ iss: z.union([z.literal(SIGN_IN_PROVIDER_ISSUERS.google), z.literal("accounts.google.com")]),
sub: z.string().min(1),
email: z.email(),
email_verified: z.literal(true),
@@ -54,7 +53,7 @@ export class GoogleSignInProfileResolver {
const signIn: GoogleAdmissionEvidence = {
identity: {
provider: "google",
- issuer: GOOGLE_ISSUER,
+ issuer: SIGN_IN_PROVIDER_ISSUERS.google,
subject: parsedClaims.data.sub,
...(parsedClaims.data.name ? { displayName: parsedClaims.data.name } : {}),
...(parsedClaims.data.picture ? { avatarUrl: parsedClaims.data.picture } : {}),
diff --git a/packages/control-plane/src/auth/user/runtime.test.ts b/packages/control-plane/src/auth/user/runtime.test.ts
index 532d37800..5552934d4 100644
--- a/packages/control-plane/src/auth/user/runtime.test.ts
+++ b/packages/control-plane/src/auth/user/runtime.test.ts
@@ -1,10 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import {
- createUserAuthFromEnv,
- createUserAuthRuntimeFromEnv,
- getUserAuthRuntime,
- parsePublicWebOrigin,
-} from "./runtime";
+import { createUserAuthRuntimeFromEnv, getUserAuthRuntime, parsePublicWebOrigin } from "./runtime";
import { createUserAuth } from "./better-auth";
import type { Env } from "../../types";
@@ -53,7 +48,7 @@ describe("parsePublicWebOrigin", () => {
});
});
-describe("createUserAuthFromEnv sign-in provider configuration", () => {
+describe("createUserAuthRuntimeFromEnv sign-in provider configuration", () => {
beforeEach(() => {
vi.mocked(createUserAuth).mockClear();
});
@@ -77,7 +72,7 @@ describe("createUserAuthFromEnv sign-in provider configuration", () => {
});
it("wires both providers when both are configured", () => {
- createUserAuthFromEnv(
+ createUserAuthRuntimeFromEnv(
envWith({
GITHUB_CLIENT_ID: "github-id",
GITHUB_CLIENT_SECRET: "github-secret",
@@ -161,7 +156,7 @@ describe("createUserAuthFromEnv sign-in provider configuration", () => {
});
it("rejects a deployment with no sign-in provider configured", () => {
- expect(() => createUserAuthFromEnv(envWith({}), STUB_DATABASE)).toThrow(
+ expect(() => createUserAuthRuntimeFromEnv(envWith({}), STUB_DATABASE)).toThrow(
/At least one sign-in provider must be configured/
);
});
@@ -170,7 +165,7 @@ describe("createUserAuthFromEnv sign-in provider configuration", () => {
["GITHUB_CLIENT_ID", { GITHUB_CLIENT_ID: "github-id" }],
["GITHUB_CLIENT_SECRET", { GITHUB_CLIENT_SECRET: "github-secret" }],
])("rejects a half-configured GitHub provider: %s alone", (_name, overrides) => {
- expect(() => createUserAuthFromEnv(envWith(overrides), STUB_DATABASE)).toThrow(
+ expect(() => createUserAuthRuntimeFromEnv(envWith(overrides), STUB_DATABASE)).toThrow(
/GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET must be configured together/
);
});
@@ -179,14 +174,14 @@ describe("createUserAuthFromEnv sign-in provider configuration", () => {
["GOOGLE_CLIENT_ID", { GOOGLE_CLIENT_ID: "google-id" }],
["GOOGLE_CLIENT_SECRET", { GOOGLE_CLIENT_SECRET: "google-secret" }],
])("rejects a half-configured Google provider: %s alone", (_name, overrides) => {
- expect(() => createUserAuthFromEnv(envWith(overrides), STUB_DATABASE)).toThrow(
+ expect(() => createUserAuthRuntimeFromEnv(envWith(overrides), STUB_DATABASE)).toThrow(
/GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be configured together/
);
});
it("treats whitespace-only credentials as unset", () => {
expect(() =>
- createUserAuthFromEnv(
+ createUserAuthRuntimeFromEnv(
envWith({ GITHUB_CLIENT_ID: " ", GITHUB_CLIENT_SECRET: " " }),
STUB_DATABASE
)
diff --git a/packages/control-plane/src/auth/user/runtime.ts b/packages/control-plane/src/auth/user/runtime.ts
index a71c12616..b8088742e 100644
--- a/packages/control-plane/src/auth/user/runtime.ts
+++ b/packages/control-plane/src/auth/user/runtime.ts
@@ -4,15 +4,20 @@ import {
parseAdmissionBoolean,
type AdmissionPolicyConfig,
} from "./admission-policy";
-import { SIGN_IN_PROVIDERS, type SignInProvider } from "@open-inspect/shared/sign-in-provider";
-import { createUserAuth, type UserAuthConfig } from "./better-auth";
+import {
+ SIGN_IN_PROVIDERS,
+ SIGN_IN_PROVIDER_ISSUERS,
+ type SignInProvider,
+} from "@open-inspect/shared/sign-in-provider";
+import { createUserAuth, type SocialProviderAuthConfig } from "./better-auth";
import { GitHubProviderIdentityResolver } from "./providers/github-identity";
import { GitHubSignInProfileResolver } from "./providers/github-profile";
import { GoogleSignInProfileResolver } from "./providers/google-profile";
-import { D1CanonicalUserProjection } from "../../db/canonical-user-projection";
+import { SignInClaim } from "./sign-in-claim";
+import { IdentityClaimStore } from "../../db/identity-claim-store";
+import type { SqlDatabase } from "../../db/sql-database";
import type { Env } from "../../types";
-const GITHUB_ISSUER = "https://github.com";
const MINIMUM_SECRET_LENGTH = 32;
export class UserAuthConfigurationError extends Error {
@@ -143,13 +148,13 @@ function createGitHubAuthConfig(
credentials: OAuthCredentials | null,
appName: string,
admissionPolicy: AdmissionPolicy
-): UserAuthConfig["github"] {
+): SocialProviderAuthConfig | undefined {
if (!credentials) return undefined;
requireProviderAdmission(admissionPolicy, "github");
const profile = new GitHubSignInProfileResolver({
identityResolver: new GitHubProviderIdentityResolver({
- issuer: GITHUB_ISSUER,
+ issuer: SIGN_IN_PROVIDER_ISSUERS.github,
userAgent: `${appName} Control Plane`,
}),
admissionPolicy,
@@ -164,7 +169,7 @@ function createGitHubAuthConfig(
function createGoogleAuthConfig(
credentials: OAuthCredentials | null,
admissionPolicy: AdmissionPolicy
-): UserAuthConfig["google"] {
+): SocialProviderAuthConfig | undefined {
if (!credentials) return undefined;
requireProviderAdmission(admissionPolicy, "google");
@@ -179,19 +184,39 @@ function createGoogleAuthConfig(
};
}
+function withClaim(
+ provider: SignInProvider,
+ claim: SignInClaim,
+ config: SocialProviderAuthConfig | undefined
+): SocialProviderAuthConfig | undefined {
+ if (!config) return undefined;
+ return {
+ ...config,
+ getUserInfo: claim.wrapResolver(provider, config.getUserInfo),
+ };
+}
+
function createUserAuthRuntime(
config: NormalizedUserAuthConfig,
- database: D1Database
+ database: SqlDatabase
): UserAuthRuntime {
const admissionPolicy = new AdmissionPolicy(config.admission);
- const github = createGitHubAuthConfig(config.providers.github, config.appName, admissionPolicy);
- const google = createGoogleAuthConfig(config.providers.google, admissionPolicy);
+ const claim = new SignInClaim(new IdentityClaimStore(database));
+ const github = withClaim(
+ "github",
+ claim,
+ createGitHubAuthConfig(config.providers.github, config.appName, admissionPolicy)
+ );
+ const google = withClaim(
+ "google",
+ claim,
+ createGoogleAuthConfig(config.providers.google, admissionPolicy)
+ );
const auth = createUserAuth({
database,
publicWebOrigin: config.publicWebOrigin,
secret: config.secret,
- userProjection: new D1CanonicalUserProjection(database),
...(github ? { github } : {}),
...(google ? { google } : {}),
});
@@ -203,7 +228,7 @@ function createUserAuthRuntime(
};
}
-export function createUserAuthRuntimeFromEnv(env: Env, database: D1Database): UserAuthRuntime {
+export function createUserAuthRuntimeFromEnv(env: Env, database: SqlDatabase): UserAuthRuntime {
return createUserAuthRuntime(normalizeUserAuthConfig(env), database);
}
@@ -214,22 +239,18 @@ export interface UserAuthRuntime {
readonly enabledProviders: readonly SignInProvider[];
}
-export function createUserAuthFromEnv(env: Env, database: D1Database): BetterAuthInstance {
- return createUserAuthRuntimeFromEnv(env, database).auth;
-}
-
interface CachedUserAuth {
readonly fingerprint: string;
readonly runtime: UserAuthRuntime;
}
-const userAuthByDatabase = new WeakMap();
+const userAuthByDatabase = new WeakMap();
function configurationFingerprint(config: NormalizedUserAuthConfig): string {
return JSON.stringify(config);
}
-export function getUserAuthRuntime(env: Env, database: D1Database): UserAuthRuntime {
+export function getUserAuthRuntime(env: Env, database: SqlDatabase): UserAuthRuntime {
const config = normalizeUserAuthConfig(env);
const fingerprint = configurationFingerprint(config);
const cached = userAuthByDatabase.get(database);
@@ -241,7 +262,7 @@ export function getUserAuthRuntime(env: Env, database: D1Database): UserAuthRunt
return runtime;
}
-export function getUserAuth(env: Env, database: D1Database): BetterAuthInstance {
+export function getUserAuth(env: Env, database: SqlDatabase): BetterAuthInstance {
return getUserAuthRuntime(env, database).auth;
}
diff --git a/packages/control-plane/src/auth/user/session-authenticator.test.ts b/packages/control-plane/src/auth/user/session-authenticator.test.ts
index 911a28b8b..26872b2fa 100644
--- a/packages/control-plane/src/auth/user/session-authenticator.test.ts
+++ b/packages/control-plane/src/auth/user/session-authenticator.test.ts
@@ -3,16 +3,17 @@ import { authenticateSession, type SessionReader } from "./session-authenticator
describe("authenticateSession", () => {
it("authenticates a browser session without enumerating provider accounts", async () => {
+ const userId = "0123456789abcdef0123456789abcdef";
const sessionReader: SessionReader = {
getSession: vi.fn(async () => ({
- session: { id: "session-1", userId: "user-1" },
- user: { id: "user-1" },
+ session: { id: "session-1", userId },
+ user: { id: userId },
})),
};
const headers = new Headers({ Cookie: "openinspect.session_token=session.signature" });
await expect(authenticateSession(sessionReader, headers)).resolves.toEqual({
- userId: "user-1",
+ userId,
authentication: {
mechanism: "browser_session",
credentialId: "session-1",
@@ -36,8 +37,8 @@ describe("authenticateSession", () => {
it("rejects a session whose user does not match", async () => {
const sessionReader: SessionReader = {
getSession: vi.fn(async () => ({
- session: { id: "session-1", userId: "user-1" },
- user: { id: "different-user" },
+ session: { id: "session-1", userId: "0123456789abcdef0123456789abcdef" },
+ user: { id: "11111111111111111111111111111111" },
})),
};
@@ -45,4 +46,17 @@ describe("authenticateSession", () => {
"Better Auth returned a cross-user session"
);
});
+
+ it("rejects a non-canonical user principal", async () => {
+ const sessionReader: SessionReader = {
+ getSession: vi.fn(async () => ({
+ session: { id: "session-1", userId: "legacy-user" },
+ user: { id: "legacy-user" },
+ })),
+ };
+
+ await expect(authenticateSession(sessionReader, new Headers())).rejects.toThrow(
+ "Better Auth returned a malformed session"
+ );
+ });
});
diff --git a/packages/control-plane/src/auth/user/session-authenticator.ts b/packages/control-plane/src/auth/user/session-authenticator.ts
index 8ff6b8314..a2094f5d8 100644
--- a/packages/control-plane/src/auth/user/session-authenticator.ts
+++ b/packages/control-plane/src/auth/user/session-authenticator.ts
@@ -13,6 +13,7 @@
* Better Auth endpoints remain responsible for session refresh.
*/
+import { isCanonicalUserId } from "@open-inspect/shared/user-id";
import { z } from "zod";
import type { AuthenticationContext } from "../principal";
@@ -22,7 +23,7 @@ const sessionSchema = z.object({
userId: z.string().min(1),
}),
user: z.object({
- id: z.string().min(1),
+ id: z.string().refine(isCanonicalUserId),
}),
});
diff --git a/packages/control-plane/src/auth/user/sign-in-claim.ts b/packages/control-plane/src/auth/user/sign-in-claim.ts
new file mode 100644
index 000000000..18995ec1d
--- /dev/null
+++ b/packages/control-plane/src/auth/user/sign-in-claim.ts
@@ -0,0 +1,146 @@
+import type { SignInProvider } from "@open-inspect/shared/sign-in-provider";
+import { createLogger } from "../../logger";
+import { normalizeEmail } from "../../db/email";
+import type { IdentityClaimStore } from "../../db/identity-claim-store";
+import type { ProviderProfile, ProviderProfileResolver } from "./provider-profile";
+
+const logger = createLogger("auth:sign-in-claim");
+
+/**
+ * Claim-at-first-login decorator around the provider profile resolvers.
+ *
+ * Better Auth persists directly into the canonical registry — a bot-created
+ * identity IS an account, so `findOAuthUser`'s account-first lookup signs
+ * bot-first users into their canonical row natively. What remains is
+ * proof-keeping for the rows that lack it: GitHub ingress creates canonical rows NULL-email, and
+ * non-attesting attribution leaves `email_verified = 0` (Slack/Linear
+ * ingress is attested — see `EMAIL_ATTESTING_PROVIDERS` in `db/user-store.ts`
+ * — so their rows normally arrive already verified). The OAuth callback is
+ * the one moment a provider-verified email is in hand, so this decorator
+ * runs just before Better Auth's own queries and:
+ *
+ * - **Subject claim**: the incoming subject already has an identity row —
+ * backfill the canonical row's NULL email (and verify it) from the OAuth
+ * proof. If a *different* canonical user owns that email, skip and event
+ * (`auth.subject_email_collision`): the sign-in still lands account-first
+ * on the subject's row, and the divergent pair is operator merge work.
+ * - **Email claim**: no identity row for the subject — normalize the owning
+ * canonical row's legacy email form (Better Auth's lookup is exact-match
+ * lowercase) and mint `email_verified = 1` from the proof so the implicit
+ * linking gate (`requireLocalEmailVerified`) admits the link. Beyond
+ * attested ingress, OAuth proof here is the only verification source.
+ *
+ * Contract: the inner profile is always returned unchanged, and inner
+ * failures (admission denials) propagate untouched. Claim failures are
+ * logged and swallowed — worst case is the undecorated behavior.
+ */
+export class SignInClaim {
+ constructor(private readonly store: IdentityClaimStore) {}
+
+ wrapResolver(provider: SignInProvider, inner: ProviderProfileResolver): ProviderProfileResolver {
+ return async (tokens) => {
+ const profile = await inner(tokens);
+ if (!profile) return profile;
+ try {
+ await this.claim(provider, profile);
+ } catch (error) {
+ logger.error("Sign-in claim failed; continuing sign-in unchanged", {
+ event: "auth.claim_failed",
+ provider,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ return profile;
+ };
+ }
+
+ private async claim(provider: SignInProvider, profile: ProviderProfile): Promise {
+ const subject = profile.user.id?.trim();
+ const email = normalizeEmail(profile.user.email);
+ // Without a provider-verified email there is nothing to claim.
+ if (!subject || !email || !profile.user.emailVerified) return;
+
+ const identityOwnerId = await this.store.findIdentityOwnerId(provider, subject);
+ if (identityOwnerId) {
+ await this.subjectClaim(provider, subject, identityOwnerId, email);
+ return;
+ }
+ await this.emailClaim(email);
+ }
+
+ /**
+ * The subject's canonical row exists (bot-first or returning user): give it
+ * the just-proven email if it has none, or the verification if the proven
+ * email matches an unproven one.
+ */
+ private async subjectClaim(
+ provider: SignInProvider,
+ subject: string,
+ targetUserId: string,
+ email: string
+ ): Promise {
+ const target = await this.store.getEmailState(targetUserId);
+ if (!target) return;
+
+ const targetEmail = normalizeEmail(target.email);
+ if (targetEmail === null) {
+ const emailOwnerId = await this.store.findEmailOwnerId(email);
+ if (emailOwnerId !== null && emailOwnerId !== targetUserId) {
+ // Divergent multi-surface pair: one canonical row owns this person's
+ // provider subject (e.g. bot-created from GitHub, no email) while a
+ // different row owns their email (e.g. Slack-created). The sign-in
+ // proceeds account-first onto the subject's row; converging the pair
+ // is operator merge work (scripts/merge-split-users.ts).
+ logger.warn("Subject and verified email belong to different canonical users", {
+ event: "auth.subject_email_collision",
+ provider,
+ subject,
+ subject_user_id: targetUserId,
+ email_owner_user_id: emailOwnerId,
+ });
+ return;
+ }
+ if (await this.store.claimEmail(targetUserId, email)) {
+ logger.info("Claimed NULL-email canonical row with verified sign-in email", {
+ event: "auth.email_claimed",
+ provider,
+ user_id: targetUserId,
+ });
+ }
+ return;
+ }
+
+ if (targetEmail === email && !target.emailVerified) {
+ await this.store.verifyEmail(targetUserId, email);
+ logger.info("Verified canonical email from completed OAuth proof", {
+ event: "auth.email_claim_verified",
+ provider,
+ user_id: targetUserId,
+ });
+ }
+ // A differing non-null canonical email is left alone: the sign-in lands
+ // account-first regardless, and re-shaping attributed emails is not this
+ // decorator's job.
+ }
+
+ /**
+ * First sign-in with this subject: prepare the email-owning canonical row
+ * (if any) for Better Auth's email lookup and linking gate. No owner means
+ * a genuinely new person — the register path proceeds untouched.
+ */
+ private async emailClaim(email: string): Promise {
+ // Legacy rows may hold an unnormalized form idx_users_email's NOCASE
+ // matches but Better Auth's exact lookup would miss — registering a
+ // whitespace-variant duplicate instead of linking. Normalize first.
+ await this.store.normalizeStoredEmail(email);
+
+ const verifiedUserId = await this.store.verifyEmailOwner(email);
+ if (verifiedUserId !== null) {
+ logger.info("Verified canonical email owner ahead of implicit link", {
+ event: "auth.email_claim_verified",
+ user_id: verifiedUserId,
+ mode: "pre-link",
+ });
+ }
+ }
+}
diff --git a/packages/control-plane/src/auth/xai.test.ts b/packages/control-plane/src/auth/xai.test.ts
index d5765dfdb..91b1ab8f7 100644
--- a/packages/control-plane/src/auth/xai.test.ts
+++ b/packages/control-plane/src/auth/xai.test.ts
@@ -1,5 +1,12 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { refreshXaiToken, XaiTokenRefreshError, type XaiTokenResponse } from "./xai";
+import {
+ checkXaiDeviceAuthorization,
+ fetchXaiAccountId,
+ refreshXaiToken,
+ startXaiDeviceAuthorization,
+ XaiTokenRefreshError,
+ type XaiTokenResponse,
+} from "./xai";
describe("refreshXaiToken", () => {
const originalFetch = globalThis.fetch;
@@ -14,11 +21,7 @@ describe("refreshXaiToken", () => {
refresh_token: "refresh-new",
expires_in: 3600,
};
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: true,
- status: 200,
- text: () => Promise.resolve(JSON.stringify(tokens)),
- } as Response);
+ globalThis.fetch = vi.fn().mockResolvedValue(Response.json(tokens));
await expect(refreshXaiToken("refresh-old")).resolves.toEqual(tokens);
@@ -31,11 +34,7 @@ describe("refreshXaiToken", () => {
});
it("accepts responses without a replacement refresh token", async () => {
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: true,
- status: 200,
- text: () => Promise.resolve('{"access_token":"access-new"}'),
- } as Response);
+ globalThis.fetch = vi.fn().mockResolvedValue(Response.json({ access_token: "access-new" }));
await expect(refreshXaiToken("refresh-old")).resolves.toEqual({
access_token: "access-new",
@@ -43,11 +42,11 @@ describe("refreshXaiToken", () => {
});
it("accepts a rotated refresh token without expires_in", async () => {
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: true,
- status: 200,
- text: () => Promise.resolve('{"access_token":"access-new","refresh_token":"refresh-new"}'),
- } as Response);
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValue(
+ Response.json({ access_token: "access-new", refresh_token: "refresh-new" })
+ );
await expect(refreshXaiToken("refresh-old")).resolves.toEqual({
access_token: "access-new",
@@ -56,11 +55,9 @@ describe("refreshXaiToken", () => {
});
it("classifies invalid_grant refresh errors", async () => {
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: false,
- status: 401,
- text: () => Promise.resolve('{"error":"invalid_grant"}'),
- } as Response);
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValue(Response.json({ error: "invalid_grant" }, { status: 401 }));
const error = await refreshXaiToken("stale").catch((cause) => cause);
expect(error).toBeInstanceOf(XaiTokenRefreshError);
@@ -73,22 +70,117 @@ describe("refreshXaiToken", () => {
'{"access_token":"access","expires_in":0}',
'{"access_token":"access","expires_in":1.5}',
])("rejects unusable successful responses: %s", async (body) => {
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: true,
- status: 200,
- text: () => Promise.resolve(body),
- } as Response);
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response(body));
await expect(refreshXaiToken("refresh")).rejects.toBeInstanceOf(XaiTokenRefreshError);
});
it("accepts provider lifetimes longer than one day", async () => {
- globalThis.fetch = vi.fn().mockResolvedValue({
- ok: true,
- status: 200,
- text: () => Promise.resolve('{"access_token":"access","expires_in":172800}'),
- } as Response);
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValue(Response.json({ access_token: "access", expires_in: 172_800 }));
await expect(refreshXaiToken("refresh")).resolves.toMatchObject({ expires_in: 172_800 });
});
});
+
+describe("xAI device authorization", () => {
+ const originalFetch = globalThis.fetch;
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ });
+
+ it("starts with the Grok CLI client and uses the complete verification URL", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ Response.json({
+ device_code: "device-secret",
+ user_code: "ABCD-EFGH",
+ verification_uri: "https://accounts.x.ai/oauth2/device",
+ verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH",
+ expires_in: 300,
+ interval: 5,
+ })
+ );
+
+ await expect(startXaiDeviceAuthorization()).resolves.toEqual({
+ deviceCode: "device-secret",
+ userCode: "ABCD-EFGH",
+ verificationUrl: "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH",
+ expiresInMs: 300_000,
+ intervalMs: 5_000,
+ });
+ const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0];
+ expect(url).toBe("https://auth.x.ai/oauth2/device/code");
+ expect(new URLSearchParams(String(init?.body))).toEqual(
+ new URLSearchParams({
+ client_id: "b1a00492-073a-47ea-816f-4c329264a828",
+ scope: "openid profile email offline_access grok-cli:access api:access",
+ referrer: "opencode",
+ })
+ );
+ });
+
+ it.each([
+ ["authorization_pending", { status: "pending" }],
+ ["slow_down", { status: "pending", intervalMs: 10_000 }],
+ ["access_denied", { status: "denied" }],
+ ["authorization_denied", { status: "denied" }],
+ ["expired_token", { status: "expired" }],
+ ["invalid_grant", { status: "failed" }],
+ ])("maps %s token responses", async (error, expected) => {
+ globalThis.fetch = vi.fn().mockResolvedValue(Response.json({ error }, { status: 400 }));
+
+ await expect(checkXaiDeviceAuthorization("device-secret", 5_000)).resolves.toEqual(expected);
+ });
+
+ it("exchanges a device code without exposing it in the result state", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ Response.json({
+ id_token: makeJwt({ sub: "xai-user" }),
+ access_token: "access",
+ refresh_token: "refresh",
+ expires_in: 3600,
+ })
+ );
+
+ await expect(checkXaiDeviceAuthorization("device-secret", 5_000)).resolves.toMatchObject({
+ status: "connected",
+ tokens: { access_token: "access", refresh_token: "refresh" },
+ });
+ const [, init] = vi.mocked(globalThis.fetch).mock.calls[0];
+ expect(new URLSearchParams(String(init?.body))).toEqual(
+ new URLSearchParams({
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+ client_id: "b1a00492-073a-47ea-816f-4c329264a828",
+ device_code: "device-secret",
+ })
+ );
+ });
+
+ it("fetches the stable OIDC subject from xAI", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(Response.json({ sub: "xai-user" }));
+
+ await expect(fetchXaiAccountId("access-secret")).resolves.toBe("xai-user");
+ const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0];
+ expect(url).toBe("https://auth.x.ai/oauth2/userinfo");
+ expect(init?.headers).toEqual({
+ Accept: "application/json",
+ Authorization: "Bearer access-secret",
+ });
+ });
+
+ it("uses the canonical bounded provider-response path", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ new Response("x", {
+ headers: { "Content-Length": String(64 * 1024 + 1) },
+ })
+ );
+
+ await expect(startXaiDeviceAuthorization()).rejects.toThrow("oversized response");
+ });
+});
+
+function makeJwt(payload: Record): string {
+ return `${btoa(JSON.stringify({ alg: "none" }))}.${btoa(JSON.stringify(payload))}.`;
+}
diff --git a/packages/control-plane/src/auth/xai.ts b/packages/control-plane/src/auth/xai.ts
index 27a689862..462295df6 100644
--- a/packages/control-plane/src/auth/xai.ts
+++ b/packages/control-plane/src/auth/xai.ts
@@ -1,16 +1,57 @@
import { z } from "zod";
+import {
+ fetchProvider,
+ parseProviderResponse,
+ readBoundedProviderBody,
+ type ProviderResponseErrorFactory,
+} from "./provider-response";
const XAI_TOKEN_URL = "https://auth.x.ai/oauth2/token";
+const XAI_DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
+const XAI_USERINFO_URL = "https://auth.x.ai/oauth2/userinfo";
const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
-const XAI_TOKEN_REQUEST_TIMEOUT_MS = 10_000;
+const XAI_DEVICE_SCOPE = "openid profile email offline_access grok-cli:access api:access";
+const XAI_DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
-export const xaiTokenResponseSchema = z.object({
+const xaiTokenResponseSchema = z.object({
+ id_token: z.string().min(1).max(16_384).optional(),
access_token: z.string().min(1),
refresh_token: z.string().optional(),
expires_in: z.number().int().positive().optional(),
});
+const xaiDeviceTokenResponseSchema = xaiTokenResponseSchema.extend({
+ refresh_token: z.string().min(1),
+});
+
+const xaiDeviceAuthorizationSchema = z.object({
+ device_code: z.string().min(1).max(4096),
+ user_code: z.string().min(1).max(128),
+ verification_uri: z.url(),
+ verification_uri_complete: z.url().optional(),
+ expires_in: z.number().int().positive().optional(),
+ interval: z.number().int().min(1).max(60).optional(),
+});
+
+const xaiOAuthErrorSchema = z.object({ error: z.string().min(1) });
+const xaiDeviceTokenExchangeResponseSchema = z.union([
+ xaiDeviceTokenResponseSchema,
+ xaiOAuthErrorSchema,
+]);
+const xaiUserInfoSchema = z.object({ sub: z.string().min(1).max(512) });
+
export type XaiTokenResponse = z.infer;
+export type XaiDeviceAuthorization = {
+ deviceCode: string;
+ userCode: string;
+ verificationUrl: string;
+ expiresInMs?: number;
+ intervalMs: number;
+};
+export type XaiDeviceStatus =
+ | { status: "pending"; intervalMs?: number }
+ | { status: "connected"; tokens: XaiTokenResponse & { refresh_token: string } }
+ | { status: "denied" | "expired" | "failed" };
type XaiTokenRefreshErrorReason = "invalid_grant" | "unauthorized" | "invalid_response" | "other";
@@ -24,6 +65,98 @@ export class XaiTokenRefreshError extends Error {
}
}
+function xaiResponseError(operation: string): ProviderResponseErrorFactory {
+ return (reason, status, invalidFields) => {
+ if (reason === "oversized") return new Error(`xAI ${operation} returned an oversized response`);
+ if (reason === "http") return new Error(`xAI ${operation} failed: ${status}`);
+ if (reason === "invalid_json") return new Error(`xAI ${operation} returned invalid JSON`);
+ const fieldSuffix = invalidFields?.length ? ` (${invalidFields.join(", ")})` : "";
+ return new Error(`xAI ${operation} returned invalid data${fieldSuffix}`);
+ };
+}
+
+export async function startXaiDeviceAuthorization(): Promise {
+ const response = await fetchProvider(XAI_DEVICE_CODE_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Accept: "application/json",
+ "User-Agent": "Open-Inspect",
+ },
+ body: new URLSearchParams({
+ client_id: XAI_CLIENT_ID,
+ scope: XAI_DEVICE_SCOPE,
+ referrer: "opencode",
+ }).toString(),
+ });
+ const result = await parseProviderResponse(
+ response,
+ xaiDeviceAuthorizationSchema,
+ xaiResponseError("device authorization")
+ );
+ return {
+ deviceCode: result.device_code,
+ userCode: result.user_code,
+ verificationUrl: result.verification_uri_complete ?? result.verification_uri,
+ expiresInMs: result.expires_in ? result.expires_in * 1000 : undefined,
+ intervalMs: (result.interval ?? 5) * 1000,
+ };
+}
+
+export async function checkXaiDeviceAuthorization(
+ deviceCode: string,
+ intervalMs: number
+): Promise {
+ const response = await fetchProvider(XAI_TOKEN_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Accept: "application/json",
+ "User-Agent": "Open-Inspect",
+ },
+ body: new URLSearchParams({
+ grant_type: XAI_DEVICE_GRANT_TYPE,
+ client_id: XAI_CLIENT_ID,
+ device_code: deviceCode,
+ }).toString(),
+ });
+ const parsed = await parseProviderResponse(
+ response,
+ xaiDeviceTokenExchangeResponseSchema,
+ xaiResponseError("device token exchange"),
+ { acceptErrorStatus: true }
+ );
+ if (response.ok) {
+ if ("error" in parsed) {
+ throw xaiResponseError("device token exchange")("invalid_data", response.status);
+ }
+ return { status: "connected", tokens: parsed };
+ }
+ if (!("error" in parsed)) {
+ throw xaiResponseError("device token exchange")("invalid_data", response.status);
+ }
+ if (parsed.error === "authorization_pending") return { status: "pending" };
+ if (parsed.error === "slow_down")
+ return { status: "pending", intervalMs: Math.min(intervalMs + 5_000, 60_000) };
+ if (parsed.error === "access_denied" || parsed.error === "authorization_denied") {
+ return { status: "denied" };
+ }
+ if (parsed.error === "expired_token") return { status: "expired" };
+ return { status: "failed" };
+}
+
+export async function fetchXaiAccountId(accessToken: string): Promise {
+ const response = await fetchProvider(XAI_USERINFO_URL, {
+ headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
+ });
+ const result = await parseProviderResponse(
+ response,
+ xaiUserInfoSchema,
+ xaiResponseError("user info request")
+ );
+ return result.sub;
+}
+
function classifyRefreshError(status: number, body: string): XaiTokenRefreshErrorReason {
try {
const parsed: unknown = JSON.parse(body);
@@ -42,9 +175,8 @@ function classifyRefreshError(status: number, body: string): XaiTokenRefreshErro
}
export async function refreshXaiToken(refreshToken: string): Promise {
- const response = await fetch(XAI_TOKEN_URL, {
+ const response = await fetchProvider(XAI_TOKEN_URL, {
method: "POST",
- signal: AbortSignal.timeout(XAI_TOKEN_REQUEST_TIMEOUT_MS),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
@@ -55,9 +187,17 @@ export async function refreshXaiToken(refreshToken: string): Promise
+ new XaiTokenRefreshError(
+ "xAI token refresh returned an oversized response",
+ 502,
+ "invalid_response"
+ )
+ );
throw new XaiTokenRefreshError(
`xAI token refresh failed: ${response.status}`,
response.status,
@@ -65,23 +205,14 @@ export async function refreshXaiToken(refreshToken: string): Promise
+ new XaiTokenRefreshError(
+ `xAI token refresh returned invalid response: ${status}`,
+ status,
+ "invalid_response"
+ )
+ );
}
diff --git a/packages/control-plane/src/automation/hydrate.ts b/packages/control-plane/src/automation/hydrate.ts
new file mode 100644
index 000000000..2a740726e
--- /dev/null
+++ b/packages/control-plane/src/automation/hydrate.ts
@@ -0,0 +1,15 @@
+import type { Automation } from "@open-inspect/shared/types/automations";
+import { AutomationStore, toAutomation, type AutomationRow } from "../db/automation-store";
+import { AutomationModelProviderAuthStore } from "../db/automation-model-provider-auth";
+import type { SqlDatabase } from "../db/sql-database";
+
+export async function hydrateAutomation(db: SqlDatabase, row: AutomationRow): Promise {
+ const store = new AutomationStore(db);
+ const providerAuthStore = new AutomationModelProviderAuthStore(db);
+ const [repositories, environments, providerAuth] = await Promise.all([
+ store.getRepositoriesForAutomation(row.id),
+ store.getEnvironmentsForAutomation(row.id),
+ providerAuthStore.list(row.id),
+ ]);
+ return toAutomation(row, repositories, environments, providerAuth);
+}
diff --git a/packages/control-plane/src/automation/repository.ts b/packages/control-plane/src/automation/repository.ts
index f33d01656..fc48bba90 100644
--- a/packages/control-plane/src/automation/repository.ts
+++ b/packages/control-plane/src/automation/repository.ts
@@ -3,7 +3,7 @@ import type { Env } from "../types";
import { createSourceControlProviderFromEnv, type SourceControlProvider } from "../source-control";
/** A repository resolved for one firing: access checked, branch defaulted. */
-export interface ResolvedAutomationRepository {
+interface ResolvedAutomationRepository {
repoOwner: string;
repoName: string;
// Access-checked at resolution, so always present (unlike the stored
diff --git a/packages/control-plane/src/automation/session-target.test.ts b/packages/control-plane/src/automation/session-target.test.ts
index 89f487e7a..d5f269589 100644
--- a/packages/control-plane/src/automation/session-target.test.ts
+++ b/packages/control-plane/src/automation/session-target.test.ts
@@ -5,6 +5,7 @@ import { HttpError, type RequestContext } from "../routes/shared";
import type { AutomationRunRow } from "../db/automation-store";
import type { Env } from "../types";
import type { Logger } from "../logger";
+import { TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support";
vi.mock("../repos/resolve", async (importOriginal) => {
const actual = (await importOriginal()) as Record;
@@ -28,6 +29,7 @@ const ctx: RequestContext = {
request_id: "req-1",
metrics: {} as RequestContext["metrics"],
db: env.DB,
+ executionCtx: TEST_BACKGROUND_TASK_CONTEXT,
};
function run(overrides?: Partial): AutomationRunRow {
diff --git a/packages/control-plane/src/background-tasks.test-support.test.ts b/packages/control-plane/src/background-tasks.test-support.test.ts
new file mode 100644
index 000000000..6d427dd55
--- /dev/null
+++ b/packages/control-plane/src/background-tasks.test-support.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect } from "vitest";
+import { createTestBackgroundTasks } from "./background-tasks.test-support";
+
+// Pins the adapter to the production submit contract; see
+// cloudflare/background-tasks.test.ts for the implementation's own suite.
+describe("createTestBackgroundTasks", () => {
+ it("absorbs a synchronous factory throw and records it", () => {
+ const background = createTestBackgroundTasks();
+ const boom = new Error("sync boom");
+
+ expect(() =>
+ background.submit(
+ () => {
+ throw boom;
+ },
+ { name: "test.sync_throw" }
+ )
+ ).not.toThrow();
+
+ expect(background.failures).toEqual([boom]);
+ expect(background.submissions).toEqual([{ name: "test.sync_throw" }]);
+ });
+
+ it("runs the factory synchronously and records the task in order", async () => {
+ const background = createTestBackgroundTasks();
+ let ran = false;
+
+ background.submit(
+ async () => {
+ ran = true;
+ },
+ { name: "test.task" }
+ );
+
+ expect(ran).toBe(true);
+ expect(background.submissions).toHaveLength(1);
+ await background.settle();
+ });
+
+ it("absorbs rejections into failures so unawaited tasks cannot fail a run", async () => {
+ const background = createTestBackgroundTasks();
+
+ background.submit(() => Promise.reject(new Error("late boom")), { name: "test.reject" });
+
+ await expect(background.settle()).resolves.toBeUndefined();
+ expect(background.failures).toEqual([expect.objectContaining({ message: "late boom" })]);
+ await expect(background.submissions[0]?.task).rejects.toThrow("late boom");
+ });
+});
diff --git a/packages/control-plane/src/background-tasks.test-support.ts b/packages/control-plane/src/background-tasks.test-support.ts
new file mode 100644
index 000000000..8ba20c137
--- /dev/null
+++ b/packages/control-plane/src/background-tasks.test-support.ts
@@ -0,0 +1,55 @@
+import type { BackgroundTasks } from "./platform-ports";
+
+/** One `submit` call: the task name and the factory's promise (absent when it threw). */
+export interface RecordedSubmission {
+ name: string;
+ task?: Promise;
+}
+
+export interface TestBackgroundTasks extends BackgroundTasks {
+ /** Every submit in order. Assert on `.length` instead of spying on `submit`. */
+ readonly submissions: RecordedSubmission[];
+ /**
+ * Errors absorbed by the boundary — synchronous factory throws immediately,
+ * rejections once the task settles — exactly the set production logs as
+ * `background_task.failed`.
+ */
+ readonly failures: unknown[];
+ /** Await every recorded task, tolerating rejections (production absorbs them). */
+ settle(): Promise;
+}
+
+/**
+ * Contract-faithful `BackgroundTasks` double, mirroring
+ * `createCloudflareBackgroundTasks`: the factory is invoked synchronously, a
+ * synchronous throw is absorbed, and rejections are absorbed; both land in
+ * `failures`. Tests drain deferred work via `settle()` (or an individual
+ * `submissions[i].task`). Keep this behaviourally identical to the production
+ * implementation — a fake with a different boundary makes collaborator tests
+ * exercise a contract production does not have.
+ */
+export function createTestBackgroundTasks(): TestBackgroundTasks {
+ const submissions: RecordedSubmission[] = [];
+ const failures: unknown[] = [];
+ return {
+ submissions,
+ failures,
+ submit(task, metadata) {
+ let pending: Promise;
+ try {
+ pending = task();
+ } catch (error) {
+ failures.push(error);
+ submissions.push({ name: metadata.name });
+ return;
+ }
+ void pending.catch((error) => failures.push(error));
+ submissions.push({ name: metadata.name, task: pending });
+ },
+ async settle() {
+ for (const submission of submissions) {
+ await submission.task?.catch(() => {});
+ }
+ },
+ };
+}
diff --git a/packages/control-plane/src/cloudflare/background-tasks.test.ts b/packages/control-plane/src/cloudflare/background-tasks.test.ts
new file mode 100644
index 000000000..90ee8e11b
--- /dev/null
+++ b/packages/control-plane/src/cloudflare/background-tasks.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it, vi } from "vitest";
+import { createCloudflareBackgroundTasks } from "./background-tasks";
+
+describe("createCloudflareBackgroundTasks", () => {
+ it("extends the Durable Object lifetime for the spawned task", () => {
+ const waitUntil = vi.fn();
+ const background = createCloudflareBackgroundTasks({ waitUntil });
+
+ background.submit(() => Promise.resolve(), { name: "test.task" });
+
+ expect(waitUntil).toHaveBeenCalledOnce();
+ expect(waitUntil).toHaveBeenCalledWith(expect.any(Promise));
+ });
+
+ it("runs the factory synchronously exactly once", () => {
+ const waitUntil = vi.fn();
+ const background = createCloudflareBackgroundTasks({ waitUntil });
+ const runs: number[] = [];
+
+ background.submit(
+ () => {
+ runs.push(runs.length + 1);
+ return Promise.resolve();
+ },
+ { name: "test.task" }
+ );
+
+ // The side effect is observable before submit returns: the factory runs
+ // synchronously, with no microtask deferral.
+ expect(runs).toEqual([1]);
+ });
+
+ it("catches and logs rejected tasks", async () => {
+ const waitUntil = vi.fn();
+ const logger = { error: vi.fn() };
+ const background = createCloudflareBackgroundTasks({ waitUntil }, () => logger as never);
+
+ background.submit(() => Promise.reject(new Error("task failed")), {
+ name: "test.task",
+ context: { session_id: "session-1" },
+ });
+ await waitUntil.mock.calls[0]![0];
+
+ expect(logger.error).toHaveBeenCalledWith("background_task.failed", {
+ task_name: "test.task",
+ session_id: "session-1",
+ error: expect.objectContaining({ message: "task failed" }),
+ });
+ });
+
+ it("absorbs and logs a factory that throws synchronously", () => {
+ const waitUntil = vi.fn();
+ const logger = { error: vi.fn() };
+ const background = createCloudflareBackgroundTasks({ waitUntil }, () => logger as never);
+
+ expect(() =>
+ background.submit(
+ () => {
+ throw new Error("construction failed");
+ },
+ { name: "test.task", context: { session_id: "session-1" } }
+ )
+ ).not.toThrow();
+
+ // Nothing started, so there is no lifetime to extend.
+ expect(waitUntil).not.toHaveBeenCalled();
+ expect(logger.error).toHaveBeenCalledWith("background_task.failed", {
+ task_name: "test.task",
+ session_id: "session-1",
+ error: expect.objectContaining({ message: "construction failed" }),
+ });
+ });
+});
diff --git a/packages/control-plane/src/cloudflare/background-tasks.ts b/packages/control-plane/src/cloudflare/background-tasks.ts
new file mode 100644
index 000000000..91a6a2309
--- /dev/null
+++ b/packages/control-plane/src/cloudflare/background-tasks.ts
@@ -0,0 +1,31 @@
+import { createLogger, type Logger } from "../logger";
+import type { BackgroundTasks } from "../platform-ports";
+
+type WaitUntilContext = Pick;
+const log = createLogger("background-tasks");
+
+/** Keep Cloudflare event-lifetime extension at Worker and Durable Object boundaries. */
+export function createCloudflareBackgroundTasks(
+ context: WaitUntilContext,
+ getLogger: () => Logger = () => log
+): BackgroundTasks {
+ return {
+ submit(task, metadata): void {
+ const logFailure = (error: unknown): void => {
+ getLogger().error("background_task.failed", {
+ task_name: metadata.name,
+ ...metadata.context,
+ error: error instanceof Error ? error : String(error),
+ });
+ };
+ let pending: Promise;
+ try {
+ pending = task();
+ } catch (error) {
+ logFailure(error);
+ return; // Nothing started, so there is no lifetime to extend.
+ }
+ context.waitUntil(pending.catch(logFailure));
+ },
+ };
+}
diff --git a/packages/control-plane/src/db/analytics-store.ts b/packages/control-plane/src/db/analytics-store.ts
index e8b18760b..57dd382be 100644
--- a/packages/control-plane/src/db/analytics-store.ts
+++ b/packages/control-plane/src/db/analytics-store.ts
@@ -5,7 +5,7 @@ import type {
AnalyticsSummaryResponse,
AnalyticsTimeseriesResponse,
} from "@open-inspect/shared/types/analytics";
-import type { SpawnSource } from "@open-inspect/shared";
+import type { SpawnSource } from "@open-inspect/shared/types/sessions";
import type { SqlDatabase } from "./sql-database";
/** Spawn sources that represent direct human-initiated sessions. */
diff --git a/packages/control-plane/src/db/automation-list-cursor.test.ts b/packages/control-plane/src/db/automation-list-cursor.test.ts
new file mode 100644
index 000000000..c7c5a3033
--- /dev/null
+++ b/packages/control-plane/src/db/automation-list-cursor.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import { encodeAutomationListCursor, parseAutomationListCursor } from "./automation-list-cursor";
+
+describe("automation list cursors", () => {
+ it("round-trips timestamp and encoded id values", () => {
+ const encoded = encodeAutomationListCursor({ createdAt: 123, id: "auto:encoded/id" });
+
+ expect(parseAutomationListCursor(encoded)).toEqual({
+ ok: true,
+ cursor: { createdAt: 123, id: "auto:encoded/id" },
+ });
+ });
+
+ it.each(["invalid", "-1:auto", "1:", "1:%E0%A4%A"])("rejects malformed cursor %s", (raw) => {
+ expect(parseAutomationListCursor(raw)).toEqual({ ok: false, error: "Invalid cursor" });
+ });
+});
diff --git a/packages/control-plane/src/db/automation-list-cursor.ts b/packages/control-plane/src/db/automation-list-cursor.ts
new file mode 100644
index 000000000..71a8471f6
--- /dev/null
+++ b/packages/control-plane/src/db/automation-list-cursor.ts
@@ -0,0 +1,31 @@
+export interface AutomationListCursor {
+ createdAt: number;
+ id: string;
+}
+
+type ParseAutomationListCursorResult =
+ | { ok: true; cursor: AutomationListCursor | null }
+ | { ok: false; error: string };
+
+export function encodeAutomationListCursor(cursor: AutomationListCursor): string {
+ return `${cursor.createdAt}:${encodeURIComponent(cursor.id)}`;
+}
+
+export function parseAutomationListCursor(raw: string | null): ParseAutomationListCursorResult {
+ if (!raw) return { ok: true, cursor: null };
+
+ const separator = raw.indexOf(":");
+ if (separator <= 0) return { ok: false, error: "Invalid cursor" };
+
+ const createdAt = Number(raw.slice(0, separator));
+ if (!Number.isSafeInteger(createdAt) || createdAt < 0) {
+ return { ok: false, error: "Invalid cursor" };
+ }
+
+ try {
+ const id = decodeURIComponent(raw.slice(separator + 1));
+ return id ? { ok: true, cursor: { createdAt, id } } : { ok: false, error: "Invalid cursor" };
+ } catch {
+ return { ok: false, error: "Invalid cursor" };
+ }
+}
diff --git a/packages/control-plane/src/db/automation-model-provider-auth.test.ts b/packages/control-plane/src/db/automation-model-provider-auth.test.ts
new file mode 100644
index 000000000..18cf2686a
--- /dev/null
+++ b/packages/control-plane/src/db/automation-model-provider-auth.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ AutomationModelProviderAuthStore,
+ toProviderSelections,
+} from "./automation-model-provider-auth";
+
+interface FakeStatement {
+ sql: string;
+ params: unknown[];
+}
+
+function createFakeDb() {
+ const statements: FakeStatement[] = [];
+ const statement = {
+ bind(...params: unknown[]) {
+ statements[statements.length - 1].params = params;
+ return statement;
+ },
+ };
+ const db = {
+ prepare(sql: string) {
+ statements.push({ sql, params: [] });
+ return statement;
+ },
+ batch: vi.fn(),
+ } as unknown as D1Database;
+ return { db, statements };
+}
+
+describe("AutomationModelProviderAuthStore", () => {
+ it("hydrates provider selections from persisted rows", () => {
+ expect(
+ toProviderSelections([
+ {
+ automation_id: "auto-1",
+ provider: "openai",
+ auth_mode: "provider_account",
+ provider_account_id: "0123456789abcdef0123456789abcdef",
+ created_at: 1,
+ updated_at: 1,
+ },
+ {
+ automation_id: "auto-1",
+ provider: "xai",
+ auth_mode: "api_key",
+ provider_account_id: null,
+ created_at: 1,
+ updated_at: 1,
+ },
+ ])
+ ).toEqual({
+ openai: {
+ mode: "provider_account",
+ accountId: "0123456789abcdef0123456789abcdef",
+ },
+ xai: { mode: "api_key" },
+ });
+ });
+
+ it("builds composable insert and replacement statements", () => {
+ const { db, statements } = createFakeDb();
+ const store = new AutomationModelProviderAuthStore(db);
+ const selections = {
+ openai: {
+ mode: "provider_account" as const,
+ accountId: "0123456789abcdef0123456789abcdef",
+ },
+ xai: { mode: "api_key" as const },
+ };
+
+ expect(store.bindInserts("auto-1", selections, 10)).toHaveLength(2);
+ expect(statements.map(({ params }) => params.slice(0, 4))).toEqual([
+ ["auto-1", "openai", "provider_account", "0123456789abcdef0123456789abcdef"],
+ ["auto-1", "xai", "api_key", null],
+ ]);
+
+ statements.length = 0;
+ expect(store.bindReplace("auto-1", selections, 10)).toHaveLength(3);
+ expect(statements[0]).toEqual({
+ sql: "DELETE FROM automation_model_provider_auth WHERE automation_id = ?",
+ params: ["auto-1"],
+ });
+ });
+});
diff --git a/packages/control-plane/src/db/automation-model-provider-auth.ts b/packages/control-plane/src/db/automation-model-provider-auth.ts
new file mode 100644
index 000000000..f20ebe136
--- /dev/null
+++ b/packages/control-plane/src/db/automation-model-provider-auth.ts
@@ -0,0 +1,101 @@
+import {
+ assertProviderAuthSelection,
+ type ProviderAuthMode,
+} from "../model-provider-accounts/provider-auth-contracts";
+import type { ModelProviderSelections } from "@open-inspect/shared/types/provider-accounts";
+import type { SqlDatabase, SqlStatement } from "./sql-database";
+
+export interface AutomationModelProviderAuthRow {
+ automation_id: string;
+ provider: string;
+ auth_mode: ProviderAuthMode;
+ provider_account_id: string | null;
+ created_at: number;
+ updated_at: number;
+}
+
+export function toProviderSelections(
+ rows: AutomationModelProviderAuthRow[]
+): ModelProviderSelections {
+ return Object.fromEntries(
+ rows.map((row) => {
+ assertProviderAuthSelection(row.provider, row.auth_mode, row.provider_account_id);
+ return [
+ row.provider,
+ row.auth_mode === "provider_account"
+ ? { mode: row.auth_mode, accountId: row.provider_account_id }
+ : { mode: row.auth_mode },
+ ];
+ })
+ ) as ModelProviderSelections;
+}
+
+export class AutomationModelProviderAuthStore {
+ constructor(private readonly db: SqlDatabase) {}
+
+ bindInserts(
+ automationId: string,
+ selections: ModelProviderSelections,
+ now: number
+ ): SqlStatement[] {
+ return Object.entries(selections).map(([provider, selection]) =>
+ this.db
+ .prepare(
+ `INSERT INTO automation_model_provider_auth
+ (automation_id, provider, auth_mode, provider_account_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`
+ )
+ .bind(
+ automationId,
+ provider,
+ selection.mode,
+ selection.mode === "provider_account" ? selection.accountId : null,
+ now,
+ now
+ )
+ );
+ }
+
+ bindReplace(
+ automationId: string,
+ selections: ModelProviderSelections,
+ now: number
+ ): SqlStatement[] {
+ return [
+ this.db
+ .prepare("DELETE FROM automation_model_provider_auth WHERE automation_id = ?")
+ .bind(automationId),
+ ...this.bindInserts(automationId, selections, now),
+ ];
+ }
+
+ async list(automationId: string): Promise {
+ const result = await this.db
+ .prepare(
+ `SELECT * FROM automation_model_provider_auth
+ WHERE automation_id = ? ORDER BY provider`
+ )
+ .bind(automationId)
+ .all();
+ return result.results || [];
+ }
+
+ async listForAutomationIds(
+ automationIds: string[]
+ ): Promise> {
+ const rowsByAutomation = new Map();
+ for (const id of automationIds) rowsByAutomation.set(id, []);
+ if (automationIds.length === 0) return rowsByAutomation;
+
+ const placeholders = automationIds.map(() => "?").join(", ");
+ const result = await this.db
+ .prepare(
+ `SELECT * FROM automation_model_provider_auth
+ WHERE automation_id IN (${placeholders}) ORDER BY automation_id, provider`
+ )
+ .bind(...automationIds)
+ .all();
+ for (const row of result.results ?? []) rowsByAutomation.get(row.automation_id)?.push(row);
+ return rowsByAutomation;
+ }
+}
diff --git a/packages/control-plane/src/db/automation-store.test.ts b/packages/control-plane/src/db/automation-store.test.ts
index f978465a0..73ab7ec0d 100644
--- a/packages/control-plane/src/db/automation-store.test.ts
+++ b/packages/control-plane/src/db/automation-store.test.ts
@@ -111,7 +111,7 @@ const sampleRunRow: AutomationRunRow = {
started_at: null,
completed_at: null,
created_at: now,
- invocation_id: null,
+ invocation_id: "inv-test1",
repo_owner: null,
repo_name: null,
repo_id: null,
@@ -123,17 +123,22 @@ const sampleRunRow: AutomationRunRow = {
describe("toAutomation", () => {
it("converts row to camelCase Automation", () => {
- const automation = toAutomation(sampleRow, [
- {
- automation_id: "auto_test1",
- repo_owner: "acme",
- repo_name: "web-app",
- repo_id: 12345,
- base_branch: "main",
- created_at: now,
- updated_at: now,
- },
- ]);
+ const automation = toAutomation(
+ sampleRow,
+ [
+ {
+ automation_id: "auto_test1",
+ repo_owner: "acme",
+ repo_name: "web-app",
+ repo_id: 12345,
+ base_branch: "main",
+ created_at: now,
+ updated_at: now,
+ },
+ ],
+ [],
+ []
+ );
expect(automation.id).toBe("auto_test1");
expect(automation.repositories).toEqual([
{ repoOwner: "acme", repoName: "web-app", repoId: 12345, baseBranch: "main" },
@@ -167,20 +172,55 @@ describe("toAutomation", () => {
created_at: now,
updated_at: now,
},
- ]
+ ],
+ []
);
expect(automation.environmentIds).toEqual(["env_abc", "env_def"]);
});
it("converts enabled=0 to false", () => {
- const automation = toAutomation({ ...sampleRow, enabled: 0 }, []);
+ const automation = toAutomation({ ...sampleRow, enabled: 0 }, [], [], []);
expect(automation.enabled).toBe(false);
});
it("maps repo-less automations to an empty repository list", () => {
- const automation = toAutomation(sampleRow, []);
+ const automation = toAutomation(sampleRow, [], [], []);
expect(automation.repositories).toEqual([]);
});
+
+ it("hydrates provider selections from auth rows", () => {
+ const automation = toAutomation(
+ sampleRow,
+ [],
+ [],
+ [
+ {
+ automation_id: sampleRow.id,
+ provider: "openai",
+ auth_mode: "provider_account",
+ provider_account_id: "0123456789abcdef0123456789abcdef",
+ created_at: now,
+ updated_at: now,
+ },
+ {
+ automation_id: sampleRow.id,
+ provider: "xai",
+ auth_mode: "api_key",
+ provider_account_id: null,
+ created_at: now,
+ updated_at: now,
+ },
+ ]
+ );
+
+ expect(automation.providerSelections).toEqual({
+ openai: {
+ mode: "provider_account",
+ accountId: "0123456789abcdef0123456789abcdef",
+ },
+ xai: { mode: "api_key" },
+ });
+ });
});
describe("toAutomationRun", () => {
@@ -245,14 +285,14 @@ describe("AutomationStore", () => {
});
describe("list", () => {
- it("returns automations and total", async () => {
+ it("returns a bounded page", async () => {
const { db } = createFakeD1({
allResults: [sampleRow],
});
const store = new AutomationStore(db);
- const result = await store.list();
- expect(result.total).toBe(1);
+ const result = await store.list({ limit: 25 });
expect(result.automations).toHaveLength(1);
+ expect(result.hasMore).toBe(false);
});
});
@@ -417,6 +457,70 @@ describe("AutomationStore", () => {
expect(statements).toHaveLength(0);
});
});
+
+ describe("claimRunSession", () => {
+ it("claims only a starting run", async () => {
+ const { db, statements } = createFakeD1();
+ const store = new AutomationStore(db);
+
+ await store.claimRunSession("run_test1", "session-1", now);
+
+ expect(statements[0].sql).toContain("SET status = 'running'");
+ expect(statements[0].sql).toContain("WHERE id = ? AND status = 'starting'");
+ expect(statements[0].params).toEqual(["session-1", now, "run_test1"]);
+ });
+ });
+
+ describe("schedule advancement", () => {
+ const invocation = {
+ id: "inv-1",
+ automation_id: "auto_test1",
+ source: "schedule" as const,
+ scheduled_at: now,
+ trigger_key: null,
+ concurrency_key: null,
+ trigger_metadata: null,
+ skip_reason: null,
+ failure_counted_at: null,
+ created_at: now,
+ updated_at: now,
+ };
+
+ it("advances a guarded invocation only while it still owns the claimed slot", async () => {
+ const { db, statements } = createFakeD1();
+ const store = new AutomationStore(db);
+
+ await store.insertInvocationGuarded({
+ invocation,
+ children: [sampleRunRow],
+ overlapScope: { kind: "automation" },
+ advanceSchedule: { fromSlot: now, nextRunAt: now + 60_000 },
+ });
+
+ const advance = statements.at(-1)!;
+ // Compare-and-set on the claimed slot, not a monotonic timestamp guard:
+ // "any later value wins" lets a loser advance again from the winner's
+ // successor and skip a slot entirely.
+ expect(advance.sql).toContain("next_run_at = ?");
+ expect(advance.sql).not.toContain("next_run_at < ?");
+ expect(advance.params.at(-1)).toBe(now);
+ });
+
+ it("advances a skipped invocation only while it still owns the claimed slot", async () => {
+ const { db, statements } = createFakeD1();
+ const store = new AutomationStore(db);
+
+ await store.insertSkippedInvocation(
+ { ...invocation, id: "inv-skipped", skip_reason: "concurrent_run_active" },
+ { fromSlot: now, nextRunAt: now + 60_000 }
+ );
+
+ const advance = statements.at(-1)!;
+ expect(advance.sql).toContain("next_run_at = ?");
+ expect(advance.sql).not.toContain("next_run_at < ?");
+ expect(advance.params.at(-1)).toBe(now);
+ });
+ });
});
describe("isDuplicateKeyError", () => {
diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts
index dd12cd8a5..8ecb0df39 100644
--- a/packages/control-plane/src/db/automation-store.ts
+++ b/packages/control-plane/src/db/automation-store.ts
@@ -7,6 +7,7 @@
import type {
Automation,
+ AutomationExecutionSummary,
AutomationInvocation,
AutomationInvocationSource,
AutomationInvocationStatus,
@@ -15,7 +16,39 @@ import type {
AutomationRunStatus,
} from "@open-inspect/shared/types/automations";
import type { TriggerConfig } from "@open-inspect/shared/triggers";
+import {
+ toProviderSelections,
+ type AutomationModelProviderAuthRow,
+} from "./automation-model-provider-auth";
import type { SqlDatabase, SqlStatement } from "./sql-database";
+import type { AutomationListCursor } from "./automation-list-cursor";
+
+function escapeLikePattern(value: string): string {
+ return value.replace(/[\\%_]/g, "\\$&");
+}
+
+function appendRepositoryFilter(
+ conditions: string[],
+ params: unknown[],
+ options: { repoOwner?: string; repoName?: string }
+): void {
+ if (options.repoOwner) {
+ conditions.push(
+ `EXISTS (SELECT 1 FROM automation_repositories ar
+ WHERE ar.automation_id = automations.id AND ar.repo_owner = ?${
+ options.repoName ? " AND ar.repo_name = ?" : ""
+ })`
+ );
+ params.push(options.repoOwner.toLowerCase());
+ if (options.repoName) params.push(options.repoName.toLowerCase());
+ } else if (options.repoName) {
+ conditions.push(
+ `EXISTS (SELECT 1 FROM automation_repositories ar
+ WHERE ar.automation_id = automations.id AND ar.repo_name = ?)`
+ );
+ params.push(options.repoName.toLowerCase());
+ }
+}
// ─── Internal row types ──────────────────────────────────────────────────────
@@ -41,11 +74,16 @@ export interface AutomationRow {
trigger_auth_data: string | null;
}
+type AutomationListResult = { automations: AutomationRow[] } & (
+ | { hasMore: false; nextCursor: null }
+ | { hasMore: true; nextCursor: AutomationListCursor }
+);
+
export interface AutomationRunRow {
id: string;
automation_id: string;
- /** Owning invocation. Nullable in DDL only; every row has one post-backfill. */
- invocation_id: string | null;
+ /** Owning invocation. */
+ invocation_id: string;
session_id: string | null;
status: AutomationRunStatus;
skip_reason: string | null;
@@ -113,6 +151,16 @@ export type InvocationOverlapScope =
| { kind: "automation" }
| { kind: "concurrencyKey"; concurrencyKey: string };
+/**
+ * A cron slot handover: move the schedule from the slot this firing claimed
+ * (`fromSlot`) to its successor. Carrying the claimed slot lets the UPDATE act
+ * as a compare-and-set, so only the firing that still owns the slot advances it.
+ */
+export interface ScheduleAdvance {
+ fromSlot: number;
+ nextRunAt: number;
+}
+
/** Sibling-run aggregate for one invocation (finalization input). */
export interface InvocationRunAggregate {
total: number;
@@ -125,7 +173,7 @@ export interface InvocationRunAggregate {
// ─── Mappers ─────────────────────────────────────────────────────────────────
-export function toAutomationRepository(row: AutomationRepositoryRow): AutomationRepository {
+function toAutomationRepository(row: AutomationRepositoryRow): AutomationRepository {
return {
repoOwner: row.repo_owner,
repoName: row.repo_name,
@@ -138,7 +186,8 @@ export function toAutomationRepository(row: AutomationRepositoryRow): Automation
export function toAutomation(
row: AutomationRow,
repositoryRows: AutomationRepositoryRow[],
- environmentRows: AutomationEnvironmentRow[] = []
+ environmentRows: AutomationEnvironmentRow[],
+ providerAuthRows: AutomationModelProviderAuthRow[]
): Automation {
const triggerConfig: TriggerConfig | null = row.trigger_config
? JSON.parse(row.trigger_config)
@@ -164,6 +213,7 @@ export function toAutomation(
triggerConfig,
repositories: repositoryRows.map(toAutomationRepository),
environmentIds: environmentRows.map((environment) => environment.environment_id),
+ providerSelections: toProviderSelections(providerAuthRows),
};
}
@@ -171,7 +221,7 @@ export function toAutomationRun(row: EnrichedRunRow): AutomationRun {
return {
id: row.id,
automationId: row.automation_id,
- invocationId: row.invocation_id ?? null,
+ invocationId: row.invocation_id,
sessionId: row.session_id,
status: row.status,
skipReason: row.skip_reason,
@@ -199,7 +249,7 @@ export function toAutomationRun(row: EnrichedRunRow): AutomationRun {
// backfilled skip rows), no failure ⇒ completed, no success ⇒ failed,
// otherwise partial_failed.
-export const DERIVED_INVOCATION_STATUS_SQL = `CASE
+const DERIVED_INVOCATION_STATUS_SQL = `CASE
WHEN COUNT(r.id) = 0 THEN 'skipped'
WHEN SUM(CASE WHEN r.status IN ('starting', 'running') THEN 1 ELSE 0 END) > 0 THEN
CASE
@@ -213,7 +263,7 @@ export const DERIVED_INVOCATION_STATUS_SQL = `CASE
END`;
/** Derived completion time: latest child completion once all children are terminal. */
-export const DERIVED_INVOCATION_COMPLETED_AT_SQL = `CASE
+const DERIVED_INVOCATION_COMPLETED_AT_SQL = `CASE
WHEN COUNT(r.id) = 0 THEN NULL
WHEN SUM(CASE WHEN r.status IN ('starting', 'running') THEN 1 ELSE 0 END) > 0 THEN NULL
ELSE MAX(r.completed_at)
@@ -243,7 +293,7 @@ export function deriveInvocationStatus(counts: {
return "partial_failed";
}
-export function toAutomationInvocation(
+function toAutomationInvocation(
row: AutomationInvocationRow & { derived_status: string; derived_completed_at: number | null },
runs: AutomationRun[]
): AutomationInvocation {
@@ -317,38 +367,97 @@ export class AutomationStore {
.first();
}
- async list(
- options: { repoOwner?: string; repoName?: string } = {}
- ): Promise<{ automations: AutomationRow[]; total: number }> {
+ async list(options: {
+ limit: number;
+ cursor?: AutomationListCursor | null;
+ nameSearch?: string;
+ repoOwner?: string;
+ repoName?: string;
+ }): Promise {
const conditions: string[] = ["deleted_at IS NULL"];
const params: unknown[] = [];
- if (options.repoOwner) {
- conditions.push(
- `EXISTS (SELECT 1 FROM automation_repositories ar
- WHERE ar.automation_id = automations.id AND ar.repo_owner = ?${
- options.repoName ? " AND ar.repo_name = ?" : ""
- })`
- );
- params.push(options.repoOwner.toLowerCase());
- if (options.repoName) params.push(options.repoName.toLowerCase());
- } else if (options.repoName) {
- conditions.push(
- `EXISTS (SELECT 1 FROM automation_repositories ar
- WHERE ar.automation_id = automations.id AND ar.repo_name = ?)`
- );
- params.push(options.repoName.toLowerCase());
+ if (options.nameSearch) {
+ conditions.push("name LIKE ? ESCAPE '\\' COLLATE NOCASE");
+ params.push(`%${escapeLikePattern(options.nameSearch)}%`);
+ }
+
+ appendRepositoryFilter(conditions, params, options);
+
+ if (options.cursor) {
+ conditions.push("(created_at < ? OR (created_at = ? AND id < ?))");
+ params.push(options.cursor.createdAt, options.cursor.createdAt, options.cursor.id);
}
const where = `WHERE ${conditions.join(" AND ")}`;
const result = await this.db
- .prepare(`SELECT * FROM automations ${where} ORDER BY created_at DESC`)
- .bind(...params)
+ .prepare(`SELECT * FROM automations ${where} ORDER BY created_at DESC, id DESC LIMIT ?`)
+ .bind(...params, options.limit + 1)
.all();
- const automations = result.results || [];
- return { automations, total: automations.length };
+ const rows = result.results || [];
+ const hasMore = rows.length > options.limit;
+ const automations = hasMore ? rows.slice(0, options.limit) : rows;
+ if (!hasMore) return { automations, hasMore: false, nextCursor: null };
+ return {
+ automations,
+ hasMore: true,
+ nextCursor: {
+ createdAt: automations[automations.length - 1].created_at,
+ id: automations[automations.length - 1].id,
+ },
+ };
+ }
+
+ async listRecentExecutionsForAutomationIds(
+ automationIds: string[],
+ limit: number
+ ): Promise> {
+ const executionsByAutomation = new Map();
+ for (const id of automationIds) executionsByAutomation.set(id, []);
+ if (automationIds.length === 0) return executionsByAutomation;
+
+ const placeholders = automationIds.map(() => "?").join(", ");
+ const result = await this.db
+ .prepare(
+ `WITH ranked_invocations AS (
+ SELECT i.id, i.automation_id, i.created_at,
+ ROW_NUMBER() OVER (
+ PARTITION BY i.automation_id
+ ORDER BY i.created_at DESC, i.id DESC
+ ) AS position
+ FROM automation_invocations i
+ WHERE i.automation_id IN (${placeholders})
+ ),
+ recent_invocations AS (
+ SELECT id, automation_id, created_at
+ FROM ranked_invocations
+ WHERE position <= ?
+ )
+ SELECT i.id, i.automation_id, i.created_at,
+ ${DERIVED_INVOCATION_STATUS_SQL} AS derived_status
+ FROM recent_invocations i
+ LEFT JOIN automation_runs r ON r.invocation_id = i.id
+ GROUP BY i.id
+ ORDER BY i.automation_id, i.created_at DESC, i.id DESC`
+ )
+ .bind(...automationIds, limit)
+ .all<{
+ id: string;
+ automation_id: string;
+ created_at: number;
+ derived_status: AutomationInvocationStatus;
+ }>();
+
+ for (const row of result.results ?? []) {
+ executionsByAutomation.get(row.automation_id)?.push({
+ id: row.id,
+ status: row.derived_status,
+ createdAt: row.created_at,
+ });
+ }
+ return executionsByAutomation;
}
/**
@@ -677,53 +786,43 @@ export class AutomationStore {
return (result.meta?.changes ?? 0) > 0;
}
- /** Fail stuck runs. Same SQL guard as updateRun — sweeps must never flip terminal rows. */
- async bulkFailRuns(runIds: string[], reason: string, completedAt: number): Promise {
- if (runIds.length === 0) return;
- const placeholders = runIds.map(() => "?").join(", ");
- await this.db
+ /** Atomically assign a session only while a run still awaits launch. */
+ async claimRunSession(id: string, sessionId: string, startedAt: number): Promise {
+ const result = await this.db
.prepare(
`UPDATE automation_runs
- SET status = 'failed', failure_reason = ?, completed_at = ?
- WHERE id IN (${placeholders}) AND status IN ('starting', 'running')`
+ SET status = 'running', session_id = ?, started_at = ?
+ WHERE id = ? AND status = 'starting'`
)
- .bind(reason, completedAt, ...runIds)
+ .bind(sessionId, startedAt, id)
.run();
+ return (result.meta?.changes ?? 0) > 0;
}
- async bulkIncrementFailures(
- automationIdCounts: Map
- ): Promise> {
- if (automationIdCounts.size === 0) return new Map();
-
- const now = Date.now();
- const automationIds = [...automationIdCounts.keys()];
+ async bulkFailStartingRuns(runIds: string[], reason: string, completedAt: number): Promise {
+ await this.bulkFailRunsInStatus(runIds, "starting", reason, completedAt);
+ }
- const statements = automationIds.map((automationId) =>
- this.db
- .prepare(
- `UPDATE automations
- SET consecutive_failures = consecutive_failures + ?, updated_at = ?
- WHERE id = ? AND deleted_at IS NULL`
- )
- .bind(automationIdCounts.get(automationId)!, now, automationId)
- );
- await this.db.batch(statements);
+ async bulkFailRunningRuns(runIds: string[], reason: string, completedAt: number): Promise {
+ await this.bulkFailRunsInStatus(runIds, "running", reason, completedAt);
+ }
- const placeholders = automationIds.map(() => "?").join(", ");
- const result = await this.db
+ private async bulkFailRunsInStatus(
+ runIds: string[],
+ status: "starting" | "running",
+ reason: string,
+ completedAt: number
+ ): Promise {
+ if (runIds.length === 0) return;
+ const placeholders = runIds.map(() => "?").join(", ");
+ await this.db
.prepare(
- `SELECT id, consecutive_failures FROM automations
- WHERE id IN (${placeholders}) AND deleted_at IS NULL`
+ `UPDATE automation_runs
+ SET status = 'failed', failure_reason = ?, completed_at = ?
+ WHERE id IN (${placeholders}) AND status = ?`
)
- .bind(...automationIds)
- .all<{ id: string; consecutive_failures: number }>();
-
- const counts = new Map();
- for (const row of result.results ?? []) {
- counts.set(row.id, row.consecutive_failures);
- }
- return counts;
+ .bind(reason, completedAt, ...runIds, status)
+ .run();
}
async getActiveRunForAutomation(automationId: string): Promise {
@@ -790,8 +889,12 @@ export class AutomationStore {
* statement ERROR — a 0-row INSERT…SELECT is a success and later statements
* still run. The invocation insert is suppressed when the overlap predicate
* matches; child inserts are 0-row no-ops when the invocation was
- * suppressed; the schedule advance is deliberately unconditional (a blocked
- * firing must still advance or the tick re-collides forever).
+ * suppressed; the schedule advance still runs for a blocked firing so the
+ * tick does not re-collide forever, but is conditioned on still owning the
+ * slot it claimed. Monotonicity is not enough: two ticks straddling a cron
+ * boundary both read slot S, and a "later timestamp wins" predicate lets the
+ * loser advance a second time from the winner's successor, skipping a slot
+ * outright. Only the transaction that observes S may move it.
*
* A UNIQUE violation (cron double-fire on the idempotency index, event dedup
* on the trigger-key index) rolls back the WHOLE batch including the
@@ -803,7 +906,7 @@ export class AutomationStore {
invocation: AutomationInvocationRow;
children: AutomationRunRow[];
overlapScope: InvocationOverlapScope;
- advanceSchedule?: { nextRunAt: number };
+ advanceSchedule?: ScheduleAdvance;
}): Promise<{ inserted: boolean }> {
const invocation = params.invocation;
const overlap = this.overlapPredicate(invocation.automation_id, params.overlapScope);
@@ -872,9 +975,14 @@ export class AutomationStore {
this.db
.prepare(
`UPDATE automations SET next_run_at = ?, updated_at = ?
- WHERE id = ? AND deleted_at IS NULL`
+ WHERE id = ? AND deleted_at IS NULL AND next_run_at = ?`
+ )
+ .bind(
+ params.advanceSchedule.nextRunAt,
+ Date.now(),
+ invocation.automation_id,
+ params.advanceSchedule.fromSlot
)
- .bind(params.advanceSchedule.nextRunAt, Date.now(), invocation.automation_id)
);
}
@@ -887,11 +995,13 @@ export class AutomationStore {
* atomically paired with the schedule advance when the skip serves a cron
* slot. INSERT OR IGNORE tolerates an idempotency-index race without
* blocking the advance — a skip recorded without the advance would
- * re-collide on (automation_id, scheduled_at) every tick thereafter.
+ * re-collide on (automation_id, scheduled_at) every tick thereafter. The
+ * advance is a compare-and-set on the claimed slot, so a firing that lost
+ * the slot cannot move the schedule a second time.
*/
async insertSkippedInvocation(
invocation: AutomationInvocationRow,
- advanceSchedule?: { nextRunAt: number }
+ advanceSchedule?: ScheduleAdvance
): Promise<{ inserted: boolean }> {
const statements: SqlStatement[] = [
this.db
@@ -921,9 +1031,14 @@ export class AutomationStore {
this.db
.prepare(
`UPDATE automations SET next_run_at = ?, updated_at = ?
- WHERE id = ? AND deleted_at IS NULL`
+ WHERE id = ? AND deleted_at IS NULL AND next_run_at = ?`
+ )
+ .bind(
+ advanceSchedule.nextRunAt,
+ Date.now(),
+ invocation.automation_id,
+ advanceSchedule.fromSlot
)
- .bind(advanceSchedule.nextRunAt, Date.now(), invocation.automation_id)
);
}
@@ -1031,7 +1146,7 @@ export class AutomationStore {
const childrenByInvocation = new Map();
for (const child of childResult.results ?? []) {
- const invocationId = child.invocation_id!;
+ const invocationId = child.invocation_id;
const bucket = childrenByInvocation.get(invocationId) ?? [];
bucket.push(toAutomationRun(child));
childrenByInvocation.set(invocationId, bucket);
diff --git a/packages/control-plane/src/db/better-auth-adapter.ts b/packages/control-plane/src/db/better-auth-adapter.ts
new file mode 100644
index 000000000..9d92de896
--- /dev/null
+++ b/packages/control-plane/src/db/better-auth-adapter.ts
@@ -0,0 +1,329 @@
+import { createAdapterFactory } from "better-auth/adapters";
+import type { AdapterFactoryOptions, CustomAdapter } from "better-auth/adapters";
+import { getSignInProviderIssuer } from "@open-inspect/shared/sign-in-provider";
+import type { SqlDatabase } from "./sql-database";
+
+/**
+ * Better Auth adapter over the canonical tables (issue #1290 consolidation).
+ *
+ * Better Auth's user model IS canonical `users` and its account model IS
+ * `user_identities` — mapped via the `modelName`/`fields` config in
+ * `auth/user/better-auth.ts`. `CanonicalSqlAdapter` is a generic SQL executor
+ * on the `SqlDatabase` seam implementing Better Auth's `CustomAdapter`
+ * interface: the factory hands it mapped table names and mapped snake_case
+ * column names (model-name and field-name resolution happen above this
+ * layer), so it contains no model knowledge beyond two schema-specific row
+ * defaults (`provider_issuer`, blank `display_name`).
+ *
+ * Representation contract with the canonical schema:
+ * - Timestamps are INTEGER epoch milliseconds (Date ⇄ epoch in the
+ * config-level transforms; they also apply to where-clause values, which
+ * covers the SQL date comparisons in verification cleanup and session
+ * listing).
+ * - Booleans are INTEGER 0/1 (`supportsBooleans: false` makes the factory
+ * convert both directions).
+ * - Ids are caller-generated: `advanced.database.generateId` mints canonical
+ * 32-hex ids for every model above this layer; this adapter never generates
+ * ids.
+ *
+ * Transactions are `false` (sequential execution): D1 exposes no interactive
+ * transactions. The consolidated schema no longer depends on cross-table
+ * atomicity for identity integrity — register writes `users` +
+ * `user_identities` with client-generated ids, and a failure between the two
+ * self-heals at the next sign-in through implicit linking and the claim
+ * decorator. A batch-buffered transaction (or a real one, on an engine with
+ * interactive transactions) can be layered in later without touching
+ * callers.
+ */
+
+/** The factory-normalized where entry (`Required`), not re-exported by name. */
+type CleanedWhere = Parameters[0]["where"][number];
+
+const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
+
+function assertIdentifier(name: string): string {
+ if (!IDENTIFIER.test(name)) {
+ throw new Error(`Unsafe SQL identifier from Better Auth schema: ${name}`);
+ }
+ return name;
+}
+
+interface WhereClause {
+ clause: string;
+ params: unknown[];
+}
+
+function compileCondition(entry: CleanedWhere): WhereClause {
+ const field = assertIdentifier(entry.field);
+ const { value, operator } = entry;
+ switch (operator) {
+ case "eq":
+ return value === null
+ ? { clause: `${field} IS NULL`, params: [] }
+ : { clause: `${field} = ?`, params: [value] };
+ case "ne":
+ return value === null
+ ? { clause: `${field} IS NOT NULL`, params: [] }
+ : { clause: `${field} <> ?`, params: [value] };
+ case "lt":
+ return { clause: `${field} < ?`, params: [value] };
+ case "lte":
+ return { clause: `${field} <= ?`, params: [value] };
+ case "gt":
+ return { clause: `${field} > ?`, params: [value] };
+ case "gte":
+ return { clause: `${field} >= ?`, params: [value] };
+ case "in":
+ case "not_in": {
+ const values = Array.isArray(value) ? value : [value];
+ if (values.length === 0) {
+ // IN () is a SQL error; an empty list matches nothing / everything.
+ return { clause: operator === "in" ? "0 = 1" : "1 = 1", params: [] };
+ }
+ const marks = values.map(() => "?").join(", ");
+ const keyword = operator === "in" ? "IN" : "NOT IN";
+ return { clause: `${field} ${keyword} (${marks})`, params: values };
+ }
+ case "contains":
+ return { clause: `${field} LIKE ?`, params: [`%${String(value)}%`] };
+ case "starts_with":
+ return { clause: `${field} LIKE ?`, params: [`${String(value)}%`] };
+ case "ends_with":
+ return { clause: `${field} LIKE ?`, params: [`%${String(value)}`] };
+ default:
+ throw new Error(`Unsupported where operator: ${operator}`);
+ }
+}
+
+function compileWhere(where: CleanedWhere[] | undefined): WhereClause {
+ if (!where || where.length === 0) return { clause: "", params: [] };
+ let clause = "";
+ const params: unknown[] = [];
+ for (const [index, entry] of where.entries()) {
+ const condition = compileCondition(entry);
+ clause += index === 0 ? "" : ` ${entry.connector === "OR" ? "OR" : "AND"} `;
+ clause += condition.clause;
+ params.push(...condition.params);
+ }
+ return { clause: ` WHERE ${clause}`, params };
+}
+
+/**
+ * Schema-specific row defaults Better Auth cannot supply itself: the issuer
+ * URL derives from the provider, and Better Auth's required `name` maps onto
+ * nullable `display_name` where an empty string must mean absent.
+ */
+function applyRowDefaults(model: string, data: Record): Record {
+ if (model === "user_identities" && data.provider_issuer === undefined) {
+ return {
+ ...data,
+ provider_issuer:
+ typeof data.provider === "string" ? getSignInProviderIssuer(data.provider) : null,
+ };
+ }
+ if (model === "users" && data.display_name === "") {
+ return { ...data, display_name: null };
+ }
+ return data;
+}
+
+class CanonicalSqlAdapter implements CustomAdapter {
+ constructor(private readonly db: SqlDatabase) {}
+
+ async create>({
+ model,
+ data,
+ }: {
+ model: string;
+ data: T;
+ select?: string[] | undefined;
+ }): Promise {
+ const table = assertIdentifier(model);
+ const row = applyRowDefaults(table, data);
+ const entries = Object.entries(row).filter(([, value]) => value !== undefined);
+ const columns = entries.map(([column]) => assertIdentifier(column)).join(", ");
+ const marks = entries.map(() => "?").join(", ");
+ await this.db
+ .prepare(`INSERT INTO ${table} (${columns}) VALUES (${marks})`)
+ .bind(...entries.map(([, value]) => value))
+ .run();
+ return row as T;
+ }
+
+ async update({
+ model,
+ where,
+ update,
+ }: {
+ model: string;
+ where: CleanedWhere[];
+ update: T;
+ }): Promise {
+ const table = assertIdentifier(model);
+ const compiled = compileWhere(where);
+ const entries = Object.entries(update as Record).filter(
+ ([, value]) => value !== undefined
+ );
+ if (entries.length === 0) return null;
+ const sets = entries.map(([column]) => `${assertIdentifier(column)} = ?`).join(", ");
+ // RETURNING avoids the re-match problem: the where clause may target
+ // the pre-update values (e.g. update token where token = old).
+ return this.db
+ .prepare(`UPDATE ${table} SET ${sets}${compiled.clause} RETURNING *`)
+ .bind(...entries.map(([, value]) => value), ...compiled.params)
+ .first();
+ }
+
+ async updateMany({
+ model,
+ where,
+ update,
+ }: {
+ model: string;
+ where: CleanedWhere[];
+ update: Record;
+ }): Promise {
+ const table = assertIdentifier(model);
+ const compiled = compileWhere(where);
+ const entries = Object.entries(update).filter(([, value]) => value !== undefined);
+ if (entries.length === 0) return 0;
+ const sets = entries.map(([column]) => `${assertIdentifier(column)} = ?`).join(", ");
+ const result = await this.db
+ .prepare(`UPDATE ${table} SET ${sets}${compiled.clause}`)
+ .bind(...entries.map(([, value]) => value), ...compiled.params)
+ .run();
+ return result.meta.changes;
+ }
+
+ async findOne({ model, where }: { model: string; where: CleanedWhere[] }): Promise {
+ const table = assertIdentifier(model);
+ const compiled = compileWhere(where);
+ return this.db
+ .prepare(`SELECT * FROM ${table}${compiled.clause} LIMIT 1`)
+ .bind(...compiled.params)
+ .first();
+ }
+
+ async findMany({
+ model,
+ where,
+ limit,
+ sortBy,
+ offset,
+ }: {
+ model: string;
+ where?: CleanedWhere[] | undefined;
+ limit: number;
+ sortBy?: { field: string; direction: "asc" | "desc" } | undefined;
+ offset?: number | undefined;
+ }): Promise {
+ const table = assertIdentifier(model);
+ const compiled = compileWhere(where);
+ let sql = `SELECT * FROM ${table}${compiled.clause}`;
+ if (sortBy) {
+ const direction = sortBy.direction === "desc" ? "DESC" : "ASC";
+ sql += ` ORDER BY ${assertIdentifier(sortBy.field)} ${direction}`;
+ }
+ sql += ` LIMIT ?`;
+ const params: unknown[] = [...compiled.params, limit];
+ if (offset !== undefined) {
+ sql += ` OFFSET ?`;
+ params.push(offset);
+ }
+ const result = await this.db
+ .prepare(sql)
+ .bind(...params)
+ .all();
+ return result.results;
+ }
+
+ async delete({ model, where }: { model: string; where: CleanedWhere[] }): Promise {
+ const table = assertIdentifier(model);
+ const compiled = compileWhere(where);
+ await this.db
+ .prepare(`DELETE FROM ${table}${compiled.clause}`)
+ .bind(...compiled.params)
+ .run();
+ }
+
+ async deleteMany({ model, where }: { model: string; where: CleanedWhere[] }): Promise {
+ const table = assertIdentifier(model);
+ const compiled = compileWhere(where);
+ const result = await this.db
+ .prepare(`DELETE FROM ${table}${compiled.clause}`)
+ .bind(...compiled.params)
+ .run();
+ return result.meta.changes;
+ }
+
+ /**
+ * Native atomic single-row consume, one round trip. Better Auth uses this
+ * for one-shot verification state (the OAuth handshake); the rowid
+ * subselect keeps the contract of deleting at most one matching row.
+ * Without it the factory falls back to findMany + deleteMany, which
+ * `transaction: false` would leave racy.
+ */
+ async consumeOne({
+ model,
+ where,
+ }: {
+ model: string;
+ where: CleanedWhere[];
+ }): Promise {
+ const table = assertIdentifier(model);
+ const compiled = compileWhere(where);
+ return this.db
+ .prepare(
+ `DELETE FROM ${table}
+ WHERE rowid IN (SELECT rowid FROM ${table}${compiled.clause} LIMIT 1)
+ RETURNING *`
+ )
+ .bind(...compiled.params)
+ .first();
+ }
+
+ async count({
+ model,
+ where,
+ }: {
+ model: string;
+ where?: CleanedWhere[] | undefined;
+ }): Promise {
+ const table = assertIdentifier(model);
+ const compiled = compileWhere(where);
+ const row = await this.db
+ .prepare(`SELECT COUNT(*) AS count FROM ${table}${compiled.clause}`)
+ .bind(...compiled.params)
+ .first<{ count: number }>();
+ return row?.count ?? 0;
+ }
+}
+
+export function createCanonicalBetterAuthAdapter(db: SqlDatabase) {
+ const options: AdapterFactoryOptions = {
+ config: {
+ adapterId: "canonical-sql",
+ adapterName: "Canonical SQL adapter",
+ usePlural: false,
+ supportsDates: true,
+ supportsBooleans: false,
+ supportsJSON: false,
+ supportsNumericIds: false,
+ transaction: false,
+ customTransformInput({ data, fieldAttributes }) {
+ if (fieldAttributes.type === "date" && data instanceof Date) {
+ return data.getTime();
+ }
+ return data;
+ },
+ customTransformOutput({ data, fieldAttributes }) {
+ if (fieldAttributes.type === "date" && typeof data === "number") {
+ return new Date(data);
+ }
+ return data;
+ },
+ },
+ adapter: () => new CanonicalSqlAdapter(db),
+ };
+ return createAdapterFactory(options);
+}
diff --git a/packages/control-plane/src/db/bulk-insert.test.ts b/packages/control-plane/src/db/bulk-insert.test.ts
new file mode 100644
index 000000000..50e5a4fca
--- /dev/null
+++ b/packages/control-plane/src/db/bulk-insert.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from "vitest";
+import { bulkInsertStatements } from "./bulk-insert";
+import { MAX_D1_QUERY_PARAMETERS } from "./query-limits";
+import type { SqlDatabase, SqlStatement } from "./sql-database";
+
+interface Recorded {
+ sql: string;
+ values: unknown[];
+}
+
+/** Capture prepared SQL and bound values without an engine. */
+function recordingDb(): { db: SqlDatabase; recorded: Recorded[] } {
+ const recorded: Recorded[] = [];
+ const db: SqlDatabase = {
+ prepare(sql: string): SqlStatement {
+ const entry: Recorded = { sql, values: [] };
+ recorded.push(entry);
+ const statement: SqlStatement = {
+ bind(...values: unknown[]) {
+ entry.values = values;
+ return statement;
+ },
+ first: async () => null,
+ run: async () => ({ results: [], meta: { changes: 0 } }),
+ all: async () => ({ results: [], meta: { changes: 0 } }),
+ };
+ return statement;
+ },
+ batch: async () => [],
+ };
+ return { db, recorded };
+}
+
+function rows(count: number, width: number): Record[] {
+ return Array.from({ length: count }, (_, row) =>
+ Object.fromEntries(
+ Array.from({ length: width }, (_, column) => [`c${column}`, `r${row}c${column}`])
+ )
+ );
+}
+
+describe("bulkInsertStatements", () => {
+ it("emits nothing for an empty row list", () => {
+ const { db, recorded } = recordingDb();
+ expect(bulkInsertStatements(db, "t", [])).toEqual([]);
+ expect(recorded).toHaveLength(0);
+ });
+
+ it("packs one statement per full parameter budget", () => {
+ const { db, recorded } = recordingDb();
+ // 10 columns => 10 rows per statement; 101 rows is one full set past ten.
+ const statements = bulkInsertStatements(db, "session_skill_revisions", rows(101, 10));
+
+ expect(statements).toHaveLength(11);
+ expect(recorded.slice(0, 10).map((entry) => entry.values.length)).toEqual(Array(10).fill(100));
+ expect(recorded[10]?.values).toHaveLength(10);
+ });
+
+ it("keeps every statement within the engine parameter ceiling", () => {
+ for (const width of [1, 2, 3, 7, 10, 33, 100]) {
+ const { db, recorded } = recordingDb();
+ bulkInsertStatements(db, "t", rows(257, width));
+ for (const entry of recorded) {
+ expect(entry.values.length).toBeLessThanOrEqual(MAX_D1_QUERY_PARAMETERS);
+ expect(entry.sql.split("?")).toHaveLength(entry.values.length + 1);
+ }
+ expect(recorded.reduce((total, entry) => total + entry.values.length, 0)).toBe(257 * width);
+ }
+ });
+
+ it("binds values in row-major order under a multi-row VALUES clause", () => {
+ const { db, recorded } = recordingDb();
+ bulkInsertStatements(db, "skill_profile_items", [
+ { profile_id: "p1", skill_id: "s1" },
+ { profile_id: "p1", skill_id: "s2" },
+ ]);
+
+ expect(recorded).toHaveLength(1);
+ expect(recorded[0]?.sql.replace(/\s+/g, " ")).toBe(
+ "INSERT INTO skill_profile_items (profile_id, skill_id) VALUES (?, ?), (?, ?)"
+ );
+ expect(recorded[0]?.values).toEqual(["p1", "s1", "p1", "s2"]);
+ });
+
+ it("reads each row by column name rather than by insertion order", () => {
+ const { db, recorded } = recordingDb();
+ bulkInsertStatements(db, "t", [
+ { a: 1, b: 2 },
+ { b: 4, a: 3 },
+ ]);
+
+ expect(recorded[0]?.sql).toContain("(a, b)");
+ expect(recorded[0]?.values).toEqual([1, 2, 3, 4]);
+ });
+
+ it("rejects rows with no columns", () => {
+ const { db } = recordingDb();
+ expect(() => bulkInsertStatements(db, "t", [{}, {}])).toThrow(/rows have no columns/);
+ });
+
+ it("rejects a row whose columns disagree with the first row", () => {
+ const { db } = recordingDb();
+ expect(() => bulkInsertStatements(db, "t", [{ a: 1, b: 2 }, { a: 3 }])).toThrow(
+ /rows disagree on columns/
+ );
+ expect(() =>
+ bulkInsertStatements(db, "t", [
+ { a: 1, b: 2 },
+ { a: 3, c: 4 },
+ ])
+ ).toThrow(/rows disagree on columns/);
+ });
+
+ it("rejects a table too wide to insert even one row", () => {
+ const { db } = recordingDb();
+ expect(() => bulkInsertStatements(db, "t", rows(1, MAX_D1_QUERY_PARAMETERS + 1))).toThrow(
+ /exceeds the parameter ceiling/
+ );
+ });
+});
diff --git a/packages/control-plane/src/db/bulk-insert.ts b/packages/control-plane/src/db/bulk-insert.ts
new file mode 100644
index 000000000..c45dadb22
--- /dev/null
+++ b/packages/control-plane/src/db/bulk-insert.ts
@@ -0,0 +1,70 @@
+import { MAX_D1_QUERY_PARAMETERS } from "./query-limits";
+import type { SqlDatabase, SqlStatement } from "./sql-database";
+
+/**
+ * Build multi-row INSERTs for a row list whose length the caller does not control.
+ *
+ * One statement per row makes a write linear in row count against the engine's
+ * per-invocation query budget; one statement covering every row blows the
+ * bound-parameter ceiling. Packing floor(ceiling / columns) rows into each
+ * statement divides the statement count by that factor.
+ *
+ * That is a smaller constant, not count-independence: this returns as many
+ * statements as the rows require and knows nothing about the caller's other
+ * queries, so the end-to-end invocation budget stays the caller's problem. See
+ * the bounds table in docs/plans/managed-skills.md for the surviving cliffs.
+ *
+ * Rows are column-keyed objects rather than positional tuples so a column can
+ * never drift from its value: the column list comes from the rows themselves,
+ * and each row is read back by key rather than by position.
+ *
+ * The result is ordinary parameterized SQL, so callers splice it into an
+ * existing batch() and keep the surrounding write atomic. Multi-row VALUES is
+ * standard SQL, so this needs no engine branch.
+ *
+ * `table` and the row keys are interpolated into the statement text: pass
+ * literals, never anything derived from a request.
+ */
+export function bulkInsertStatements(
+ db: SqlDatabase,
+ table: string,
+ rows: readonly Readonly>[]
+): SqlStatement[] {
+ const [first] = rows;
+ if (!first) return [];
+ const columns = Object.keys(first);
+ if (columns.length === 0) {
+ throw new Error(`Cannot bulk insert into ${table}: rows have no columns`);
+ }
+ const rowsPerStatement = Math.floor(MAX_D1_QUERY_PARAMETERS / columns.length);
+ if (rowsPerStatement < 1) {
+ throw new Error(
+ `Cannot bulk insert into ${table}: ${columns.length} columns exceeds the parameter ceiling`
+ );
+ }
+ const expected = new Set(columns);
+ const rowPlaceholder = `(${columns.map(() => "?").join(", ")})`;
+ const statements: SqlStatement[] = [];
+ for (let start = 0; start < rows.length; start += rowsPerStatement) {
+ const chunk = rows.slice(start, start + rowsPerStatement);
+ const values: unknown[] = [];
+ for (const row of chunk) {
+ const keys = Object.keys(row);
+ if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
+ throw new Error(
+ `Cannot bulk insert into ${table}: rows disagree on columns (${keys.join(", ")})`
+ );
+ }
+ for (const column of columns) values.push(row[column]);
+ }
+ statements.push(
+ db
+ .prepare(
+ `INSERT INTO ${table} (${columns.join(", ")})
+ VALUES ${chunk.map(() => rowPlaceholder).join(", ")}`
+ )
+ .bind(...values)
+ );
+ }
+ return statements;
+}
diff --git a/packages/control-plane/src/db/canonical-user-projection.ts b/packages/control-plane/src/db/canonical-user-projection.ts
deleted file mode 100644
index 88c07df1b..000000000
--- a/packages/control-plane/src/db/canonical-user-projection.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { isCanonicalUserId } from "@open-inspect/shared/user-id";
-import type {
- CanonicalUserProjection,
- UserProjectionInput,
-} from "../auth/user/canonical-user-projection";
-import type { SqlDatabase } from "./sql-database";
-
-function requireNonEmpty(value: string, field: string): string {
- const normalized = value.trim();
- if (!normalized) {
- throw new Error(`Canonical user projection ${field} is empty`);
- }
- return normalized;
-}
-
-function requireTimestamp(value: Date, field: string): number {
- const timestamp = value.getTime();
- if (!Number.isFinite(timestamp)) {
- throw new Error(`Canonical user projection ${field} is invalid`);
- }
- return timestamp;
-}
-
-export class D1CanonicalUserProjection implements CanonicalUserProjection {
- constructor(private readonly db: SqlDatabase) {}
-
- async project(user: UserProjectionInput): Promise {
- const id = requireNonEmpty(user.id, "id");
- if (!isCanonicalUserId(id)) {
- throw new Error("Projected user id is not canonical");
- }
- const email = requireNonEmpty(user.email, "email").toLowerCase();
- const createdAt = requireTimestamp(user.createdAt, "createdAt");
- const updatedAt = requireTimestamp(user.updatedAt, "updatedAt");
-
- await this.db
- .prepare(
- `INSERT INTO users (
- id, display_name, email, avatar_url, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?)
- ON CONFLICT(id) DO UPDATE SET
- display_name = excluded.display_name,
- email = excluded.email,
- avatar_url = excluded.avatar_url,
- updated_at = excluded.updated_at`
- )
- .bind(id, user.name.trim() || null, email, user.image ?? null, createdAt, updatedAt)
- .run();
- }
-}
diff --git a/packages/control-plane/src/db/email.ts b/packages/control-plane/src/db/email.ts
new file mode 100644
index 000000000..47a52c174
--- /dev/null
+++ b/packages/control-plane/src/db/email.ts
@@ -0,0 +1,20 @@
+/**
+ * Canonical email normalization for every database write, lookup, and
+ * comparison, kept equal to the SQL-side `lower(trim(...))` used by the
+ * sign-in claim queries and migration 0057. `idx_users_email` is COLLATE
+ * NOCASE but not whitespace-normalizing, so an untrimmed write could create a
+ * whitespace-variant duplicate of an existing email.
+ *
+ * A blank (or whitespace-only) email normalizes to `null`: `idx_users_email`
+ * is unique, so persisting `""` would make every blank-emailed identity
+ * collide on one slot instead of being treated as absent.
+ *
+ * `user-merge.ts` carries a byte-identical mirror: the operator CLI loads it
+ * under Node's type-stripping loader, which cannot resolve extensionless
+ * runtime imports, so that module must stay free of value imports. Change
+ * both together.
+ */
+export function normalizeEmail(email: string | null | undefined): string | null {
+ const normalized = email?.trim().toLowerCase();
+ return normalized ? normalized : null;
+}
diff --git a/packages/control-plane/src/db/environments.ts b/packages/control-plane/src/db/environments.ts
index ce2d30f19..22c3a16d0 100644
--- a/packages/control-plane/src/db/environments.ts
+++ b/packages/control-plane/src/db/environments.ts
@@ -37,7 +37,7 @@ export type EnvironmentRepositoryInsert = Pick<
"position" | "repo_owner" | "repo_name" | "repo_id" | "base_branch"
>;
-export function toEnvironmentRepository(row: EnvironmentRepositoryRow): EnvironmentRepository {
+function toEnvironmentRepository(row: EnvironmentRepositoryRow): EnvironmentRepository {
return {
repoOwner: row.repo_owner,
repoName: row.repo_name,
diff --git a/packages/control-plane/src/db/errors.ts b/packages/control-plane/src/db/errors.ts
index c400e48a7..065dc82ee 100644
--- a/packages/control-plane/src/db/errors.ts
+++ b/packages/control-plane/src/db/errors.ts
@@ -7,8 +7,3 @@ export function isUniqueConstraintError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return msg.toLowerCase().includes("unique constraint failed");
}
-
-export function isCheckConstraintError(err: unknown, constraint: string): boolean {
- const message = err instanceof Error ? err.message : String(err);
- return message.toLowerCase().includes(`check constraint failed: ${constraint.toLowerCase()}`);
-}
diff --git a/packages/control-plane/src/db/identity-claim-store.ts b/packages/control-plane/src/db/identity-claim-store.ts
new file mode 100644
index 000000000..fea44a788
--- /dev/null
+++ b/packages/control-plane/src/db/identity-claim-store.ts
@@ -0,0 +1,108 @@
+import type { SqlDatabase } from "./sql-database";
+
+/**
+ * Persistence layer for the sign-in claim (`auth/user/sign-in-claim.ts`):
+ * the canonical-registry reads and guarded writes the claim performs around
+ * Better Auth's own queries. Kept apart from `UserStore` because every write
+ * here is proof-carrying — it mints `email_verified` from a completed OAuth
+ * sign-in, which bot ingress may only do for attesting providers.
+ *
+ * All email parameters must arrive pre-normalized (`normalizeEmail`); the
+ * `lower(trim(...))` comparisons here exist to match legacy stored forms
+ * against that canonical input, not to normalize the input itself.
+ */
+export interface UserEmailState {
+ email: string | null;
+ emailVerified: boolean;
+}
+
+export class IdentityClaimStore {
+ constructor(private readonly db: SqlDatabase) {}
+
+ /** The canonical owner of a provider identity, if the subject is known. */
+ async findIdentityOwnerId(provider: string, providerUserId: string): Promise {
+ const row = await this.db
+ .prepare(`SELECT user_id FROM user_identities WHERE provider = ? AND provider_user_id = ?`)
+ .bind(provider, providerUserId)
+ .first<{ user_id: string }>();
+ return row?.user_id ?? null;
+ }
+
+ async getEmailState(userId: string): Promise {
+ const row = await this.db
+ .prepare(`SELECT email, email_verified FROM users WHERE id = ?`)
+ .bind(userId)
+ .first<{ email: string | null; email_verified: number }>();
+ return row ? { email: row.email, emailVerified: row.email_verified === 1 } : null;
+ }
+
+ /** The user owning `email` under any stored legacy form, if one exists. */
+ async findEmailOwnerId(email: string): Promise {
+ const row = await this.db
+ .prepare(`SELECT id FROM users WHERE email IS NOT NULL AND lower(trim(email)) = ?`)
+ .bind(email)
+ .first<{ id: string }>();
+ return row?.id ?? null;
+ }
+
+ /**
+ * Give a NULL-email user the just-proven email, verified. Guarded on the
+ * target still being email-less; OR IGNORE nets a concurrent claim of the
+ * same email so a race never fails the sign-in. Returns whether the row
+ * changed.
+ */
+ async claimEmail(userId: string, email: string): Promise {
+ const result = await this.db
+ .prepare(
+ `UPDATE OR IGNORE users
+ SET email = ?, email_verified = 1, updated_at = ?
+ WHERE id = ? AND email IS NULL`
+ )
+ .bind(email, Date.now(), userId)
+ .run();
+ return result.meta.changes > 0;
+ }
+
+ /** Mint verification for a user whose stored email matches the proven one. */
+ async verifyEmail(userId: string, email: string): Promise {
+ await this.db
+ .prepare(
+ `UPDATE users SET email_verified = 1, updated_at = ?
+ WHERE id = ? AND lower(trim(email)) = ?`
+ )
+ .bind(Date.now(), userId, email)
+ .run();
+ }
+
+ /**
+ * Rewrite a legacy stored form of `email` to its canonical form so Better
+ * Auth's exact-match lookup finds it. OR IGNORE: if the canonical form is
+ * already taken by another row, the legacy row keeps its form (and stays
+ * findable by identity subject).
+ */
+ async normalizeStoredEmail(email: string): Promise {
+ await this.db
+ .prepare(
+ `UPDATE OR IGNORE users SET email = lower(trim(email)), updated_at = ?
+ WHERE email IS NOT NULL AND lower(trim(email)) = ? AND email <> lower(trim(email))`
+ )
+ .bind(Date.now(), email)
+ .run();
+ }
+
+ /**
+ * Mint verification for the canonical-form owner of `email`, returning the
+ * owner's id when a row actually transitioned.
+ */
+ async verifyEmailOwner(email: string): Promise {
+ const row = await this.db
+ .prepare(
+ `UPDATE users SET email_verified = 1, updated_at = ?
+ WHERE email = ? AND email_verified = 0
+ RETURNING id`
+ )
+ .bind(Date.now(), email)
+ .first<{ id: string }>();
+ return row?.id ?? null;
+ }
+}
diff --git a/packages/control-plane/src/db/instrumented-d1.ts b/packages/control-plane/src/db/instrumented-d1.ts
index 1dc5bc6fd..c3c343ab8 100644
--- a/packages/control-plane/src/db/instrumented-d1.ts
+++ b/packages/control-plane/src/db/instrumented-d1.ts
@@ -17,7 +17,7 @@ import type { SqlDatabase, SqlResult, SqlStatement } from "./sql-database";
// ---------------------------------------------------------------------------
/** Record of a single D1 query execution. */
-export interface D1QueryRecord {
+interface D1QueryRecord {
/** Wall-clock time in ms (includes network round-trip from Worker to D1 primary). */
query_ms: number;
/** Engine-reported server-side execution time in ms (from meta.duration). */
diff --git a/packages/control-plane/src/db/integration-settings.test.ts b/packages/control-plane/src/db/integration-settings.test.ts
index 1f5409137..c9e1b59fb 100644
--- a/packages/control-plane/src/db/integration-settings.test.ts
+++ b/packages/control-plane/src/db/integration-settings.test.ts
@@ -707,6 +707,14 @@ describe("IntegrationSettingsStore", () => {
expect(config.settings.enabled).toBe(true);
});
+ it("applies VNC environment overrides", async () => {
+ await store.setGlobal("vnc", { defaults: { enabled: false } });
+ await store.setEnvironmentSettings("vnc", "env_1", { enabled: true });
+
+ const config = await store.getResolvedConfig("vnc", "acme/widgets", "env_1");
+ expect(config.settings.enabled).toBe(true);
+ });
+
it("skips the environment layer for integrations that don't support it", async () => {
await store.setGlobal("github", { defaults: { autoReviewOnOpen: true } });
@@ -719,10 +727,17 @@ describe("IntegrationSettingsStore", () => {
it("declares environment support for exactly the session-scoped integrations", () => {
expect(supportsEnvironmentSettings("sandbox")).toBe(true);
expect(supportsEnvironmentSettings("code-server")).toBe(true);
+ expect(supportsEnvironmentSettings("vnc")).toBe(true);
expect(supportsEnvironmentSettings("github")).toBe(false);
expect(supportsEnvironmentSettings("linear")).toBe(false);
expect(supportsEnvironmentSettings("slack")).toBe(false);
});
+
+ it("rejects a non-boolean VNC enabled setting", async () => {
+ await expect(
+ store.setRepoSettings("vnc", "acme/widgets", { enabled: "yes" } as never)
+ ).rejects.toThrow("enabled must be a boolean");
+ });
});
describe("cross-field validation", () => {
@@ -756,6 +771,27 @@ describe("IntegrationSettingsStore", () => {
});
});
+ describe("SCM field-level overrides", () => {
+ it("keeps omitted repository fields inherited when global defaults change", async () => {
+ await store.setGlobal("scm", {
+ defaults: { alwaysUseDraftMode: false, pullRequestLabel: "global" },
+ });
+ await store.setRepoSettings("scm", "acme/widgets", {
+ pullRequestLabel: "repository",
+ });
+
+ await store.setGlobal("scm", {
+ defaults: { alwaysUseDraftMode: true, pullRequestLabel: "new-global" },
+ });
+ const config = await store.getResolvedConfig("scm", "acme/widgets");
+
+ expect(config.settings).toEqual({
+ alwaysUseDraftMode: true,
+ pullRequestLabel: "repository",
+ });
+ });
+ });
+
describe("validation errors", () => {
it("rejects invalid model ID", async () => {
await expect(
diff --git a/packages/control-plane/src/db/integration-settings.ts b/packages/control-plane/src/db/integration-settings.ts
index 2b627eca2..53fbb210a 100644
--- a/packages/control-plane/src/db/integration-settings.ts
+++ b/packages/control-plane/src/db/integration-settings.ts
@@ -14,6 +14,7 @@ import {
type GitHubBotSettings,
type LinearBotSettings,
type CodeServerSettings,
+ type VncSettings,
type SlackGlobalSettings,
type SlackMentionsPolicy,
type SlackRoutingRule,
@@ -307,6 +308,10 @@ export class IntegrationSettingsStore {
this.validateCodeServerSettings(settings as CodeServerSettings);
}
+ if (integrationId === "vnc") {
+ this.validateVncSettings(settings as VncSettings);
+ }
+
if (integrationId === "sandbox") {
return normalizeSandboxSettings(settings, {
invalid: "throw",
@@ -422,6 +427,12 @@ export class IntegrationSettingsStore {
}
}
+ private validateVncSettings(settings: VncSettings): void {
+ if (settings.enabled !== undefined && typeof settings.enabled !== "boolean") {
+ throw new IntegrationSettingsValidationError("enabled must be a boolean");
+ }
+ }
+
private validateSlackSettings(
settings: SlackGlobalSettings,
level: SettingsLevel
diff --git a/packages/control-plane/src/db/keyboard-shortcut-preferences.ts b/packages/control-plane/src/db/keyboard-shortcut-preferences.ts
new file mode 100644
index 000000000..a41070239
--- /dev/null
+++ b/packages/control-plane/src/db/keyboard-shortcut-preferences.ts
@@ -0,0 +1,49 @@
+import {
+ DEFAULT_KEYBOARD_SHORTCUTS,
+ KEYBOARD_SHORTCUT_PREFERENCES_VERSION,
+ keyboardShortcutPreferencesSchema,
+ type KeyboardShortcutPreferences,
+} from "@open-inspect/shared/types/keyboard-shortcuts";
+import { z } from "zod";
+import type { SqlDatabase } from "./sql-database";
+
+const storedKeyboardShortcutPreferencesSchema = z.strictObject({
+ version: z.literal(KEYBOARD_SHORTCUT_PREFERENCES_VERSION),
+ shortcuts: keyboardShortcutPreferencesSchema,
+});
+
+export class KeyboardShortcutPreferencesStore {
+ constructor(private readonly db: SqlDatabase) {}
+
+ async get(userId: string): Promise {
+ const row = await this.db
+ .prepare("SELECT shortcuts FROM keyboard_shortcut_preferences WHERE user_id = ?")
+ .bind(userId)
+ .first<{ shortcuts: string }>();
+ if (!row) return DEFAULT_KEYBOARD_SHORTCUTS;
+ return storedKeyboardShortcutPreferencesSchema.parse(JSON.parse(row.shortcuts)).shortcuts;
+ }
+
+ async set(
+ userId: string,
+ shortcuts: KeyboardShortcutPreferences
+ ): Promise {
+ const validated = keyboardShortcutPreferencesSchema.parse(shortcuts);
+ await this.db
+ .prepare(
+ `INSERT INTO keyboard_shortcut_preferences (user_id, shortcuts, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET shortcuts = excluded.shortcuts, updated_at = excluded.updated_at`
+ )
+ .bind(
+ userId,
+ JSON.stringify({
+ version: KEYBOARD_SHORTCUT_PREFERENCES_VERSION,
+ shortcuts: validated,
+ }),
+ Date.now()
+ )
+ .run();
+ return validated;
+ }
+}
diff --git a/packages/control-plane/src/db/mcp-servers.test.ts b/packages/control-plane/src/db/mcp-servers.test.ts
index 0bc0e9c84..9d10849ca 100644
--- a/packages/control-plane/src/db/mcp-servers.test.ts
+++ b/packages/control-plane/src/db/mcp-servers.test.ts
@@ -6,7 +6,9 @@
*/
import { describe, it, expect, vi } from "vitest";
+import type { ValidatedCreateMcpServerInput } from "@open-inspect/shared/types/integrations";
import { McpServerStore, McpServerValidationError } from "./mcp-servers";
+import { generateEncryptionKey } from "../auth/crypto";
// ─── Fake D1 helpers ────────────────────────────────────────────────────────
@@ -62,6 +64,7 @@ function createFakeD1(options?: {
const sampleRow = {
id: "abc123",
+ revision: 1,
name: "playwright",
type: "local",
command: JSON.stringify(["npx", "-y", "@playwright/mcp"]),
@@ -75,6 +78,7 @@ const sampleRow = {
const remoteRow = {
id: "def456",
+ revision: 1,
name: "remote-mcp",
type: "remote",
command: null,
@@ -95,11 +99,13 @@ const remoteRowWithHeaders = {
// ─── Tests ────────────────────────────────────────────────────────────────────
+const TEST_ENCRYPTION_KEY = generateEncryptionKey();
+
describe("McpServerStore", () => {
describe("list()", () => {
it("returns all servers when no repoScope filter", async () => {
const { db } = createFakeD1({ allResults: [sampleRow, remoteRow] });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const results = await store.list();
expect(results).toHaveLength(2);
expect(results[0].name).toBe("playwright");
@@ -107,7 +113,7 @@ describe("McpServerStore", () => {
it("filters by repoScope (global servers always included)", async () => {
const { db } = createFakeD1({ allResults: [sampleRow, remoteRow] });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
// sampleRow has no repo_scope (global) → should be included
// remoteRow is scoped to carboncopyinc/habakkuk → should be included
const results = await store.list("carboncopyinc/habakkuk");
@@ -116,7 +122,7 @@ describe("McpServerStore", () => {
it("excludes repo-scoped servers when repo does not match", async () => {
const { db } = createFakeD1({ allResults: [sampleRow, remoteRow] });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
// remoteRow is scoped to carboncopyinc/habakkuk, not bencered/dom
const results = await store.list("bencered/dom");
expect(results).toHaveLength(1);
@@ -127,7 +133,7 @@ describe("McpServerStore", () => {
describe("get()", () => {
it("returns metadata (no credentials) when row found", async () => {
const { db } = createFakeD1({ firstResult: sampleRow });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const result = await store.get("abc123");
expect(result).not.toBeNull();
expect(result!.name).toBe("playwright");
@@ -141,7 +147,7 @@ describe("McpServerStore", () => {
it("returns null when not found", async () => {
const { db } = createFakeD1({ firstResult: null });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const result = await store.get("nonexistent");
expect(result).toBeNull();
});
@@ -149,7 +155,7 @@ describe("McpServerStore", () => {
it("handles corrupted JSON in command gracefully", async () => {
const corruptRow = { ...sampleRow, command: "not-json" };
const { db } = createFakeD1({ firstResult: corruptRow });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const result = await store.get("abc123");
// Should fall back to wrapping the string in an array
expect(result!.command).toEqual(["not-json"]);
@@ -158,7 +164,7 @@ describe("McpServerStore", () => {
it("reports hasEnv=false when env is empty", async () => {
const emptyEnvRow = { ...sampleRow, env: "{}" };
const { db } = createFakeD1({ firstResult: emptyEnvRow });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const result = await store.get("abc123");
expect(result!.hasEnv).toBe(false);
});
@@ -169,7 +175,7 @@ describe("McpServerStore", () => {
env: JSON.stringify({ Authorization: "Bearer tok" }),
};
const { db } = createFakeD1({ firstResult: remoteWithHeaders });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const result = await store.get("def456");
expect(result!.hasHeaders).toBe(true);
expect(result!.hasEnv).toBe(false);
@@ -179,24 +185,35 @@ describe("McpServerStore", () => {
describe("create()", () => {
it("throws McpServerValidationError for local server without command", async () => {
const { db } = createFakeD1();
- const store = new McpServerStore(db);
- await expect(store.create({ name: "test", type: "local", enabled: true })).rejects.toThrow(
- McpServerValidationError
- );
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
+ const invalid = {
+ name: "test",
+ type: "local",
+ enabled: true,
+ } as unknown as ValidatedCreateMcpServerInput;
+ await expect(store.create(invalid)).rejects.toThrow(McpServerValidationError);
});
it("throws McpServerValidationError for remote server without url", async () => {
const { db } = createFakeD1({ firstResult: remoteRow });
- const store = new McpServerStore(db);
- await expect(store.create({ name: "test", type: "remote", enabled: true })).rejects.toThrow(
- McpServerValidationError
- );
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
+ const invalid = {
+ name: "test",
+ type: "remote",
+ enabled: true,
+ } as unknown as ValidatedCreateMcpServerInput;
+ await expect(store.create(invalid)).rejects.toThrow(McpServerValidationError);
});
it("throws McpServerValidationError (not generic Error) so routes can return 400", async () => {
const { db } = createFakeD1();
- const store = new McpServerStore(db);
- const err = await store.create({ name: "x", type: "local", enabled: true }).catch((e) => e);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
+ const invalid = {
+ name: "x",
+ type: "local",
+ enabled: true,
+ } as unknown as ValidatedCreateMcpServerInput;
+ const err = await store.create(invalid).catch((e) => e);
expect(err).toBeInstanceOf(McpServerValidationError);
expect(err).toBeInstanceOf(Error);
});
@@ -205,7 +222,7 @@ describe("McpServerStore", () => {
describe("update()", () => {
it("returns null when server not found", async () => {
const { db } = createFakeD1({ firstResult: null });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const result = await store.update("nonexistent", { name: "new-name" });
expect(result).toBeNull();
});
@@ -239,7 +256,7 @@ describe("McpServerStore", () => {
};
const db = { prepare: () => fakeStmt, dump: vi.fn(), exec: vi.fn() } as unknown as D1Database;
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
// Attempt to patch id (not in the allowed type, but simulate via cast)
const result = await store.update("abc123", {
id: "malicious-id",
@@ -252,7 +269,7 @@ describe("McpServerStore", () => {
it("throws McpServerValidationError when changing type to remote without url", async () => {
// sampleRow is a local server with no url
const { db } = createFakeD1({ firstResult: sampleRow });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const err = await store.update("abc123", { type: "remote" }).catch((e) => e);
expect(err).toBeInstanceOf(McpServerValidationError);
expect(err.message).toMatch(/require a URL/i);
@@ -261,7 +278,7 @@ describe("McpServerStore", () => {
it("throws McpServerValidationError when changing type to local without command", async () => {
// remoteRow is a remote server with no command
const { db } = createFakeD1({ firstResult: remoteRow });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const err = await store.update("def456", { type: "local" }).catch((e) => e);
expect(err).toBeInstanceOf(McpServerValidationError);
expect(err.message).toMatch(/require a command/i);
@@ -271,14 +288,14 @@ describe("McpServerStore", () => {
describe("delete()", () => {
it("returns true when row deleted", async () => {
const { db } = createFakeD1({ changes: 1 });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const result = await store.delete("abc123");
expect(result).toBe(true);
});
it("returns false when row not found", async () => {
const { db } = createFakeD1({ changes: 0 });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const result = await store.delete("nonexistent");
expect(result).toBe(false);
});
@@ -287,7 +304,7 @@ describe("McpServerStore", () => {
describe("getDecryptedForSession()", () => {
it("returns global and matching repo-scoped servers", async () => {
const { db } = createFakeD1({ allResults: [sampleRow, remoteRow] });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const results = await store.getDecryptedForSession([
{ repoOwner: "carboncopyinc", repoName: "habakkuk" },
]);
@@ -296,7 +313,7 @@ describe("McpServerStore", () => {
it("excludes servers scoped to different repos", async () => {
const { db } = createFakeD1({ allResults: [sampleRow, remoteRow] });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const results = await store.getDecryptedForSession([
{ repoOwner: "bencered", repoName: "dom" },
]);
@@ -306,7 +323,7 @@ describe("McpServerStore", () => {
it("matches scoped servers through any member of a multi-repo session", async () => {
const { db } = createFakeD1({ allResults: [sampleRow, remoteRow] });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const results = await store.getDecryptedForSession([
{ repoOwner: "bencered", repoName: "dom" },
{ repoOwner: "carboncopyinc", repoName: "habakkuk" },
@@ -316,7 +333,7 @@ describe("McpServerStore", () => {
it("returns only unscoped servers for repo-less sessions", async () => {
const { db } = createFakeD1({ allResults: [sampleRow, remoteRow] });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const results = await store.getDecryptedForSession([]);
expect(results).toHaveLength(1);
expect(results[0].name).toBe("playwright");
@@ -324,7 +341,7 @@ describe("McpServerStore", () => {
it("returns headers (not env) for remote servers", async () => {
const { db } = createFakeD1({ allResults: [remoteRowWithHeaders] });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const results = await store.getDecryptedForSession([
{ repoOwner: "carboncopyinc", repoName: "habakkuk" },
]);
@@ -340,7 +357,7 @@ describe("McpServerStore", () => {
it("returns env (not headers) for local servers", async () => {
const { db } = createFakeD1({ allResults: [sampleRow] });
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const results = await store.getDecryptedForSession([{ repoOwner: "any", repoName: "repo" }]);
expect(results).toHaveLength(1);
const local = results[0];
@@ -348,6 +365,36 @@ describe("McpServerStore", () => {
expect(local.env).toEqual({ DEBUG: "1" });
expect(local.headers).toBeUndefined();
});
+
+ it("reads an empty credential map without a doomed decrypt attempt", async () => {
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const emptyEnvRow = { ...sampleRow, env: "{}" };
+ const { db } = createFakeD1({ allResults: [emptyEnvRow] });
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
+
+ const results = await store.getDecryptedForSession([{ repoOwner: "any", repoName: "repo" }]);
+
+ expect(results).toHaveLength(1);
+ expect(results[0].env ?? {}).toEqual({});
+ // The "{}" sentinel is written plaintext by encryptEnv; reading it must
+ // not attempt a decrypt that fails into the env_decrypt_error path.
+ expect(errorSpy).not.toHaveBeenCalled();
+ errorSpy.mockRestore();
+ });
+
+ it('reads the legacy "null" credential sentinel as an empty map', async () => {
+ // rowToMetadata's credential-free set is "", "{}", and "null" — the
+ // decrypt path must accept all three. JSON.parse("null") is null, so
+ // without the guard this row throws in the catch and rejects the call.
+ const nullEnvRow = { ...sampleRow, env: "null" };
+ const { db } = createFakeD1({ allResults: [nullEnvRow] });
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
+
+ const results = await store.getDecryptedForSession([{ repoOwner: "any", repoName: "repo" }]);
+
+ expect(results).toHaveLength(1);
+ expect(results[0].env ?? {}).toEqual({});
+ });
});
describe("UNIQUE constraint handling", () => {
@@ -376,7 +423,7 @@ describe("McpServerStore", () => {
it("create() throws McpServerValidationError on duplicate name (not 503)", async () => {
const db = createConstraintErrorD1();
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const err = await store
.create({ name: "playwright", type: "local", command: ["npx", "x"], enabled: true })
.catch((e) => e);
@@ -385,8 +432,6 @@ describe("McpServerStore", () => {
});
it("update() throws McpServerValidationError on duplicate name", async () => {
- // First call (get existing) returns the row; second call (update) throws constraint error
- let runCallCount = 0;
let firstCallCount = 0;
const fakeStmt = {
bind(..._params: unknown[]) {
@@ -394,7 +439,8 @@ describe("McpServerStore", () => {
},
async first(): Promise {
firstCallCount++;
- return firstCallCount <= 1 ? (sampleRow as T) : null;
+ if (firstCallCount === 1) return sampleRow as T;
+ throw new Error("UNIQUE constraint failed: mcp_servers.name");
},
async all(): Promise> {
return {
@@ -404,29 +450,27 @@ describe("McpServerStore", () => {
} as unknown as D1Result;
},
async run(): Promise {
- runCallCount++;
- throw new Error("UNIQUE constraint failed: mcp_servers.name");
+ throw new Error("Unexpected run");
},
};
const db = { prepare: () => fakeStmt, dump: vi.fn(), exec: vi.fn() } as unknown as D1Database;
- const store = new McpServerStore(db);
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const err = await store.update("abc123", { name: "other-server" }).catch((e) => e);
expect(err).toBeInstanceOf(McpServerValidationError);
- expect(runCallCount).toBeGreaterThan(0);
});
});
describe("encryption / decryption (via getDecryptedForSession)", () => {
it("no-key path returns plaintext env as-is", async () => {
const { db } = createFakeD1({ allResults: [sampleRow] });
- const store = new McpServerStore(db); // no encryption key
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY); // no encryption key
const results = await store.getDecryptedForSession([{ repoOwner: "any", repoName: "repo" }]);
expect(results[0].env).toEqual({ DEBUG: "1" });
});
it("falls back to plaintext when decryption fails (pre-encryption row)", async () => {
const { db } = createFakeD1({ allResults: [sampleRow] });
- const store = new McpServerStore(db, "bm90YXJlYWxrZXlub3RhcmVhbGtleW5vdGFyZWFsa2V5eA==");
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const results = await store.getDecryptedForSession([{ repoOwner: "any", repoName: "repo" }]);
expect(results[0].env).toEqual({ DEBUG: "1" });
});
@@ -435,7 +479,7 @@ describe("McpServerStore", () => {
const { db } = createFakeD1({
allResults: [{ ...sampleRow, env: "notjson_notcipher" }],
});
- const store = new McpServerStore(db, "bm90YXJlYWxrZXlub3RhcmVhbGtleW5vdGFyZWFsa2V5eA==");
+ const store = new McpServerStore(db, TEST_ENCRYPTION_KEY);
const results = await store.getDecryptedForSession([{ repoOwner: "any", repoName: "repo" }]);
expect(results[0].env).toEqual({});
});
diff --git a/packages/control-plane/src/db/mcp-servers.ts b/packages/control-plane/src/db/mcp-servers.ts
index 91308b68f..ef717e4f9 100644
--- a/packages/control-plane/src/db/mcp-servers.ts
+++ b/packages/control-plane/src/db/mcp-servers.ts
@@ -1,4 +1,9 @@
-import type { McpServerConfig, McpServerMetadata } from "@open-inspect/shared/types/integrations";
+import type {
+ McpServerConfig,
+ McpServerMetadata,
+ ValidatedCreateMcpServerInput,
+ ValidatedUpdateMcpServerInput,
+} from "@open-inspect/shared/types/integrations";
import { encryptToken, decryptToken } from "../auth/crypto";
import { createLogger } from "../logger";
import { isUniqueConstraintError } from "./errors";
@@ -13,12 +18,15 @@ export class McpServerValidationError extends Error {
}
}
+export class McpServerConflictError extends Error {}
+
function generateId(): string {
return crypto.randomUUID().replace(/-/g, "").slice(0, 16);
}
interface McpServerRow {
id: string;
+ revision: number;
name: string;
type: string;
command: string | null;
@@ -51,7 +59,12 @@ function safeJsonParseCommand(raw: string | null): string[] | undefined {
function safeJsonParseEnv(raw: string): Record {
try {
- return JSON.parse(raw);
+ const parsed: unknown = JSON.parse(raw);
+ // JSON.parse accepts non-object documents ("null", numbers, strings);
+ // callers iterate keys, so anything but a plain object is "no env".
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : {};
} catch {
return {};
}
@@ -64,8 +77,8 @@ function rowToConfig(row: McpServerRow, payload: Record): McpSer
id: row.id,
name: row.name,
type: row.type as "local" | "remote",
- command: safeJsonParseCommand(row.command),
- url: row.url ?? undefined,
+ command: row.type === "local" ? safeJsonParseCommand(row.command) : undefined,
+ url: row.type === "remote" ? (row.url ?? undefined) : undefined,
...envOrHeaders,
repoScopes: parseRepoScopes(row.repo_scope),
enabled: row.enabled === 1,
@@ -76,10 +89,11 @@ function rowToMetadata(row: McpServerRow): McpServerMetadata {
const hasCredentials = row.env !== "" && row.env !== "{}" && row.env !== "null";
return {
id: row.id,
+ revision: row.revision,
name: row.name,
type: row.type as "local" | "remote",
- command: safeJsonParseCommand(row.command),
- url: row.url ?? undefined,
+ command: row.type === "local" ? safeJsonParseCommand(row.command) : undefined,
+ url: row.type === "remote" ? (row.url ?? undefined) : undefined,
hasEnv: row.type === "local" && hasCredentials,
hasHeaders: row.type === "remote" && hasCredentials,
repoScopes: parseRepoScopes(row.repo_scope),
@@ -90,18 +104,22 @@ function rowToMetadata(row: McpServerRow): McpServerMetadata {
export class McpServerStore {
constructor(
private readonly db: SqlDatabase,
- private readonly encryptionKey?: string
+ private readonly encryptionKey: string
) {}
/** Empty dicts are stored as plaintext "{}" so rowToMetadata() can detect "no credentials". */
private async encryptEnv(env: Record): Promise {
const plain = JSON.stringify(env);
- if (!this.encryptionKey || Object.keys(env).length === 0) return plain;
+ if (Object.keys(env).length === 0) return plain;
return encryptToken(plain, this.encryptionKey);
}
private async decryptEnv(raw: string): Promise> {
- if (!this.encryptionKey) return safeJsonParseEnv(raw);
+ // The write side stores an empty credential map as plaintext "{}" (see
+ // encryptEnv) — recognize the full credential-free sentinel set that
+ // rowToMetadata classifies ("", "{}", "null") before attempting a decrypt
+ // that is guaranteed to fail into the error path.
+ if (!raw || raw === "{}" || raw === "null") return {};
try {
const plain = await decryptToken(raw, this.encryptionKey);
return safeJsonParseEnv(plain);
@@ -147,7 +165,7 @@ export class McpServerStore {
return row ? rowToMetadata(row) : null;
}
- async create(config: Omit): Promise {
+ async create(config: ValidatedCreateMcpServerInput): Promise {
const id = generateId();
const now = Date.now();
@@ -172,8 +190,8 @@ export class McpServerStore {
id,
config.name,
config.type,
- config.command ? JSON.stringify(config.command) : null,
- config.url ?? null,
+ config.type === "local" ? JSON.stringify(config.command) : null,
+ config.type === "remote" ? config.url : null,
encryptedEnv,
config.repoScopes?.length
? JSON.stringify(config.repoScopes.map((r) => r.toLowerCase()))
@@ -199,18 +217,25 @@ export class McpServerStore {
async update(
id: string,
- patch: Partial<
- Pick<
- McpServerConfig,
- "name" | "type" | "command" | "url" | "env" | "headers" | "repoScopes" | "enabled"
- >
- >
+ patch: ValidatedUpdateMcpServerInput,
+ expectedRevision?: number
): Promise {
const row = await this.db
.prepare("SELECT * FROM mcp_servers WHERE id = ?")
.bind(id)
.first();
if (!row) return null;
+ if (expectedRevision !== undefined && row.revision !== expectedRevision) {
+ throw new McpServerConflictError("MCP server changed; reload and try again");
+ }
+
+ const mergedType = patch.type ?? (row.type as "local" | "remote");
+ if (mergedType === "local" && (patch.url !== undefined || patch.headers !== undefined)) {
+ throw new McpServerValidationError("Local MCP servers do not support url or headers");
+ }
+ if (mergedType === "remote" && (patch.command !== undefined || patch.env !== undefined)) {
+ throw new McpServerValidationError("Remote MCP servers do not support command or env");
+ }
const credentialsChanged =
patch.env !== undefined || patch.headers !== undefined || patch.type !== undefined;
@@ -228,7 +253,6 @@ export class McpServerStore {
encryptedEnv = row.env;
}
- const mergedType = patch.type ?? (row.type as "local" | "remote");
const mergedCommand =
patch.command !== undefined ? patch.command : safeJsonParseCommand(row.command);
const mergedUrl = patch.url !== undefined ? patch.url : (row.url ?? undefined);
@@ -243,16 +267,17 @@ export class McpServerStore {
const now = Date.now();
try {
- await this.db
- .prepare(
- `UPDATE mcp_servers SET name = ?, type = ?, command = ?, url = ?, env = ?, repo_scope = ?, enabled = ?, updated_at = ?
- WHERE id = ?`
- )
+ const statement = this.db.prepare(
+ `UPDATE mcp_servers SET name = ?, type = ?, command = ?, url = ?, env = ?, repo_scope = ?, enabled = ?, updated_at = ?, revision = revision + 1
+ WHERE id = ? AND revision = COALESCE(?, revision)
+ RETURNING *`
+ );
+ const updated = await statement
.bind(
patch.name ?? row.name,
mergedType,
- mergedCommand ? JSON.stringify(mergedCommand) : null,
- mergedUrl ?? null,
+ mergedType === "local" && mergedCommand ? JSON.stringify(mergedCommand) : null,
+ mergedType === "remote" ? (mergedUrl ?? null) : null,
encryptedEnv,
patch.repoScopes !== undefined
? patch.repoScopes?.length
@@ -261,9 +286,14 @@ export class McpServerStore {
: row.repo_scope,
patch.enabled !== undefined ? (patch.enabled ? 1 : 0) : row.enabled,
now,
- id
+ id,
+ expectedRevision ?? null
)
- .run();
+ .first();
+ if (!updated) {
+ throw new McpServerConflictError("MCP server changed; reload and try again");
+ }
+ return rowToMetadata(updated);
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new McpServerValidationError(
@@ -272,12 +302,6 @@ export class McpServerStore {
}
throw err;
}
-
- const updated = await this.get(id);
- if (!updated) {
- throw new Error(`MCP server '${id}' not found after update — this should not happen`);
- }
- return updated;
}
async delete(id: string): Promise {
diff --git a/packages/control-plane/src/db/model-provider-account-atomic-writer.ts b/packages/control-plane/src/db/model-provider-account-atomic-writer.ts
new file mode 100644
index 000000000..d9bcb1fa8
--- /dev/null
+++ b/packages/control-plane/src/db/model-provider-account-atomic-writer.ts
@@ -0,0 +1,471 @@
+import type { ModelProviderId } from "../model-provider-accounts/provider-auth-contracts";
+import { encryptProviderAccountPayload } from "../auth/provider-account-crypto";
+import type { ProcessingProviderAuthorization } from "./provider-account-authorizations";
+import { ProviderAccountAuthorizationStore } from "./provider-account-authorizations";
+import type { ModelProviderAccount, ModelProviderAccountStatus } from "./model-provider-accounts";
+import { ModelProviderAccountStore } from "./model-provider-accounts";
+import {
+ ProviderCredentialStore,
+ type ProviderCredentialExchangeAccountStatus,
+} from "./provider-account-credentials";
+import type { SqlDatabase, SqlStatement } from "./sql-database";
+import { ProviderDefaultStore } from "./provider-account-defaults";
+
+interface CredentialWriteInput {
+ providerAccountId: string;
+ provider: ModelProviderId;
+ credentialSchemaVersion: number;
+ payload: unknown;
+ accessTokenExpiresAt?: number | null;
+ now: number;
+}
+
+export interface AccountConnectionWriteInput extends CredentialWriteInput {
+ expectedCredentialVersion: number;
+ externalAccountId: string | null;
+ status: ModelProviderAccountStatus;
+ actorId: string;
+ lastVerifiedAt: number;
+}
+
+export interface CompleteVerificationCredentialAndAccountInput extends AccountConnectionWriteInput {
+ expectedAccountStatus: ProviderCredentialExchangeAccountStatus;
+ exchangeGeneration: number;
+ exchangeOwner: string;
+}
+
+export interface FenceProviderCredentialExchangeInput {
+ providerAccountId: string;
+ credentialVersion: number;
+ exchangeGeneration: number;
+ exchangeOwner: string;
+ now: number;
+}
+
+export interface CreateAccountWithCredentialInput {
+ id: string;
+ provider: ModelProviderId;
+ displayName: string;
+ externalAccountId: string | null;
+ actorId: string;
+ now: number;
+ credential: Pick<
+ CredentialWriteInput,
+ "credentialSchemaVersion" | "payload" | "accessTokenExpiresAt"
+ >;
+}
+
+interface DeviceAuthorizationCredentialInput {
+ authorization: ProcessingProviderAuthorization;
+ externalAccountId: string;
+ credential: unknown;
+ credentialSchemaVersion: number;
+ accessTokenExpiresAt: number | null;
+ now: number;
+}
+
+export interface FinalizeDeviceAuthorizationCreateInput extends DeviceAuthorizationCredentialInput {
+ authorization: ProcessingProviderAuthorization & { operation: "create" };
+ accountId: string;
+}
+
+export interface FinalizeDeviceAuthorizationReconnectInput extends DeviceAuthorizationCredentialInput {
+ accountId: string;
+}
+
+export type DeviceAuthorizationCreateOutcome =
+ | { type: "created" }
+ | { type: "identity_conflict" }
+ | { type: "claim_lost" };
+
+export type DeviceAuthorizationReconnectOutcome =
+ | { type: "connected" }
+ | { type: "claim_lost" }
+ | { type: "target_changed" };
+
+export interface ModelProviderAccountAtomicWriter {
+ createAccountWithCredential(
+ input: CreateAccountWithCredentialInput
+ ): Promise;
+ reconnectCredentialAndAccount(input: AccountConnectionWriteInput): Promise;
+ completeVerificationCredentialAndAccount(
+ input: CompleteVerificationCredentialAndAccountInput
+ ): Promise;
+ finalizeDeviceAuthorizationCreate(
+ input: FinalizeDeviceAuthorizationCreateInput
+ ): Promise;
+ finalizeDeviceAuthorizationReconnect(
+ input: FinalizeDeviceAuthorizationReconnectInput
+ ): Promise;
+ fenceExchangeAndRequireReconnect(input: FenceProviderCredentialExchangeInput): Promise;
+}
+
+export class D1ModelProviderAccountAtomicWriter implements ModelProviderAccountAtomicWriter {
+ private readonly accounts: ModelProviderAccountStore;
+ private readonly credentials: ProviderCredentialStore;
+ private readonly authorizations: ProviderAccountAuthorizationStore;
+ private readonly defaults: ProviderDefaultStore;
+
+ constructor(
+ private readonly db: SqlDatabase,
+ private readonly encryptionKey: string
+ ) {
+ this.accounts = new ModelProviderAccountStore(db);
+ this.credentials = new ProviderCredentialStore(db, encryptionKey);
+ this.authorizations = new ProviderAccountAuthorizationStore(db);
+ this.defaults = new ProviderDefaultStore(db);
+ }
+
+ async createAccountWithCredential(
+ input: CreateAccountWithCredentialInput
+ ): Promise {
+ const accountStatement = this.accounts.bindCreate({ ...input, lastVerifiedAt: input.now });
+ const credentialStatement = await this.credentials.bindCreateForAccountBatch({
+ providerAccountId: input.id,
+ provider: input.provider,
+ ...input.credential,
+ now: input.now,
+ });
+ await this.db.batch([
+ accountStatement,
+ credentialStatement,
+ this.defaults.bindSetForFirstActiveAccount(
+ input.id,
+ input.provider,
+ input.actorId,
+ input.now
+ ),
+ ]);
+ const account = await this.accounts.getById(input.id);
+ if (!account) throw new Error("Created provider account could not be read");
+ return account;
+ }
+
+ async reconnectCredentialAndAccount(input: AccountConnectionWriteInput): Promise {
+ const prepared = await this.credentials.prepareReplace(input);
+ const results = await this.db.batch([
+ prepared.statement,
+ this.accounts.bindUpdateConnection(input.providerAccountId, {
+ ...input,
+ credentialVersion: input.expectedCredentialVersion + 1,
+ encryptedPayload: prepared.encryptedPayload,
+ }),
+ ]);
+ return results[0].meta.changes === 1 && results[1].meta.changes === 1;
+ }
+
+ async completeVerificationCredentialAndAccount(
+ input: CompleteVerificationCredentialAndAccountInput
+ ): Promise {
+ const prepared = await this.credentials.prepareCompleteExchange(input);
+ const results = await this.db.batch([
+ prepared.statement,
+ this.accounts.bindUpdateConnection(input.providerAccountId, {
+ ...input,
+ credentialVersion: input.expectedCredentialVersion + 1,
+ encryptedPayload: prepared.encryptedPayload,
+ }),
+ ]);
+ return results[0].meta.changes === 1 && results[1].meta.changes === 1;
+ }
+
+ async finalizeDeviceAuthorizationCreate(
+ input: FinalizeDeviceAuthorizationCreateInput
+ ): Promise {
+ const encryptedPayload = await this.encryptDeviceCredential(input, input.accountId);
+ const authorizationGuard = this.deviceAuthorizationGuard();
+ const guardValues = this.deviceAuthorizationGuardValues(input.authorization, input.now);
+ const results = await this.db.batch([
+ this.db
+ .prepare(
+ `INSERT INTO model_provider_accounts
+ (id, provider, display_name, external_account_id, status, created_by, updated_by,
+ last_verified_at, created_at, updated_at)
+ SELECT ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?
+ WHERE EXISTS (${authorizationGuard})
+ AND NOT EXISTS (SELECT 1 FROM model_provider_accounts
+ WHERE provider = ? AND external_account_id = ? AND archived_at IS NULL)`
+ )
+ .bind(
+ input.accountId,
+ input.authorization.provider,
+ input.authorization.displayName,
+ input.externalAccountId,
+ input.authorization.userId,
+ input.authorization.userId,
+ input.now,
+ input.now,
+ input.now,
+ ...guardValues,
+ input.authorization.provider,
+ input.externalAccountId
+ ),
+ this.db
+ .prepare(
+ `INSERT INTO model_provider_account_credentials
+ (provider_account_id, encrypted_payload, credential_schema_version,
+ access_token_expires_at, updated_at)
+ SELECT ?, ?, ?, ?, ? WHERE changes() = 1
+ AND EXISTS (SELECT 1 FROM model_provider_accounts
+ WHERE id = ? AND provider = ? AND external_account_id = ?
+ AND status = 'active' AND archived_at IS NULL AND lifecycle_version = 0)`
+ )
+ .bind(
+ input.accountId,
+ encryptedPayload,
+ input.credentialSchemaVersion,
+ input.accessTokenExpiresAt,
+ input.now,
+ input.accountId,
+ input.authorization.provider,
+ input.externalAccountId
+ ),
+ this.connectedAuthorizationStatement({
+ ...input,
+ encryptedPayload,
+ credentialVersion: 1,
+ accountLifecycleVersion: 0,
+ reconnectedExisting: false,
+ }),
+ this.defaults.bindSetForFirstActiveAccount(
+ input.accountId,
+ input.authorization.provider,
+ input.authorization.userId,
+ input.now
+ ),
+ ]);
+ const requiredResults = results.slice(0, 3);
+ if (requiredResults.every((result) => result.meta.changes === 1)) return { type: "created" };
+ if (requiredResults.some((result) => result.meta.changes !== 0)) {
+ throw new Error("Provider authorization create finalization violated atomic invariants");
+ }
+ if (!(await this.ownsDeviceAuthorizationClaim(input.authorization, input.now))) {
+ return { type: "claim_lost" };
+ }
+ const conflict = await this.accounts.findLifecycleSnapshotByExternalIdentity(
+ input.authorization.provider,
+ input.externalAccountId
+ );
+ if (conflict) return { type: "identity_conflict" };
+ throw new Error("Provider authorization create finalization rejected without a conflict");
+ }
+
+ async finalizeDeviceAuthorizationReconnect(
+ input: FinalizeDeviceAuthorizationReconnectInput
+ ): Promise {
+ if (!(await this.ownsDeviceAuthorizationClaim(input.authorization, input.now))) {
+ return { type: "claim_lost" };
+ }
+ const snapshot = await this.accounts.getLifecycleSnapshot(input.accountId);
+ if (
+ !snapshot ||
+ snapshot.account.archivedAt !== null ||
+ snapshot.account.provider !== input.authorization.provider ||
+ snapshot.account.externalAccountId !== input.externalAccountId ||
+ (input.authorization.operation === "create" && snapshot.account.status === "disabled") ||
+ (input.authorization.operation === "reconnect" &&
+ (input.authorization.providerAccountId !== input.accountId ||
+ input.authorization.targetAccountStatus !== snapshot.account.status ||
+ input.authorization.targetAccountLifecycleVersion !== snapshot.lifecycleVersion))
+ ) {
+ return { type: "target_changed" };
+ }
+ const currentCredential = await this.credentials.readCredentialState(
+ input.accountId,
+ input.authorization.provider
+ );
+ if (!currentCredential) return { type: "target_changed" };
+
+ const encryptedPayload = await this.encryptDeviceCredential(input, input.accountId);
+ const nextCredentialVersion = currentCredential.credentialVersion + 1;
+ const nextLifecycleVersion = snapshot.lifecycleVersion + 1;
+ const authorizationGuard = this.deviceAuthorizationGuard();
+ const guardValues = this.deviceAuthorizationGuardValues(input.authorization, input.now);
+ const results = await this.db.batch([
+ this.db
+ .prepare(
+ `UPDATE model_provider_accounts
+ SET status = 'active', updated_by = ?, last_verified_at = ?, updated_at = ?,
+ lifecycle_version = lifecycle_version + 1
+ WHERE id = ? AND provider = ? AND external_account_id = ?
+ AND archived_at IS NULL AND status = ? AND lifecycle_version = ?
+ AND EXISTS (${authorizationGuard})
+ AND EXISTS (SELECT 1 FROM model_provider_account_credentials
+ WHERE provider_account_id = ? AND credential_version = ?)`
+ )
+ .bind(
+ input.authorization.userId,
+ input.now,
+ input.now,
+ input.accountId,
+ input.authorization.provider,
+ input.externalAccountId,
+ snapshot.account.status,
+ snapshot.lifecycleVersion,
+ ...guardValues,
+ input.accountId,
+ currentCredential.credentialVersion
+ ),
+ this.db
+ .prepare(
+ `UPDATE model_provider_account_credentials
+ SET encrypted_payload = ?, credential_schema_version = ?,
+ credential_version = credential_version + 1,
+ exchange_state = 'idle', exchange_owner = NULL, exchange_started_at = NULL,
+ access_token_expires_at = ?, updated_at = ?
+ WHERE changes() = 1 AND provider_account_id = ? AND credential_version = ?`
+ )
+ .bind(
+ encryptedPayload,
+ input.credentialSchemaVersion,
+ input.accessTokenExpiresAt,
+ input.now,
+ input.accountId,
+ currentCredential.credentialVersion
+ ),
+ this.connectedAuthorizationStatement({
+ ...input,
+ encryptedPayload,
+ credentialVersion: nextCredentialVersion,
+ accountLifecycleVersion: nextLifecycleVersion,
+ reconnectedExisting: true,
+ }),
+ ]);
+ if (results.every((result) => result.meta.changes === 1)) return { type: "connected" };
+ if (results.some((result) => result.meta.changes !== 0)) {
+ throw new Error("Provider authorization reconnect finalization violated atomic invariants");
+ }
+ return (await this.ownsDeviceAuthorizationClaim(input.authorization, input.now))
+ ? { type: "target_changed" }
+ : { type: "claim_lost" };
+ }
+
+ private encryptDeviceCredential(
+ input: DeviceAuthorizationCredentialInput,
+ providerAccountId: string
+ ): Promise {
+ if (!Number.isInteger(input.credentialSchemaVersion) || input.credentialSchemaVersion <= 0) {
+ throw new Error("Credential schema version must be a positive integer");
+ }
+ return encryptProviderAccountPayload(input.credential, this.encryptionKey, {
+ providerAccountId,
+ provider: input.authorization.provider,
+ credentialSchemaVersion: input.credentialSchemaVersion,
+ });
+ }
+
+ private deviceAuthorizationGuard(): string {
+ return `SELECT 1 FROM model_provider_account_authorizations
+ WHERE id = ? AND user_id = ? AND state = 'processing' AND processing_owner = ?
+ AND expires_at > ?`;
+ }
+
+ private deviceAuthorizationGuardValues(
+ authorization: ProcessingProviderAuthorization,
+ now: number
+ ): unknown[] {
+ return [authorization.id, authorization.userId, authorization.processingOwner, now];
+ }
+
+ private async ownsDeviceAuthorizationClaim(
+ authorization: ProcessingProviderAuthorization,
+ now: number
+ ): Promise {
+ const current = await this.authorizations.getOwned(authorization.userId, authorization.id);
+ return (
+ current?.state === "processing" &&
+ current.processingOwner === authorization.processingOwner &&
+ current.expiresAt > now
+ );
+ }
+
+ private connectedAuthorizationStatement(
+ input: DeviceAuthorizationCredentialInput & {
+ accountId: string;
+ encryptedPayload: string;
+ credentialVersion: number;
+ accountLifecycleVersion: number;
+ reconnectedExisting: boolean;
+ }
+ ): SqlStatement {
+ return this.db
+ .prepare(
+ `UPDATE model_provider_account_authorizations
+ SET state = 'connected', encrypted_provider_data = NULL, provider_state_version = NULL,
+ processing_owner = NULL, processing_started_at = NULL,
+ result_provider_account_id = ?, reconnected_existing = ?,
+ completed_at = ?, updated_at = ?
+ WHERE changes() = 1
+ AND id = ? AND user_id = ? AND state = 'processing' AND processing_owner = ?
+ AND expires_at > ?
+ AND EXISTS (SELECT 1 FROM model_provider_accounts
+ WHERE id = ? AND provider = ? AND status = 'active' AND archived_at IS NULL
+ AND lifecycle_version = ?)
+ AND EXISTS (SELECT 1 FROM model_provider_account_credentials
+ WHERE provider_account_id = ? AND credential_version = ? AND encrypted_payload = ?)`
+ )
+ .bind(
+ input.accountId,
+ input.reconnectedExisting ? 1 : 0,
+ input.now,
+ input.now,
+ input.authorization.id,
+ input.authorization.userId,
+ input.authorization.processingOwner,
+ input.now,
+ input.accountId,
+ input.authorization.provider,
+ input.accountLifecycleVersion,
+ input.accountId,
+ input.credentialVersion,
+ input.encryptedPayload
+ );
+ }
+
+ async fenceExchangeAndRequireReconnect(
+ input: FenceProviderCredentialExchangeInput
+ ): Promise {
+ const leaseGuard = `SELECT 1 FROM model_provider_account_credentials
+ WHERE provider_account_id = model_provider_accounts.id
+ AND credential_version = ? AND exchange_generation = ?
+ AND exchange_owner = ? AND exchange_state = 'in_flight'`;
+ const results = await this.db.batch([
+ this.db
+ .prepare(
+ `UPDATE model_provider_accounts
+ SET status = 'reconnect_required', updated_by = NULL, updated_at = ?
+ WHERE id = ? AND archived_at IS NULL AND status = 'active'
+ AND EXISTS (${leaseGuard})`
+ )
+ .bind(
+ input.now,
+ input.providerAccountId,
+ input.credentialVersion,
+ input.exchangeGeneration,
+ input.exchangeOwner
+ ),
+ this.db
+ .prepare(
+ `UPDATE model_provider_account_credentials
+ SET exchange_state = 'idle', exchange_owner = NULL, exchange_started_at = NULL,
+ exchange_generation = exchange_generation + 1, updated_at = ?
+ WHERE provider_account_id = ? AND credential_version = ?
+ AND exchange_generation = ? AND exchange_owner = ? AND exchange_state = 'in_flight'
+ AND EXISTS (
+ SELECT 1 FROM model_provider_accounts
+ WHERE model_provider_accounts.id = model_provider_account_credentials.provider_account_id
+ AND model_provider_accounts.status = 'reconnect_required'
+ AND model_provider_accounts.archived_at IS NULL
+ )`
+ )
+ .bind(
+ input.now,
+ input.providerAccountId,
+ input.credentialVersion,
+ input.exchangeGeneration,
+ input.exchangeOwner
+ ),
+ ]);
+ return results[0].meta.changes === 1 && results[1].meta.changes === 1;
+ }
+}
diff --git a/packages/control-plane/src/db/model-provider-accounts.test.ts b/packages/control-plane/src/db/model-provider-accounts.test.ts
new file mode 100644
index 000000000..ca787c427
--- /dev/null
+++ b/packages/control-plane/src/db/model-provider-accounts.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it, vi } from "vitest";
+import type { SqlDatabase, SqlStatement } from "./sql-database";
+import { ModelProviderAccountStore } from "./model-provider-accounts";
+
+function database(row: Record | null = null) {
+ const queries: string[] = [];
+ const db: SqlDatabase = {
+ prepare(query) {
+ queries.push(query);
+ const statement: SqlStatement = {
+ bind: () => statement,
+ first: async () => row as T | null,
+ run: vi.fn(async () => ({ results: [], meta: { changes: 1 } })),
+ all: vi.fn(async () => ({ results: [], meta: { changes: 0 } })),
+ };
+ return statement;
+ },
+ batch: vi.fn(async () => []),
+ };
+ return { db, queries };
+}
+
+describe("ModelProviderAccountStore lifecycle version", () => {
+ it("returns an internal lifecycle snapshot without extending the account contract", async () => {
+ const { db } = database({
+ id: "account-1",
+ provider: "openai",
+ display_name: "OpenAI",
+ external_account_id: "external-1",
+ status: "active",
+ created_by: null,
+ updated_by: null,
+ last_verified_at: null,
+ last_used_at: null,
+ created_at: 1,
+ updated_at: 2,
+ archived_at: null,
+ lifecycle_version: 3,
+ });
+
+ const snapshot = await new ModelProviderAccountStore(db).getLifecycleSnapshot("account-1");
+
+ expect(snapshot?.lifecycleVersion).toBe(3);
+ expect(snapshot?.account).not.toHaveProperty("lifecycleVersion");
+ });
+
+ it("increments lifecycle mutations but not rename or last-used metadata", async () => {
+ const { db, queries } = database();
+ const store = new ModelProviderAccountStore(db);
+
+ store.bindUpdateConnection("account-1", {
+ externalAccountId: "external-1",
+ status: "active",
+ actorId: "user-1",
+ lastVerifiedAt: 10,
+ now: 10,
+ credentialVersion: 2,
+ encryptedPayload: "encrypted",
+ });
+ await store.setStatus("account-1", "disabled", "user-1", 11);
+ await store.archive("account-1", "user-1", 12);
+ await store.updateDetails("account-1", { displayName: "Renamed", now: 13 });
+ await store.touchLastUsed("account-1", 13, 14);
+
+ expect(queries.slice(0, 3).every((query) => query.includes("lifecycle_version + 1"))).toBe(
+ true
+ );
+ expect(queries.slice(3).every((query) => !query.includes("lifecycle_version"))).toBe(true);
+ });
+});
diff --git a/packages/control-plane/src/db/model-provider-accounts.ts b/packages/control-plane/src/db/model-provider-accounts.ts
new file mode 100644
index 000000000..9153656b3
--- /dev/null
+++ b/packages/control-plane/src/db/model-provider-accounts.ts
@@ -0,0 +1,264 @@
+import {
+ modelProviderAccountStatusSchema,
+ type ModelProviderAccount as SharedModelProviderAccount,
+ type ModelProviderAccountStatus,
+} from "@open-inspect/shared/types/provider-accounts";
+import type { SqlDatabase, SqlStatement } from "./sql-database";
+import {
+ assertModelProviderId,
+ type ModelProviderId,
+} from "../model-provider-accounts/provider-auth-contracts";
+
+export type { ModelProviderAccountStatus };
+export type ModelProviderAccount = SharedModelProviderAccount;
+
+interface AccountRow {
+ id: string;
+ provider: string;
+ display_name: string;
+ external_account_id: string | null;
+ status: string;
+ created_by: string | null;
+ updated_by: string | null;
+ last_verified_at: number | null;
+ last_used_at: number | null;
+ created_at: number;
+ updated_at: number;
+ archived_at: number | null;
+ lifecycle_version: number;
+}
+
+export interface CreateModelProviderAccount {
+ id: string;
+ provider: ModelProviderId;
+ displayName: string;
+ externalAccountId?: string | null;
+ status?: ModelProviderAccountStatus;
+ actorId?: string | null;
+ lastVerifiedAt?: number | null;
+ now?: number;
+}
+
+export interface ModelProviderAccountLifecycleSnapshot {
+ account: ModelProviderAccount;
+ lifecycleVersion: number;
+}
+
+function toAccount(row: AccountRow): ModelProviderAccount {
+ assertModelProviderId(row.provider);
+ return {
+ id: row.id,
+ provider: row.provider,
+ displayName: row.display_name,
+ externalAccountId: row.external_account_id,
+ status: modelProviderAccountStatusSchema.parse(row.status),
+ createdBy: row.created_by,
+ updatedBy: row.updated_by,
+ lastVerifiedAt: row.last_verified_at,
+ lastUsedAt: row.last_used_at,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ archivedAt: row.archived_at,
+ };
+}
+
+function toLifecycleSnapshot(row: AccountRow): ModelProviderAccountLifecycleSnapshot {
+ return { account: toAccount(row), lifecycleVersion: row.lifecycle_version };
+}
+
+export class ModelProviderAccountStore {
+ constructor(private readonly db: SqlDatabase) {}
+
+ async create(input: CreateModelProviderAccount): Promise {
+ await this.bindCreate(input).run();
+ }
+
+ bindCreate(input: CreateModelProviderAccount): SqlStatement {
+ assertModelProviderId(input.provider);
+ const now = input.now ?? Date.now();
+ return this.db
+ .prepare(
+ `INSERT INTO model_provider_accounts (
+ id, provider, display_name, external_account_id,
+ status, created_by, updated_by, last_verified_at,
+ created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ )
+ .bind(
+ input.id,
+ input.provider,
+ input.displayName,
+ input.externalAccountId ?? null,
+ input.status ?? "active",
+ input.actorId ?? null,
+ input.actorId ?? null,
+ input.lastVerifiedAt ?? null,
+ now,
+ now
+ );
+ }
+
+ async getById(id: string): Promise {
+ const row = await this.db
+ .prepare("SELECT * FROM model_provider_accounts WHERE id = ?")
+ .bind(id)
+ .first();
+ return row ? toAccount(row) : null;
+ }
+
+ async getLifecycleSnapshot(id: string): Promise