diff --git a/.changeset/config.json b/.changeset/config.json index 80ca5d7fc0..a26580776e 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -18,7 +18,8 @@ "@executor-js/plugin-openapi", "@executor-js/plugin-example", "@executor-js/plugin-desktop-settings", - "@executor-js/desktop" + "@executor-js/desktop", + "@executor-js/local" ] ], "linked": [], @@ -29,7 +30,6 @@ "onlyUpdatePeerDependentsWhenOutOfRange": true }, "ignore": [ - "@executor-js/local", "@executor-js/host-mcp", "@executor-js/ir", "@executor-js/runtime-deno-subprocess", diff --git a/.changeset/first-party-oauth-listing-policy.md b/.changeset/first-party-oauth-listing-policy.md new file mode 100644 index 0000000000..fe346656f5 --- /dev/null +++ b/.changeset/first-party-oauth-listing-policy.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Allow hosts to control first-party OAuth app listings for each acting user without disrupting existing connections. diff --git a/.changeset/oauth-client-basic.md b/.changeset/oauth-client-basic.md new file mode 100644 index 0000000000..4d806f576b --- /dev/null +++ b/.changeset/oauth-client-basic.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Allow manually registered OAuth apps to use HTTP Basic client authentication for token exchange and refresh. diff --git a/.changeset/openapi-update-spec-tool.md b/.changeset/openapi-update-spec-tool.md new file mode 100644 index 0000000000..7275057f3a --- /dev/null +++ b/.changeset/openapi-update-spec-tool.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Expose OpenAPI spec updates as an approval-gated Executor tool so agents can refresh URL-backed integrations without removing their connections. diff --git a/.changeset/raw-oauth-basic.md b/.changeset/raw-oauth-basic.md new file mode 100644 index 0000000000..aacb495754 --- /dev/null +++ b/.changeset/raw-oauth-basic.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Add a raw HTTP Basic compatibility mode for OAuth providers that reject form-encoded client credentials. diff --git a/.changeset/selfhost-sso-sign-in.md b/.changeset/selfhost-sso-sign-in.md new file mode 100644 index 0000000000..6cecc2268c --- /dev/null +++ b/.changeset/selfhost-sso-sign-in.md @@ -0,0 +1,9 @@ +--- +"executor": patch +--- + +**Self-host: bring-your-own SSO (Google, Okta, any OIDC IdP) with a verified-domain allowlist** + +Operators can enable a single OIDC sign-in provider on a self-hosted instance by setting `EXECUTOR_SSO_PROVIDER_ID`, `EXECUTOR_SSO_CLIENT_ID`, `EXECUTOR_SSO_CLIENT_SECRET`, and `EXECUTOR_SSO_ALLOWED_DOMAINS` (comma-separated email domains), plus `EXECUTOR_SSO_DISCOVERY_URL` for providers without a preset (`google` is preset; `EXECUTOR_SSO_PROVIDER_NAME` overrides the button label). The login page renders a "Continue with " button when configured (discovered through the new unauthenticated `GET /api/auth-config`, which returns provider id + display name only), and the MCP OAuth connect flow's login step gains the same option since it lands on the same page. + +The domain allowlist replaces the invite code for SSO sign-ups: a sign-in whose IdP-verified email (`email_verified`) has an allowlisted domain auto-joins the instance organization as a member; unverified emails and any other domain are refused. Enabling the provider without an allowlist is refused at boot, as is a half-configured client id/secret pair, so SSO can never silently become open registration. Email/password sign-in and invite-based signup are unchanged. The end-to-end flow (discovery → redirect → consent → callback → membership) is exercised in tests against an emulated OIDC IdP from `@executor-js/emulate`. diff --git a/.changeset/workspace-writes-admin-only.md b/.changeset/workspace-writes-admin-only.md new file mode 100644 index 0000000000..cdfe405a1c --- /dev/null +++ b/.changeset/workspace-writes-admin-only.md @@ -0,0 +1,52 @@ +--- +"@executor-js/sdk": patch +"@executor-js/api": patch +"@executor-js/plugin-graphql": patch +"@executor-js/plugin-mcp": patch +"@executor-js/plugin-openapi": patch +--- + +**Workspace writes now require an administrator** + +Executor bindings accept `orgWrites: "allowed" | "denied" | "request"`. +Request-aware hosts use `"request"` and bind `CurrentOrgWriteAccess` from the +authenticated principal for each request. An approval, decline, cancellation, +or form response also rebinds the paused execution to the resumer's current +access. Browser approvals derive access from the authenticated browser user's +live organization membership when that user posts the decision, rather than +from the earlier MCP request waiting for it or the user's global role. Self-host +uses the same Better Auth membership lookup for ordinary requests and browser +decisions. A demotion before either kind of resume therefore takes effect +before the paused execution can reach a workspace-write sink. + +`Principal` now declares its role model explicitly: organization-backed hosts +carry `orgRoleModel: "organization"` and an optional normalized admin/member +role, while hosts without roles carry `orgRoleModel: "none"` and cannot also +carry an organization role. Missing role data under the organization model +fails closed, including legacy persisted MCP session metadata. Cloud derives +roles from WorkOS memberships and self-host derives them from Better Auth. + +Members may still read and execute shared workspace resources and perform +operational maintenance such as token refresh and tool-catalog synchronization. +User-requested workspace mutations now return `OrgWriteDeniedError` (HTTP 403): +workspace connections and reconnects, organization OAuth clients and connect +flows, tool policies, and integration add/update/replace/remove/health-check +operations. Personal connection management remains available. + +Pasted connection credentials, OAuth client secrets, OAuth connection tokens, +and dependent tool discovery run only after the outermost transaction commits +their row, including when a plugin wraps creation in `ctx.transaction`. Each +committed row records unique provider item references owned by that write +attempt. Reads resolve only those recorded references, so the post-commit +window fails closed with a retryable incomplete-write error and can never +resolve a predecessor's credential. A process crash leaves detectable missing +references; a later executor incarnation can atomically replace and retry a +stranded pasted connection, while OAuth client and connection retries replace +their rows through their existing update paths. + +If credential persistence fails while the process remains alive, row and +provider compensation restore the prior state where possible and surface +incomplete cleanup explicitly. Best-effort cleanup can leave inert orphaned +attempt items, but an attempt never shares an item reference with a successor, +eliminating the former successor-clobber interval without requiring provider +compare-and-set support. diff --git a/.claude/skills/prod-telemetry/SKILL.md b/.claude/skills/prod-telemetry/SKILL.md index 47f79bbc75..0128c9ecd0 100644 --- a/.claude/skills/prod-telemetry/SKILL.md +++ b/.claude/skills/prod-telemetry/SKILL.md @@ -29,6 +29,20 @@ join the same traces via traceparent). **Span names worth querying** (and their custom attrs): +- `mcp.execute` / `mcp.execute.resume` — `mcp.execute.mode` + (`pausable`/`inline`), `mcp.execute.code_length`, and + `mcp.execute.outcome` (`ok`/`fail`/`paused`) with, on failures, + `mcp.execute.error_kind` (`type_error` | `reference_error` | + `syntax_error` | `range_error` | `tool_error` | `timeout` | + `resource_limit` | `serialization_error` | `thrown` | `unknown`). + Sandbox script failures ride the MCP success channel, so `status.code` + stays OK — filter on these attributes, not span status. Spans from + before the attributes shipped carry neither; absence is not success. + Also `mcp.execute.result_chars` (compact-JSON size of the returned + value, pre-truncation; -1 = unmeasurable), `mcp.execute.log_chars`, + `mcp.execute.emitted` — the dump-vs-narrow signal (the model preview + truncates at 30k chars, so `result_chars > 30000` means the model tried + to pull a truncated blob into context). - `executor.tool.execute` — `mcp.tool.name` (full address), and since PR #992: `executor.tool.outcome` (`ok`/`fail`), `executor.tool.error_code`, `executor.tool.error_status`, @@ -39,7 +53,10 @@ join the same traces via traceparent). `base_url`, and since PR #992 `http.status_code`. - `mcp.request` (outer) — `mcp.auth.organization_id`, `mcp.auth.account_id`, `mcp.tool.name`, CF edge fields (`cf.country`…), - MCP client fingerprint (`mcp.client.name`…). + MCP client fingerprint (`mcp.client.name`…), and on managed-cloud + `execute`/`execute-action` calls `mcp.execute.code` (the script itself, + capped at 10k chars — cloud-only content capture; local/self-host + telemetry never records content). **Recipe — error signatures by class (the daily-digest query):** diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..a2fb5605ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,100 @@ +name: Bug report +description: Something in Executor does not work as expected. +title: "[bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Please update to the latest version first — the bug may already be fixed. + If the desktop app crashes, will not start, or shows a blank window, use the + **[Desktop app crash](?template=desktop-crash.yml)** template instead. + - type: input + id: version + attributes: + label: Executor version + description: Run `executor --version`, or open the About menu in the desktop app. + placeholder: "1.5.4" + validations: + required: true + - type: dropdown + id: surface + attributes: + label: How do you run Executor? + options: + - Executor Cloud + - CLI + - Desktop app + - Self-host (Docker) + - Self-host (Cloudflare) + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + description: For Executor Cloud, select the system you browse from. + options: + - macOS (Apple Silicon) + - macOS (Intel) + - Windows + - Linux + validations: + required: true + - type: input + id: integration + attributes: + label: Integration involved + description: The integration or source type, if the bug is specific to one. + placeholder: "MCP server, OpenAPI spec, GraphQL API, …" + - type: textarea + id: what-happened + attributes: + label: What happened + description: Describe the behaviour you saw. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What you expected + description: Describe the behaviour you wanted instead. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: The smallest sequence that shows the bug. Include the commands you ran. + value: | + 1. + 2. + 3. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Diagnostics / logs + description: | + In the desktop app, open the **Executor** menu → **Export Diagnostics…** and attach + the zip — it has everything we need and no secrets. + + Otherwise attach the relevant log: + - CLI / self-host: `~/.executor/logs/` (run `executor service status` for the exact path) + - macOS desktop: `~/Library/Logs/Executor/main.log` + - Windows desktop: `%APPDATA%\Executor\logs\main.log` + - Linux desktop: `~/.config/Executor/logs/main.log` + + (Please don't attach anything else from `~/.executor` — that's where your data and + secrets live. Remove API keys and tokens from anything you paste.) + placeholder: Drag the diagnostics zip or log file here. + - type: checkboxes + id: checks + attributes: + label: Before you submit + options: + - label: I searched the open issues for a duplicate. + required: true + - label: I removed all keys, tokens, and credentials from this report. + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..82345ebfeb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,51 @@ +name: Feature request +description: Suggest a capability or an improvement for Executor. +title: "[feature] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Tell us the task you want to do and why Executor cannot do it today. + A concrete example helps more than a general proposal. + - type: textarea + id: problem + attributes: + label: The problem + description: What are you trying to do, and where does Executor stop you? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed shape + description: | + The change you want, as concretely as you can describe it. Name the command, + the setting, or the screen where it would live. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives + description: Workarounds you tried, and why they are not sufficient. + - type: dropdown + id: surface + attributes: + label: Where it belongs + description: Select every form of Executor this applies to. + options: + - Executor Cloud + - CLI + - Desktop app + - Self-host (Docker) + - Self-host (Cloudflare) + - Not sure + multiple: true + - type: checkboxes + id: checks + attributes: + label: Before you submit + options: + - label: I searched the open issues for a duplicate. + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..1fc620b824 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,30 @@ + + +## Summary + + + +## Linked issue + +Fixes # + +## Verification + + + +- [ ] `bun run format:check` +- [ ] `bun run lint` +- [ ] `bun run typecheck` +- [ ] `bun run test` +- [ ] e2e — name the scenario, and link the recording or trace: + +## Checklist + +- [ ] Added a changeset (`bun run changeset`), or this change needs none. +- [ ] Added or updated tests for the new behaviour. +- [ ] No secrets, credentials, or private data in the diff. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f71e000fe..a9daf5378b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,18 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +# CI is not a user. Without this, executor's own product telemetry reports to the +# production PostHog project from every e2e shard: the analytics layer +# (integration_added, execution_completed, artifact_*) and the +# integrations-registry fetch, which lands as a `hit` event. Each shard is a +# fresh container, so it mints a fresh anonymous id and arrives on a fresh +# runner IP — inflating machine and user counts by roughly an order of +# magnitude. Both opt-outs are the packages' documented CI/test hooks. +env: + DO_NOT_TRACK: "1" + EXECUTOR_DISABLE_ANALYTICS: "1" + EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" + jobs: changes: name: Changed paths @@ -18,6 +30,7 @@ jobs: outputs: desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }} selfhost_docker_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.selfhost_docker_smoke == 'true' }} + cloud_closure: ${{ github.event_name != 'pull_request' || steps.filter.outputs.cloud_closure == 'true' }} steps: - uses: actions/checkout@v4 @@ -41,6 +54,14 @@ jobs: - "packages/kernel/runtime-quickjs/**" - "packages/plugins/**" - "packages/react/**" + cloud_closure: + - ".github/workflows/**" + - "bun.lock" + - "package.json" + - "turbo.json" + - "apps/cloud/**" + - "packages/**" + selfhost_docker_smoke: - ".github/workflows/**" - ".dockerignore" @@ -136,17 +157,26 @@ jobs: - run: bun run typecheck + # The committed routeTree.gen.ts files are generated but checked in, and + # they carry @ts-nocheck — so the typecheck above structurally CANNOT see + # a route that the contract declares and the tree lacks. That drift has + # already shipped a live 404 once. Regenerate and diff instead. + - run: bun run check:routes + test: name: Test - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 15 - # Tuned for Blacksmith's 4 vCPU runners. + # Run eight independent packages at once and cap each Vitest process at two + # workers. This uses all 16 vCPUs without every nested runner seeing the + # whole machine and oversubscribing it. env: TURBO_API: ${{ vars.TURBO_API }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} - TURBO_TEST_CONCURRENCY: 4 + TURBO_TEST_CONCURRENCY: 8 + VITEST_MAX_WORKERS: 2 steps: - uses: actions/checkout@v4 @@ -178,6 +208,8 @@ jobs: # no signal. This is just a few file reads plus greps, so ~zero cost. - run: bun run check:patches + - run: bun run --cwd e2e test:ci-shard + - run: bun run test e2e: @@ -186,14 +218,41 @@ jobs: fail-fast: false matrix: include: - # Each cloud shard boots its own fresh dev stack. On 4 vCPU runners, - # four fatter shards keep the longest shard below selfhost while saving - # four runner boots and four warm cache restores. - - { target: cloud, shard: 1/4, shard-name: 1of4 } - - { target: cloud, shard: 2/4, shard-name: 2of4 } - - { target: cloud, shard: 3/4, shard-name: 3of4 } - - { target: cloud, shard: 4/4, shard-name: 4of4 } - - target: selfhost + # The planner assigns every file exactly once using recorded slow-file + # durations plus a conservative weight for new tests. The cloud DB's + # connection teardown and concurrent socket protocol have dedicated + # regression tests; these shards balance wall clock, not hide retries. + - { target: cloud, shard-index: 1, shard-name: 1of16 } + - { target: cloud, shard-index: 2, shard-name: 2of16 } + - { target: cloud, shard-index: 3, shard-name: 3of16 } + - { target: cloud, shard-index: 4, shard-name: 4of16 } + - { target: cloud, shard-index: 5, shard-name: 5of16 } + - { target: cloud, shard-index: 6, shard-name: 6of16 } + - { target: cloud, shard-index: 7, shard-name: 7of16 } + - { target: cloud, shard-index: 8, shard-name: 8of16 } + - { target: cloud, shard-index: 9, shard-name: 9of16 } + - { target: cloud, shard-index: 10, shard-name: 10of16 } + - { target: cloud, shard-index: 11, shard-name: 11of16 } + - { target: cloud, shard-index: 12, shard-name: 12of16 } + - { target: cloud, shard-index: 13, shard-name: 13of16 } + - { target: cloud, shard-index: 14, shard-name: 14of16 } + - { target: cloud, shard-index: 15, shard-name: 15of16 } + - { target: cloud, shard-index: 16, shard-name: 16of16 } + - { target: selfhost, shard-index: 1, shard-name: 1of10 } + - { target: selfhost, shard-index: 2, shard-name: 2of10 } + - { target: selfhost, shard-index: 3, shard-name: 3of10 } + - { target: selfhost, shard-index: 4, shard-name: 4of10 } + - { target: selfhost, shard-index: 5, shard-name: 5of10 } + - { target: selfhost, shard-index: 6, shard-name: 6of10 } + - { target: selfhost, shard-index: 7, shard-name: 7of10 } + - { target: selfhost, shard-index: 8, shard-name: 8of10 } + - { target: selfhost, shard-index: 9, shard-name: 9of10 } + - { target: selfhost, shard-index: 10, shard-name: 10of10 } + # Local files own their server, browser and data directory. Separate + # runners preserve that isolation while removing its 69-second serial + # lane from the two-minute critical path. + - { target: local, shard-index: 1, shard-name: 1of2 } + - { target: local, shard-index: 2, shard-name: 2of2 } runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 30 steps: @@ -229,26 +288,31 @@ jobs: # Install from e2e so bunx resolves ITS pinned playwright (the version # the tests run against) rather than floating to the latest. + # Blacksmith's Ubuntu image carries the official GitHub runner system + # dependencies. Restore the pinned browser binaries without apt-updating + # every matrix machine. - name: Install Playwright Chromium - run: bunx playwright install --with-deps chromium chromium-headless-shell + run: bunx playwright install chromium chromium-headless-shell working-directory: e2e - # The globalsetup boots the target's own dev server (ports are claimed - # per checkout, so this is hermetic) and tears it down after the run. - # --retry=2: browser scenarios can still hit isolated waitFor timeouts - # (single-test waitFor timeouts, not systemic failures); a retry on the - # same booted stack clears them. - - name: Run cloud scenarios + # Each target either boots its own shared dev server or lets each file own + # its server. Ports and data paths are hermetic in both cases. + # Do not retry scenarios: retries hide flakes and multiply slow timeout + # failures. The fixtures and process lifecycle are deterministic enough + # that the first result is the result. + - name: Run cloud shard if: matrix.target == 'cloud' env: MCP_SESSION_TIMEOUT_MS: "3000" - MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "6000" - run: bunx vitest run --project cloud --retry=2 ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} + # Still 18x shorter than production, but long enough for a cold Vite + # resume route to compile under a fully loaded CI runner. + MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "30000" + run: bun scripts/run-ci-shard.ts cloud ${{ matrix['shard-index'] }} working-directory: e2e - - name: Run selfhost scenarios - if: matrix.target == 'selfhost' - run: bunx vitest run --project selfhost --retry=2 + - name: Run scenarios + if: matrix.target != 'cloud' + run: bun scripts/run-ci-shard.ts ${{ matrix.target }} ${{ matrix['shard-index'] }} working-directory: e2e # Failed runs keep their trace.zip / session.mp4 / step screenshots in @@ -261,13 +325,23 @@ jobs: path: e2e/runs/ retention-days: 7 - e2e-local: - name: E2E (stdio MCP) - # Skipped on pull_request: the local scenario boots a real `executor web` - # plus a browser and is currently flaky on PRs. Still runs on push to main. - if: github.event_name != 'pull_request' + cloud-closure: + name: Cloud evaluated closure + needs: changes + if: needs.changes.outputs.cloud_closure == 'true' runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 20 + env: + TURBO_API: ${{ vars.TURBO_API }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + # Every cold isolate evaluates this closure before it can answer a + # request, and it only ever grows by accident - a barrel export or a new + # module-scope import quietly pulls megabytes into the server graph. The + # budget is a ratchet just above today's size, not a discovered limit: + # when it trips, make the new dependency lazy rather than raising it. + START_CLOSURE_BUDGET_MB: 13.5 steps: - uses: actions/checkout@v4 @@ -283,41 +357,17 @@ jobs: restore-keys: | ${{ runner.os }}-bun-1.3.11- - # The local scenarios boot a real `executor web` (which spawns a Node - # sidecar) and some drive a browser, so pin Node 24 and install Chromium. - uses: actions/setup-node@v4 with: node-version: 24 - run: bun install --frozen-lockfile - - name: Cache Playwright browsers - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-1.60.0 - restore-keys: | - ${{ runner.os }}-playwright- - - # `chromium` and the new `chromium-headless-shell` ship as separate - # downloads; the browser-driven scenarios launch the headless shell. - # Install from e2e so bunx resolves ITS pinned playwright (the version the - # tests run against) rather than floating to the latest, which would fetch - # a browser build the test runtime does not look for. - - name: Install Playwright Chromium - run: bunx playwright install --with-deps chromium chromium-headless-shell - working-directory: e2e + - run: bun run build + working-directory: apps/cloud - # The `local` project is excluded from the default `test` chain (each - # scenario boots its own `executor web`). Run just the stdio MCP scenario - # here: it is the auto-connect / env-as-secret regression guard, and - # running it alone avoids the boot-resource accumulation and the - # pre-existing browser flakiness of the rest of the local suite. Expanding - # to the full `local` project (bun run test:local) is a follow-up once - # those are stabilized. - - name: Run the stdio MCP scenario - run: bunx vitest run --project local local/stdio-mcp.test.ts - working-directory: e2e + - run: node scripts/start-closure.mjs + working-directory: apps/cloud desktop-smoke: name: Desktop smoke build @@ -369,7 +419,13 @@ jobs: - name: Run sidecar outside the workspace working-directory: apps/desktop run: | - docker run --rm -d --name sidecar-smoke -p 45841:45841 -e HOME=/tmp \ + # Containers do not inherit the job environment. Keep the compiled + # product smoke from becoming a production analytics/integrations hit. + docker run --rm -d --name sidecar-smoke -p 45841:45841 \ + -e HOME=/tmp \ + -e DO_NOT_TRACK=1 \ + -e EXECUTOR_DISABLE_ANALYTICS=1 \ + -e EXECUTOR_DISABLE_INTEGRATIONS_FETCH=1 \ -v "$PWD/resources/executor:/opt/executor:ro" \ debian:bookworm-slim /opt/executor/executor daemon run --foreground \ --port 45841 --hostname 0.0.0.0 --auth-token=ci-smoke diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 80bff301a4..2f0d97c242 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,6 +9,16 @@ concurrency: group: deploy-production cancel-in-progress: false +# CI is not a user — see the note in ci.yml. Applied to every workflow rather +# than the ones that look like they run product code: the first pass guessed, +# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert +# where the product is not executed, so the blanket application is the cheap +# structural answer. +env: + DO_NOT_TRACK: "1" + EXECUTOR_DISABLE_ANALYTICS: "1" + EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" + jobs: migrate: name: Migrate database @@ -77,12 +87,30 @@ jobs: VITE_PUBLIC_SENTRY_DSN: ${{ secrets.VITE_PUBLIC_SENTRY_DSN }} - name: Deploy cloud - run: bun run wrangler deploy -c dist/server/wrangler.json + run: bun run wrangler deploy -c dist/server/wrangler.json --var GIT_COMMIT_SHA:${{ github.sha }} working-directory: apps/cloud env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # Deploy marker: one event per deploy into the same Axiom dataset the + # worker traces land in, so a latency step-change lines up with its + # deploy in one query. Skipped (not failed) when the secret is absent, + # and never blocks the deploy. + - name: Record deploy marker in Axiom + env: + AXIOM_INGEST_TOKEN: ${{ secrets.AXIOM_INGEST_TOKEN }} + run: | + if [ -z "$AXIOM_INGEST_TOKEN" ]; then + echo "AXIOM_INGEST_TOKEN not configured; skipping deploy marker" + exit 0 + fi + curl -sf -X POST "https://api.axiom.co/v1/datasets/executor-cloud/ingest" \ + -H "Authorization: Bearer $AXIOM_INGEST_TOKEN" \ + -H "Content-Type: application/json" \ + -d "[{\"event\":\"deploy\",\"service\":\"executor-cloud\",\"commit_sha\":\"${{ github.sha }}\",\"actor\":\"${{ github.actor }}\",\"run_id\":\"${{ github.run_id }}\"}]" \ + || echo "deploy marker ingest failed (non-blocking)" + deploy-marketing: name: Deploy marketing runs-on: blacksmith-4vcpu-ubuntu-2404 diff --git a/.github/workflows/graph-slices.yml b/.github/workflows/graph-slices.yml new file mode 100644 index 0000000000..9102c93df9 --- /dev/null +++ b/.github/workflows/graph-slices.yml @@ -0,0 +1,47 @@ +# Refresh the Microsoft Graph slice release assets. +# +# The Graph OpenAPI monolith (~43MB) cannot be processed inside a Workers +# isolate, so the runtime reads per-selection slices published on the +# `graph-slices` release tag (see packages/plugins/openapi/src/providers/ +# microsoft/slices.ts). This workflow rebuilds the slices from the current +# upstream spec on a schedule and on demand. +name: Graph slices + +on: + schedule: + # Weekly; Microsoft's msgraph-metadata automation lands upstream refreshes + # on a similar cadence. A failed run leaves the previous assets serving. + - cron: "17 6 * * 1" + workflow_dispatch: {} + +permissions: + contents: write + +jobs: + slices: + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Generate slices + working-directory: packages/plugins/openapi + run: bun scripts/generate-graph-slices.ts --out "$RUNNER_TEMP/graph-slices" + + - name: Publish to the graph-slices release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view graph-slices --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \ + gh release create graph-slices --repo "$GITHUB_REPOSITORY" \ + --title "Microsoft Graph slices" --latest=false \ + --notes "Generated per-preset Microsoft Graph OpenAPI slices. Data release consumed by the openapi plugin's Microsoft adapter; refreshed by the graph-slices workflow." + gh release upload graph-slices "$RUNNER_TEMP/graph-slices"/* \ + --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index a4720cf515..1966249d59 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -10,6 +10,16 @@ concurrency: group: pkg-pr-new-${{ github.event.pull_request.number }} cancel-in-progress: true +# CI is not a user — see the note in ci.yml. Applied to every workflow rather +# than the ones that look like they run product code: the first pass guessed, +# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert +# where the product is not executed, so the blanket application is the cheap +# structural answer. +env: + DO_NOT_TRACK: "1" + EXECUTOR_DISABLE_ANALYTICS: "1" + EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" + jobs: # Per-platform matrix: build the executor binary, tar it, upload to R2. # The wrapper npm package is built later by the `publish` job which just @@ -26,7 +36,7 @@ jobs: include: - runner: blacksmith-4vcpu-ubuntu-2404 target: executor-linux-x64 - - runner: macos-14 + - runner: blacksmith-6vcpu-macos-latest target: executor-darwin-arm64 runs-on: ${{ matrix.runner }} steps: diff --git a/.github/workflows/preview-sweep.yml b/.github/workflows/preview-sweep.yml index 243e654cea..fd7ab6483e 100644 --- a/.github/workflows/preview-sweep.yml +++ b/.github/workflows/preview-sweep.yml @@ -16,6 +16,16 @@ permissions: contents: read pull-requests: read +# CI is not a user — see the note in ci.yml. Applied to every workflow rather +# than the ones that look like they run product code: the first pass guessed, +# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert +# where the product is not executed, so the blanket application is the cheap +# structural answer. +env: + DO_NOT_TRACK: "1" + EXECUTOR_DISABLE_ANALYTICS: "1" + EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" + jobs: sweep: name: Destroy previews for closed PRs diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 288e33cbf7..2409fd4f23 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -23,6 +23,16 @@ concurrency: group: preview-${{ github.event.pull_request.number }} cancel-in-progress: ${{ github.event.action != 'closed' }} +# CI is not a user — see the note in ci.yml. Applied to every workflow rather +# than the ones that look like they run product code: the first pass guessed, +# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert +# where the product is not executed, so the blanket application is the cheap +# structural answer. +env: + DO_NOT_TRACK: "1" + EXECUTOR_DISABLE_ANALYTICS: "1" + EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" + jobs: deploy: name: Deploy preview diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index 090e0d6d40..e42832181c 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -13,6 +13,19 @@ on: description: Git tag to publish (e.g. v1.4.1) required: true type: string + dry_run: + description: Build all distributables but do not touch the GitHub release + required: false + default: false + type: boolean + executor_run_id: + description: >- + Publish Executor run id for this tag. Set by the automatic trigger; + the promote gate waits for exactly this run. Leave empty for manual + dispatches. + required: false + default: "" + type: string permissions: contents: read @@ -21,6 +34,18 @@ concurrency: group: publish-desktop-${{ github.ref }} cancel-in-progress: false +# CI is not a user. Without this, executor's own product telemetry reports to the +# production PostHog project from every e2e shard: the analytics layer +# (integration_added, execution_completed, artifact_*) and the +# integrations-registry fetch, which lands as a `hit` event. Each shard is a +# fresh container, so it mints a fresh anonymous id and arrives on a fresh +# runner IP — inflating machine and user counts by roughly an order of +# magnitude. Both opt-outs are the packages' documented CI/test hooks. +env: + DO_NOT_TRACK: "1" + EXECUTOR_DISABLE_ANALYTICS: "1" + EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" + jobs: build: permissions: @@ -32,22 +57,22 @@ jobs: # smoke: run the compiled-sidecar smoke test on legs whose target # matches the runner (the mac x64 leg cross-compiles on an arm64 # runner, so its binary can't be executed natively there). - - os: macos-latest + - os: blacksmith-6vcpu-macos-latest arch: arm64 platform: mac bun-target: bun-darwin-arm64 smoke: true - - os: macos-latest + - os: blacksmith-6vcpu-macos-latest arch: x64 platform: mac bun-target: bun-darwin-x64 smoke: false - - os: ubuntu-latest + - os: blacksmith-4vcpu-ubuntu-2404 arch: x64 platform: linux bun-target: bun-linux-x64 smoke: true - - os: windows-latest + - os: blacksmith-8vcpu-windows-2025 arch: x64 platform: win bun-target: bun-windows-x64 @@ -91,9 +116,25 @@ jobs: with: node-version: 24 + # No package/electron caches here on purpose: on Blacksmith's NVMe + # runners a cold `bun install` (8-55s) is as fast as a cache + # restore + warm install, and bun.lock changes on every release + # (Version Packages), so each publish would pay the cache-save tail + # (~45s on the mac critical path) for nothing. - name: Install dependencies run: bun install --frozen-lockfile + # apps/desktop/build/ holds committed static inputs (icon, entitlements) + # that no module imports, so a sweep can delete them without breaking a + # build or a test. v1.6.1 was tagged without them: both mac legs ran ~8 + # minutes, then died inside codesign with "build/entitlements.mac.plist: + # cannot read entitlement data" against an arbitrary locale.pak, while + # the missing icon only ever produced a "default Electron icon is used" + # notice. Check the tag's own tree here so the failure names the cause. + - name: Check desktop build resources + run: bun ./scripts/check-build-resources.ts + working-directory: apps/desktop + - name: Build web app run: bun run --filter @executor-js/local build @@ -143,8 +184,22 @@ jobs: # → notarytool authentication for the notarization upload # When the secrets aren't set (forks, local), electron-builder # silently produces an unsigned build. - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + # + # CSC_LINK / CSC_KEY_PASSWORD MUST stay scoped to the mac legs. + # Despite the name used here, CSC_LINK is electron-builder's + # cross-platform certificate variable: WinPackager falls back to it + # whenever WIN_CSC_LINK is unset, via + # platformPackager.getCscLink("WIN_CSC_LINK"). Handing the Apple + # Developer ID .p12 to the windows leg made electron-builder read its + # subject and write `publisherName: Developer ID Application: ...` + # into app-update.yml, so every Windows client rejected the update it + # had just downloaded with "New version is not signed by the + # application owner". Empty string is the documented "no certificate" + # value — WindowsSignToolManager treats `cscLink === ""` exactly like + # unset. The APPLE_* vars below need no such guard: they are only + # read by notarytool on darwin and are inert elsewhere. + CSC_LINK: ${{ matrix.platform == 'mac' && secrets.CSC_LINK || '' }} + CSC_KEY_PASSWORD: ${{ matrix.platform == 'mac' && secrets.CSC_KEY_PASSWORD || '' }} APPLE_API_KEY: ${{ env.APPLE_API_KEY_PATH }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} @@ -178,9 +233,14 @@ jobs: release: needs: build + # dry_run builds and uploads workflow artifacts but never touches the + # GitHub release — used to rehearse workflow changes against a real tag. + if: ${{ !inputs.dry_run }} runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write + # Read Publish Executor run state for the promote gate below. + actions: read steps: - name: Checkout validation script @@ -218,14 +278,61 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - while IFS= read -r file; do - echo "Uploading: $file" - gh release upload "$RELEASE_TAG" "$file" --repo "$GITHUB_REPOSITORY" --clobber - done < <(find artifacts -type f \ + # Parallel uploads; xargs exits non-zero if any single upload fails, + # which keeps the promote step below from running on a partial set. + find artifacts -type f \ \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" \ -o -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" \ -o -name "*.blockmap" \ - -o -name "latest*.yml" \)) + -o -name "latest*.yml" \) -print0 \ + | xargs -0 -n1 -P8 -I{} sh -c \ + 'echo "Uploading: $1"; exec gh release upload "$RELEASE_TAG" "$1" --repo "$GITHUB_REPOSITORY" --clobber' _ {} + + # A published release must imply the npm packages for this tag exist. + # Publish Executor triggers this workflow before its npm publishes (to + # overlap them with the desktop build) and hands over its own run id, + # so wait for exactly that run to conclude successfully before going + # public. A run-name search would fail open whenever the run fell + # outside the listing window, so only an explicitly empty run id (a + # manual dispatch) skips the gate — the operator owns the invariant + # then. + - name: Wait for executor package publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EXECUTOR_RUN_ID: ${{ inputs.executor_run_id }} + run: | + set -euo pipefail + if [ -z "$EXECUTOR_RUN_ID" ]; then + echo "No executor_run_id given (manual dispatch); skipping the publish gate." + exit 0 + fi + deadline=$((SECONDS + 1200)) + while :; do + run=$(gh run view "$EXECUTOR_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --json status,conclusion,url,displayTitle) + title=$(echo "$run" | jq -r .displayTitle) + if [ "$title" != "publish executor $RELEASE_TAG" ]; then + echo "Run $EXECUTOR_RUN_ID is '$title', not 'publish executor $RELEASE_TAG' — refusing to promote." + exit 1 + fi + status=$(echo "$run" | jq -r .status) + if [ "$status" = "completed" ]; then + conclusion=$(echo "$run" | jq -r .conclusion) + if [ "$conclusion" = "success" ]; then + echo "Publish Executor succeeded." + break + fi + echo "Publish Executor concluded '$conclusion' — leaving the release in draft." + echo "$run" | jq -r .url + exit 1 + fi + if [ "$SECONDS" -ge "$deadline" ]; then + echo "Timed out waiting for Publish Executor — leaving the release in draft." + exit 1 + fi + echo "Publish Executor status: $status; waiting..." + sleep 15 + done # Flip draft → published only after every desktop asset is uploaded — # this is the atomic point where the new tag becomes "latest". diff --git a/.github/workflows/publish-executor-package.yml b/.github/workflows/publish-executor-package.yml index 2ef813a679..b3d0d669e1 100644 --- a/.github/workflows/publish-executor-package.yml +++ b/.github/workflows/publish-executor-package.yml @@ -21,6 +21,16 @@ concurrency: group: publish-executor-package-${{ github.ref }} cancel-in-progress: false +# CI is not a user — see the note in ci.yml. Applied to every workflow rather +# than the ones that look like they run product code: the first pass guessed, +# missed preview/deploy/pkg-pr-new, and kept leaking. These vars are inert +# where the product is not executed, so the blanket application is the cheap +# structural answer. +env: + DO_NOT_TRACK: "1" + EXECUTOR_DISABLE_ANALYTICS: "1" + EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" + jobs: publish: runs-on: ubuntu-latest @@ -73,19 +83,34 @@ jobs: - name: Run release checks run: bun run release:check - - name: Publish package and create release + # --skip-build reuses the artifacts release:check's dry-run just built + # in this same tree (validated against the release version) instead of + # rebuilding everything a second time. + - name: Stage release artifacts and draft GitHub release env: GH_TOKEN: ${{ github.token }} run: | export GITHUB_REF_TYPE=tag export GITHUB_REF_NAME="$RELEASE_TAG" export GITHUB_REF="refs/tags/$RELEASE_TAG" - bun run release:publish + bun run --cwd apps/cli src/release.ts --stage-only --skip-build + # Desktop needs only the tag and the draft release staged above — it + # compiles its own sidecar from the tag's source. Triggering it before + # the npm publishes overlaps its build with them; its release job waits + # for THIS run (by the id passed here) to succeed before promoting the + # release, so a failed npm publish still leaves the release in draft. - name: Trigger desktop build env: GH_TOKEN: ${{ github.token }} - run: gh workflow run publish-desktop.yml -f tag="$RELEASE_TAG" + run: gh workflow run publish-desktop.yml -f tag="$RELEASE_TAG" -f executor_run_id="$GITHUB_RUN_ID" + + - name: Publish executor to npm + run: | + export GITHUB_REF_TYPE=tag + export GITHUB_REF_NAME="$RELEASE_TAG" + export GITHUB_REF="refs/tags/$RELEASE_TAG" + bun run --cwd apps/cli src/release.ts --publish-only - name: Trigger self-host Docker publish env: diff --git a/.github/workflows/publish-selfhost-docker.yml b/.github/workflows/publish-selfhost-docker.yml index 51715ff860..a70192b13b 100644 --- a/.github/workflows/publish-selfhost-docker.yml +++ b/.github/workflows/publish-selfhost-docker.yml @@ -16,6 +16,18 @@ concurrency: group: publish-selfhost-docker-${{ inputs.tag }} cancel-in-progress: false +# CI is not a user. Without this, executor's own product telemetry reports to the +# production PostHog project from every e2e shard: the analytics layer +# (integration_added, execution_completed, artifact_*) and the +# integrations-registry fetch, which lands as a `hit` event. Each shard is a +# fresh container, so it mints a fresh anonymous id and arrives on a fresh +# runner IP — inflating machine and user counts by roughly an order of +# magnitude. Both opt-outs are the packages' documented CI/test hooks. +env: + DO_NOT_TRACK: "1" + EXECUTOR_DISABLE_ANALYTICS: "1" + EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" + jobs: metadata: runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -281,9 +293,6 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: packages: write - env: - HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }} - steps: - name: Download image digests uses: actions/download-artifact@v4 @@ -330,10 +339,28 @@ jobs: docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" + # Mirrors each release to the pre-org-move namespace + # (ghcr.io/rhyssullivan/executor-selfhost), which GHCR cannot redirect. + # Deliberately a separate job: the canonical publish above never depends on + # it, and a failure here (an expired GHCR_LEGACY_TOKEN, historically) shows + # as one red job pointing at exactly what to fix. + mirror-legacy: + needs: + - metadata + - merge + runs-on: blacksmith-4vcpu-ubuntu-2404 + env: + HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }} + + steps: - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured) if: env.HAS_LEGACY_TOKEN != 'true' run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror." + - name: Set up Docker Buildx + if: env.HAS_LEGACY_TOKEN == 'true' + uses: docker/setup-buildx-action@v3 + - name: Log in to legacy GHCR namespace if: env.HAS_LEGACY_TOKEN == 'true' uses: docker/login-action@v3 @@ -342,7 +369,7 @@ jobs: username: rhyssullivan password: ${{ secrets.GHCR_LEGACY_TOKEN }} - - name: Mirror self-host image to legacy GHCR namespace + - name: Mirror release tags to the legacy namespace if: env.HAS_LEGACY_TOKEN == 'true' shell: bash env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 241d4757f8..ea447113f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,18 @@ concurrency: group: release-${{ github.ref }} cancel-in-progress: false +# CI is not a user. Without this, executor's own product telemetry reports to the +# production PostHog project from every e2e shard: the analytics layer +# (integration_added, execution_completed, artifact_*) and the +# integrations-registry fetch, which lands as a `hit` event. Each shard is a +# fresh container, so it mints a fresh anonymous id and arrives on a fresh +# runner IP — inflating machine and user counts by roughly an order of +# magnitude. Both opt-outs are the packages' documented CI/test hooks. +env: + DO_NOT_TRACK: "1" + EXECUTOR_DISABLE_ANALYTICS: "1" + EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" + jobs: release: runs-on: ubuntu-latest @@ -135,6 +147,7 @@ jobs: # triggers publish-executor-package.yml. A second workflow_dispatch path # races a duplicate publish for the same release. - # Desktop build downloads CLI binaries from the release, so it must - # run after CLI publish completes. Trigger it from the CLI workflow - # or manually via: gh workflow run publish-desktop.yml -f tag=vX.Y.Z + # Desktop compiles its own sidecar from the tag's source; it needs only + # the tag and the staged draft release, so publish-executor-package.yml + # triggers it as soon as those exist. Manual repair: + # gh workflow run publish-desktop.yml -f tag=vX.Y.Z diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d92df54ec7..73f7255814 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -13,6 +13,7 @@ "*.tsbuildinfo", "executor-*.tgz", "**/routeTree.gen.ts", + "**/smoke-harness-bundle.gen.ts", "apps/cloud/src/services/executor-schema.ts" ] } diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 9c7d8caf1f..c86ee9dc58 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -64,6 +64,7 @@ "apps/desktop/src/main.ts", "scripts/**/*.{ts,js}", "apps/*/scripts/**/*.{ts,js}", + "packages/*/*/scripts/**/*.{ts,js}", "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}", ], "rules": { @@ -119,6 +120,31 @@ "executor/no-try-catch-or-throw": "off", }, }, + { + // boundary: the MCP-Apps shell is browser code, not Effect domain code. + // It runs inside a sandboxed iframe and talks to the host over + // postMessage and the MCP bridge — both of which are throw/Promise APIs + // with no Effect runtime present. `new Function` evaluation of generated + // JSX, CSS harvesting off `document.styleSheets`, and JSON on the wire + // all surface untyped JS errors that must be caught and rendered rather + // than modelled. Scoped to the iframe payload plus its build config; the + // package's Effect-facing loader (`src/shell-html.ts`) is deliberately + // NOT listed and uses inline suppressions instead. + "files": [ + "packages/hosts/mcp-apps-shell/src/shell/**/*.{ts,tsx}", + "packages/hosts/mcp-apps-shell/vite.config.shell.ts", + ], + "rules": { + "executor/no-double-cast": "off", + "executor/no-error-constructor": "off", + "executor/no-instanceof-error": "off", + "executor/no-json-parse": "off", + "executor/no-promise-catch": "off", + "executor/no-promise-reject": "off", + "executor/no-try-catch-or-throw": "off", + "executor/no-unknown-error-message": "off", + }, + }, { "files": ["packages/kernel/core/src/code-recovery.ts"], "rules": { diff --git a/AGENTS.md b/AGENTS.md index 515f491af7..244b88678a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,9 @@ Stable contracts live here. Current setup and server mechanics are in package script. Never use `bun test`. - Use the narrowest meaningful verification while iterating. Merge-ready gates are `bun run format:check`, `lint`, `typecheck`, and `test`. +- e2e is slow. Avoid running it often during development: optimize for + iteration speed first, then test. Save e2e for when the change is settled, + and run the narrowest scenario rather than the full suite. - Run `bun run format` before a PR and include only files owned by the branch. - User-visible work requires a specific e2e run, a browsable dev instance, and the recording or trace showing what to inspect. Add a scenario when none diff --git a/README.md b/README.md index e66ea111da..cbf860f1bd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ and per-tool policies, then use that one catalog from any MCP-compatible agent. [Website](https://executor.sh) · [Documentation](https://executor.sh/docs) · [Discord](https://discord.gg/eF29HBHwM6) -[https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad](https://github.com/user-attachments/assets/11225f83-e848-42ba-99b2-a993bcc88dad) +![Executor demo](https://raw.githubusercontent.com/UsefulSoftwareCo/executor/main/assets/executor-demo.gif) ## Why Executor diff --git a/RUNNING.md b/RUNNING.md index f478b86362..d14b306ead 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -40,6 +40,34 @@ develop on its `main`, publish a bump, then bump the dependency here. The The e2e globalsetup files are the source of truth for "how do I boot a working instance of X" — read them before inventing a boot path. +A desktop dev run collides with an INSTALLED Executor in three places, all of +which look like something else. Give the dev run its own of each: + +``` +EXECUTOR_DESKTOP_USER_DATA=/tmp/executor-desktop-dev-userdata \ +EXECUTOR_DESKTOP_SCOPE_DIR=/tmp/executor-desktop-dev-scope \ +EXECUTOR_DESKTOP_SETTINGS_DIR=/tmp/executor-desktop-dev-settings \ +bun run dev +``` + +- **userData** holds Electron's single-instance lock, so the second process + quits at startup with **exit code 0 and no message** — the log simply stops + after "starting electron app". +- **The scope dir** (`~/.executor`) holds the sidecar's SQLite, owned by + whoever opened it first; the app reports "Failed to open local SQLite data". +- **The port** comes from `settings.json` in the settings dir; write + `{"server":{"port":}}` there before the first launch. + +Do NOT move these by pointing `HOME` at a scratch directory. The plugins a +local run drives resolve their own paths from the real home, and a synthetic +one breaks them in ways that read as product bugs: Codex Computer Use fails +every call with "Sky Computer Use native pipe startup failed", even with +`.codex` symlinked back. + +Renderer edits inside a workspace package can be served from vite's dep +cache rather than the source the package exports. If a change does not appear +after a reload, delete `apps/desktop/node_modules/.vite` and restart. + ## E2E: running, viewing, sharing `e2e/AGENTS.md` covers writing scenarios. Operationally: diff --git a/TELEMETRY.md b/TELEMETRY.md new file mode 100644 index 0000000000..f84600c065 --- /dev/null +++ b/TELEMETRY.md @@ -0,0 +1,101 @@ +# Telemetry + +Executor's local products (the CLI, the desktop app) and self-hosted +deployments send a small set of anonymous usage events. This document is the +complete account of what is sent, what is deliberately not sent, why the +feature exists, and how to turn it off. + +The hosted cloud product has its own browser-side analytics, disclosed +separately in its terms; this document covers the software that runs on your +machines. + +## Why + +Executor is used mostly outside our infrastructure. Without some signal from +local and self-hosted installs, every product decision about them is a guess: +we cannot tell whether a feature ships broken, whether anyone uses toolkits, +whether executions fail at unusual rates after a release, or whether the +product is growing anywhere except cloud. + +The events exist to answer exactly one kind of question: **how are product +features being used?** They are metadata about the product, not about you. +Anything that would answer "what is this user doing" — which APIs you call, +what your tools are named, what your code does — is out of scope by design, +not by omission. + +## What is sent + +Each event carries its named properties plus, on every event: a random +per-install id, the product surface (`cli`, `desktop`, or `selfhost`), the +release channel, and the app version. + +| Event | Properties | Meaning | +| --------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `execution_completed` | `ok`, `plane` (`mcp`/`api`), `toolkit` (boolean) | A code execution finished, split by whether an agent (MCP) or a human-facing API triggered it, and whether a toolkit-scoped endpoint served it. | +| `integration_added` | `plugin_key` | An integration was added, by kind (`openapi`, `mcp`, `graphql`, ...). | +| `integration_removed` | `plugin_key` | An integration was removed, by kind. | +| `artifact_created` | `via` (`agent`/`ui`) | A generative-UI artifact was saved. | +| `artifact_viewed` | `via` | An artifact was opened for its content. | +| `artifact_updated` | `via` | An artifact was overwritten or renamed. | +| `artifact_deleted` | `via` | An artifact was deleted. | + +That table is exhaustive. The typed catalog the code compiles against is +[`packages/core/analytics/src/events.ts`](packages/core/analytics/src/events.ts); +an event that is not in that file cannot be sent. + +## What is never sent + +- No code, tool arguments, tool results, or error messages. +- No secrets, tokens, or credentials. +- No names you typed: no integration slugs, connection names, toolkit slugs, + tool names, or artifact titles. The **kind** of integration (`openapi`, + `mcp`, ...) is a product question; **which** service you connected it to is + your business. +- No identity: no emails, usernames, hostnames, IP-derived location, or org + names. Events are marked so the analytics backend builds no person profile. + +## The anonymous id + +A random UUID is minted the first time the daemon or server starts and stored +as `analytics-id` in the data directory (`~/.executor` for local installs, the +configured data dir for self-host). It exists so that ten events from one +install count as one install, not ten. It is not derived from your machine, +account, or network, and deleting the file resets it. When telemetry is +disabled the file is never created. + +## Opting out + +Set either environment variable to `1`, `true`, or `yes`: + +- `DO_NOT_TRACK` — the [cross-tool convention](https://consoledonottrack.com), + which Executor also honors for its other outbound calls (crash reporting, + the integrations.sh catalog fetch). +- `EXECUTOR_DISABLE_ANALYTICS` — telemetry only, if you want the catalog + fetch and crash reporting to keep working. + +Opting out is total: the analytics service becomes a no-op, nothing is +buffered, nothing is sent, and no id file is written. The CLI's managed +service (launchd/systemd) forwards both variables into the supervised +daemon's environment, so an opted-out install stays opted out. + +## Delivery mechanics + +Events buffer in memory (bounded) and flush in batches on a fixed cadence, +plus once at shutdown. Delivery is best-effort: a failed flush re-queues and +retries later, failures are swallowed, and no user-facing operation ever +waits on — or can be failed by — analytics. The ingest endpoint is PostHog +(`us.i.posthog.com`); the project key in the source identifies the project +and grants no read access. + +## Where the line is enforced + +Structurally, not by convention: + +- The event catalog is a closed, typed interface — adding a property means + editing [`events.ts`](packages/core/analytics/src/events.ts) in a reviewed + change, next to the property rules at the top of that file. +- Attribution (`plane`, `via`) is bound where each serving surface is + composed, so events cannot carry request-controlled labels. +- Observers hang off neutral seams (an engine wrapper, post-commit hooks); the + core SDK carries no analytics vocabulary and hosts that do not opt in — like + every test — compose with no analytics at all. diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index e66abf7670..331d918d96 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,612 @@ # executor +## 1.6.7 + +### Patch Changes + +- Updated dependencies [[`98d6c6a`](https://github.com/UsefulSoftwareCo/executor/commit/98d6c6ad3272fca371fc2d8b14b2e332100d8322)]: + - @executor-js/sdk@1.6.7 + - @executor-js/local@1.6.7 + - @executor-js/api@1.4.70 + - @executor-js/runtime-quickjs@1.6.7 + +## 1.6.6 + +### Patch Changes + +- [#1869](https://github.com/UsefulSoftwareCo/executor/pull/1869) [`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Explain macOS permissions for Codex plugins instead of failing with an opaque + error. A refused grant used to surface as `Internal tool error [id]` — the + plugin reports "Unknown error" and only a numeric code says what happened, so + neither the user nor the model could tell that macOS was the blocker. + + The bridge now recognises those codes and answers with the grant to enable and + where to find it. Each plugin's add screen also states what macOS will ask for + before anything runs, with a link straight to the right Privacy pane — macOS + asks once, and a dismissed prompt never returns. + + The add screen checks that access when it opens, and holds the Add button + until the plugin answers. Adding one that macOS is still blocking produced an + integration that looked connected and failed on its first call, by which point + the screen explaining the fix was gone. + +- Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: + - @executor-js/local@1.6.6 + - @executor-js/api@1.4.69 + - @executor-js/sdk@1.6.6 + - @executor-js/runtime-quickjs@1.6.6 + +## 1.6.5 + +### Patch Changes + +- [#1863](https://github.com/UsefulSoftwareCo/executor/pull/1863) [`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Adding a Codex plugin no longer asks for anything. `CODEX_HOME` is a path the + scanner already resolved, but it was passed on the channel that makes an + environment variable a credential — so the integration declared it as one, and + a person who reached the connect step was shown a masked field for a value + they should never have to know. + + Stdio integrations can now carry non-secret environment as static + configuration, separate from declared secrets. The Codex plugins use it: they + declare no auth, and their connection is created for them. + +- Updated dependencies []: + - @executor-js/local@1.6.5 + - @executor-js/sdk@1.6.5 + - @executor-js/runtime-quickjs@1.6.5 + - @executor-js/api@1.4.68 + +## 1.6.4 + +### Patch Changes + +- [#1858](https://github.com/UsefulSoftwareCo/executor/pull/1858) [`ffcfbc0`](https://github.com/UsefulSoftwareCo/executor/commit/ffcfbc0de27d0ae55215839fb70395b0b7d9a65c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add locally installed OpenAI Codex plugins as one-click integrations: Messages + (iMessage/SMS), Chrome, Computer Use, Computer History, and OpenAI Developer + Docs. They appear in the connect dialog with their own icons, and a card that + cannot run yet says what to install and links to it. + + Tool calls reach the plugins through `codex app-server` rather than a plugin's + own MCP server, because their services only honour calls from a Codex host + session. Computer Use and Chrome ship no MCP server at all, so their APIs are + projected as typed tools — `list_apps`, `click`, `read_page`, `navigate` — that + compile to a single call each. No model turn is involved; nothing is bundled or + downloaded, and a machine without Codex simply sees the setup steps. + + A plugin's own approval prompt now reaches the caller, and states the terms it + carries: a browser prompt that persists for a site says so. Approvals are asked + once per session rather than per call. + + Elicitation requests can carry implementation-defined metadata through + `FormElicitation` / `UrlElicitation`, and a paused execution reports it. Both + fields are optional and additive. + +- [#1477](https://github.com/UsefulSoftwareCo/executor/pull/1477) [`c0c7a0d`](https://github.com/UsefulSoftwareCo/executor/commit/c0c7a0db41623077ba8b7d09de5ef8ee8e6a99e3) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Stop exporting credential-bearing URLs in telemetry. Every query parameter + value, URL fragment, and userinfo component is stripped from exported span + URLs — no parameter name is trusted — on every exporter path: the cloud span + processors, the self-host OTLP exporter, the browser client's OTLP exporter, + and the forwarded browser trace batches. User-supplied MCP endpoints are + sanitized before being stamped onto spans. + +- [#1841](https://github.com/UsefulSoftwareCo/executor/pull/1841) [`d13151a`](https://github.com/UsefulSoftwareCo/executor/commit/d13151ac19453f11b40c7f49303a10854c66e464) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - 1Password: multiple named accounts. The provider now holds any number of named accounts — a work account next to a personal one, or a service-account token next to desktop-app biometrics — each scoping its own set of vaults. The settings card lists every account with independent edit and disconnect, existing single-account configs upgrade in place, and `op://` refs keep their vault-first addressing: a vault name that exists in more than one account is an explicit ambiguity error, never a silent pick. + +- Updated dependencies [[`ffcfbc0`](https://github.com/UsefulSoftwareCo/executor/commit/ffcfbc0de27d0ae55215839fb70395b0b7d9a65c), [`10e16a5`](https://github.com/UsefulSoftwareCo/executor/commit/10e16a5baa2648657b70038e7d11429c58e4d242), [`515d6aa`](https://github.com/UsefulSoftwareCo/executor/commit/515d6aa391a04a3579a7b10f974ec316a563cf7a), [`06bf742`](https://github.com/UsefulSoftwareCo/executor/commit/06bf74254f3432e8d75fd8b493ef7a435ea4bc84)]: + - @executor-js/sdk@1.6.4 + - @executor-js/local@1.6.4 + - @executor-js/api@1.4.67 + - @executor-js/runtime-quickjs@1.6.4 + +## 1.6.3 + +### Patch Changes + +- [#1560](https://github.com/UsefulSoftwareCo/executor/pull/1560) [`8c20c33`](https://github.com/UsefulSoftwareCo/executor/commit/8c20c33817ad815b252c3d72290004905aedab10) Thanks [@Adityakk9031](https://github.com/Adityakk9031)! - **Stale tool catalogs refresh together instead of one after another, and self-host can set the freshness window** + + A tools read rebuilds every connection whose catalog has gone stale. Those rebuilds each dial their own upstream, but ran strictly one after another, so a host with several stale remote catalogs paid the sum of every server's latency on the read that tripped the TTL. The upstream listings now run concurrently, bounded so a large stale set cannot open an unbounded number of listings from one read. + + Only the listings overlap. Each rebuild's catalog write stays single-file, because a self-host database is one connection issuing raw `BEGIN`/`COMMIT` and a second transaction opened while one is live fails outright. A rebuild that fails now also logs a warning naming the connection and the reason, instead of disappearing: the read still succeeds on the stale-but-working catalog and the other connections still finish, but a permanently broken connection no longer re-fails silently on every read. + + Self-host also exposes the freshness window as `EXECUTOR_TOOLS_SYNC_TTL_MS`. Leave it unset for the 15-minute default, or set `off` (equivalently `null` or `false`, in any case) to disable time-based re-sync and leave stale-marking and config revision as the only refresh triggers. The value forwards to the SDK verbatim, so `0` keeps its SDK meaning: every catalog is expired on every read. A malformed, negative, or too-large-to-represent value is refused at boot rather than silently falling back to the default. + +- [#1572](https://github.com/UsefulSoftwareCo/executor/pull/1572) [`27cb466`](https://github.com/UsefulSoftwareCo/executor/commit/27cb4664282308e86fdb43218b42fe457d22e782) Thanks [@GeiserX](https://github.com/GeiserX)! - **Irreversible cleanup now waits for the transaction to commit, and plugins can do the same** + + `oauth.removeClient` deleted the client row and then deleted the client secret from the credential provider. The provider does not enlist in the caller's transaction and does not roll back with it, so an abort restored the client row while its secret stayed destroyed — a client that looks configured and can never authenticate again. The deletion now waits until the removal is durable and is discarded if the removal rolls back. With no transaction active it runs immediately, exactly as before. + + Deferring the deletion is not enough on its own. The secret is stored under a key derived from the app's `(owner, slug)` identity alone, so the key outlives the row it belonged to: whoever holds that identity when the deletion finally runs owns the key. A slug registered again before the removal committed would lose the new app's secret to the old app's queued deletion — the same unauthenticatable client, reached the other way round. The deferred deletion now re-checks that the app is still gone and stands down when it is not. A removal that matched no row also no longer queues a deletion at all: it removed nothing, so it has no claim on the key, which may well hold another subject's live secret. + + The same trap was reachable by plugins and they had no way out of it. `removeConnection` and `removeIntegration` run inside core's removal transaction — deliberately, so a plugin's own rows die atomically with the connection — which makes them exactly the wrong place to revoke a token at the provider's API, delete a remote object, or notify a third party. Nothing in the hooks' documentation said so, and `PluginCtx` exposed `transaction` but nothing to defer past it. + + `PluginCtx` gains `afterCommit`. It runs the effect once the outermost transaction commits, discards it if that transaction rolls back, and runs it immediately when no transaction is active. The lifecycle hooks now document that they run inside core's transaction and that outside-world work belongs in `afterCommit`. + + Sequencing work after your own `transaction(...)` call is not equivalent, and the documentation says so explicitly: `transaction` nests by pass-through, so inside an active transaction the inner call simply runs its effect and "afterwards" is still before any commit. + +- [#1569](https://github.com/UsefulSoftwareCo/executor/pull/1569) [`d874455`](https://github.com/UsefulSoftwareCo/executor/commit/d874455c5d5ea83a69e2010152aeb703d74f51c2) Thanks [@GeiserX](https://github.com/GeiserX)! - **Abandoned authorization sessions no longer keep their PKCE verifier forever** + + An OAuth authorization session stores its PKCE verifier so the callback can redeem the code. `complete` discarded an expired session lazily, but an _abandoned_ flow is never completed, so that check never ran for it and nothing else swept the table — the verifier sat there in plaintext indefinitely. + + Starting a new authorization now sweeps sessions that have already expired. Doing it on `start` bounds the table by how often authorization is begun rather than by how often it is abandoned, and needs no scheduler in any host. A session whose completion cannot be retried is dropped rather than left behind. + + The sweep only ever reaches rows the caller can already see, so one member's authorization never touches another member's sessions. It is best-effort: a sweep that fails logs a warning and lets the authorization continue. + +- [#1825](https://github.com/UsefulSoftwareCo/executor/pull/1825) [`06a7b75`](https://github.com/UsefulSoftwareCo/executor/commit/06a7b75fb62ac1c3eb22e28154fc250cd32a3ccb) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **A console URL naming an organization you cannot see is a not-found page, every time** + + Opening `//policies` sometimes rendered the full authenticated console — sidebar, org switcher showing your OWN organization, page chrome — under an address naming an organization you are not a member of. The page body was a failing org-scoped query with a Retry button, so the workspace on screen belonged to nobody and the URL belonged to someone else. + + The shell's not-found only fired once `/account/me` had answered for the URL's slug. Until then the console read its identity from the auth-hint cookie, which always names the organization the session last landed in, never the one in the address bar. So the first paint answered a question about a different organization and built a whole workspace out of it, and whether you ever saw that depended on how fast the server replied. + + The shell is now built only from an answer that names the organization the URL names. A slug the current answer does not cover renders nothing at all until `/account/me` resolves for that slug, and then either the workspace or — for an organization this session cannot see — the not-found page. The URL is never rewritten: a wrong address stays a wrong address. + + The ordinary cold load is untouched. The hint names the slug already in the URL, so it matches on the very first paint and the shell renders with no round trip. Only a slug the hint does not name waits: a foreign one, and the single frame after switching organizations, which now paints the organization the URL asked for instead of briefly showing the previous one. + +- [#1575](https://github.com/UsefulSoftwareCo/executor/pull/1575) [`e5526f3`](https://github.com/UsefulSoftwareCo/executor/commit/e5526f32b355e56aabe20024bc2f96296bfa982d) Thanks [@GeiserX](https://github.com/GeiserX)! - **GraphQL introspection no longer logs a credential carried in the endpoint URL** + + `query` is a supported credential carrier, so a GraphQL endpoint can be reached with `?token=`. Introspection built its request from a URL **string**, and `HttpClientRequest.setUrl` keeps a string verbatim as `request.url`. Every `HttpClientError` renders `${method} ${request.url}` into its `message` getter, and introspection logs the raw failure cause — so on any transport failure or non-JSON response, the connection's secret was written to the process log. + + The request is now built from a URL **object**, which moves the query into `request.urlParams` and clears it from `request.url`. The secret is therefore absent from the error message, and from anything else that renders the request URL. The credential still reaches the upstream: the client recombines url and urlParams when it executes the request. The endpoint's own query string is handled the same way, not just the separately-supplied query parameters, since a configured endpoint can carry a credential too. + + **The query string is now normalized on the wire.** Recombination appends each pair through `URLSearchParams`, so the query is re-serialized in form-urlencoded form instead of passed through byte-for-byte: + - a space written as `%20` is sent as `+` + - `~` and `!'()` are percent-encoded + - a valueless `?flag` is sent as `flag=` + + Key order, repeated keys, and already-encoded reserved characters are unchanged, and every parameter still decodes to the same value. This is not avoidable while the fix holds: raw query bytes only survive inside `request.url`, which is the one field every error message renders, so byte-transparency and keeping the credential out of the log cannot both hold. An upstream that signs its raw query string is the case to watch. The exact resulting URLs are pinned by test. + + Two endpoints are now rejected up front with an `invalid-endpoint` failure rather than dialed: + - an endpoint that is not a valid URL, which cannot be split this way and would otherwise be sent without the query parameters it was asked to include + - an endpoint carrying userinfo (`https://user:pass@host/…`), which `URL` keeps in the origin, so it would stay in `request.url` and leak into error messages exactly the way a query-carried secret used to + + Neither rejection echoes any part of the endpoint. A health check on such an integration now reports the invalid configuration and points the operator at the endpoint URL, instead of blaming the credential that was never sent. + +- [#1596](https://github.com/UsefulSoftwareCo/executor/pull/1596) [`b77ee69`](https://github.com/UsefulSoftwareCo/executor/commit/b77ee69aeeb90e40a2902c4d849e89d2343d91de) Thanks [@GeiserX](https://github.com/GeiserX)! - **Credentials are kept out of the health-check result that gets persisted** + + A health check stores a sample of the probed operation's response body, plus the extracted identity, in `connection.last_health` — so whatever those carry is written to the database. The operation is user-chosen from the plugin's catalog, which means it can just as easily be a key-listing endpoint as a `/me`, and those return secrets that no scrub of the connection's own credential value can recognise, because they are different secrets entirely. + + Two passes now cover both kinds of secret: + - **By key name.** Leaves whose key names a credential (`token`, `api_key`, `secret`, `authorization`, `session`, …) have their value replaced with `[redacted]`. The row itself is kept, so the live preview still shows the response shape and the identity picker still works. Keys that merely contain a matching substring, such as `author`, are left alone. camelCase spellings are recognised too: `accessToken`, `refreshToken`, `clientSecret`, `privateKey` and `sessionId` have no separator before the credential word, so a matcher that only looks for one reads them as innocent. + - **By value.** The OpenAPI health check removes the connection's own credential value from each sampled value, covering the other direction: a body that echoes back the key it was authenticated with under an innocent-looking name. This runs before the sample's 120-char truncation, not after — truncating first leaves a prefix of a long credential that an exact-value scrub can no longer match, and that prefix is what would be persisted. + + The key check reads a dotted path two ways. It uses the nearest NAMED segment, because array elements are named by index: `{"tokens": ["sk-live-…"]}` produces the path `tokens.0`, and testing the literal `"0"` matches nothing. It also uses an enclosing array container, because a key listing returns `{"api_keys": [{"value": "sk-live-…"}]}`, whose path is `api_keys.0.value` — the nearest named segment there is the innocent `value`, and only the array's own key says what the collection holds. A collection whose key names nothing, such as `names.0`, is still shown in full. + + The extracted `identity` goes through both passes as well. It is read straight off the raw body, so it previously bypassed them even though it is persisted the same way, and `identityField` is user-chosen from whatever the picker listed. + +- [#1823](https://github.com/UsefulSoftwareCo/executor/pull/1823) [`d74865f`](https://github.com/UsefulSoftwareCo/executor/commit/d74865ff8cb7c4ada1badac512169beafe925cf8) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Saved integrations come back after an upgrade left an OAuth expiry in the old number format** + + Some installs lost every saved integration from the MCP gateway at once. The credentials were never deleted — the gateway simply could not read the table they live in, so it served an empty tool list and restarting did not help. + + The `connection.expires_at` column records when an OAuth access token expires. It used to be a plain number; it now holds the value's digits, because a millisecond timestamp is larger than a 32-bit integer. SQLite does not rewrite rows when a column's type changes, so a connection saved by an older build still held the old form. Reading one back failed, and because the failure happened while mapping the row, it failed the whole query rather than that one field — one stale row was enough to hide every integration. + + A boot-time migration now converts those values to the current form. It runs before anything reads the table, so the integrations are back on the first restart after upgrading. It only touches values still in the old numeric form: rows already written by a current build are left exactly as they are, and it runs once. + +- [#1607](https://github.com/UsefulSoftwareCo/executor/pull/1607) [`75c917f`](https://github.com/UsefulSoftwareCo/executor/commit/75c917fdb0cb8499678f842c62133483472b4814) Thanks [@timkley](https://github.com/timkley)! - **Fix: allow MCP integrations to declare OAuth scopes when resource metadata omits them** + + MCP OAuth methods can now carry an optional non-empty scope list. Declared scopes + take precedence over protected-resource scope discovery, so servers with fixed + scopes can connect even when their dynamically registered OAuth client has no + resource identifier. Existing integrations without declared scopes keep + discovering them from the server at connect time. + +- [#1577](https://github.com/UsefulSoftwareCo/executor/pull/1577) [`4879d47`](https://github.com/UsefulSoftwareCo/executor/commit/4879d47bd7bc0d80440fe0438ce0e4b36b19a2d2) Thanks [@GeiserX](https://github.com/GeiserX)! - **Idle MCP connections age out on the pool's next acquire, even when their identity is never dialled again** + + The pool's five-minute idle window was only consulted against the entry being requested, so an identity that was never asked for a second time was never examined a second time. Its session stayed open and authenticated for as long as the pool lived, holding the bearer token or API key it was dialled with. The advertised bound applied only to connections that happened to be reused. + + `acquire` now sweeps every entry past the window, closing each one, rather than just the entry matching the key. This stays lazy in the sense the pool intends — activity drives it, there is no timer and no background fiber — and the map holds at most one entry per identity, so the scan is trivial. + + Because the sweep is paid for by whichever invocation acquires next, it cannot be allowed to stall that caller. The expired entries leave the pool synchronously, before any close is awaited, and the closes then run concurrently with each one bounded by a two-second timeout — so a server that accepts a close and goes quiet is abandoned rather than waited on, and cannot hold up an unrelated request or the connections queued behind it. + + Reuse is unchanged: an entry still inside the window is left alone, and a second call for the same identity still gets the parked session rather than a fresh dial. + +- [#1821](https://github.com/UsefulSoftwareCo/executor/pull/1821) [`435c0f2`](https://github.com/UsefulSoftwareCo/executor/commit/435c0f2d36ee891158a4e5cec35024cf439af6fe) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: add remote MCP servers that sit behind an authenticating proxy** + + The add-MCP form now carries an optional request headers editor. The name/value + pairs are sent on the connection check and on every later request, so an + endpoint gated by an edge authenticator — a Cloudflare Access service token, + for example — can be discovered and added. + + A `403` from such a gate is also no longer read as an unreachable server. It is + classified the same way a `401` is: the endpoint needs credentials, so the add + flow continues to the auth step instead of stopping on "Couldn't reach this + URL". + +- [#1775](https://github.com/UsefulSoftwareCo/executor/pull/1775) [`ecb87de`](https://github.com/UsefulSoftwareCo/executor/commit/ecb87debfcaa6676062cc09b8c847fde330f6337) Thanks [@The-AarushiSingh](https://github.com/The-AarushiSingh)! - **Stdio MCP integrations can be edited from the UI** + + The integration Edit sheet showed stdio servers as read-only text and told you + to remove and recreate the integration to change its command. Fixing a typo in + an argument, moving a server to a new path, or adding a static environment + variable meant editing `executor.jsonc` by hand, or losing the integration's + connections and tool policies to a delete-and-re-add. + + The sheet now edits the command, its arguments, the working directory, and the + declared environment map, staged and applied by the sheet's own Save like the + remote editor beside it. Arguments use the same quote-aware parsing as the add + flow, so an argument containing spaces survives a round trip. + + The environment field edits the DECLARED static variables only. A stdio server + receives those plus a small fixed base set — it does not inherit executor's + environment — and secret values still belong to the connection, entered per + account against the server's declared `stdio_env` method. + + Saving revises the integration config, which is already enough to rebuild the + tool catalog: connections whose catalog predates the revision re-list on their + next read, so an edited command's tools are correct without an explicit + refresh. + +- [#1402](https://github.com/UsefulSoftwareCo/executor/pull/1402) [`742a144`](https://github.com/UsefulSoftwareCo/executor/commit/742a144f169e24d5e0c082bfebb4334106267962) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **A disabled upstream API now reports `misconfigured` instead of `expired`** + + A 403 caused by the provider disabling the API (Google SERVICE_DISABLED / accessNotConfigured shapes) is a configuration problem, not a credential problem — reconnecting cannot fix it. Health checks now classify it as a fifth status, `misconfigured`, shown with an amber badge and a link to the provider console instead of a Reconnect prompt. Ordinary 401/403 credential rejections still report `expired`. + +- [#1377](https://github.com/UsefulSoftwareCo/executor/pull/1377) [`98615b4`](https://github.com/UsefulSoftwareCo/executor/commit/98615b4bd6ee8b18bc63a304fe215c76332e108f) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **A credential-store outage no longer costs an OAuth connection its grant** + + Refreshing an OAuth token spends the stored refresh token: the authorization server rotates it, so the copy we sent stops working the moment the grant succeeds and the rotated one is the only thing that can mint again. Persisting the rotated token first bounds what a partial write can lose, but it cannot help when the store is refusing writes outright — the grant has already run, there is nowhere to put the successor, and every later refresh replays a token the server has revoked. The connection then reports `invalid_grant` and demands a re-auth over what was only a storage blip. + + The refresh is now gated on a store that is proven writable. Before the grant runs, it writes a fixed value to an item of its own that holds no credential and sits in the same partition as the connection's tokens. A store that cannot take that write fails the resolve while the stored refresh token is still valid, so the connection recovers on its own once the store does. + + The probe deliberately does not test the store by rewriting the refresh token with the value it just read. That is a read-then-write with no compare-and-set, and two instances refreshing one connection would lose the newer token to it: one reads the stored token, the other spends that same token and stores its rotated replacement, and the first then writes the spent one back over the replacement. The connection would die exactly the way the gate is meant to prevent. + + The probe also removes a write rather than adding one in the common case. Authorization servers that do not rotate hand back the same refresh token, and that value is no longer re-persisted when it has not changed — a rotated token never matches, so the write that matters still happens. + +- [#1822](https://github.com/UsefulSoftwareCo/executor/pull/1822) [`d7e4b73`](https://github.com/UsefulSoftwareCo/executor/commit/d7e4b73a86b8e413af70e0fcb26f38a35a3f4546) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **An OAuth app can now be registered without an RFC 8707 resource, and that absence holds on every request** + + Microsoft Entra v2 rejects any authorization request that carries both a v2 `scope` (such as `https://api.fabric.microsoft.com/.default`) and the RFC 8707 `resource` parameter, failing with `AADSTS9010010` before the consent screen. Executor made that unavoidable for MCP servers behind Entra: registering an app for an MCP integration always derived the MCP endpoint as the resource, the form had no field to change it, and so every request carried the parameter Entra rejects. + + The register/edit OAuth app form now shows the resource indicator. It is still prefilled for MCP servers — nothing changes for providers that accept the parameter — but it can be cleared, and a cleared value persists as "no resource". A resource-less app then omits `resource` on all four grants alike: the authorization request, the code exchange, token refresh, and client-credentials. Symmetry matters here — sending `resource` on authorize but not on the token request (or the reverse) would bind the two tokens to different audiences. + + Two adjacent gaps closed with it: + - MCP scope discovery no longer depends on the app's resource. It now falls back to the integration's own discovery URL (the MCP endpoint), so clearing the resource does not break connecting. + - Token refresh for a first-party OAuth app dropped the app's configured resource, refreshing to a different audience than the original grant. It now sends the same resource the authorization request sent. + + Apps that keep their resource — the default for every discovered MCP server — behave exactly as before: the parameter is sent on every grant, as the MCP authorization spec expects. + +- [#1567](https://github.com/UsefulSoftwareCo/executor/pull/1567) [`6225d5e`](https://github.com/UsefulSoftwareCo/executor/commit/6225d5ede05b23f552211ad0a99267f4b94fcaf6) Thanks [@GeiserX](https://github.com/GeiserX)! - **Keep token material out of OAuth token-endpoint error messages** + + A token-endpoint failure renders a preview of the upstream body into its + message, and that message is persisted onto connection health, returned to the + caller, and carried into telemetry. On a malformed HTTP 200 the body being + previewed is a _successful_ token response, so an access token and a refresh + token could be rendered into it. + + The preview is now built from an allowlist of fields that are safe to show + (`error`, `errors`, `error_description`, `error_uri`, plus `code`, `message`, + and `detail` nested inside them) instead of a denylist of fields to hide. A + field nobody anticipated is omitted by default rather than printed by default. + Keys stay visible and only non-allowlisted string values are replaced, so an + operator can still read the shape of what the server sent. `code` is readable + only when nested, because at the top level of a token response it is the RFC + 6749 authorization code. + + Form-encoded bodies take the same allowlist, the walk over a body is + depth-bounded, and the failure summary records the token endpoint's hostname + rather than its full URL, which can carry identifiers in its path. + + On that same malformed-200 path the failure no longer keeps the underlying + rejection as its `cause`. That rejection carries the parsed token response, so + keeping it put the raw tokens back into anything that renders the whole failure + rather than only its message. Everything the path needs from the body — the + status, the error code, the redacted preview — is read before the failure is + built. A transport failure still keeps its cause, which is what tells a DNS miss + apart from a refused connection. + + No public API changes. The dead-grant classification added for HTTP 200 refresh + refusals is unaffected: it reads the HTTP status, not the rendered preview. + +- [#1829](https://github.com/UsefulSoftwareCo/executor/pull/1829) [`369fa0a`](https://github.com/UsefulSoftwareCo/executor/commit/369fa0a32d5800c8333d2be00610c89f5feeb9ae) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **The 1Password provider can now be scoped to several vaults, with explicit per-vault addressing** + + The provider previously bound exactly one vault. The configuration now holds a set of vaults selected with checkboxes, and every reference is explicit about which vault it means: the item picker is a searchable list that shows each item's vault and stores a vault-qualified `op://` reference, so identically-titled items in different vaults can never collide. A bare item name is accepted only when it matches exactly one item across the selected vaults — a name that exists in more than one place fails with an error naming the matching vaults instead of silently picking one. + + Reopening the vault or item pickers no longer flashes a loading state: listings are retained and re-validated in the background, so the last-known list renders instantly. + + Configurations saved before this change keep working: the stored single-vault shape is read as a one-vault list and upgrades to the new shape the next time it is saved. The `status` tool reports `vaultNames` for all configured vaults and flags any configured vault the account can no longer see. Provider entries also gained an optional `group` label, which pickers use to show where an item lives. + +- [#1772](https://github.com/UsefulSoftwareCo/executor/pull/1772) [`597ce90`](https://github.com/UsefulSoftwareCo/executor/commit/597ce90b30b96d2dd333a6c34e5f2cd0ee4c7d42) Thanks [@baggiiiie](https://github.com/baggiiiie)! - Persist the Connect-an-agent card's transport, artifact, integration-search, and approval preferences in the browser so its generated MCP install command remains stable across page reloads. + +- [#1529](https://github.com/UsefulSoftwareCo/executor/pull/1529) [`0004ea3`](https://github.com/UsefulSoftwareCo/executor/commit/0004ea353411c9806273698e42d95ab45e0ba2f9) Thanks [@jadch](https://github.com/jadch)! - Keep Last-Event-ID recovery scoped to its originating MCP stream. + +- [#1441](https://github.com/UsefulSoftwareCo/executor/pull/1441) [`a9bf623`](https://github.com/UsefulSoftwareCo/executor/commit/a9bf623dd4de359ca1d87275973b6ddf8b4f0ea6) Thanks [@salmonumbrella](https://github.com/salmonumbrella)! - Self-hosted instances can now allow additional browser origins without changing + their canonical public URL. Set `EXECUTOR_TRUSTED_ORIGINS` to a comma-separated + list of exact HTTP or HTTPS origins when one instance is intentionally reachable + through multiple hostnames or addresses. OAuth callbacks, MCP metadata, approval + links, and other generated URLs remain pinned to `EXECUTOR_WEB_BASE_URL`, and + origins are never inferred from request headers. + +- [#1720](https://github.com/UsefulSoftwareCo/executor/pull/1720) [`2da4953`](https://github.com/UsefulSoftwareCo/executor/commit/2da49535562e27291a1544f10e12b079b41f0362) Thanks [@Zeko369](https://github.com/Zeko369)! - **Connect an integration from the sidebar** + + The sidebar lists your integrations on every console route, but connecting + another one meant navigating back to the integrations page to reach its Connect + action — the picker state was owned by that page, so the shared shell could + render the list without being able to open the flow behind it. + + The connect dialog now belongs to the shell. A labelled plus button sits beside + the sidebar's Integrations heading and opens the same picker, records the same + event, and leaves the current route in place behind it. On mobile the navigation + drawer closes first so the dialog gets the full viewport. + +- [#1824](https://github.com/UsefulSoftwareCo/executor/pull/1824) [`97ff388`](https://github.com/UsefulSoftwareCo/executor/commit/97ff38856c1618cfe8b6dc4394d3761d70b076b6) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Tools reads stop waiting on slow upstream servers** + + A tools read rebuilds every connection whose catalog has gone stale before answering. The rebuilds already ran concurrently, but the read still waited for all of them, so one slow or unreachable MCP server gated every catalog read behind its network timeout — a tools listing could take tens of seconds while healthy connections sat ready. + + A read now waits at most a short grace budget (2 seconds by default) for the rebuilds, then answers from the persisted catalog. The rebuilds keep running after the read returns and land on a later read, so the catalog still converges — it just no longer holds the reader hostage while it does. Overlapping reads share one in-flight rebuild per connection instead of stacking new ones. + + The budget is `toolsSyncGraceMs` on the SDK config. Pass `null` to restore the strict behavior, where a read blocks until every rebuild finishes and always reflects a fully converged catalog. + +- [#1819](https://github.com/UsefulSoftwareCo/executor/pull/1819) [`1cd81d6`](https://github.com/UsefulSoftwareCo/executor/commit/1cd81d62cefa6fb8cfa2e21f2c0adbaedee60041) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: stop the update check from claiming a newer version is available on builds it cannot compare** + + A build stamped with the placeholder 0.0.0 version always compared as older + than the latest release, and a prerelease on a channel with no matching + dist-tag (rc, alpha, and similar) always lost the comparison too. Both cases + now short-circuit to "no update available" before the check reaches the + registry. + + This applies wherever the update check runs, so the CLI check and the sidebar + update card both stop showing an update prompt that a user could never act on. + +- Updated dependencies [[`1908dd6`](https://github.com/UsefulSoftwareCo/executor/commit/1908dd6d7611489362d451f7594adca542c13ba1), [`c1f51b7`](https://github.com/UsefulSoftwareCo/executor/commit/c1f51b7f96328b795669bb3d241667660dc2b060), [`02b52cd`](https://github.com/UsefulSoftwareCo/executor/commit/02b52cd01b09d3601ffe88d1f9c0b777f26e76ae)]: + - @executor-js/local@1.6.3 + - @executor-js/sdk@1.6.3 + - @executor-js/api@1.4.66 + - @executor-js/runtime-quickjs@1.6.3 + +## 1.6.2 + +### Patch Changes + +- [#1802](https://github.com/UsefulSoftwareCo/executor/pull/1802) [`2afb6e8`](https://github.com/UsefulSoftwareCo/executor/commit/2afb6e86ddf081afb8a246d76291a7ae668e05f4) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Concurrent API requests no longer share one database provider build** + + Every HTTP request gets its own database connection, opened when the request fiber's scope opens and closed when it closes. The middleware that builds a request's execution stack, however, captures the boot fiber's context once at layer-construction time and re-applies it to every request. A captured context carries Effect's current memoization map, and re-applying it replaced the fresh per-request map with the boot one — which every in-flight request in the isolate shares. + + The per-request stack build then memoized itself there. Sequential requests still rebuilt, because the memo entry is released once the request that built it finishes, so the problem was confined to requests that overlap: the second request reused the first one's stack build, and with it the first one's database connection. A request could therefore issue queries on a connection it did not own, and lose that connection mid-flight when the owner finished and closed it — typically surfacing as a failed read after a slow outbound call, on a request that had already read successfully. + + The stack is now built with a request-local memoization scope, so overlapping requests each build their own stack over their own connection. The captured context still carries the long-lived services it exists to carry. The two other per-request provider builds that ran under a captured context — the account provider and the admin-users provider — are built the same way, for the same reason. + +- [#1807](https://github.com/UsefulSoftwareCo/executor/pull/1807) [`78311a1`](https://github.com/UsefulSoftwareCo/executor/commit/78311a1ce506705a2420239fd96823203a9a1374) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Desktop: restore the macOS signing entitlements and app icon that were accidentally removed from the build inputs, and fail fast at PR time and before publishing when any configured build resource is missing. The v1.6.1 desktop build could not be signed; this release supersedes it. + +- Updated dependencies []: + - @executor-js/sdk@1.6.2 + - @executor-js/runtime-quickjs@1.6.2 + - @executor-js/local@1.6.2 + - @executor-js/api@1.4.65 + +## 1.6.1 + +### Patch Changes + +- [#1576](https://github.com/UsefulSoftwareCo/executor/pull/1576) [`6535a74`](https://github.com/UsefulSoftwareCo/executor/commit/6535a74de4860e376e5a2e273cb2d5a662f98c5c) Thanks [@GeiserX](https://github.com/GeiserX)! - **The CLI's server-connection store is written owner-only, and two of its tests now actually run** + + `~/.executor/server-connections.json` holds live credentials for a hosted server — a bearer token, or an OAuth access token together with its long-lived refresh token, rewritten on every silent refresh. It was created with no explicit mode, so the process umask applied and it landed world-readable (0644 by default). Any other account on the machine — or anything that copies a home directory, such as a backup or a container layer — could read a durable credential until the user ran `executor logout`. + + It is now created `0600` with a follow-up `chmod`, matching what the local-server manifest already does for the sibling secret it keeps under `server-control/`. Both steps are needed: `mode` applies only on create, and the `chmod` covers rewriting a store that already exists with looser permissions — which is the common path here, since the file is rewritten on every token refresh. + + Separately, two tests in `server-profile.test.ts` were written as `it("…", () => Effect.gen(…))`. An `Effect` is not a thenable, so Vitest treated each as passing without ever running its body — a deliberately falsified assertion still passed. They are now `it.effect` and execute for real. No production behaviour was wrong; the tests simply were not checking it. + +- [#1454](https://github.com/UsefulSoftwareCo/executor/pull/1454) [`e8ea62c`](https://github.com/UsefulSoftwareCo/executor/commit/e8ea62c8ba295931cf969cd310278b6a50771bdc) Thanks [@jadch](https://github.com/jadch)! - Prevent concurrent SQLite data-migration runners from failing when another runner commits the same ledger stamp first. + +- [#1579](https://github.com/UsefulSoftwareCo/executor/pull/1579) [`31e17c7`](https://github.com/UsefulSoftwareCo/executor/commit/31e17c725f0987f86896519f0626f9c94d39ccb7) Thanks [@GeiserX](https://github.com/GeiserX)! - **The desktop settings store is written owner-only** + + `settings.json` holds `serverProfiles`, which carries a remote server's credential — a bearer token, or a basic-auth username and password — for any "Custom server" the user connects to. `conf` (under electron-store) defaults to `configFileMode: 0o666`, so with no explicit mode the file was created `0644`. On Linux, where `~/.config` is not reliably `0700`, that is readable by every other account on the machine. macOS is protected by `~/Library` being `0700` and Windows by ACLs, so this is primarily a Linux-desktop exposure — but owner-only credential files are already this app's standard: `local-auth.ts` writes `auth.json` at `0o600`, and the sidecar manifest is chmodded the same way. + + One option is sufficient here, rather than the mode-plus-chmod pair used elsewhere: `atomically` chmods the temp inode only when the requested mode differs from its own default, and the atomic rename then carries the tight mode onto an already-loose file. Verified against the installed `conf` — with no option a fresh file lands `0644`; with `configFileMode: 0o600` a fresh file lands `0600` and an existing `0644` file becomes `0600` on the next write. + +- [#1582](https://github.com/UsefulSoftwareCo/executor/pull/1582) [`e8e97c4`](https://github.com/UsefulSoftwareCo/executor/commit/e8e97c4c008fce702ee07ec0ffe2a8d3e6946a21) Thanks [@GeiserX](https://github.com/GeiserX)! - **An MCP health check no longer reports `healthy` when the connection's credential is missing** + + Rendering skips an auth placement whose value is unresolved — that is the renderer's documented behaviour, and callers own the missing-value policy. The MCP health check had no such policy, so it dialled unauthenticated, and any server that lists tools without auth answered. `discoverTools` succeeding maps straight to `healthy`, so a connection whose credential was gone reported as healthy. + + Health status is the signal telling a user to re-authenticate, which makes `healthy` the one answer it must never give in that state. The check now reports `expired` with the unresolved input names, mirroring the OpenAPI health check, which already did exactly this. + + The MCP tool-invocation path already refused for the same reason. `resolveTools` is deliberately left alone — its own comment records that discovery tolerating unresolved credentials is intended, since an open server lists tools unauthenticated. + +- [#1741](https://github.com/UsefulSoftwareCo/executor/pull/1741) [`62748e8`](https://github.com/UsefulSoftwareCo/executor/commit/62748e86122b747226c76c2e112c5c4d2b4f7095) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Opt-in per-integration search tools on the MCP surface** + + Connecting with `?search_tools=true` (stdio: `executor mcp --search-tools`) adds one minimally-described `search_` MCP tool per connected integration, so the integration namespaces reach the model as tool names it can see without calling anything. Each call routes through the same flow as `tools.search({ namespace })` inside `execute`, and the tool list comes from the same inventory the `execute` description shows. Off by default; a clean endpoint URL is unchanged. + +- [#1573](https://github.com/UsefulSoftwareCo/executor/pull/1573) [`45ba141`](https://github.com/UsefulSoftwareCo/executor/commit/45ba1419b4e33ad10b0856c5fbc40fabc8f6efe1) Thanks [@GeiserX](https://github.com/GeiserX)! - **The MCP connection pool no longer keeps credentials in its cache key** + + A pooled remote MCP session is looked up by a key describing the connection's identity, and that key included the connection's resolved credential values — plus the headers and query params those same secrets had already been rendered into. The key is retained as a `Map` key for the pool's lifetime, so the secret stayed readable in process memory long after the call that needed it had finished, with nothing left to read it. + + The key is now the SHA-256 digest of that identity rather than the identity itself. Reuse is unchanged, because equal identities still produce equal keys, and separation is unchanged too: a rotated access token, a different rendered auth header and a credential carried in a query param each still dial a fresh session instead of reusing one authenticated as somebody else. Hashing the whole identity rather than only the fields known to be sensitive means a field added later is covered without anyone having to remember it carries a secret. + + Nothing reads the key back — the pool only compares it, and it reaches no log, span or error message — so nothing observable changes. + +- [#1800](https://github.com/UsefulSoftwareCo/executor/pull/1800) [`eac13e7`](https://github.com/UsefulSoftwareCo/executor/commit/eac13e7032c027d866c06728f1d5b634fd9ef7dd) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Idle MCP session runtimes are actually reclaimed** + + The MCP session Durable Object has an idle timeout that disposes a session's execution runtime — the execution engine and its executor closure, the built tool catalog, and a live database handle — once the session has gone quiet. That timeout never ran. + + The session arms an idle alarm on every request. The agents framework independently recomputes the Durable Object alarm from its own schedule table and keep-alive refcount, and when it finds neither it does not leave the alarm alone: it deletes it. It releases the last keep-alive reference at the end of every ordinary tool call, from a `waitUntil` that runs just after the response goes out — so the idle alarm the session had armed moments earlier was erased, and a session that had just served a request was left with no alarm at all. Its runtime then stayed resident until the platform evicted the whole object. + + Durable Objects are colocated many-to-one onto an isolate with a single heap, so runtimes that are never reclaimed accumulate there. When the heap is exhausted the allocation that fails is whichever comes next, anywhere in the isolate — which is why the failure tended to surface from storage rather than from the runtimes that had consumed the memory. + + The idle deadline belongs to the session, not to the framework's scheduler, so it is now re-asserted after the framework has arranged whatever it needs — and only while a runtime is actually resident, since once there is nothing left to reclaim the framework's answer is correct. + + Disposal also now emits a span carrying a per-isolate resident-runtime gauge, alongside the same gauge on runtime build, so the reclaim can be confirmed in production rather than inferred. + +- [#1609](https://github.com/UsefulSoftwareCo/executor/pull/1609) [`662ebe2`](https://github.com/UsefulSoftwareCo/executor/commit/662ebe28d5109156a1aa3b27eaa80ba9daacaf11) Thanks [@timkley](https://github.com/timkley)! - **Fix: declare the OAuth application type during dynamic client registration** + + Dynamic OAuth registrations now identify HTTPS callbacks as web applications + and loopback HTTP callbacks as native applications. This lets strict OAuth + servers validate Executor's redirect URI against the correct client type. + +- [#1570](https://github.com/UsefulSoftwareCo/executor/pull/1570) [`e66a3d8`](https://github.com/UsefulSoftwareCo/executor/commit/e66a3d893c948fcccc7fc028870d051cb2d31860) Thanks [@GeiserX](https://github.com/GeiserX)! - **The OAuth popup clears its result out of `localStorage` after handing it over** + + The popup writes its result to `localStorage` as the fallback completion channel, because `postMessage` is severed when a provider's consent page sets COOP and `BroadcastChannel` can be partitioned or raced by the auto-close. Nothing removed that entry afterwards, so the payload — which carries the identity label, an email, and on failure the error preview — stayed parked in the user's browser profile. + + The entry is now cleared once the handover has had time to land, and on `pagehide` as a backstop — the failure page never auto-closes so the user can read the error, and closing it by hand would otherwise cancel the pending timer and strand the entry. This cannot cost a listener the result: a `storage` event captures `newValue` at dispatch, so an opener that has been notified already holds it. + +- [#1574](https://github.com/UsefulSoftwareCo/executor/pull/1574) [`c8bb857`](https://github.com/UsefulSoftwareCo/executor/commit/c8bb8578c3afdc9731274d9e9abc68d7ce32d9ab) Thanks [@GeiserX](https://github.com/GeiserX)! - **The 1Password service-account token is cleared from the op-js global after each call** + + `@1password/op-js` keeps the service-account token on a module-level CLI instance (`cli.serviceAccountToken`) and reads it when it spawns `op`. The CLI backend set that global before each call and never cleared it, so one reachable reference to the token stayed live for the rest of the process. + + It is now cleared as soon as the call that needed it is done, on success, failure and interruption alike. Authentication is unaffected: every read and write of that global already happens inside the backend's semaphore, so the next operation re-sets the token before it spawns anything. + + This is hygiene rather than a boundary change. No unrelated `op` child ever received a stale token — every call routes through the same critical section that sets the correct one immediately before invoking — and the token is separately persisted in plaintext in the plugin's config blob, so an attacker's reach is unchanged. What it removes is a long-lived reachable reference that nothing needed to keep. + +- [#1749](https://github.com/UsefulSoftwareCo/executor/pull/1749) [`d4afe0c`](https://github.com/UsefulSoftwareCo/executor/commit/d4afe0c79f146dd169a00988a2d5d0469297be19) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Slim the per-integration `search_` tool definitions to under half their size: one shared one-line description (the tool name already carries the namespace) and a single bare `query` parameter, dropping the `limit`/`offset` knobs. A session pays for these definitions once per connected integration, so the surface now costs ~2k tokens instead of ~5k at 30 integrations; paging through a namespace belongs in `execute`. + +- [#1798](https://github.com/UsefulSoftwareCo/executor/pull/1798) [`69b0e64`](https://github.com/UsefulSoftwareCo/executor/commit/69b0e6413737599f11848b3877048f789ca84ac5) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **The packed Worker toolchain is verified at build time, and an incomplete copy is now reported instead of silently ignored** + + `@cloudflare/worker-bundler` cannot live inside the compiled binary: bunfs has no `node_modules`, so a bare specifier is unresolvable there by construction. The build instead copies the package's `dist/` next to the executable and `native-bindings.ts` publishes that path as `EXECUTOR_WORKER_BUNDLER_DIR` for consumers to load from. + + That handoff was described in two places that were free to drift, and did. The build writes `dist/index.bundled.js` — the entry consumers actually load, packed so it has no bare imports of its own — while the runtime check only looked for `dist/index.js` and `dist/esbuild.wasm`. Nothing verified the staged copy after the compile, so a partial staging produced a binary that looked fine on the build machine and failed on the user's, at startup. Worse, the runtime check failed open: when a file was missing it silently declined to set the environment variable, leaving a consumer to fall through to the bare specifier and crash. + + The required file list is now one shared contract used by both sides, so they cannot disagree. The build asserts the staged copy after compiling each target — every required file present, a size floor on the packed entry, and the `\0asm` magic on the wasm so a truncated copy cannot pass — turning a packaging slip into a failed build rather than a broken install. At runtime, a directory that is present but incomplete is reported on stderr naming the missing files, instead of being swallowed. An absent directory stays quiet, since that is the normal non-packaged path. + +- Updated dependencies [[`55180cb`](https://github.com/UsefulSoftwareCo/executor/commit/55180cb1487f9a3a28ddc0ee0bedfab8464c1f72)]: + - @executor-js/sdk@1.6.1 + - @executor-js/api@1.4.64 + - @executor-js/local@1.6.1 + - @executor-js/runtime-quickjs@1.6.1 + +## 1.6.0 + +### Patch Changes + +- [#1737](https://github.com/UsefulSoftwareCo/executor/pull/1737) [`9296f36`](https://github.com/UsefulSoftwareCo/executor/commit/9296f36a8adbfdeec700ce33c37987127857b2fd) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Scope the `skills` tool to Executor's own documentation. Its description, argument, index, and unknown-name error now state that it serves a fixed catalog of how-to docs for this server's tools, so an agent on a host without a skill tool of its own no longer reads it as a general reader for the harness's or the user's skills. + +- Updated dependencies [[`a2d1417`](https://github.com/UsefulSoftwareCo/executor/commit/a2d141758e478274813c8c24d354e1fd0f66af49)]: + - @executor-js/sdk@1.6.0 + - @executor-js/local@1.6.0 + - @executor-js/api@1.4.63 + - @executor-js/runtime-quickjs@1.6.0 + +## 1.5.42 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + - @executor-js/local@1.5.42 + - @executor-js/api@1.4.62 + - @executor-js/runtime-quickjs@1.5.42 + +## 1.5.41 + +### Patch Changes + +- [#1600](https://github.com/UsefulSoftwareCo/executor/pull/1600) [`1b5f931`](https://github.com/UsefulSoftwareCo/executor/commit/1b5f931d90b52fa9eca7b6f53359a117d757c7c1) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Add `integrations.remove` to the core tools so an agent can drop a catalog integration** + + `integrations.list` advertises `canRemove` per integration, but nothing on the agent surface could act on it: removal existed only on the HTTP API and the web console, so an agent that could add an integration could never take one back out. Cleaning up a catalog meant clicking through the UI once per integration. + + The core-tools plugin now contributes `integrations.remove`, taking the `slug` reported by `integrations.list` and cascading to every connection under the integration and the tools those produced. It is approval-gated, being strictly more destructive than `connections.remove`. The `removed` flag is honest rather than always-true: `false` means no catalog row matched, so an already-absent slug and a built-in namespace like `executor` are distinguishable from a real removal, and an integration pinned with `canRemove: false` is refused with `IntegrationRemovalNotAllowedError` instead of silently surviving. + +- [#1556](https://github.com/UsefulSoftwareCo/executor/pull/1556) [`f674fb8`](https://github.com/UsefulSoftwareCo/executor/commit/f674fb80eebd597f922edd5ec21b8035ab195a78) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: native MCP elicitation now reaches clients on the local HTTP endpoint instead of timing out** + + The local daemon's Streamable HTTP transport ran with `enableJsonResponse: true`, which buffers a `tools/call` into a single JSON body and leaves no open stream for the server to write on. A server-to-client `elicitation/create` raised during that call was therefore never delivered, and approval-gated tools failed with a `-32001` request timeout even though the session had negotiated `elicitation_mode=native` and the client's `elicitation.form` capability. The transport now uses the spec-default SSE streaming, so the reverse request rides the originating tool call's stream — matching the Cloudflare host's behaviour. + +- [#1603](https://github.com/UsefulSoftwareCo/executor/pull/1603) [`624e85f`](https://github.com/UsefulSoftwareCo/executor/commit/624e85f033632a7624c2bddf0944112166b1f481) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: `oauth.clients.remove` reported success for clients it never removed** + + The tool returned `{ removed: true }` unconditionally. `oauth.removeClient` is idempotent by design at the storage layer — `deleteMany` on a missing row is a no-op, which is the right behaviour for a delete — but the tool mapped that silence to success, so a typo'd slug, an already-deleted client, and the wrong owner were all indistinguishable from a real deletion. + + This bites hardest because clients are keyed by BOTH owner and slug, so the same slug can exist separately under `org` and `user`. An agent sweeping a list of slugs under one hardcoded owner would delete only half of them and report every call as a success, leaving org-owned OAuth apps registered after everything they authorized was gone. + + The tool now checks the caller-visible client set first and returns `removed: false` when nothing matched that `(owner, slug)` pair. The service-level `removeClient` is unchanged and stays idempotent. + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/local@1.5.41 + - @executor-js/api@1.4.61 + - @executor-js/runtime-quickjs@1.5.41 + +## 1.5.40 + +### Patch Changes + +- [#1528](https://github.com/UsefulSoftwareCo/executor/pull/1528) [`676af1a`](https://github.com/UsefulSoftwareCo/executor/commit/676af1a78301d83cdab52b0389b4c67ed07ae872) Thanks [@baggiiiie](https://github.com/baggiiiie)! - Prevent execute agents from attempting unsupported base64 decoding by directing file payloads through ToolFile emission and bodyBase64 forwarding. + +- [#1545](https://github.com/UsefulSoftwareCo/executor/pull/1545) [`df62bb3`](https://github.com/UsefulSoftwareCo/executor/commit/df62bb3c8753edf2db32cb45961cf1723114ea2d) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: reconnecting an OAuth connection now refreshes its health status in place — no page reload needed** + + Completing a reconnect previously left the stale "Expired" verdict on the connection row (and the integrations-list summary) until a hard refresh. Re-minting now clears the persisted verdict, and the UI re-probes as soon as the refreshed connection arrives. + +- [#1534](https://github.com/UsefulSoftwareCo/executor/pull/1534) [`80e5530`](https://github.com/UsefulSoftwareCo/executor/commit/80e553026278b1ecd7807f1ba99ba13b19d2c336) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Report the real product surface and version in the integrations.sh registry user-agent. The daemon previously sent `local` with a version frozen at 1.4.4; it now reports `cli` or `desktop` (matching analytics surfaces) and `@executor-js/local` is versioned with the release train. + +- Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a), [`80e5530`](https://github.com/UsefulSoftwareCo/executor/commit/80e553026278b1ecd7807f1ba99ba13b19d2c336)]: + - @executor-js/sdk@1.5.40 + - @executor-js/local@1.5.40 + - @executor-js/api@1.4.60 + - @executor-js/runtime-quickjs@1.5.40 + +## 1.5.39 + +### Patch Changes + +- Updated dependencies [[`6c316c7`](https://github.com/UsefulSoftwareCo/executor/commit/6c316c77a9efc98784976236852b58c6156e016e)]: + - @executor-js/sdk@1.5.39 + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.59 + - @executor-js/runtime-quickjs@1.5.39 + +## 1.5.38 + +### Patch Changes + +- [#1417](https://github.com/UsefulSoftwareCo/executor/pull/1417) [`046d67d`](https://github.com/UsefulSoftwareCo/executor/commit/046d67d75c3a8bc4cf0ab9dc4e723bc26ff130a3) Thanks [@morluto](https://github.com/morluto)! - Show policy and OAuth app removal failures in the UI, and keep success-only state unchanged when those writes fail. + +- [#1511](https://github.com/UsefulSoftwareCo/executor/pull/1511) [`7eb795d`](https://github.com/UsefulSoftwareCo/executor/commit/7eb795dda19c6177ad3bd590005eca1e326f760c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: `execute` scripts that both `emit()` output and `return` a value no longer lose the returned value in MCP clients that ignore `structuredContent` — the return value is now appended to the tool-result content after the emitted items** + +- [#1418](https://github.com/UsefulSoftwareCo/executor/pull/1418) [`d3610e3`](https://github.com/UsefulSoftwareCo/executor/commit/d3610e386324891ccfde111e1ff519ec9218d30f) Thanks [@morluto](https://github.com/morluto)! - Return an execution error when a Deno subprocess closes stdin instead of emitting an unhandled write failure. + +- [#1507](https://github.com/UsefulSoftwareCo/executor/pull/1507) [`541549a`](https://github.com/UsefulSoftwareCo/executor/commit/541549a5dd8806f45b1a01ea6f4fa18ac41f53b1) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: OAuth refresh rejections with non-spec error bodies (e.g. Datadog) now surface as expired connections with a reconnect path, and definitively dead refresh tokens are no longer retried against the authorization server** + +- [#1517](https://github.com/UsefulSoftwareCo/executor/pull/1517) [`59a6640`](https://github.com/UsefulSoftwareCo/executor/commit/59a6640e575c740d22e7804cdb67cd76efe8332b) Thanks [@kitze](https://github.com/kitze)! - **Fix: OpenAPI query parameters that use form-style exploded objects now serialize each object field as a query parameter.** + +- [#1416](https://github.com/UsefulSoftwareCo/executor/pull/1416) [`f1b617c`](https://github.com/UsefulSoftwareCo/executor/commit/f1b617ce82475d4fe35be7a98c6bf9f468dbbd60) Thanks [@morluto](https://github.com/morluto)! - Prevent provider service migration row loss caused by generated ID conflicts. + +- [#1420](https://github.com/UsefulSoftwareCo/executor/pull/1420) [`8c71744`](https://github.com/UsefulSoftwareCo/executor/commit/8c7174452fe05f32815950ed06f38516883a7c8f) Thanks [@morluto](https://github.com/morluto)! - Abort GraphQL tool calls that exceed the configured invocation timeout instead of waiting indefinitely for an upstream response. + +- Updated dependencies [[`6a924dd`](https://github.com/UsefulSoftwareCo/executor/commit/6a924dd98de916d6ff8cea2329bf672f149b64f4)]: + - @executor-js/sdk@1.5.38 + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.58 + - @executor-js/runtime-quickjs@1.5.38 + +## 1.5.37 + +### Patch Changes + +- [#1506](https://github.com/UsefulSoftwareCo/executor/pull/1506) [`c05a1cf`](https://github.com/UsefulSoftwareCo/executor/commit/c05a1cfa629b7f28a7d870c584998cb9cbbaf303) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Artifacts are now on by default for MCP connections.** A plain endpoint URL serves the full artifact surface — the artifact tools, the app shell resource, and the artifact skills. Connections that don't want it opt out with `?artifacts=false` (or `--no-artifacts` on the stdio CLI); `?artifacts=true` remains accepted as the explicit default. Previously the surface required a `?artifacts=true` opt-in. + +- [#1498](https://github.com/UsefulSoftwareCo/executor/pull/1498) [`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add anonymous product analytics to the local daemon (CLI + desktop) and self-host: execution counts split by MCP/API plane, toolkit usage, integration add/remove, and artifact usage (created/viewed/updated/deleted, attributed to agent tools vs the console UI), filed under a persisted per-install anonymous id. Opt out with DO_NOT_TRACK or EXECUTOR_DISABLE_ANALYTICS. + +- [#1500](https://github.com/UsefulSoftwareCo/executor/pull/1500) [`5eb2ca3`](https://github.com/UsefulSoftwareCo/executor/commit/5eb2ca36f93d2dae6eb8b3506c5de04ca141bb20) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: the artifact migration no longer narrows `definition.name` to varchar(255), which failed on existing long definition names** + +- [#1472](https://github.com/UsefulSoftwareCo/executor/pull/1472) [`1178e3b`](https://github.com/UsefulSoftwareCo/executor/commit/1178e3b31cd62cd2c05d6504d1586c2d8f018692) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add an Artifacts tab. Interactive components an agent generates with `render-ui` are saved and listed in the console, and each one has its own page that renders it live — the page an MCP client without MCP Apps support deep-links to. Artifacts can be renamed and deleted from the console, and agents find them again by title. + +- [#1505](https://github.com/UsefulSoftwareCo/executor/pull/1505) [`9bd4a5b`](https://github.com/UsefulSoftwareCo/executor/commit/9bd4a5b6063bd98f5ae8070baf0dd3ee3e110d68) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Artifact tool results now include the web deep link beside the inline widget payload, not just on the no-apps fallback. Clients can lose a rendered widget in ways the server never sees — a reopened transcript that skips the resource re-read shows raw JSON — and the URL in the result is the model's way to point the user back at the artifact. + +- [#1483](https://github.com/UsefulSoftwareCo/executor/pull/1483) [`54df2e3`](https://github.com/UsefulSoftwareCo/executor/commit/54df2e3e99509008759269b27484ee6581ce8827) Thanks [@davidwrossiter](https://github.com/davidwrossiter)! - **Fix: GraphQL connections now reject credentials when schema introspection fails and show actionable tool sync diagnostics** + +- [#1499](https://github.com/UsefulSoftwareCo/executor/pull/1499) [`010ea98`](https://github.com/UsefulSoftwareCo/executor/commit/010ea98e520643731b07e68e21119f12b8ef1505) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: the add-connection wizard no longer wipes a pasted credential when the key check saves a health check mid-flow** + +- [#1503](https://github.com/UsefulSoftwareCo/executor/pull/1503) [`a7c4689`](https://github.com/UsefulSoftwareCo/executor/commit/a7c468944837cfe097f03f69d612bde31903f284) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: MCP responses larger than Durable Object storage's 128 KiB per-value cap hung the client instead of being delivered** + + The DO transport persisted every outbound message for reconnect replay before writing the live SSE frame, and `storage.put` of an oversize value throws — so a large response (the `ui://executor/shell.html` resource is ~5 MB) was neither stored nor sent, and the client waited on keepalives forever. The transport now delivers the live frame first and treats persistence as best-effort: an oversize message skips the event store with a logged warning and arrives without a replay id, which only costs replayability if the connection drops mid-delivery. + +- [#1502](https://github.com/UsefulSoftwareCo/executor/pull/1502) [`25270b1`](https://github.com/UsefulSoftwareCo/executor/commit/25270b1f1f091778ba6584e41b041092fc1bdd00) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: MCP clients of the cloud host got a "Shell not built" placeholder as the `ui://executor/shell.html` resource, so every artifact rendered as a widget that never finished loading** + + The deployed Worker has no filesystem, and the shell loader silently fell back to an inert placeholder document when its `fs.readFile` failed. Workers hosts now fetch the built shell through the static-assets binding (the app build emits a stable-named copy alongside the hashed one), the self-host image reads the same emitted asset from its SPA dist, and a host that cannot produce the shell now fails the resource read with an actionable error instead of serving a document that hangs the client. App builds fail if the shell asset was not emitted. + +- Updated dependencies [[`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb)]: + - @executor-js/sdk@1.5.37 + - @executor-js/api@1.4.57 + - @executor-js/local@1.4.4 + - @executor-js/runtime-quickjs@1.5.37 + +## 1.5.36 + +### Patch Changes + +- [#1478](https://github.com/UsefulSoftwareCo/executor/pull/1478) [`8ecbfd6`](https://github.com/UsefulSoftwareCo/executor/commit/8ecbfd65f2c1393c75661c792723961877866cc5) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Store minted OAuth tokens in the durable file secret store (`auth.json` under `EXECUTOR_DATA_DIR`) instead of the system keychain. On sandbox/headless hosts the keychain can be an in-memory keyring that a stop/recreate wipes, leaving OAuth connections expired with "Stored refresh token could not be resolved." Existing keychain-backed connections migrate with one clean reconnect. + +- [#1462](https://github.com/UsefulSoftwareCo/executor/pull/1462) [`5a70675`](https://github.com/UsefulSoftwareCo/executor/commit/5a706756c66e53c9a929e9a8c30e57166b8d121b) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: org OAuth connections on self-host worked only for whoever ran the consent** + + The encrypted-secrets credential provider (the writable provider on the self-hosted and Cloudflare hosts) filed token rows under the _acting user's_ private partition instead of the credential's own owner. An org-owned OAuth connection whose consent completed in one member's browser session therefore resolved only for that member — every other principal failed with `oauth_connection_missing`, while the UI showed the connection healthy. The provider now partitions by the owner embedded in the item id (`oauth:org:…` → org-shared), matching the WorkOS Vault provider, and a boot-time data migration re-files rows already written wrong. The encrypted value itself was never affected. + +- [#1459](https://github.com/UsefulSoftwareCo/executor/pull/1459) [`fc1e589`](https://github.com/UsefulSoftwareCo/executor/commit/fc1e589613a14750c2ca8c34838a71c758544c8d) Thanks [@wan0net](https://github.com/wan0net)! - Preserve `elicitation_mode=native` when creating self-hosted MCP sessions. + +- [#1475](https://github.com/UsefulSoftwareCo/executor/pull/1475) [`77b0821`](https://github.com/UsefulSoftwareCo/executor/commit/77b0821ff9bddd6fb419d81a18b9e1af804fdb55) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Refresh OAuth tokens when the upstream rejects them with HTTP 401, not only when the stored expiry says they are due. Connections whose authorization server omits `expires_in` can now recover without a manual reconnect, and the refresh path is traced. + +- [#1476](https://github.com/UsefulSoftwareCo/executor/pull/1476) [`167d899`](https://github.com/UsefulSoftwareCo/executor/commit/167d899162794064eeac0a755697c2c943f1b9ac) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Remove the custom apps plugin. Git and local-directory app sources are no + longer supported. The packed binary still ships the workerd and worker-bundler + sidecars. +- Updated dependencies []: + - @executor-js/sdk@1.5.36 + - @executor-js/runtime-quickjs@1.5.36 + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.56 + ## 1.5.35 ### Patch Changes diff --git a/apps/cli/package.json b/apps/cli/package.json index 3953a85f96..860507a264 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "executor", - "version": "1.5.35", + "version": "1.6.7", "private": true, "bin": { "executor": "./bin/executor.ts" @@ -36,9 +36,11 @@ "quickjs-emscripten": "catalog:" }, "devDependencies": { + "@cloudflare/worker-bundler": "0.2.1", "@effect/vitest": "catalog:", "@types/node": "catalog:", "bun-types": "catalog:", + "esbuild": "^0.27.3", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/apps/cli/scripts/smoke-packed-apps.ts b/apps/cli/scripts/smoke-packed-apps.ts deleted file mode 100644 index 633cf64873..0000000000 --- a/apps/cli/scripts/smoke-packed-apps.ts +++ /dev/null @@ -1,192 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-promise-reject, executor/no-promise-catch -- boundary: standalone build smoke harness tears down a spawned packed binary */ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { spawn, type Subprocess } from "bun"; - -const binary = process.argv[2]; -if (!binary) throw new Error("usage: bun apps/cli/scripts/smoke-packed-apps.ts "); - -const AUTH_TOKEN = "packed-apps-smoke-token"; -const READY_TIMEOUT_MS = 45_000; - -const fail = (message: string): never => { - throw new Error(`[smoke-packed-apps] FAIL: ${message}`); -}; - -const requestJson = async (origin: string, path: string, init: RequestInit = {}): Promise => { - const response = await fetch(new URL(path, origin), { - ...init, - headers: { - authorization: `Bearer ${AUTH_TOKEN}`, - ...(init.body === undefined ? {} : { "content-type": "application/json" }), - ...init.headers, - }, - }); - const text = await response.text(); - if (!response.ok) fail(`${init.method ?? "GET"} ${path} returned ${response.status}: ${text}`); - return (text.length > 0 ? JSON.parse(text) : null) as T; -}; - -const waitForReadyPort = async (proc: Subprocess<"ignore", "pipe", "pipe">): Promise => - new Promise((resolveReady, rejectReady) => { - const timer = setTimeout( - () => rejectReady(new Error("packed binary did not report ready in time")), - READY_TIMEOUT_MS, - ); - let stdout = ""; - let stderr = ""; - const finish = (result: number | Error): void => { - clearTimeout(timer); - if (result instanceof Error) rejectReady(result); - else resolveReady(result); - }; - void proc.exited.then((code) => { - finish(new Error(`packed binary exited before ready with code ${code}: ${stderr}`)); - }); - void (async () => { - const reader = proc.stdout.getReader(); - const decoder = new TextDecoder(); - try { - while (true) { - const chunk = await reader.read(); - if (chunk.done) return; - stdout += decoder.decode(chunk.value); - const match = stdout.match(/EXECUTOR_READY:(\d+)/); - if (match) { - finish(Number(match[1])); - return; - } - } - } catch (cause) { - finish(cause instanceof Error ? cause : new Error(String(cause))); - } - })(); - void (async () => { - const reader = proc.stderr.getReader(); - const decoder = new TextDecoder(); - while (true) { - const chunk = await reader.read(); - if (chunk.done) return; - stderr += decoder.decode(chunk.value); - } - })().catch(() => undefined); - }); - -const main = async (): Promise => { - const fixture = await mkdtemp(join(tmpdir(), "executor-packed-apps-fixture-")); - const dataDir = await mkdtemp(join(tmpdir(), "executor-packed-apps-data-")); - const scopeDir = await mkdtemp(join(tmpdir(), "executor-packed-apps-scope-")); - let proc: Subprocess<"ignore", "pipe", "pipe"> | null = null; - try { - await mkdir(join(fixture, "tools"), { recursive: true }); - await writeFile( - join(fixture, "tools", "greet.ts"), - ` - import { z } from "zod"; - import { defineTool } from "executor:app"; - - const Input = z.object({ name: z.string() }); - - export default defineTool({ - description: "Greet", - input: Input, - async handler(input) { - return { greeting: "hello " + input.name }; - }, - }); - `, - ); - await writeFile( - join(fixture, "package.json"), - `${JSON.stringify({ name: "packed-apps-smoke", dependencies: { zod: "4.3.6" } })}\n`, - ); - await writeFile(join(fixture, "bun.lock"), ""); - - proc = spawn({ - cmd: [ - resolve(binary), - "daemon", - "run", - "--foreground", - "--port", - "0", - "--hostname", - "127.0.0.1", - "--auth-token", - AUTH_TOKEN, - ], - env: { - ...process.env, - EXECUTOR_CLIENT: "desktop", - EXECUTOR_DATA_DIR: dataDir, - EXECUTOR_SCOPE_DIR: scopeDir, - }, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - - const port = await waitForReadyPort(proc); - const origin = `http://127.0.0.1:${port}`; - console.log(`[smoke-packed-apps] ready on ${origin}`); - - await requestJson(origin, "/api/apps/sources", { - method: "POST", - body: JSON.stringify({ - kind: "local-directory", - slug: "packed-apps-smoke", - app: "packed-apps-smoke", - path: fixture, - }), - }); - - const sync = await requestJson<{ - readonly status: string; - readonly tools: readonly string[]; - readonly errors?: readonly unknown[]; - }>(origin, "/api/apps/sources/packed-apps-smoke/sync", { method: "POST" }); - if (sync.status !== "published") fail(`sync failed: ${JSON.stringify(sync.errors)}`); - if (JSON.stringify(sync.tools) !== JSON.stringify(["greet"])) { - fail(`expected greet tool, got ${JSON.stringify(sync.tools)}`); - } - - const invoked = await requestJson<{ - readonly status: string; - readonly structured: unknown; - readonly isError: boolean; - readonly text: string; - }>(origin, "/api/executions", { - method: "POST", - body: JSON.stringify({ - code: `export default await tools["packed-apps-smoke.org.published.greet"]({ name: "Ada" })`, - autoApprove: true, - }), - }); - if (invoked.status !== "completed" || invoked.isError) { - fail(`execution failed: ${JSON.stringify(invoked)}`); - } - if ( - JSON.stringify(invoked.structured) !== - JSON.stringify({ - status: "completed", - result: { ok: true, data: { greeting: "hello Ada" } }, - logs: [], - }) - ) { - fail(`unexpected execution result: ${JSON.stringify(invoked)}`); - } - console.log("[smoke-packed-apps] OK - local-directory app source bundled and invoked"); - } finally { - if (proc) { - proc.kill("SIGTERM"); - await Promise.race([proc.exited, new Promise((resolve) => setTimeout(resolve, 3000))]); - proc.kill("SIGKILL"); - } - await rm(fixture, { recursive: true, force: true }).catch(() => undefined); - await rm(dataDir, { recursive: true, force: true }).catch(() => undefined); - await rm(scopeDir, { recursive: true, force: true }).catch(() => undefined); - } -}; - -await main(); diff --git a/apps/cli/src/build.ts b/apps/cli/src/build.ts index 0f61ec225e..6c1f701383 100644 --- a/apps/cli/src/build.ts +++ b/apps/cli/src/build.ts @@ -5,6 +5,7 @@ import { resolve, join, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; import { $ } from "bun"; +import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const cliRoot = resolve(repoRoot, "apps/cli"); @@ -12,7 +13,6 @@ const webRoot = resolve(repoRoot, "apps/local"); const distDir = resolve(cliRoot, "dist"); const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm"; const WORKERD_VERSION = "1.20260708.1"; -const WORKER_BUNDLER_DIRNAME = "worker-bundler"; const resolveQuickJsWasmPath = (): string => { const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json")); @@ -25,6 +25,32 @@ const resolveQuickJsWasmPath = (): string => { return wasmPath; }; +// --------------------------------------------------------------------------- +// MCP-Apps shell (mcp-app.html) +// +// The MCP host serves the shell as the `ui://executor/shell.html` resource, and +// `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime +// `fs.readFile` is invisible to `bun build --compile`, so without this the +// packaged binary would fail every artifact resource read. Build it if missing +// and copy it next to the executable, where the loader finds it via +// `process.execPath` — the same colocation trick used for `libsql.node` / +// `emscripten-module.wasm`. +// --------------------------------------------------------------------------- + +const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell"); +const mcpAppsShellHtml = join(mcpAppsShellDir, "dist/mcp-app.html"); + +const ensureMcpAppsShell = async (): Promise => { + if (!existsSync(mcpAppsShellHtml)) { + console.log("Building MCP-Apps shell (mcp-app.html)..."); + await $`bun run --cwd ${mcpAppsShellDir} build:shell`.quiet(); + } + if (!existsSync(mcpAppsShellHtml)) { + throw new Error(`MCP-Apps shell missing at ${mcpAppsShellHtml} after build:shell`); + } + return mcpAppsShellHtml; +}; + const resolveOnePasswordCoreWasmPath = (): string => { const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json")); const sdkPkg = req.resolve("@1password/sdk/package.json"); @@ -36,7 +62,7 @@ const resolveOnePasswordCoreWasmPath = (): string => { }; const resolveWorkerBundlerDistPath = (): string => { - const req = createRequire(join(repoRoot, "packages/plugins/apps/package.json")); + const req = createRequire(join(repoRoot, "apps/cli/package.json")); const pkgJson = req.resolve("@cloudflare/worker-bundler/package.json"); const distPath = join(dirname(pkgJson), "dist"); if (!existsSync(join(distPath, "index.js")) || !existsSync(join(distPath, "esbuild.wasm"))) { @@ -63,6 +89,54 @@ const createPackedWorkerBundlerSource = async (distPath: string): Promise => { + const stagedDir = join(binDir, WORKER_BUNDLER_DIRNAME); + + const missing = missingWorkerBundlerFiles(stagedDir, existsSync); + if (missing.length > 0) { + throw new Error( + `Packed @cloudflare/worker-bundler is incomplete for ${targetName}: missing ${missing.join(", ")} under ${stagedDir}. ` + + `The compiled binary resolves this package from disk, not from bunfs, so shipping it partial means a runtime failure on the user's machine.`, + ); + } + + const bundledEntry = join(stagedDir, "dist", "index.bundled.js"); + const bundledSize = (await Bun.file(bundledEntry).arrayBuffer()).byteLength; + if (bundledSize < 100_000) { + throw new Error( + `Packed worker-bundler entry for ${targetName} is implausibly small (${bundledSize} bytes at ${bundledEntry}). Expected the esbuild-packed bundle.`, + ); + } + + const wasmPath = join(stagedDir, "dist", "esbuild.wasm"); + const wasmHead = new Uint8Array((await Bun.file(wasmPath).arrayBuffer()).slice(0, 4)); + if ( + wasmHead[0] !== 0x00 || + wasmHead[1] !== 0x61 || + wasmHead[2] !== 0x73 || + wasmHead[3] !== 0x6d + ) { + throw new Error( + `Staged esbuild.wasm for ${targetName} is not a WebAssembly module (bad magic at ${wasmPath}).`, + ); + } +}; + // --------------------------------------------------------------------------- // Metadata // --------------------------------------------------------------------------- @@ -398,6 +472,7 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`); const quickJsWasmPath = resolveQuickJsWasmPath(); + const mcpAppsShellPath = await ensureMcpAppsShell(); const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); const workerBundlerDistPath = resolveWorkerBundlerDistPath(); const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath); @@ -423,6 +498,10 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { // Copy QuickJS WASM next to binary — loaded at runtime by the server await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm")); + // Copy the MCP-Apps shell next to the binary. Platform-independent HTML, + // read from execDir at runtime by @executor-js/mcp-apps-shell. + await cp(mcpAppsShellPath, join(binDir, "mcp-app.html")); + // Copy 1Password's sdk-core WASM next to the binary. The patched // sdk-core loader falls back to this sibling file when bun --compile // bakes a build-machine node_modules path into __dirname. @@ -456,6 +535,7 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { join(binDir, WORKER_BUNDLER_DIRNAME, "dist", "index.bundled.js"), packedWorkerBundlerSource, ); + await assertPackedWorkerBundler(binDir, name); // Smoke test on current platform if (isCurrentPlatform(target)) { @@ -463,15 +543,6 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { console.log(` Smoke test: ${bin} --version`); const version = await $`${bin} --version`.text(); console.log(` OK: ${version.trim()}`); - console.log(` Smoke test: packed apps source sync and invoke`); - const smoke = Bun.spawn( - ["bun", "run", join(cliRoot, "scripts/smoke-packed-apps.ts"), bin], - { - cwd: repoRoot, - stdio: ["ignore", "inherit", "inherit"], - }, - ); - if ((await smoke.exited) !== 0) throw new Error("packed apps smoke failed"); } // Variant package.json. All variants publish to the SAME npm package diff --git a/apps/cli/src/device-login.test.ts b/apps/cli/src/device-login.test.ts index 805eb4610b..94cda1a8f0 100644 --- a/apps/cli/src/device-login.test.ts +++ b/apps/cli/src/device-login.test.ts @@ -1,6 +1,44 @@ -import { describe, expect, it } from "@effect/vitest"; +import { afterEach, describe, expect, it } from "@effect/vitest"; -import { browserOpenCommand } from "./device-login"; +import { + browserOpenCommand, + discoverCliLogin, + refreshDeviceTokens, + requestDeviceCode, + type CliLoginDiscovery, +} from "./device-login"; + +const originalFetch = globalThis.fetch; + +interface FetchCall { + readonly url: string; + readonly headers: Record; +} + +const responseJson = (body: Record, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const installFetch = (handler: (url: string, init: RequestInit | undefined) => Response) => { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + return Promise.resolve(handler(url, init)); + }) as typeof fetch; +}; + +const recordCall = (calls: Array, url: string, init: RequestInit | undefined): void => { + calls.push({ + url, + headers: Object.fromEntries(new Headers(init?.headers).entries()), + }); +}; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); describe("browserOpenCommand", () => { it("opens Windows browser URLs without cmd.exe", () => { @@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { expect(browserOpenCommand("not a url", "win32")).toBeUndefined(); }); }); + +describe("device login headers", () => { + it("sends configured headers when discovering CLI login", async () => { + const calls: Array = []; + installFetch((url, init) => { + recordCall(calls, url, init); + return responseJson({ + provider: "better-auth", + deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code", + tokenEndpoint: "https://executor.example/api/auth/device/token", + clientId: "executor-cli", + requestFormat: "json", + }); + }); + + const discovery = await discoverCliLogin("https://executor.example", { + headers: { "CF-Access-Client-Id": "client-id" }, + }); + + expect(discovery.clientId).toBe("executor-cli"); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("https://executor.example/api/auth/cli-login"); + expect(calls[0]?.headers).toMatchObject({ + accept: "application/json", + "cf-access-client-id": "client-id", + }); + }); + + it("sends configured headers only to same-origin device endpoints", async () => { + const calls: Array = []; + installFetch((url, init) => { + recordCall(calls, url, init); + if (url.endsWith("/api/auth/device/code")) { + return responseJson({ + device_code: "device-code", + user_code: "USER-CODE", + verification_uri: "https://executor.example/device", + expires_in: 300, + interval: 5, + }); + } + return responseJson({ + access_token: "access-token", + refresh_token: "refresh-token-2", + expires_in: 600, + }); + }); + const discovery: CliLoginDiscovery = { + provider: "better-auth", + deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code", + tokenEndpoint: "https://accounts.example/oauth/token", + clientId: "executor-cli", + requestFormat: "form", + }; + const headers = { "CF-Access-Client-Id": "client-id" }; + + await requestDeviceCode(discovery, { serverOrigin: "https://executor.example", headers }); + await refreshDeviceTokens({ + tokenEndpoint: discovery.tokenEndpoint, + clientId: discovery.clientId, + refreshToken: "refresh-token", + serverOrigin: "https://executor.example", + headers, + }); + + expect(calls).toHaveLength(2); + expect(calls[0]?.headers).toMatchObject({ + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + "cf-access-client-id": "client-id", + }); + expect(calls[1]?.headers).toMatchObject({ + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }); + expect(calls[1]?.headers["cf-access-client-id"]).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/device-login.ts b/apps/cli/src/device-login.ts index 09682444c2..feb7359616 100644 --- a/apps/cli/src/device-login.ts +++ b/apps/cli/src/device-login.ts @@ -50,6 +50,15 @@ export interface DeviceTokens { readonly organizationId?: string; } +export interface DeviceLoginHttpOptions { + readonly headers?: Readonly>; + readonly serverOrigin?: string; +} + +export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions { + readonly now?: () => number; +} + const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; const DEFAULT_INTERVAL_SECONDS = 5; @@ -126,17 +135,43 @@ const definedFields = (fields: Record): Record; +const isSameOrigin = (url: string, origin: string): boolean => { + try { + return new URL(url).origin === new URL(origin).origin; + } catch { + return false; + } +}; + +const headersForUrl = ( + url: string, + baseHeaders: Record, + options: DeviceLoginHttpOptions = {}, +): Record => { + const configured = + options.headers && (!options.serverOrigin || isSameOrigin(url, options.serverOrigin)) + ? options.headers + : {}; + return { ...configured, ...baseHeaders }; +}; + const post = async ( url: string, fields: Record, format: "form" | "json", + options: DeviceLoginHttpOptions = {}, ) => fetch(url, { method: "POST", - headers: { - "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded", - accept: "application/json", - }, + headers: headersForUrl( + url, + { + "content-type": + format === "json" ? "application/json" : "application/x-www-form-urlencoded", + accept: "application/json", + }, + options, + ), body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields), }); @@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise> => } }; -export const discoverCliLogin = async (origin: string): Promise => { +export const discoverCliLogin = async ( + origin: string, + options: DeviceLoginHttpOptions = {}, +): Promise => { let response: Response; + const url = cliLoginUrl(origin); try { - response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } }); + response = await fetch(url, { + headers: headersForUrl(url, { accept: "application/json" }, options), + }); } catch (cause) { throw new DeviceLoginError( `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -182,11 +223,15 @@ export const discoverCliLogin = async (origin: string): Promise => { +export const requestDeviceCode = async ( + discovery: CliLoginDiscovery, + options: DeviceLoginHttpOptions = {}, +): Promise => { const response = await post( discovery.deviceAuthorizationEndpoint, { client_id: discovery.clientId, scope: discovery.scope }, discovery.requestFormat, + options, ); const body = await readJson(response); if (!response.ok) { @@ -220,7 +265,7 @@ const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout export const pollForDeviceTokens = async ( discovery: CliLoginDiscovery, grant: DeviceCodeGrant, - options: { readonly now?: () => number } = {}, + options: PollForDeviceTokensOptions = {}, ): Promise => { const now = options.now ?? (() => Date.now()); const deadline = now() + grant.expiresInSeconds * 1000; @@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( client_id: discovery.clientId, }, discovery.requestFormat, + options, ); const body = await readJson(response); @@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { readonly tokenEndpoint: string; readonly clientId: string; readonly refreshToken: string; + readonly headers?: Readonly>; + readonly serverOrigin?: string; }): Promise => { const response = await post( input.tokenEndpoint, @@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { client_id: input.clientId, }, "form", + input, ); const body = await readJson(response); if (!response.ok) { diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index bae18617c0..fc2a9bf391 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -84,10 +84,13 @@ import { getExecutorServerAuthorizationHeader, normalizeExecutorServerConnection, normalizeExecutorServerOrigin, + resolveExecutorServerConfiguredHeaders, + resolveExecutorServerRequestHeaders, type ExecutorLocalServerKind, type ExecutorLocalServerManifest, type ExecutorServerConnection, type ExecutorServerConnectionInput, + type ExecutorServerHeaders, } from "@executor-js/sdk/shared"; import { decodeAccessTokenClaims, @@ -212,9 +215,11 @@ const waitForShutdownSignal = () => const shutdown = () => resume(Effect.void); process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); + process.once("SIGHUP", shutdown); return Effect.sync(() => { process.off("SIGINT", shutdown); process.off("SIGTERM", shutdown); + process.off("SIGHUP", shutdown); }); }); @@ -752,7 +757,7 @@ const OAUTH_REFRESH_SKEW_SECONDS = 60; const refreshOAuthConnection = ( connection: ExecutorServerConnection, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const auth = connection.auth; if (!auth || auth.kind !== "oauth") return connection; @@ -763,8 +768,19 @@ const refreshOAuthConnection = ( const { refreshToken, tokenEndpoint, clientId } = auth; if (!refreshToken || !tokenEndpoint || !clientId) return connection; + const headers = yield* resolveExecutorServerConfiguredHeaders(connection, process.env).pipe( + Effect.mapError(toError), + ); + const refreshed = yield* Effect.tryPromise({ - try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }), + try: () => + refreshDeviceTokens({ + tokenEndpoint, + clientId, + refreshToken, + serverOrigin: connection.origin, + headers, + }), catch: toError, // On a failed refresh, keep the existing token and let the eventual 401 // surface, better than blocking the command on a transient hiccup. @@ -1054,31 +1070,36 @@ const printExecutionOutcome = (input: { // Typed API client // --------------------------------------------------------------------------- -const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => { - const authorization = getExecutorServerAuthorizationHeader(connection); - return HttpApiClient.make(ExecutorApi, { - baseUrl: connection.apiBaseUrl, - ...(authorization - ? { - transformClient: HttpClient.mapRequest((request) => - HttpClientRequest.setHeader(request, "authorization", authorization), - ), - } - : {}), - // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite - // the transport-level error into the login hint. Without this the client - // fails decoding the unexpected status and prints the opaque - // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors) - // decode before this catch and pass through untouched. - transformResponse: (effect) => - Effect.catchIf( - effect, - (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401, - () => - Effect.fail(new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target }))), - ), +const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => + Effect.gen(function* () { + const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe( + Effect.mapError(toError), + ); + return yield* HttpApiClient.make(ExecutorApi, { + baseUrl: connection.apiBaseUrl, + ...(Object.keys(headers).length > 0 + ? { + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.setHeaders(request, headers), + ), + } + : {}), + // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite + // the transport-level error into the login hint. Without this the client + // fails decoding the unexpected status and prints the opaque + // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors) + // decode before this catch and pass through untouched. + transformResponse: (effect) => + Effect.catchIf( + effect, + (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401, + () => + Effect.fail( + new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target })), + ), + ), + }); }).pipe(Effect.provide(FetchHttpClient.layer)); -}; // --------------------------------------------------------------------------- // Foreground session @@ -1337,14 +1358,26 @@ const runBackgroundDaemonStart = (input: { // Stdio MCP session: a pure stdio <-> HTTP bridge to the owning local daemon. // --------------------------------------------------------------------------- -const mcpUrlForActiveLocalServer = ( - connection: ExecutorServerConnection, - elicitationMode: "browser" | "model", -): URL => { - const url = new URL("/mcp", connection.origin); - if (elicitationMode === "browser") { +const mcpUrlForActiveLocalServer = (input: { + readonly connection: ExecutorServerConnection; + readonly elicitationMode: "browser" | "model"; + readonly artifacts: boolean; + readonly searchTools: boolean; +}): URL => { + const url = new URL("/mcp", input.connection.origin); + if (input.elicitationMode === "browser") { url.searchParams.set("elicitation_mode", "browser"); } + // Artifacts are on by default; only the opt-out is spelled out, so the + // default endpoint stays clean. + if (!input.artifacts) { + url.searchParams.set("artifacts", "false"); + } + // Per-integration search tools are off by default; only the opt-in is + // spelled out. + if (input.searchTools) { + url.searchParams.set("search_tools", "true"); + } return url; }; @@ -1359,11 +1392,18 @@ const mcpUrlForActiveLocalServer = ( const runMcpHttpBridge = async (input: { readonly manifest: ExecutorLocalServerManifest; readonly elicitationMode: "browser" | "model"; + readonly artifacts: boolean; + readonly searchTools: boolean; }): Promise => { const stdio = new StdioServerTransport(); const authorization = getExecutorServerAuthorizationHeader(input.manifest.connection); const http = new StreamableHTTPClientTransport( - mcpUrlForActiveLocalServer(input.manifest.connection, input.elicitationMode), + mcpUrlForActiveLocalServer({ + connection: input.manifest.connection, + elicitationMode: input.elicitationMode, + artifacts: input.artifacts, + searchTools: input.searchTools, + }), authorization ? { requestInit: { headers: { Authorization: authorization } } } : undefined, ); @@ -1438,7 +1478,11 @@ const runMcpHttpBridge = async (input: { } }; -const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) => +const runStdioMcpSession = (input: { + readonly elicitationMode: "browser" | "model"; + readonly artifacts: boolean; + readonly searchTools: boolean; +}) => Effect.gen(function* () { // `executor mcp` never owns the local database. If a local server is already // running, bridge this stdio client to it; otherwise ensure a durable @@ -1450,7 +1494,12 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode const active = yield* readActiveLocalServerManifest(); if (active) { yield* Effect.promise(() => - runMcpHttpBridge({ manifest: active, elicitationMode: input.elicitationMode }), + runMcpHttpBridge({ + manifest: active, + elicitationMode: input.elicitationMode, + artifacts: input.artifacts, + searchTools: input.searchTools, + }), ); return; } @@ -1472,7 +1521,12 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode ); } yield* Effect.promise(() => - runMcpHttpBridge({ manifest: elected, elicitationMode: input.elicitationMode }), + runMcpHttpBridge({ + manifest: elected, + elicitationMode: input.elicitationMode, + artifacts: input.artifacts, + searchTools: input.searchTools, + }), ); }); @@ -2155,17 +2209,47 @@ const toolsCommand = Command.make("tools").pipe( Command.withDescription("Discover available tools and integrations"), ); +const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; +const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +const parseHeaderEnvOption = ( + headerEnv: Option.Option>, +): ExecutorServerHeaders | undefined => { + const values = Option.getOrUndefined(headerEnv); + if (!values) return undefined; + const headers: Record = {}; + for (const [rawHeaderName, rawEnvName] of Object.entries(values)) { + const headerName = rawHeaderName.trim(); + const envName = rawEnvName.trim(); + if (!HEADER_NAME_PATTERN.test(headerName)) { + throw new Error( + `Invalid --header-env header name "${rawHeaderName}". Use an HTTP header token like CF-Access-Client-Id.`, + ); + } + if (!ENV_NAME_PATTERN.test(envName)) { + throw new Error( + `Invalid --header-env env name "${rawEnvName}". Use an environment variable name like EXECUTOR_CF_ACCESS_CLIENT_ID.`, + ); + } + headers[headerName] = { kind: "env", name: envName }; + } + return Object.keys(headers).length > 0 ? headers : undefined; +}; + const profileConnectionInput = (input: { readonly origin: string; readonly displayName: Option.Option; readonly kind: Option.Option<"http" | "desktop-sidecar">; + readonly headerEnv: Option.Option>; }): ExecutorServerConnectionInput => { const selectedKind = Option.getOrUndefined(input.kind); const displayName = Option.getOrUndefined(input.displayName); + const headers = parseHeaderEnvOption(input.headerEnv); return { kind: selectedKind ?? "http", origin: input.origin, ...(displayName ? { displayName } : {}), + ...(headers ? { headers } : {}), }; }; @@ -2185,13 +2269,16 @@ const printServerProfiles = () => origin: profile.connection.origin, displayName: profile.connection.displayName, auth: profile.connection.auth ? "stored-auth" : "env-auth", + headers: profile.connection.headers + ? `${Object.keys(profile.connection.headers).length} header-env` + : "no-headers", })); const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4); const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4); for (const row of rows) { console.log( - `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth}`, + `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth} ${row.headers}`, ); } }); @@ -2209,17 +2296,23 @@ const serverAddCommand = Command.make( Options.optional, Options.withDescription("Server kind. Defaults to http."), ), + headerEnv: Options.keyValuePair("header-env").pipe( + Options.optional, + Options.withDescription( + "HTTP header mapping header-name=ENV_VAR. Repeat for Cloudflare Access service tokens.", + ), + ), makeDefault: Options.boolean("default").pipe( Options.withDefault(false), Options.withDescription("Make this profile the default server."), ), }, - ({ name, origin, displayName, kind, makeDefault }) => + ({ name, origin, displayName, kind, headerEnv, makeDefault }) => Effect.gen(function* () { const profileName = validateCliServerConnectionProfileName(name); const store = yield* upsertCliServerConnectionProfile({ name: profileName, - connection: profileConnectionInput({ origin, displayName, kind }), + connection: profileConnectionInput({ origin, displayName, kind, headerEnv }), makeDefault, }); const profile = findCliServerConnectionProfile(store, profileName); @@ -2418,12 +2511,18 @@ const loginCommand = Command.make( // The target may have been picked implicitly (default profile, or the // hosted fallback): say where the login is going before the device flow. console.log(`Signing in to ${target.origin}`); + const targetProfile = target.profile; + const headers = targetProfile + ? yield* resolveExecutorServerConfiguredHeaders(targetProfile.connection, process.env).pipe( + Effect.mapError(toError), + ) + : {}; const discovery = yield* Effect.tryPromise({ - try: () => discoverCliLogin(target.origin), + try: () => discoverCliLogin(target.origin, { headers }), catch: toError, }); const grant = yield* Effect.tryPromise({ - try: () => requestDeviceCode(discovery), + try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }), catch: toError, }); const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; @@ -2434,7 +2533,7 @@ const loginCommand = Command.make( if (!noBrowser) openBrowser(verifyUrl); console.log("Waiting for you to approve in the browser..."); const tokens = yield* Effect.tryPromise({ - try: () => pollForDeviceTokens(discovery, grant), + try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }), catch: toError, }); @@ -2459,6 +2558,9 @@ const loginCommand = Command.make( kind: "http", origin: target.origin, ...(email ? { displayName: email } : {}), + ...(target.profile?.connection.headers + ? { headers: target.profile.connection.headers } + : {}), auth: { kind: "oauth", accessToken: tokens.accessToken, @@ -2521,6 +2623,7 @@ const logoutCommand = Command.make( kind: profile.connection.kind, origin: profile.connection.origin, displayName: profile.connection.displayName, + ...(profile.connection.headers ? { headers: profile.connection.headers } : {}), }, makeDefault: store.defaultProfile === profile.name, }); @@ -2781,11 +2884,25 @@ const mcpCommand = Command.make( "Choose the stdio approval flow: browser approval or a CLI resume tool exposed to the model.", ), ), + noArtifacts: Options.boolean("no-artifacts") + .pipe(Options.withDefault(false)) + .pipe( + Options.withDescription( + "Withhold the artifact surface from this connection: the artifact tools, the app shell resource, and the artifact skills. Served by default.", + ), + ), + searchTools: Options.boolean("search-tools") + .pipe(Options.withDefault(false)) + .pipe( + Options.withDescription( + "Serve one search_ tool per connected integration. Off by default; each routes through the same flow as tools.search inside execute.", + ), + ), }, - ({ scope, elicitationMode }) => + ({ scope, elicitationMode, noArtifacts, searchTools }) => Effect.gen(function* () { applyScope(scope); - yield* runStdioMcpSession({ elicitationMode }); + yield* runStdioMcpSession({ elicitationMode, artifacts: !noArtifacts, searchTools }); }), ).pipe(Command.withDescription("Start an MCP server over stdio")); diff --git a/apps/cli/src/native-bindings.ts b/apps/cli/src/native-bindings.ts index 386d43ef69..ed79fb89e2 100644 --- a/apps/cli/src/native-bindings.ts +++ b/apps/cli/src/native-bindings.ts @@ -17,6 +17,8 @@ import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; +import { WORKER_BUNDLER_DIRNAME, missingWorkerBundlerFiles } from "./worker-bundler-artifact"; + const execDir = dirname(process.execPath); // libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the @@ -48,12 +50,27 @@ if (typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKERD_BIN && existsSyn process.env.EXECUTOR_WORKERD_BIN = workerdOnDisk; } -const workerBundlerOnDisk = join(execDir, "worker-bundler"); +// worker-bundler: the compiled binary cannot resolve `@cloudflare/worker-bundler` +// by name (bunfs has no node_modules), so build.ts stages the package's dist +// beside the executable and we publish its path here. An absent directory is +// normal off the packaged path (dev, `bun run`), so that stays quiet — but a +// directory that is PRESENT AND INCOMPLETE is a broken install, and swallowing +// it is what turns a packaging slip into an unresolvable bare import at +// startup. Report it on stderr instead of failing open. +const workerBundlerOnDisk = join(execDir, WORKER_BUNDLER_DIRNAME); if ( typeof Bun !== "undefined" && !process.env.EXECUTOR_WORKER_BUNDLER_DIR && - existsSync(join(workerBundlerOnDisk, "dist", "index.js")) && - existsSync(join(workerBundlerOnDisk, "dist", "esbuild.wasm")) + existsSync(workerBundlerOnDisk) ) { - process.env.EXECUTOR_WORKER_BUNDLER_DIR = workerBundlerOnDisk; + const missing = missingWorkerBundlerFiles(workerBundlerOnDisk, existsSync); + if (missing.length === 0) { + process.env.EXECUTOR_WORKER_BUNDLER_DIR = workerBundlerOnDisk; + } else { + process.stderr.write( + `executor: the bundled Worker toolchain at ${workerBundlerOnDisk} is incomplete ` + + `(missing ${missing.join(", ")}). Features that build Workers will be unavailable; ` + + `reinstall or update executor to repair it.\n`, + ); + } } diff --git a/apps/cli/src/release.ts b/apps/cli/src/release.ts index 4471fe56f3..3f3331adc2 100644 --- a/apps/cli/src/release.ts +++ b/apps/cli/src/release.ts @@ -5,8 +5,11 @@ import { fileURLToPath } from "node:url"; type ReleaseChannel = "latest" | "beta"; +type ReleaseMode = "full" | "dry-run" | "stage-only" | "publish-only"; + type ReleaseCliOptions = { - readonly dryRun: boolean; + readonly mode: ReleaseMode; + readonly skipBuild: boolean; }; type CommandInput = { @@ -31,18 +34,40 @@ const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; const parseArgs = (argv: ReadonlyArray): ReleaseCliOptions => { - let dryRun = false; + let mode: ReleaseMode = "full"; + let skipBuild = false; for (const arg of argv) { - if (arg === "--dry-run") { - dryRun = true; + if (arg === "--skip-build") { + skipBuild = true; continue; } - throw new Error(`Unknown argument: ${arg}`); + const next: ReleaseMode | null = + arg === "--dry-run" + ? "dry-run" + : arg === "--stage-only" + ? "stage-only" + : arg === "--publish-only" + ? "publish-only" + : null; + if (next === null) { + throw new Error(`Unknown argument: ${arg}`); + } + if (mode !== "full") { + throw new Error(`--${next} conflicts with --${mode}; pass at most one mode flag`); + } + mode = next; } - return { dryRun }; + if (mode === "publish-only" && skipBuild) { + throw new Error("--skip-build is implied by --publish-only; drop it"); + } + if (mode === "dry-run" && skipBuild) { + throw new Error("--skip-build with --dry-run would validate artifacts and do nothing else"); + } + + return { mode, skipBuild }; }; const runCommand = async (input: CommandInput): Promise => { @@ -223,7 +248,7 @@ const syncGitHubRelease = async (input: { // Draft until publish-desktop.yml finishes uploading installers and flips // it; otherwise /releases/latest/download/ 404s during the - // ~15-20 min desktop build window. + // desktop build window. const args = [ "release", "create", @@ -249,6 +274,83 @@ const syncGitHubRelease = async (input: { }); }; +/** Locate the wrapper archive a previous build of this exact version left in + * dist/release. Used by --skip-build and --publish-only so a run that just + * built (release:check's dry-run, or a --stage-only step) is not repeated. + * Fails hard on any mismatch — reusing stale artifacts must never be a + * silent fallback. */ +const locateBuiltArtifacts = async (version: string): Promise => { + const wrapperPkgPath = join(wrapperDir, "package.json"); + if (!existsSync(wrapperPkgPath)) { + throw new Error(`No built wrapper package at ${wrapperPkgPath}; run without --skip-build.`); + } + + const wrapperPkg = (await Bun.file(wrapperPkgPath).json()) as { + version?: string; + optionalDependencies?: Record; + }; + if (wrapperPkg.version !== version) { + throw new Error( + `Built wrapper version ${wrapperPkg.version} does not match ${version}; run without --skip-build.`, + ); + } + + const expectedArchive = join(releaseDir, `executor-${version}.tgz`); + if (!existsSync(expectedArchive)) { + throw new Error(`Missing packed wrapper ${expectedArchive}; run without --skip-build.`); + } + + // The wrapper's optionalDependencies are the source of truth for which + // platform variants this release ships. `build.ts publish` globs the + // dist/executor-*/ directories and publishes whatever it finds, so a + // missing dir would ship a wrapper referencing a variant that never + // reached npm, and an extra stale dir (say a leftover beta variant) + // would be published alongside. Require the exact set, each at the exact + // aliased version, each with its release archive present. + const optional = wrapperPkg.optionalDependencies ?? {}; + const expectedVariants = Object.keys(optional).sort(); + if (expectedVariants.length === 0) { + throw new Error(`Built wrapper has no optionalDependencies; run without --skip-build.`); + } + + const variantDirs = [...new Bun.Glob("executor-*/package.json").scanSync({ cwd: distDir })] + .map((entry) => dirname(entry)) + .sort(); + if (variantDirs.join(",") !== expectedVariants.join(",")) { + throw new Error( + `Platform variant dirs [${variantDirs.join(", ")}] do not match the wrapper's ` + + `optionalDependencies [${expectedVariants.join(", ")}]; run without --skip-build.`, + ); + } + + for (const name of expectedVariants) { + const spec = optional[name]!; + const aliasPrefix = "npm:executor@"; + if (!spec.startsWith(aliasPrefix)) { + throw new Error(`Unexpected optionalDependency spec for ${name}: ${spec}`); + } + const aliasVersion = spec.slice(aliasPrefix.length); + + const variantPkg = (await Bun.file(join(distDir, name, "package.json")).json()) as { + version?: string; + }; + if (variantPkg.version !== aliasVersion) { + throw new Error( + `${name} version ${variantPkg.version} does not match the wrapper's ` + + `${aliasVersion}; run without --skip-build.`, + ); + } + + if (!existsSync(join(distDir, `${name}.tar.gz`)) && !existsSync(join(distDir, `${name}.zip`))) { + throw new Error( + `Missing release archive for ${name} in ${distDir}; run without --skip-build.`, + ); + } + } + + return expectedArchive; +}; + const main = async () => { const options = parseArgs(process.argv.slice(2)); const version = await readVersion(); @@ -262,22 +364,38 @@ const main = async () => { throw new Error(`GitHub tag ${refTag} does not match ${versionPackagePath} version ${version}`); } - await rm(releaseDir, { recursive: true, force: true }); - await mkdir(releaseDir, { recursive: true }); + if (options.mode === "publish-only") { + await locateBuiltArtifacts(version); + await runCommand({ + command: "bun", + args: ["run", "src/build.ts", "publish", channel], + cwd: cliRoot, + }); + return; + } - await runCommand({ - command: "bun", - args: ["run", "src/build.ts", "binary"], - cwd: cliRoot, - }); + let wrapperArchivePath: string; + if (options.skipBuild) { + wrapperArchivePath = await locateBuiltArtifacts(version); + } else { + await rm(releaseDir, { recursive: true, force: true }); + await mkdir(releaseDir, { recursive: true }); - await runCommand({ - command: "bun", - args: ["run", "src/build.ts", "release-assets"], - cwd: cliRoot, - }); + await runCommand({ + command: "bun", + args: ["run", "src/build.ts", "binary"], + cwd: cliRoot, + }); + + await runCommand({ + command: "bun", + args: ["run", "src/build.ts", "release-assets"], + cwd: cliRoot, + }); + + wrapperArchivePath = await packWrapperPackage(); + } - const wrapperArchivePath = await packWrapperPackage(); const assetPaths = await collectReleaseAssetPaths(wrapperArchivePath); console.log(`Prepared executor@${version} for ${channel}`); @@ -285,7 +403,7 @@ const main = async () => { console.log(`- ${assetPath}`); } - if (options.dryRun) { + if (options.mode === "dry-run") { return; } @@ -295,6 +413,10 @@ const main = async () => { assetPaths, }); + if (options.mode === "stage-only") { + return; + } + await runCommand({ command: "bun", args: ["run", "src/build.ts", "publish", channel], diff --git a/apps/cli/src/server-profile.test.ts b/apps/cli/src/server-profile.test.ts index 465aa75fe2..95831b0abc 100644 --- a/apps/cli/src/server-profile.test.ts +++ b/apps/cli/src/server-profile.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync, rmSync } from "node:fs"; +import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import * as Effect from "effect/Effect"; @@ -25,7 +25,7 @@ afterEach(() => { }); describe("CLI server connection profiles", () => { - it("round-trips named server connections and default selection", () => + it.effect("round-trips named server connections and default selection", () => Effect.gen(function* () { const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-")); process.env.EXECUTOR_DATA_DIR = dataDir; @@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { connection: { origin: "https://executor.example/api", auth: { kind: "bearer", token: "key_123" }, + headers: { + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, + }, }, makeDefault: true, }); @@ -50,13 +53,17 @@ describe("CLI server connection profiles", () => { kind: "bearer", token: "key_123", }); + expect(store.profiles[0]?.connection.headers).toEqual({ + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, + }); expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote"); } finally { rmSync(dataDir, { recursive: true, force: true }); } - }).pipe(Effect.provide(BunServices.layer))); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("switches and removes the default profile", () => + it.effect("switches and removes the default profile", () => Effect.gen(function* () { const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-")); process.env.EXECUTOR_DATA_DIR = dataDir; @@ -82,7 +89,8 @@ describe("CLI server connection profiles", () => { } finally { rmSync(dataDir, { recursive: true, force: true }); } - }).pipe(Effect.provide(BunServices.layer))); + }).pipe(Effect.provide(BunServices.layer)), + ); it("drops malformed profiles when parsing", () => { const store = parseCliServerConnectionStore( @@ -114,6 +122,12 @@ describe("CLI server connection profiles", () => { key: "desktop-sidecar", origin: "http://127.0.0.1:4789", auth: { kind: "basic", username: "executor", password: "secret" }, + headers: { + "CF-Access-Client-Id": { + kind: "env", + name: "EXECUTOR_CF_ACCESS_CLIENT_ID", + }, + }, }, }, ], @@ -127,5 +141,72 @@ describe("CLI server connection profiles", () => { username: "executor", password: "secret", }); + expect(store.profiles[0]?.connection.headers).toEqual({ + "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" }, + }); }); + + // ------------------------------------------------------------------------- + // File permissions. + // + // This store holds live credentials for a hosted server — a bearer token, or + // an OAuth access token and its long-lived refresh token. Nothing pinned its + // mode before, so it was created with the process umask (0644 by default) and + // readable by every other account on the machine. + // + // Both cases are covered because they need different mechanisms: `mode` on + // create, and a `chmod` for the overwrite. The overwrite is the common path + // here, since the store is rewritten on every silent token refresh. + // ------------------------------------------------------------------------- + + it.effect("creates the credential store owner-only", () => + Effect.gen(function* () { + const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-mode-")); + process.env.EXECUTOR_DATA_DIR = dataDir; + + try { + yield* upsertCliServerConnectionProfile({ + name: "remote", + connection: { + origin: "https://executor.example", + auth: { kind: "bearer", token: "key_should_not_be_world_readable" }, + }, + makeDefault: true, + }); + + const mode = statSync(join(dataDir, "server-connections.json")).mode & 0o777; + expect(mode).toBe(0o600); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("tightens a pre-existing world-readable credential store on rewrite", () => + Effect.gen(function* () { + const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-chmod-")); + process.env.EXECUTOR_DATA_DIR = dataDir; + const storePath = join(dataDir, "server-connections.json"); + + try { + // A store left behind by an older version, world-readable. `mode` on + // write is ignored for an existing file, so only the chmod fixes this. + writeFileSync(storePath, JSON.stringify({ version: 1, profiles: [] })); + chmodSync(storePath, 0o644); + + yield* upsertCliServerConnectionProfile({ + name: "remote", + connection: { + origin: "https://executor.example", + auth: { kind: "bearer", token: "key_should_not_be_world_readable" }, + }, + makeDefault: true, + }); + + expect(statSync(storePath).mode & 0o777).toBe(0o600); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/server-profile.ts b/apps/cli/src/server-profile.ts index dd8df30855..44f5a4a813 100644 --- a/apps/cli/src/server-profile.ts +++ b/apps/cli/src/server-profile.ts @@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ }), ]); +const PersistedHeader = Schema.Struct({ + kind: Schema.Literal("env"), + name: Schema.String, +}); + const PersistedConnection = Schema.Struct({ kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])), key: Schema.optional(Schema.String), @@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ apiBaseUrl: Schema.optional(Schema.String), displayName: Schema.optional(Schema.String), auth: Schema.optional(PersistedAuth), + headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)), }); const PersistedProfile = Schema.Struct({ @@ -144,10 +150,22 @@ export const writeCliServerConnectionStore = ( const path = yield* Path.Path; const dataDir = resolveDataDir(path); yield* fs.makeDirectory(dataDir, { recursive: true }); - yield* fs.writeFileString( - serverConnectionStorePath(path), - serializeCliServerConnectionStore(store), - ); + const storePath = serverConnectionStorePath(path); + // This store holds live credentials for a hosted server — a bearer token, or + // an OAuth access token AND its long-lived refresh token — so create it + // owner-only, exactly as the local-server manifest does for the sibling + // secret it keeps under `server-control/`. + // + // Both steps are needed, and the second matters more here than it does + // there. `mode` applies only when the file is CREATED, so it closes the + // window where a fresh store is briefly world-readable. The `chmod` covers + // overwriting a store that already exists with looser permissions — and this + // file is rewritten on every silent token refresh, so overwrite is the + // common path, not the rare one. + yield* fs.writeFileString(storePath, serializeCliServerConnectionStore(store), { + mode: 0o600, + }); + yield* fs.chmod(storePath, 0o600).pipe(Effect.ignore); }); export const upsertCliServerConnectionProfile = (input: { diff --git a/apps/cli/src/service.test.ts b/apps/cli/src/service.test.ts index 4da92cc35b..07dcfab191 100644 --- a/apps/cli/src/service.test.ts +++ b/apps/cli/src/service.test.ts @@ -185,6 +185,36 @@ describe("service unit generation", () => { expect(wrapper).not.toContain("EXECUTOR_AUTH_PASSWORD"); }); + it("preserves custom TLS trust paths in the supervised environment", () => { + const previousNodeExtraCaCerts = process.env.NODE_EXTRA_CA_CERTS; + const previousSslCertFile = process.env.SSL_CERT_FILE; + const previousSslCertDir = process.env.SSL_CERT_DIR; + process.env.NODE_EXTRA_CA_CERTS = "/Users/x/.certs/node-extra.pem"; + process.env.SSL_CERT_FILE = "/Users/x/.certs/combined.pem"; + process.env.SSL_CERT_DIR = "/Users/x/.certs"; + + const wrapper = generateWindowsDaemonWrapper( + { + executablePath: "C:\\Program Files\\Executor\\executor.exe", + port: 4789, + version: "1.5.10", + }, + "C:\\Users\\x\\.executor", + "C:\\Users\\x\\.executor\\logs", + ); + + if (previousNodeExtraCaCerts === undefined) delete process.env.NODE_EXTRA_CA_CERTS; + else process.env.NODE_EXTRA_CA_CERTS = previousNodeExtraCaCerts; + if (previousSslCertFile === undefined) delete process.env.SSL_CERT_FILE; + else process.env.SSL_CERT_FILE = previousSslCertFile; + if (previousSslCertDir === undefined) delete process.env.SSL_CERT_DIR; + else process.env.SSL_CERT_DIR = previousSslCertDir; + + expect(wrapper).toContain('set "NODE_EXTRA_CA_CERTS=/Users/x/.certs/node-extra.pem"'); + expect(wrapper).toContain('set "SSL_CERT_FILE=/Users/x/.certs/combined.pem"'); + expect(wrapper).toContain('set "SSL_CERT_DIR=/Users/x/.certs"'); + }); + it("sanitizes cmd.exe metacharacters in baked env values (cmdSetValue)", () => { // A `"` in PATH would close the `set "PATH=..."` quote early and let a // `& cmd &` fragment run at boot as the user; strip it (illegal in a path diff --git a/apps/cli/src/service.ts b/apps/cli/src/service.ts index 2bb8c87ae5..ffaf567266 100644 --- a/apps/cli/src/service.ts +++ b/apps/cli/src/service.ts @@ -177,6 +177,17 @@ const serviceEnvironment = ( "EXECUTOR_SENTRY_RELEASE", "EXECUTOR_SENTRY_ENVIRONMENT", "EXECUTOR_RUN_ID", + // Analytics opt-out must survive into the supervised unit's minimal env, + // or an opted-out install would silently re-enable analytics under launchd. + "DO_NOT_TRACK", + "EXECUTOR_DISABLE_ANALYTICS", + // Preserve the caller's TLS trust configuration. Corporate/intercepting + // CAs commonly live outside the OS trust store; dropping these paths in a + // minimal launchd/systemd environment makes HTTPS integrations fail only + // after service installation while the same CLI call succeeds. + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "SSL_CERT_DIR", ] as const; const passThrough = Object.fromEntries( passThroughKeys.flatMap((key) => { diff --git a/apps/cli/src/worker-bundler-artifact.test.ts b/apps/cli/src/worker-bundler-artifact.test.ts new file mode 100644 index 0000000000..1d63070f33 --- /dev/null +++ b/apps/cli/src/worker-bundler-artifact.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "@effect/vitest"; +import { join } from "node:path"; + +import { + WORKER_BUNDLER_DIRNAME, + WORKER_BUNDLER_RUNTIME_FILES, + missingWorkerBundlerFiles, +} from "./worker-bundler-artifact"; + +const STAGED_DIR = join("/opt/executor/bin", WORKER_BUNDLER_DIRNAME); + +/** An existence probe that reports only the given POSIX-relative paths. */ +const staged = + (...relatives: readonly string[]) => + (path: string): boolean => + relatives.some((relative) => path === join(STAGED_DIR, ...relative.split("/"))); + +describe("missingWorkerBundlerFiles", () => { + it("reports nothing when the full artifact is staged", () => { + expect(missingWorkerBundlerFiles(STAGED_DIR, staged(...WORKER_BUNDLER_RUNTIME_FILES))).toEqual( + [], + ); + }); + + it("reports every file when the directory is empty", () => { + expect(missingWorkerBundlerFiles(STAGED_DIR, () => false)).toEqual([ + ...WORKER_BUNDLER_RUNTIME_FILES, + ]); + }); + + // The regression this whole seam exists for: the packaged daemon reads the + // wasm from disk beside the entry. A copy that brought the JS but dropped the + // wasm used to pass the build and fail on the user's machine at startup. + it("reports the esbuild wasm when only the JS entrypoints are staged", () => { + expect( + missingWorkerBundlerFiles(STAGED_DIR, staged("dist/index.js", "dist/index.bundled.js")), + ).toEqual(["dist/esbuild.wasm"]); + }); + + // `index.bundled.js` is the file consumers load — the package's own + // `dist/index.js` still has bare imports nothing can resolve inside the + // compiled binary. Staging index.js alone is not enough. + it("reports the packed entry when only the unbundled package entry is staged", () => { + expect( + missingWorkerBundlerFiles(STAGED_DIR, staged("dist/index.js", "dist/esbuild.wasm")), + ).toEqual(["dist/index.bundled.js"]); + }); + + it("names files relative to the staged directory so errors are quotable", () => { + for (const relative of WORKER_BUNDLER_RUNTIME_FILES) { + expect(relative.startsWith("dist/")).toBe(true); + } + }); + + it("requires the packed entry and the wasm, not just the package entry", () => { + expect(WORKER_BUNDLER_RUNTIME_FILES).toContain("dist/index.bundled.js"); + expect(WORKER_BUNDLER_RUNTIME_FILES).toContain("dist/esbuild.wasm"); + }); +}); diff --git a/apps/cli/src/worker-bundler-artifact.ts b/apps/cli/src/worker-bundler-artifact.ts new file mode 100644 index 0000000000..b541515084 --- /dev/null +++ b/apps/cli/src/worker-bundler-artifact.ts @@ -0,0 +1,55 @@ +// --------------------------------------------------------------------------- +// The packed `@cloudflare/worker-bundler` artifact that ships NEXT TO the +// compiled binary. +// +// `bun build --compile` cannot embed a package that is only ever addressed by +// bare specifier at runtime: bunfs has no `node_modules`, so an +// `import("@cloudflare/worker-bundler")` evaluated inside the binary resolves +// against the virtual root and throws +// ResolveMessage: Cannot find module '@cloudflare/worker-bundler' +// The build therefore copies the package's `dist/` next to the executable and +// `native-bindings.ts` publishes its absolute path as +// EXECUTOR_WORKER_BUNDLER_DIR. Consumers must load from that directory; a bare +// specifier is unresolvable in the packaged daemon by construction. +// +// This module is the single source of truth for which files that contract +// needs, so the build-time staging check and the runtime lookup cannot drift. +// They previously did: the build wrote `dist/index.bundled.js` (the file +// consumers actually load) while the runtime check only looked for +// `dist/index.js` and `dist/esbuild.wasm`. That drift fails OPEN — an +// incomplete staged copy passes the build, the runtime silently declines to +// publish the env var, and the consumer falls through to the bare specifier. +// --------------------------------------------------------------------------- + +import { join } from "node:path"; + +/** Directory name staged beside the executable, and looked up from execDir. */ +export const WORKER_BUNDLER_DIRNAME = "worker-bundler"; + +/** + * Files the colocated copy must contain to be usable. + * + * `index.bundled.js` is the entrypoint consumers load (the build packs it with + * esbuild so it has no bare-specifier imports of its own); `esbuild.wasm` is + * read at runtime beside it; `index.js` is the package's own entry, kept so the + * staged directory stays a faithful copy rather than a lookalike. + */ +export const WORKER_BUNDLER_RUNTIME_FILES = [ + "dist/index.js", + "dist/index.bundled.js", + "dist/esbuild.wasm", +] as const; + +/** + * Which required files are absent from a staged worker-bundler directory. + * + * Takes the existence probe as a parameter so the build script, the runtime + * bootstrap, and tests all agree on the requirement without touching a real + * filesystem. Returns the POSIX-relative names, which is what error messages + * should quote. + */ +export const missingWorkerBundlerFiles = ( + dir: string, + exists: (path: string) => boolean, +): readonly string[] => + WORKER_BUNDLER_RUNTIME_FILES.filter((relative) => !exists(join(dir, ...relative.split("/")))); diff --git a/apps/cloud/CHANGELOG.md b/apps/cloud/CHANGELOG.md index b7200748c2..037c50c93a 100644 --- a/apps/cloud/CHANGELOG.md +++ b/apps/cloud/CHANGELOG.md @@ -1,5 +1,345 @@ # @executor-js/cloud +## 1.4.68 + +### Patch Changes + +- Updated dependencies [[`75b3674`](https://github.com/UsefulSoftwareCo/executor/commit/75b3674136b44a2e43fb23eb7a058e7e51528527), [`98d6c6a`](https://github.com/UsefulSoftwareCo/executor/commit/98d6c6ad3272fca371fc2d8b14b2e332100d8322)]: + - @executor-js/plugin-mcp@1.6.7 + - @executor-js/sdk@1.6.7 + - @executor-js/api@1.4.70 + - @executor-js/execution@1.6.7 + - @executor-js/vite-plugin@0.0.67 + - @executor-js/cloudflare@0.0.49 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.18 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-graphql@1.6.7 + - @executor-js/plugin-openapi@1.6.7 + - @executor-js/plugin-toolkits@1.5.42 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.70 + - @executor-js/runtime-quickjs@1.6.7 + +## 1.4.67 + +### Patch Changes + +- [#1864](https://github.com/UsefulSoftwareCo/executor/pull/1864) [`fad3650`](https://github.com/UsefulSoftwareCo/executor/commit/fad36504439a07e6080beba243b24b73cd1b9741) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **API key validation is cached per isolate** + + Every MCP request and every API-key-authenticated `/api/*` request used to pay a live WorkOS round trip (~100-150ms) to validate the presented key, on every single request. The JWT bearer path beside it already verified locally against a JWKS cached for an hour; API keys had no cache at all. + + Successful validations are now cached in a bounded per-isolate map for 60 seconds, keyed by the SHA-256 digest of the key value (never the raw credential). The MCP handler used to rebuild its whole auth layer (and with it the cache) on every request; it now builds the layer once per isolate, so the cache holds on both the `/api/*` and `/mcp` planes. Invalid keys and upstream failures are never cached, so probing bad keys cannot pollute the map and a freshly created key works immediately. The tradeoff: a revoked key remains usable for up to 60 seconds within an isolate that validated it before revocation — far tighter than the one-hour rotation window the JWT path already accepts. + +- Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed), [`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d), [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: + - @executor-js/plugin-mcp@1.6.6 + - @executor-js/execution@1.6.6 + - @executor-js/react@1.4.69 + - @executor-js/api@1.4.69 + - @executor-js/cloudflare@0.0.48 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.17 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-graphql@1.6.6 + - @executor-js/plugin-openapi@1.6.6 + - @executor-js/plugin-toolkits@1.5.41 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/sdk@1.6.6 + - @executor-js/runtime-quickjs@1.6.6 + - @executor-js/vite-plugin@0.0.66 + +## 1.4.66 + +### Patch Changes + +- Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]: + - @executor-js/plugin-mcp@1.6.5 + - @executor-js/sdk@1.6.5 + - @executor-js/runtime-quickjs@1.6.5 + - @executor-js/execution@1.6.5 + - @executor-js/plugin-graphql@1.6.5 + - @executor-js/plugin-openapi@1.6.5 + - @executor-js/api@1.4.68 + - @executor-js/vite-plugin@0.0.65 + - @executor-js/cloudflare@0.0.47 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.16 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-toolkits@1.5.40 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.68 + +## 1.4.65 + +### Patch Changes + +- Updated dependencies [[`ffcfbc0`](https://github.com/UsefulSoftwareCo/executor/commit/ffcfbc0de27d0ae55215839fb70395b0b7d9a65c), [`10e16a5`](https://github.com/UsefulSoftwareCo/executor/commit/10e16a5baa2648657b70038e7d11429c58e4d242), [`9dcfaa5`](https://github.com/UsefulSoftwareCo/executor/commit/9dcfaa5ee8ad2ebc17407caf94d8d4dcf55e3562), [`515d6aa`](https://github.com/UsefulSoftwareCo/executor/commit/515d6aa391a04a3579a7b10f974ec316a563cf7a), [`06bf742`](https://github.com/UsefulSoftwareCo/executor/commit/06bf74254f3432e8d75fd8b493ef7a435ea4bc84)]: + - @executor-js/plugin-mcp@1.6.4 + - @executor-js/sdk@1.6.4 + - @executor-js/react@1.4.67 + - @executor-js/api@1.4.67 + - @executor-js/execution@1.6.4 + - @executor-js/vite-plugin@0.0.64 + - @executor-js/cloudflare@0.0.46 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.15 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-graphql@1.6.4 + - @executor-js/plugin-openapi@1.6.4 + - @executor-js/plugin-toolkits@1.5.39 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/runtime-quickjs@1.6.4 + +## 1.4.64 + +### Patch Changes + +- [#1806](https://github.com/UsefulSoftwareCo/executor/pull/1806) [`93817ed`](https://github.com/UsefulSoftwareCo/executor/commit/93817ed43919934092a4706327f50f6adfd14e47) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Team pricing is per member with unlimited executions** + + The Team plan moves from $150 per organization with a 250,000-execution + allowance to $15 per member per month with unlimited executions. The + `members` feature is unarchived in `autumn.config.ts` and billed in arrears + on the seat count the app reports; Free keeps its 3-member, 100,000-execution + shape and Enterprise stays custom with seat usage tracked for visibility. + + Seat counts reconcile from a full WorkOS recount (active members only — + pending invites hold a seat for the plan gate but are not billed) after + member removal, invitation acceptance, organization creation, and on every + login callback, which also picks up joins the app never sees a mutation for + (SSO JIT provisioning, join by domain, dashboard edits). Plans that predate + seat pricing have no members balance and are skipped, so existing + subscriptions keep billing exactly as before on their current plan version. + + The plans page, billing page, and marketing pricing cards now show the + per-member price. + +- Updated dependencies [[`66fb1a4`](https://github.com/UsefulSoftwareCo/executor/commit/66fb1a4154226d28691ca83bdf6f3daa417ef0ce), [`4b0fbf6`](https://github.com/UsefulSoftwareCo/executor/commit/4b0fbf68550516af9235c9267f91a962da993946), [`ba62f1a`](https://github.com/UsefulSoftwareCo/executor/commit/ba62f1a5d14b7002ba0a4686a9e1ae43bd77f54f), [`8324e1e`](https://github.com/UsefulSoftwareCo/executor/commit/8324e1eb8b03965050147309f049bdb52be6fcad), [`6305b6d`](https://github.com/UsefulSoftwareCo/executor/commit/6305b6d11505358fa73ec2b3e768ec4256c36435), [`c1f51b7`](https://github.com/UsefulSoftwareCo/executor/commit/c1f51b7f96328b795669bb3d241667660dc2b060), [`d7e4b73`](https://github.com/UsefulSoftwareCo/executor/commit/d7e4b73a86b8e413af70e0fcb26f38a35a3f4546), [`85b1955`](https://github.com/UsefulSoftwareCo/executor/commit/85b1955b4d24c332e637e15a025d64455e28a626), [`02b52cd`](https://github.com/UsefulSoftwareCo/executor/commit/02b52cd01b09d3601ffe88d1f9c0b777f26e76ae)]: + - @executor-js/react@1.4.66 + - @executor-js/plugin-mcp@1.6.3 + - @executor-js/sdk@1.6.3 + - @executor-js/plugin-openapi@1.6.3 + - @executor-js/fumadb@1.5.8 + - @executor-js/mcp-apps-shell@1.4.14 + - @executor-js/plugin-graphql@1.6.3 + - @executor-js/plugin-toolkits@1.5.38 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/api@1.4.66 + - @executor-js/execution@1.6.3 + - @executor-js/vite-plugin@0.0.63 + - @executor-js/cloudflare@0.0.45 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/runtime-quickjs@1.6.3 + +## 1.4.63 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.2 + - @executor-js/runtime-quickjs@1.6.2 + - @executor-js/execution@1.6.2 + - @executor-js/plugin-graphql@1.6.2 + - @executor-js/plugin-mcp@1.6.2 + - @executor-js/plugin-openapi@1.6.2 + - @executor-js/api@1.4.65 + - @executor-js/vite-plugin@0.0.62 + - @executor-js/cloudflare@0.0.44 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.13 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-toolkits@1.5.37 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.65 + +## 1.4.62 + +### Patch Changes + +- Updated dependencies [[`9dff4e8`](https://github.com/UsefulSoftwareCo/executor/commit/9dff4e8e6598e7d3108634a71269245ba9b480bb), [`7c12aee`](https://github.com/UsefulSoftwareCo/executor/commit/7c12aeea390225291ce4c97865b392237ee7934d), [`ddbf0fe`](https://github.com/UsefulSoftwareCo/executor/commit/ddbf0feba38c8502d78fa20c3081391b8ba3d112), [`91062c2`](https://github.com/UsefulSoftwareCo/executor/commit/91062c2b1d7b8edbc8470ca5eaa544045652afaa), [`9c35f26`](https://github.com/UsefulSoftwareCo/executor/commit/9c35f269dd5de3548111fe5c83cf1e877f23c80d), [`62748e8`](https://github.com/UsefulSoftwareCo/executor/commit/62748e86122b747226c76c2e112c5c4d2b4f7095), [`0007474`](https://github.com/UsefulSoftwareCo/executor/commit/0007474602d8da3642648f216bfdb0f09eb0914f), [`d4afe0c`](https://github.com/UsefulSoftwareCo/executor/commit/d4afe0c79f146dd169a00988a2d5d0469297be19), [`55180cb`](https://github.com/UsefulSoftwareCo/executor/commit/55180cb1487f9a3a28ddc0ee0bedfab8464c1f72)]: + - @executor-js/react@1.4.64 + - @executor-js/plugin-openapi@1.6.1 + - @executor-js/plugin-mcp@1.6.1 + - @executor-js/execution@1.6.1 + - @executor-js/sdk@1.6.1 + - @executor-js/api@1.4.64 + - @executor-js/mcp-apps-shell@1.4.12 + - @executor-js/plugin-graphql@1.6.1 + - @executor-js/plugin-toolkits@1.5.36 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/cloudflare@0.0.43 + - @executor-js/host-mcp@1.4.4 + - @executor-js/vite-plugin@0.0.61 + - @executor-js/runtime-quickjs@1.6.1 + +## 1.4.61 + +### Patch Changes + +- Updated dependencies [[`c11bef2`](https://github.com/UsefulSoftwareCo/executor/commit/c11bef2cd049db7bbf51b15e18761b14acccb534), [`46cea2c`](https://github.com/UsefulSoftwareCo/executor/commit/46cea2cbb1f414ae58ac876819a51b11967909a6), [`a2d1417`](https://github.com/UsefulSoftwareCo/executor/commit/a2d141758e478274813c8c24d354e1fd0f66af49), [`2bdbedf`](https://github.com/UsefulSoftwareCo/executor/commit/2bdbedf257f54d7c209e8c856c618174c10d6bb3), [`0b0b74f`](https://github.com/UsefulSoftwareCo/executor/commit/0b0b74f673b8098c5248159be36c648097f3c87b), [`256e25e`](https://github.com/UsefulSoftwareCo/executor/commit/256e25e7b291b0c023bc7547d092004b66781bba)]: + - @executor-js/plugin-mcp@1.6.0 + - @executor-js/plugin-openapi@1.6.0 + - @executor-js/sdk@1.6.0 + - @executor-js/react@1.4.63 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/api@1.4.63 + - @executor-js/execution@1.6.0 + - @executor-js/vite-plugin@0.0.60 + - @executor-js/cloudflare@0.0.42 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.11 + - @executor-js/plugin-graphql@1.6.0 + - @executor-js/plugin-toolkits@1.5.35 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/runtime-quickjs@1.6.0 + +## 1.4.60 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`86c68af`](https://github.com/UsefulSoftwareCo/executor/commit/86c68afef9bf8b7c19ab58f59acfedca0b3c4ca7), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be), [`9ecc7cb`](https://github.com/UsefulSoftwareCo/executor/commit/9ecc7cb8b30375ffa960e3fefe4d211e0254e691)]: + - @executor-js/sdk@1.5.42 + - @executor-js/cloudflare@0.0.41 + - @executor-js/plugin-openapi@1.5.42 + - @executor-js/plugin-mcp@1.5.42 + - @executor-js/api@1.4.62 + - @executor-js/execution@1.5.42 + - @executor-js/vite-plugin@0.0.59 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.10 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-graphql@1.5.42 + - @executor-js/plugin-toolkits@1.5.34 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.62 + - @executor-js/runtime-quickjs@1.5.42 + +## 1.4.59 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]: + - @executor-js/sdk@1.5.41 + - @executor-js/plugin-mcp@1.5.41 + - @executor-js/api@1.4.61 + - @executor-js/execution@1.5.41 + - @executor-js/vite-plugin@0.0.58 + - @executor-js/cloudflare@0.0.40 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.9 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-graphql@1.5.41 + - @executor-js/plugin-openapi@1.5.41 + - @executor-js/plugin-toolkits@1.5.33 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.61 + - @executor-js/runtime-quickjs@1.5.41 + +## 1.4.58 + +### Patch Changes + +- Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a)]: + - @executor-js/sdk@1.5.40 + - @executor-js/api@1.4.60 + - @executor-js/execution@1.5.40 + - @executor-js/vite-plugin@0.0.57 + - @executor-js/cloudflare@0.0.39 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.8 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-graphql@1.5.40 + - @executor-js/plugin-mcp@1.5.40 + - @executor-js/plugin-openapi@1.5.40 + - @executor-js/plugin-toolkits@1.5.32 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.60 + - @executor-js/runtime-quickjs@1.5.40 + +## 1.4.57 + +### Patch Changes + +- Updated dependencies [[`6c316c7`](https://github.com/UsefulSoftwareCo/executor/commit/6c316c77a9efc98784976236852b58c6156e016e)]: + - @executor-js/sdk@1.5.39 + - @executor-js/api@1.4.59 + - @executor-js/execution@1.5.39 + - @executor-js/vite-plugin@0.0.56 + - @executor-js/cloudflare@0.0.38 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.7 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-graphql@1.5.39 + - @executor-js/plugin-mcp@1.5.39 + - @executor-js/plugin-openapi@1.5.39 + - @executor-js/plugin-toolkits@1.5.31 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.59 + - @executor-js/runtime-quickjs@1.5.39 + +## 1.4.56 + +### Patch Changes + +- Updated dependencies [[`df01d91`](https://github.com/UsefulSoftwareCo/executor/commit/df01d9197e7b4fca9bd0adaca0705a80435e188c), [`6a924dd`](https://github.com/UsefulSoftwareCo/executor/commit/6a924dd98de916d6ff8cea2329bf672f149b64f4), [`1de85fc`](https://github.com/UsefulSoftwareCo/executor/commit/1de85fc0201c0c23c0e71e003c49228d406af6c8)]: + - @executor-js/plugin-openapi@1.5.38 + - @executor-js/sdk@1.5.38 + - @executor-js/react@1.4.58 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/api@1.4.58 + - @executor-js/execution@1.5.38 + - @executor-js/vite-plugin@0.0.55 + - @executor-js/cloudflare@0.0.37 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.6 + - @executor-js/plugin-graphql@1.5.38 + - @executor-js/plugin-mcp@1.5.38 + - @executor-js/plugin-toolkits@1.5.30 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/runtime-quickjs@1.5.38 + +## 1.4.55 + +### Patch Changes + +- Updated dependencies [[`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb)]: + - @executor-js/sdk@1.5.37 + - @executor-js/api@1.4.57 + - @executor-js/execution@1.5.37 + - @executor-js/vite-plugin@0.0.54 + - @executor-js/cloudflare@0.0.36 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.5 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-graphql@1.5.37 + - @executor-js/plugin-mcp@1.5.37 + - @executor-js/plugin-openapi@1.5.37 + - @executor-js/plugin-toolkits@1.5.29 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.57 + - @executor-js/runtime-quickjs@1.5.37 + +## 1.4.54 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.36 + - @executor-js/runtime-quickjs@1.5.36 + - @executor-js/execution@1.5.36 + - @executor-js/plugin-graphql@1.5.36 + - @executor-js/plugin-mcp@1.5.36 + - @executor-js/plugin-openapi@1.5.36 + - @executor-js/api@1.4.56 + - @executor-js/vite-plugin@0.0.53 + - @executor-js/cloudflare@0.0.35 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-toolkits@1.5.28 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.56 + ## 1.4.53 ### Patch Changes diff --git a/apps/cloud/drizzle/0012_woozy_shiva.sql b/apps/cloud/drizzle/0012_woozy_shiva.sql new file mode 100644 index 0000000000..2d57a4c743 --- /dev/null +++ b/apps/cloud/drizzle/0012_woozy_shiva.sql @@ -0,0 +1,10 @@ +CREATE TABLE "subject" ( + "external_id" varchar(255) NOT NULL, + "created_at" timestamp NOT NULL, + "last_seen_at" bigint, + "status" text, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "subject_uidx" ON "subject" USING btree ("tenant","external_id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/0013_curly_rocket_racer.sql b/apps/cloud/drizzle/0013_curly_rocket_racer.sql new file mode 100644 index 0000000000..8f6004e5c3 --- /dev/null +++ b/apps/cloud/drizzle/0013_curly_rocket_racer.sql @@ -0,0 +1,14 @@ +CREATE TABLE "artifact" ( + "id" varchar(255) NOT NULL, + "title" text NOT NULL, + "description" text, + "code" text NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "artifact_uidx" ON "artifact" USING btree ("tenant","owner","subject","id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/0014_new_karma.sql b/apps/cloud/drizzle/0014_new_karma.sql new file mode 100644 index 0000000000..606cbfd19b --- /dev/null +++ b/apps/cloud/drizzle/0014_new_karma.sql @@ -0,0 +1 @@ +ALTER TABLE "artifact" ADD COLUMN "bindings" json; \ No newline at end of file diff --git a/apps/cloud/drizzle/0015_equal_the_leader.sql b/apps/cloud/drizzle/0015_equal_the_leader.sql new file mode 100644 index 0000000000..585f98e027 --- /dev/null +++ b/apps/cloud/drizzle/0015_equal_the_leader.sql @@ -0,0 +1 @@ +ALTER TABLE "artifact" ADD COLUMN "preview" text; \ No newline at end of file diff --git a/apps/cloud/drizzle/0016_oauth_client_token_auth.sql b/apps/cloud/drizzle/0016_oauth_client_token_auth.sql new file mode 100644 index 0000000000..4028187b25 --- /dev/null +++ b/apps/cloud/drizzle/0016_oauth_client_token_auth.sql @@ -0,0 +1 @@ +ALTER TABLE "oauth_client" ADD COLUMN "token_endpoint_auth_method" text; \ No newline at end of file diff --git a/apps/cloud/drizzle/0017_lush_thunderbolts.sql b/apps/cloud/drizzle/0017_lush_thunderbolts.sql new file mode 100644 index 0000000000..f2dc4a7e9e --- /dev/null +++ b/apps/cloud/drizzle/0017_lush_thunderbolts.sql @@ -0,0 +1,2 @@ +ALTER TABLE "connection" ADD COLUMN "credential_write" json;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "credential_write" json; \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0012_snapshot.json b/apps/cloud/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000000..acde2117c5 --- /dev/null +++ b/apps/cloud/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1367 @@ +{ + "id": "97114994-f0fc-4419-80bf-21c529a6cf0b", + "prevId": "e285be7d-2427-4b1b-8b86-50a80ada25af", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0013_snapshot.json b/apps/cloud/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000000..62eff6ef9d --- /dev/null +++ b/apps/cloud/drizzle/meta/0013_snapshot.json @@ -0,0 +1,1474 @@ +{ + "id": "edc90067-822c-443a-8fcd-3bac77bca317", + "prevId": "97114994-f0fc-4419-80bf-21c529a6cf0b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0014_snapshot.json b/apps/cloud/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000000..c52059579e --- /dev/null +++ b/apps/cloud/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1480 @@ +{ + "id": "0857c83f-0124-4739-bd88-a0a4a92aab84", + "prevId": "edc90067-822c-443a-8fcd-3bac77bca317", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0015_snapshot.json b/apps/cloud/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000000..b9904b865b --- /dev/null +++ b/apps/cloud/drizzle/meta/0015_snapshot.json @@ -0,0 +1,1486 @@ +{ + "id": "d666b31a-c3d1-4bd7-9bd6-85f2abc4fb55", + "prevId": "0857c83f-0124-4739-bd88-a0a4a92aab84", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0016_snapshot.json b/apps/cloud/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000000..455ee12104 --- /dev/null +++ b/apps/cloud/drizzle/meta/0016_snapshot.json @@ -0,0 +1,1492 @@ +{ + "id": "f22622e4-cfd6-4224-b653-050244dc511e", + "prevId": "d666b31a-c3d1-4bd7-9bd6-85f2abc4fb55", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0017_snapshot.json b/apps/cloud/drizzle/meta/0017_snapshot.json new file mode 100644 index 0000000000..9c00f4e65a --- /dev/null +++ b/apps/cloud/drizzle/meta/0017_snapshot.json @@ -0,0 +1,1504 @@ +{ + "id": "42251aa3-ae24-4010-ac65-9f41e26cdc20", + "prevId": "f22622e4-cfd6-4224-b653-050244dc511e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 72f9cbb970..375397ceca 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -85,6 +85,48 @@ "when": 1784581232732, "tag": "0011_tense_caretaker", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1785193313601, + "tag": "0012_woozy_shiva", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1785354087138, + "tag": "0013_curly_rocket_racer", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1785355312973, + "tag": "0014_new_karma", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1785355354955, + "tag": "0015_equal_the_leader", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1788164746073, + "tag": "0016_oauth_client_token_auth", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1788287088210, + "tag": "0017_lush_thunderbolts", + "breakpoints": true } ] } diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 5f2759f899..71cd359999 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -10,11 +10,6 @@ import { } from "@executor-js/plugin-openapi/providers/microsoft"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; -import { - makeDynamicWorkerAppToolExecutor, - makeDynamicWorkerBundlerBackend, -} from "@executor-js/plugin-apps/cloud"; -import { appsHttpPlugin } from "@executor-js/plugin-apps/api"; import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; @@ -53,61 +48,10 @@ interface CloudPluginDeps { * falls back to the credential-driven default. */ readonly workosVaultClient?: WorkOSVaultClient; readonly activeToolkitSlug?: string; - /** Mirrors `HostConfig.allowLocalNetwork` (`ALLOW_LOCAL_NETWORK`). Off by - * default; production leaves it unset. */ - readonly allowLocalNetwork?: boolean; - readonly workerLoader?: { - readonly get: ( - name: string | null, - factory: () => { - readonly compatibilityDate: string; - readonly compatibilityFlags?: readonly string[]; - readonly mainModule: string; - readonly modules: Readonly>; - readonly globalOutbound?: null; - }, - ) => { readonly getEntrypoint: () => unknown }; - readonly load?: (code: { - readonly compatibilityDate: string; - readonly compatibilityFlags?: readonly string[]; - readonly mainModule: string; - readonly modules: Readonly>; - readonly globalOutbound?: null; - }) => { readonly getEntrypoint: () => unknown }; - }; - readonly workerAssets?: { - readonly fetch: (request: Request) => Promise; - }; } -const base64ToArrayBuffer = (value: string): ArrayBuffer => { - const bytes = Uint8Array.from(atob(value), (char) => char.charCodeAt(0)); - return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); -}; - -const assetRequest = (path: string): Request => new Request(new URL(path, "https://assets.local")); - -const fetchAsset = async ( - assets: { readonly fetch: (request: Request) => Promise }, - path: string, -): Promise => { - const response = await assets.fetch(assetRequest(path)); - if (!response.ok) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: worker artifact asset fetch must reject the dynamic bundler setup - throw new Error(`failed to fetch worker-bundler asset ${path}: ${response.status}`); - } - return response; -}; - export default defineExecutorConfig({ - plugins: ({ - workosCredentials, - workosVaultClient, - activeToolkitSlug, - allowLocalNetwork, - workerLoader, - workerAssets, - }: CloudPluginDeps = {}) => + plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) => [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], @@ -117,43 +61,6 @@ export default defineExecutorConfig({ dangerouslyAllowStdioMCP: false, }), graphqlHttpPlugin(), - appsHttpPlugin({ - ...(workerLoader - ? { - executor: makeDynamicWorkerAppToolExecutor({ loader: workerLoader }), - bundler: makeDynamicWorkerBundlerBackend({ - loader: workerLoader, - artifact: async () => { - const artifact = await import("virtual:executor/worker-bundler-artifact"); - if (artifact.source !== undefined && artifact.wasmBase64 !== undefined) { - return { - source: artifact.source, - wasm: base64ToArrayBuffer(artifact.wasmBase64), - }; - } - if (workerAssets === undefined) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: worker artifact asset binding is required for production bundler setup - throw new Error("worker-bundler artifact assets binding is unavailable"); - } - const [source, wasm] = await Promise.all([ - fetchAsset(workerAssets, artifact.sourcePath).then((response) => - response.text(), - ), - fetchAsset(workerAssets, artifact.wasmPath).then((response) => - response.arrayBuffer(), - ), - ]); - return { - source, - wasm, - }; - }, - }), - } - : {}), - sourceKinds: ["git"], - allowPrivateGitHosts: allowLocalNetwork === true, - }), toolkitsPlugin({ activeToolkitSlug }), workosVaultPlugin({ credentials: workosCredentials ?? { apiKey: "", clientId: "" }, diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 74276f6a6d..75fd2c7d7c 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cloud", - "version": "1.4.53", + "version": "1.4.68", "private": true, "type": "module", "scripts": { @@ -30,7 +30,10 @@ "typecheck:slow": "tsc --noEmit", "db:backfill-org-slugs:prod": "op run --env-file=.env.production -- bun run scripts/backfill-org-slugs.ts", "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts", - "routes:gen": "bun scripts/gen-routes.ts" + "db:backfill-subjects:prod": "op run --env-file=.env.production -- bun run scripts/backfill-subjects.ts", + "db:backfill-subjects:dev": "op run --env-file=.env.op -- bun run scripts/backfill-subjects.ts", + "routes:gen": "bun scripts/gen-routes.ts", + "vendor-wasm": "bun run scripts/vendor-quickjs-wasm.ts" }, "dependencies": { "@cloudflare/vite-plugin": "^1.31.1", @@ -41,7 +44,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", - "@executor-js/plugin-apps": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", @@ -52,6 +55,7 @@ "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", + "@jitl/quickjs-wasmfile-release-sync": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "~1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", @@ -73,6 +77,7 @@ "jose": "^5.6.3", "postgres": "^3.4.9", "posthog-js": "^1.372.5", + "quickjs-emscripten-core": "0.31.0", "react": "catalog:", "react-dom": "catalog:", "sonner": "^2.0.7" diff --git a/apps/cloud/scripts/assert-worker-bundler-assets.mjs b/apps/cloud/scripts/assert-worker-bundler-assets.mjs deleted file mode 100644 index 71851487f6..0000000000 --- a/apps/cloud/scripts/assert-worker-bundler-assets.mjs +++ /dev/null @@ -1,54 +0,0 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; - -const appRoot = new URL("..", import.meta.url); -const serverAssetsDir = new URL("dist/server/assets/", appRoot); -const clientAssetsDir = new URL("dist/client/assets/", appRoot); -const largeFileLimit = 3 * 1024 * 1024; -const sourceAssetMinimum = 512 * 1024; - -const fail = (message) => { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build assertion script reports a failed artifact contract - throw new Error(message); -}; - -const fileSize = (dir, name) => statSync(join(dir.pathname, name)).size; - -const serverAssets = readdirSync(serverAssetsDir); -for (const name of serverAssets) { - const path = join(serverAssetsDir.pathname, name); - const size = statSync(path).size; - if (size <= largeFileLimit) continue; - if (name.includes("worker-bundler")) { - fail(`server assets include oversized worker-bundler chunk ${name}`); - } - const sample = readFileSync(path, "utf8"); - if (sample.includes("worker-bundler")) { - fail(`server assets include worker-bundler content in oversized chunk ${name}`); - } -} - -const clientAssets = readdirSync(clientAssetsDir); -const source = clientAssets.find((name) => /^worker-bundler-source-[a-f0-9]{12}\.js$/.test(name)); -const wasm = clientAssets.find((name) => - /^worker-bundler-esbuild-wasm-[a-f0-9]{12}\.wasm$/.test(name), -); - -if (source === undefined) fail("missing worker-bundler source asset"); -if (wasm === undefined) fail("missing worker-bundler esbuild wasm asset"); - -const sourceSize = fileSize(clientAssetsDir, source); -const wasmSize = fileSize(clientAssetsDir, wasm); -if (sourceSize <= sourceAssetMinimum) { - fail(`worker-bundler source asset is unexpectedly small: ${source}`); -} -if (wasmSize <= largeFileLimit) fail(`worker-bundler wasm asset is unexpectedly small: ${wasm}`); - -const wasmMagic = readFileSync(join(clientAssetsDir.pathname, wasm)).subarray(0, 4); -if (!wasmMagic.equals(Buffer.from([0x00, 0x61, 0x73, 0x6d]))) { - fail(`worker-bundler wasm asset does not start with wasm magic bytes: ${wasm}`); -} - -console.log( - `[assert-worker-bundler-assets] ${source} (${sourceSize} bytes), ${wasm} (${wasmSize} bytes)`, -); diff --git a/apps/cloud/scripts/backfill-subjects.ts b/apps/cloud/scripts/backfill-subjects.ts new file mode 100644 index 0000000000..ab3d0bc0e2 --- /dev/null +++ b/apps/cloud/scripts/backfill-subjects.ts @@ -0,0 +1,87 @@ +/* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */ +// --------------------------------------------------------------------------- +// One-off data backfill: mint `subject` rows for principals that predate the +// subject table (added by migration 0012, written only on sightings from then +// on). +// +// bun run db:backfill-subjects:prod # op run --env-file=.env.production +// bun run db:backfill-subjects:dev # against the local PGlite dev db +// +// Source of truth is the `connection` table: before 0012, "which users exist +// under this tenant" was answerable only through connection rows, so a distinct +// (tenant, subject) there — excluding the org sentinel '' — is exactly the set +// of users the admin console under-reports until their next request. +// +// created_at ← the tenant+subject's oldest connection, the closest honest +// answer to "when did this user first appear". +// last_seen_at ← the tenant+subject's newest connection `updated_at`, the +// latest activity storage can attest to. Rendering every +// pre-0012 user as "Never" was literally true of the sighting +// column but reads as "inactive user" to an operator, which +// is the wrong answer for a tenant full of active people. The +// first real sighting overwrites this with request truth. +// +// Idempotent — the insert's ON CONFLICT on the (tenant, external_id) unique +// index leaves rows the runtime has since minted (or a prior run inserted) +// untouched, and the last_seen_at repair only fills NULLs — so re-running is +// safe and never rewinds a live row's fields. +// Pass --dry-run to print the plan without writing. +// --------------------------------------------------------------------------- + +import postgres from "postgres"; +import { createId } from "@executor-js/fumadb/cuid"; + +const dryRun = process.argv.includes("--dry-run"); + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + console.error("DATABASE_URL is not set"); + process.exit(1); +} + +const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" }); + +const rows = await sql< + { tenant: string; subject: string; first_connection_at: Date; last_activity_at: Date }[] +>` + SELECT tenant, subject, + MIN(created_at) AS first_connection_at, + MAX(updated_at) AS last_activity_at + FROM connection + WHERE subject <> '' + GROUP BY tenant, subject + ORDER BY tenant, subject +`; +console.log(`${rows.length} (tenant, subject) pair(s) with connections`); + +let inserted = 0; +let repaired = 0; +for (const row of rows) { + console.log( + `${row.tenant} ${row.subject} first connection ${row.first_connection_at.toISOString()} last activity ${row.last_activity_at.toISOString()}`, + ); + if (dryRun) continue; + const lastSeenMs = row.last_activity_at.getTime(); + const result = await sql` + INSERT INTO subject (row_id, tenant, external_id, created_at, last_seen_at, status) + VALUES (${createId()}, ${row.tenant}, ${row.subject}, ${row.first_connection_at}, ${lastSeenMs}, NULL) + ON CONFLICT (tenant, external_id) DO NOTHING + `; + inserted += result.count; + if (result.count > 0) continue; + // The row predates this run but may still carry NULL from a run of the + // pre-repair version of this script. Fill it in; a sighted row keeps its + // request-truth timestamp. + const repair = await sql` + UPDATE subject SET last_seen_at = ${lastSeenMs} + WHERE tenant = ${row.tenant} AND external_id = ${row.subject} AND last_seen_at IS NULL + `; + repaired += repair.count; +} + +console.log( + dryRun + ? `dry run — would insert up to ${rows.length} row(s)` + : `inserted ${inserted} row(s), repaired last_seen_at on ${repaired} row(s)`, +); +await sql.end(); diff --git a/apps/cloud/scripts/build.mjs b/apps/cloud/scripts/build.mjs index 707bfc60a4..cc02077be0 100644 --- a/apps/cloud/scripts/build.mjs +++ b/apps/cloud/scripts/build.mjs @@ -9,7 +9,7 @@ import { spawnSync } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { rmSync } from "node:fs"; +import { existsSync, readFileSync, rmSync } from "node:fs"; if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) { process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex"); @@ -22,9 +22,8 @@ const steps = [ // Workspace packages whose exports app code (or this very vite config) // resolves from dist under Node — vite's config loader externalizes bare // imports, so they must be built before vite starts. - "turbo run build --filter @executor-js/vite-plugin --filter @executor-js/react --filter @executor-js/plugin-apps", + "turbo run build --filter @executor-js/vite-plugin --filter @executor-js/react", "vite build", - "node scripts/assert-worker-bundler-assets.mjs", ]; for (const step of steps) { @@ -33,3 +32,22 @@ for (const step of steps) { process.exit(result.status ?? 1); } } + +// The deployed Worker serves the MCP-Apps shell (`ui://executor/shell.html`) +// by fetching this stable-named asset through the ASSETS binding — it has no +// filesystem to read the shell from. A build that failed to emit it would +// deploy a Worker whose artifact resource reads all fail, so make the miss a +// build failure here instead of a production incident. Same shape as the +// worker-bundler asset assertion from #1378. +const shellAsset = new URL( + "../dist/client/assets/executor-mcp-apps-shell-stable.html", + import.meta.url, +); +if (!existsSync(shellAsset)) { + console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`); + process.exit(1); +} +if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) { + console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`); + process.exit(1); +} diff --git a/apps/cloud/scripts/dev-db.ts b/apps/cloud/scripts/dev-db.ts index 482c869007..5b8ac7b91a 100644 --- a/apps/cloud/scripts/dev-db.ts +++ b/apps/cloud/scripts/dev-db.ts @@ -15,6 +15,7 @@ import { PGlite } from "@electric-sql/pglite"; import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; import { drizzle } from "drizzle-orm/pglite"; import { migrate } from "drizzle-orm/pglite/migrator"; +import postgres from "postgres"; const __dirname = dirname(fileURLToPath(import.meta.url)); // Port + data dir default to the dev values but are env-overridable so a second @@ -104,25 +105,46 @@ await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); // but prepared statement requires M" -> random 500s on whichever request lost // the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch // batches each socket data event into one queue entry and holds handler -// affinity while a pipeline is open; -// src/db/dev-db-socket-concurrency.node.test.ts is the regression test. -const server = new PGLiteSocketServer({ - db, - port: PORT, - host: "127.0.0.1", - maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000), - // Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse - // sent, no Sync) with its socket still OPEN would hold the queue's handler - // affinity forever and starve every other connection, since affinity only - // releases on detach and detach needs close/error/idle-timeout. In ms; the - // timer resets on every data event, so only a genuinely dead client trips it. - idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000), -}); +// affinity while a pipeline is open. The patch also fixes the queue's two +// self-bricking failure paths — both surfaced in CI as the e2e "cloud signIn: +// callback set no session (500)" cascade, where new connections' startup +// packets sat unanswered (postgres.js CONNECT_TIMEOUT) until restart: +// 1. Stock 0.1.4 `return`ed out of the drain loop when a query REJECTED at +// the JS level, leaving its `processing` flag latched true; nothing was +// ever dequeued again. +// 2. A client whose socket died WHILE its pipeline-opening entry executed: +// detach() cleared affinity before the entry finished, the queue then +// took affinity for the already-dead handler, and no timer was left to +// release it. The queue now tracks detached handlers and repairs any +// transaction or pipeline affinity they can no longer release. +// src/db/dev-db-socket-concurrency.node.test.ts is the regression test for +// all of the above. +const makeServer = () => + new PGLiteSocketServer({ + db, + port: PORT, + host: "127.0.0.1", + maxConnections: Number(process.env.DEV_DB_MAX_CONNECTIONS ?? 1000), + // Backstop for pipeline affinity: a client that stalls mid-pipeline (Parse + // sent, no Sync) with its socket still OPEN would hold the queue's handler + // affinity forever and starve every other connection, since affinity only + // releases on detach and detach needs close/error/idle-timeout. In ms; the + // timer resets on every data event. The patch scopes the reap to connections + // actually HOLDING affinity (open pipeline or transaction): an idle-at-rest + // connection is the normal state of a healthy postgres.js pool held by a + // long-lived scope (SSE), and reaping those raced live queries into + // sporadic `write CONNECTION_ENDED` 500s. + idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000), + }); +let server = makeServer(); await server.start(); console.log(`[dev-db] Listening on postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`); +let stopping = false; + const shutdown = async () => { + stopping = true; console.log("\n[dev-db] Shutting down"); await server.stop(); await db.close(); @@ -131,3 +153,129 @@ const shutdown = async () => { process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); + +// --------------------------------------------------------------------------- +// Wedge watchdog +// --------------------------------------------------------------------------- +// +// Twice the socket server has shipped a state machine that could stop +// answering NEW connections while the process, the port, and PGlite all stayed +// up (the CI e2e "cloud signIn: callback set no session (500)" cascades: every +// in-flight query dies once, then every fresh connection's startup packet +// times out — CONNECT_TIMEOUT — for the rest of the shard). The known paths +// are patched with regression tests, but each recurrence so far has found a +// new path, and a wedged front-end turns ONE infra hiccup into a failure of +// every remaining test in the shard. +// +// So: probe the server the way the app does — a fresh TCP connection, real +// startup handshake, `select 1` — and when several consecutive probes fail, +// dump the server's internals to the boot log and swap in a fresh socket +// server on the same PGlite instance (all state is in PGlite; the front-end is +// stateless, so this drops only already-doomed connections). If a restart +// doesn't restore service, exit non-zero: the boot supervisor logs the exit +// loudly and the run fails fast with an attributable cause instead of minutes +// of anonymous CONNECT_TIMEOUTs. The wedge itself stays visible in the +// server-logs artifact via the [dev-db][watchdog] lines. +const WATCHDOG_INTERVAL_MS = Number(process.env.DEV_DB_WATCHDOG_INTERVAL_MS ?? 5_000); +// 3 consecutive failures ≈ 15s+ of hard unavailability. PGlite serves queries +// in milliseconds; even a deep queue clears in well under one probe interval, +// so consecutive startup failures this sustained only happen wedged. +const WATCHDOG_FAILURES_TO_RESTART = 3; +const WATCHDOG_MAX_RESTARTS = 3; + +const probe = async (): Promise => { + const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, { + max: 1, + idle_timeout: 0, + connect_timeout: 5, + fetch_types: false, + prepare: false, + onnotice: () => undefined, + }); + try { + // connect_timeout only bounds the handshake; race the query too so a + // post-startup wedge cannot hang the watchdog itself. + await Promise.race([ + sql.unsafe("select 1"), + sleep(10_000).then(() => { + throw new Error("probe query timed out after 10s"); + }), + ]); + } finally { + await sql.end({ timeout: 5 }).catch(() => {}); + } +}; + +const watchdog = async () => { + let consecutiveFailures = 0; + let restarts = 0; + // Heartbeat: the CI e2e cascade of 2026-08-28 (run 33129376530) showed the + // app starving on CONNECT_TIMEOUT for 100+ seconds while every watchdog + // probe silently PASSED — the stall was on the app's side of the socket, + // not this server's. A silent-when-healthy watchdog cannot distinguish + // "healthy" from "not running", and it discards the one signal that would + // test the leading theory (workerd leaking dev-db connections until its + // socket layer starves): the active connection count over time. Log stats + // periodically and whenever the count jumps a bucket. + let lastHeartbeatAt = Date.now(); + let lastLoggedBucket = 0; + for (;;) { + await sleep(WATCHDOG_INTERVAL_MS); + if (stopping) return; + try { + await probe(); + consecutiveFailures = 0; + const stats = server.getStats(); + const bucket = Math.floor(stats.activeConnections / 50); + if (bucket !== lastLoggedBucket || Date.now() - lastHeartbeatAt >= 60_000) { + lastLoggedBucket = bucket; + lastHeartbeatAt = Date.now(); + console.log(`[dev-db][watchdog] healthy; stats: ${JSON.stringify(stats)}`); + } + } catch (cause) { + consecutiveFailures += 1; + console.error( + `[dev-db][watchdog] probe failed (${consecutiveFailures}/${WATCHDOG_FAILURES_TO_RESTART}): ${String(cause)}`, + ); + if (consecutiveFailures < WATCHDOG_FAILURES_TO_RESTART) continue; + console.error( + `[dev-db][watchdog] socket server wedged; stats: ${JSON.stringify(server.getStats())}`, + ); + if (restarts >= WATCHDOG_MAX_RESTARTS) { + console.error( + `[dev-db][watchdog] still wedged after ${restarts} restarts — giving up so the boot supervisor reports it`, + ); + process.exit(1); + } + restarts += 1; + consecutiveFailures = 0; + console.error( + `[dev-db][watchdog] restarting socket server (${restarts}/${WATCHDOG_MAX_RESTARTS})`, + ); + // stop() itself goes through the query queue (detach rolls back open + // transactions), so a wedge deep enough can hang the restart too — + // bound it and treat that as fatal rather than hanging the watchdog. + const restart = async () => { + await server.stop(); + server = makeServer(); + await server.start(); + }; + try { + await Promise.race([ + restart(), + sleep(15_000).then(() => { + throw new Error("restart timed out after 15s"); + }), + ]); + console.error(`[dev-db][watchdog] socket server restarted`); + } catch (restartCause) { + console.error( + `[dev-db][watchdog] restart failed (${String(restartCause)}) — exiting so the boot supervisor reports it`, + ); + process.exit(1); + } + } + } +}; + +void watchdog(); diff --git a/apps/cloud/scripts/start-closure.mjs b/apps/cloud/scripts/start-closure.mjs new file mode 100644 index 0000000000..a841cb5977 --- /dev/null +++ b/apps/cloud/scripts/start-closure.mjs @@ -0,0 +1,124 @@ +// --------------------------------------------------------------------------- +// Measure the Worker's *evaluated* module closures from the build output. +// --------------------------------------------------------------------------- +// +// Upload size does not predict cold-isolate cost: a Worker can ship megabytes +// that never load, and the bytes that matter are the ones an isolate must +// evaluate before it can answer. Rollup already separates static from dynamic +// edges, so we can compute that split directly instead of reading totals. +// +// Two closures matter: +// startup - statically reachable from the Worker entry. Evaluated on every +// cold isolate before any request is served. +// start - the TanStack Start server graph, reached through the lazy +// `loadEntries` dynamic imports. Evaluated on the first request +// that enters the app - i.e. a page request. +// app - the Effect app plane. `/api/*` dispatches at the Worker entry and +// skips Start entirely, so an API request evaluates this instead of +// `start`; reported separately because the two planes now diverge. +// +// Anything reachable only through a dynamic import is not counted: making a +// heavy dependency lazy is exactly the outcome this rewards. +// +// Scope note: this measures bytes, which is a proxy for cold-start cost, not a +// proven cause of any particular regression. The Aug 2026 MCP incident was +// NOT explained by this number - reverting the offending packages moved the +// evaluated closure by 0.02 MB while restoring production latency, so +// module-scope execution cost, not size, drove that one. Treat a budget breach +// as "this will make cold starts worse", not as "this is why the site is slow". +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; + +const DIST = resolve(process.argv[2] ?? "dist/server"); +const ENTRY = join(DIST, "index.js"); + +// Rollup emits `from "./x.js"`, bare `import "./x.js"`, `export ... from "./x.js"` +// (all static) and `import("./x.js")` (dynamic). Matching the emitted output +// rather than source means we see the graph as the runtime sees it. +const STATIC_RE = /(?:from|import)\s*["'](\.[^"']+)["']/g; +const DYNAMIC_RE = /import\(\s*["'](\.[^"']+)["']\s*\)/g; + +const listChunks = (dir) => + readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() + ? listChunks(join(dir, e.name)) + : e.name.endsWith(".js") + ? [join(dir, e.name)] + : [], + ); + +const graph = new Map(); +for (const file of listChunks(DIST)) { + const code = readFileSync(file, "utf8"); + const dynamic = new Set([...code.matchAll(DYNAMIC_RE)].map((m) => resolve(dirname(file), m[1]))); + // A specifier inside `import(...)` also matches STATIC_RE's `import` branch, + // so subtract the dynamic set rather than trusting the static matches alone. + const staticDeps = new Set( + [...code.matchAll(STATIC_RE)] + .map((m) => resolve(dirname(file), m[1])) + .filter((p) => !dynamic.has(p)), + ); + graph.set(file, { size: statSync(file).size, static: staticDeps, dynamic }); +} + +/** Bytes evaluated when `roots` are loaded, following static edges only. */ +const closure = (roots) => { + const seen = new Set(); + const queue = [...roots]; + while (queue.length > 0) { + const file = queue.pop(); + if (seen.has(file) || !graph.has(file)) continue; + seen.add(file); + queue.push(...graph.get(file).static); + } + return seen; +}; + +const bytes = (files) => [...files].reduce((sum, f) => sum + (graph.get(f)?.size ?? 0), 0); +const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`; +const name = (f) => relative(DIST, f); + +const startup = closure([ENTRY]); +// The lazy server-graph entries Start pulls on first request. +const startRoots = [...graph.get(ENTRY).dynamic].filter((f) => + /(start|router|tanstack)/.test(name(f)), +); +const start = closure(startRoots); +const appRoots = [...graph.get(ENTRY).dynamic].filter((f) => /\/app-[A-Za-z0-9_-]+\.js$/.test(f)); +const app = closure([ENTRY, ...appRoots]); +// The budget tracks the worst plane: whichever costs a cold isolate more. +const evaluated = new Set([...startup, ...start]); + +const report = (label, files) => { + console.log(`\n${label}: ${mb(bytes(files))} (${files.size} chunks)`); + const own = [...files].filter((f) => !startup.has(f) || label === "startup"); + for (const f of own.sort((a, b) => graph.get(b).size - graph.get(a).size).slice(0, 12)) { + console.log(` ${(graph.get(f).size / 1024).toFixed(0).padStart(6)} KB ${name(f)}`); + } +}; + +report("startup", startup); +report("start", start); +console.log(`\npage request (startup + start): ${mb(bytes(evaluated))}`); +console.log( + `API request (startup + app): ${mb(bytes(app))}${appRoots.length ? "" : " [no app chunk - /api still routes through Start]"}`, +); +const lazyOnly = [...graph.keys()].filter((f) => !evaluated.has(f)); +console.log( + `deferred behind dynamic import: ${mb(bytes(lazyOnly))} (${lazyOnly.length} chunks)`, +); + +const budget = Number(process.env.START_CLOSURE_BUDGET_MB ?? 0); +if (budget > 0) { + const actual = bytes(evaluated) / 1024 / 1024; + console.log(`\nbudget ${budget} MB — actual ${actual.toFixed(2)} MB`); + if (actual > budget) { + console.error( + `\nFAIL: evaluated closure ${actual.toFixed(2)} MB exceeds the ${budget} MB budget.\n` + + `Every cold isolate pays to evaluate this closure before it can answer. Move the\n` + + `new weight behind a dynamic import rather than raising the budget; run this\n` + + `script with no budget set to see the biggest members and what is already lazy.`, + ); + process.exit(1); + } +} diff --git a/apps/cloud/scripts/test-globalsetup.ts b/apps/cloud/scripts/test-globalsetup.ts index d7dd916edb..7efc25afe0 100644 --- a/apps/cloud/scripts/test-globalsetup.ts +++ b/apps/cloud/scripts/test-globalsetup.ts @@ -13,12 +13,31 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const PORT = 5434; +// 0 asks the OS for a free port — used by the globalsetup-exit fixture, whose +// test never connects to the database. A suite whose tests DO connect (the +// default 5434 path, matched by DATABASE_URL in vitest.config.ts) must pass a +// real port. Fixed ports must stay below 32768: the Linux ephemeral range +// (32768-60999) is contested by every concurrent suite's outbound sockets. +const parsePort = (input: string | undefined): number => { + if (input === undefined) return 5434; + if (!/^\d+$/.test(input)) throw new Error("CLOUD_TEST_DB_PORT must be an integer"); + const port = Number(input); + if (!Number.isSafeInteger(port) || port > 65_535) { + throw new Error("CLOUD_TEST_DB_PORT must be between 0 and 65535"); + } + return port; +}; + +const PORT = parsePort(process.env.CLOUD_TEST_DB_PORT); const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle"); let db: PGlite | undefined; let server: PGLiteSocketServer | undefined; +/** + * Starts the cloud unit-test database and returns teardown that releases every + * resource without allowing PGlite shutdown to erase Vitest's failure status. + */ export default async function setup() { db = await PGlite.create(); await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); @@ -30,7 +49,12 @@ export default async function setup() { console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`); return async () => { + // PGlite sets an internal 99 sentinel on startup and replaces it with 0 on + // close. Preserve Vitest's failure status across that close so + // teardown can turn neither a red test green nor a green test red. + const testsFailed = process.exitCode === 1; await server?.stop(); await db?.close(); + if (testsFailed) process.exitCode = 1; }; } diff --git a/apps/cloud/scripts/vendor-quickjs-wasm.ts b/apps/cloud/scripts/vendor-quickjs-wasm.ts new file mode 100644 index 0000000000..1518babf47 --- /dev/null +++ b/apps/cloud/scripts/vendor-quickjs-wasm.ts @@ -0,0 +1,18 @@ +// Vendors the QuickJS engine WASM into src/ so wrangler's CompiledWasm module +// rule (rooted at the app dir) can statically compile it at build time. Workers +// forbid runtime WASM compilation, and the rule's glob won't reach the +// monorepo-root node_modules, so the bytes must live inside this app. +// +// Re-run after bumping @jitl/quickjs-wasmfile-release-sync: +// bun run scripts/vendor-quickjs-wasm.ts +import { copyFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const source = require.resolve("@jitl/quickjs-wasmfile-release-sync/wasm"); +const dest = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "quickjs-engine.wasm"); + +copyFileSync(source, dest); +console.log(`vendored ${source} -> ${dest}`); diff --git a/apps/cloud/src/account/account-api.ts b/apps/cloud/src/account/account-api.ts index 94f4342781..a67d6f4f6d 100644 --- a/apps/cloud/src/account/account-api.ts +++ b/apps/cloud/src/account/account-api.ts @@ -72,13 +72,16 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi // Built inside the request body so the WorkOS account service closes // over the per-request `UserStoreService` (postgres socket) supplied by - // the combined request-scoped layer. + // the combined request-scoped layer. `local` keeps that promise: the + // `longLived` context re-applied below carries the boot `CurrentMemoMap`, + // so a shared build would hand overlapping requests one another's socket. const accountProvider = yield* Effect.provide( AccountProvider.asEffect(), workosAccountProvider.pipe( Layer.provide(ApiKeyService.WorkOS), Layer.provide(Layer.succeed(AccountCaller)({ session })), ), + { local: true }, ); return yield* Effect.provideService(httpEffect, AccountProvider, accountProvider); }).pipe(Effect.provideContext(longLived)); diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts new file mode 100644 index 0000000000..5b96f01f74 --- /dev/null +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { AccountProvider } from "@executor-js/api/server"; +import { AccountError, AccountForbidden } from "@executor-js/api"; + +import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys"; +import { UserStoreService } from "../auth/context"; +import { ORG_SELECTOR_HEADER } from "../auth/organization"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { AutumnService } from "../extensions/billing/service"; +import { AccountCaller, workosAccountProvider } from "./workos-account-service"; + +// --------------------------------------------------------------------------- +// `DELETE /account/org-api-keys/:apiKeyId` at the PROVIDER boundary. +// +// An org key reads every user and connection in the tenant through `/admin/*`, +// so it is the most privileged credential the product issues. Two properties +// have to hold together, and only this seam sees both: +// +// 1. Revoke is gated on org ADMIN membership — the same gate as the mint. A +// plain member calling it gets 403 and no delete goes out. +// 2. Revoke resolves ownership against the ORG's keys. `revokeApiKey` (the +// personal path) checks the caller's OWN key list, so it can never revoke +// an org key; conversely this path must never destroy a member's personal +// key. The scope-blind `deleteApiKey` would do exactly that if handed a +// user key id, so a wrong id has to fail cleanly instead. +// +// Ownership filtering itself is pinned per-shape in +// `auth/org-api-key-mint.node.test.ts`; here it is asserted end-to-end through +// the gate, on the provider the HTTP handler actually calls. +// --------------------------------------------------------------------------- + +const ORG = "org_123"; +const ADMIN = "user_admin"; +const MEMBER = "user_member"; +const ORG_KEY = "key_org_1"; +const USER_KEY = "key_user_1"; +const createdAt = new Date("2026-01-01T00:00:00.000Z"); +const orgHeaders = { [ORG_SELECTOR_HEADER]: ORG }; + +const session = (accountId: string) => ({ + accountId, + email: `${accountId}@example.test`, + name: null, + avatarUrl: null, + organizationId: ORG, + sealedSession: "sealed", + refreshedSession: null, +}); + +/** Membership roles: only ADMIN carries the `admin` role slug. */ +const stubWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => { + if (prop === "listUserMemberships") { + return (userId: string) => + Effect.succeed({ + data: [{ userId, organizationId: ORG, status: "active" }], + }); + } + if (prop === "getUserOrgMembership") { + return (organizationId: string, userId: string) => + Effect.succeed( + organizationId === ORG + ? { + id: `om_${userId}`, + userId, + organizationId, + role: { slug: userId === ADMIN ? "admin" : "member" }, + } + : null, + ); + } + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); + }, + }), +); + +const stubUsers = Layer.succeed(UserStoreService)({ + use: (_op, fn) => + Effect.promise(() => + fn({ + ensureAccount: async (id: string) => ({ id, createdAt }), + getAccount: async (id: string) => ({ id, createdAt }), + upsertOrganization: async (org: { id: string; name: string }) => ({ + ...org, + slug: org.id, + createdAt, + }), + getOrganization: async (id: string) => ({ + id, + name: `Org ${id}`, + slug: id, + createdAt, + }), + getOrganizationBySlug: async (slug: string) => ({ + id: slug, + name: `Org ${slug}`, + slug, + createdAt, + }), + deleteOrganizationCascade: async () => {}, + }), + ), +}); + +const stubAutumn = Layer.succeed(AutumnService)({ + use: () => Effect.die("revoke does not touch billing"), + ensureCustomer: () => Effect.die("revoke does not touch billing"), + checkExecutionBalance: () => Effect.die("revoke does not touch billing"), + trackExecution: () => Effect.void, + setMemberSeats: () => Effect.void, +}); + +/** + * The provider under test, over an `ApiKeyService` whose org listing holds a + * single org-owned key. Revoked ids land in `revoked` so "no delete happened" + * is assertable, not merely inferred from the error. + */ +const providerWith = (accountId: string) => { + const revoked: string[] = []; + const stubApiKeys = Layer.succeed(ApiKeyService)({ + validate: () => Effect.die("revoke test does not validate keys"), + listUserKeys: () => Effect.succeed([]), + createUserKey: () => Effect.die("revoke test does not create user keys"), + revokeUserKey: () => Effect.die("the ORG path must not call the personal revoke"), + listOrgKeys: () => Effect.die("revoke test does not list org keys"), + createOrgKey: () => Effect.die("revoke test does not create org keys"), + revokeOrgKey: ({ keyId }) => + keyId === ORG_KEY + ? Effect.sync(() => { + revoked.push(keyId); + }) + : Effect.fail(new OrgApiKeyNotFound({ keyId })), + }); + + const provider = AccountProvider.asEffect().pipe( + Effect.provide( + workosAccountProvider.pipe( + Layer.provide( + Layer.mergeAll( + stubWorkOS, + stubUsers, + stubApiKeys, + stubAutumn, + Layer.succeed(AccountCaller)({ session: session(accountId) }), + ), + ), + ), + ), + ); + + return { provider, revoked }; +}; + +describe("revokeOrgApiKey · provider boundary", () => { + it.effect("an org admin revokes an org-owned key", () => + Effect.gen(function* () { + const { provider, revoked } = providerWith(ADMIN); + const account = yield* provider; + + const result = yield* account.revokeOrgApiKey(orgHeaders, ORG_KEY); + + expect(result).toEqual({ success: true }); + expect(revoked, "the revoke reached the key service").toEqual([ORG_KEY]); + }), + ); + + it.effect("a plain member is forbidden, and nothing is revoked", () => + Effect.gen(function* () { + const { provider, revoked } = providerWith(MEMBER); + const account = yield* provider; + + const error = yield* Effect.flip(account.revokeOrgApiKey(orgHeaders, ORG_KEY)); + + expect(error, "same admin gate as the mint").toBeInstanceOf(AccountForbidden); + expect(revoked, "the gate runs BEFORE the key service is touched").toEqual([]); + }), + ); + + it.effect("a user-scoped key id fails cleanly through this path", () => + Effect.gen(function* () { + // Not found — not a 500, and emphatically not a silent success that would + // report a member's live personal credential as destroyed. + const { provider, revoked } = providerWith(ADMIN); + const account = yield* provider; + + const error = yield* Effect.flip(account.revokeOrgApiKey(orgHeaders, USER_KEY)); + + expect(error).toBeInstanceOf(AccountError); + expect(revoked).toEqual([]); + }), + ); +}); diff --git a/apps/cloud/src/account/organization-limits.node.test.ts b/apps/cloud/src/account/organization-limits.node.test.ts index f957a314b2..502c96a0fe 100644 --- a/apps/cloud/src/account/organization-limits.node.test.ts +++ b/apps/cloud/src/account/organization-limits.node.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { FREE_ORGANIZATIONS_PER_USER_LIMIT, + hasNonFreeOrganizationSubscription, hasPaidOrganizationSubscription, isOverFreeOrganizationLimit, shouldApplyFreeOrganizationLimit, @@ -22,6 +23,37 @@ describe("organization limits", () => { expect(hasPaidOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false); }); + it("exempts any active non-free subscription from the execution rate limit", () => { + expect(hasNonFreeOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true); + expect(hasNonFreeOrganizationSubscription([{ planId: "enterprise", status: "trialing" }])).toBe( + true, + ); + // Grandfathered and pay-as-you-go plans are not in the paid set, but they + // are not Free either — the backstop must never cap them. + expect(hasNonFreeOrganizationSubscription([{ planId: "professional", status: "active" }])).toBe( + true, + ); + expect(hasNonFreeOrganizationSubscription([{ planId: "hobby", status: "active" }])).toBe(true); + expect( + hasNonFreeOrganizationSubscription([{ planId: "free-pay-as-you-go", status: "active" }]), + ).toBe(true); + expect( + hasNonFreeOrganizationSubscription([ + { planId: "free", status: "active" }, + { planId: "team", status: "active" }, + ]), + ).toBe(true); + }); + + it("rate-limits orgs that hold nothing but Free or inactive subscriptions", () => { + expect(hasNonFreeOrganizationSubscription([])).toBe(false); + expect(hasNonFreeOrganizationSubscription([{ planId: "free", status: "active" }])).toBe(false); + expect(hasNonFreeOrganizationSubscription([{ planId: "team", status: "canceled" }])).toBe( + false, + ); + expect(hasNonFreeOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false); + }); + it("applies the free org limit only when none of the user's active orgs are paid", () => { const activeMemberships = [ { organizationId: "org_free_1", status: "active" }, diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts index e2ab740e36..dc8b234b1f 100644 --- a/apps/cloud/src/account/workos-account-service.ts +++ b/apps/cloud/src/account/workos-account-service.ts @@ -14,6 +14,7 @@ import type { Session } from "../auth/middleware"; import { WorkOSClient } from "../auth/workos"; import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; import { AutumnService } from "../extensions/billing/service"; +import { forkReportMemberSeats } from "../extensions/billing/member-seats"; import { countSeatsUsed, getMemberLimitForPlan, @@ -82,7 +83,7 @@ export const workosAccountProvider: Layer.Layer< // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) — // can be erased to `R = never`, as the neutral AccountProvider shape // requires. Provided per method below. - const ctx = yield* Effect.context(); + const ctx = yield* Effect.context(); // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly // as the old inline `requireSession` did. @@ -91,16 +92,19 @@ export const workosAccountProvider: Layer.Layer< ? Effect.succeed(caller.session) : Effect.fail(new AccountUnauthorized()); - // The org scope for an org-scoped request: the console URL's org (sent in - // the selector header) when present, else the session's own org. Membership - // is re-checked live, so the header is a selector, not a trust boundary — + // The org scope for an org-scoped request: the console URL's org, sent in + // the selector header. FAIL CLOSED when it's missing — the session's own + // org is a browser-global pinned to whichever org WorkOS last touched, so + // falling back to it scopes a multi-org user's request to the WRONG org + // (see workos-auth-provider.resolveSessionPrincipal). Membership is + // re-checked live, so the header is a selector, not a trust boundary — // and two browser tabs on different orgs each send their own header, so // they stay independent (see organization.ts). Yields the session + // resolved org, or AccountNoOrganization. const requireOrganization = (headers: AccountHeaders) => Effect.gen(function* () { const session = yield* requireSession(); - const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; + const selector = headers[ORG_SELECTOR_HEADER]; if (!selector) { return yield* new AccountNoOrganization(); } @@ -180,9 +184,15 @@ export const workosAccountProvider: Layer.Layer< me: (headers) => Effect.gen(function* () { const session = yield* requireSession(); - // Same selector precedence as requireOrganization: the URL's org - // (header) drives /account/me so the shell reflects the org the tab - // is viewing, not a session-global active org. + // The ONE sanctioned fallback to the session org. /account/me is + // identity, not tenant data: on a bare URL (no org slug yet, so no + // header) it answers "which org should this browser land in", and + // the session's pinned org is the only candidate. Every org-scoped + // DATA read fails closed instead (requireOrganization above, + // resolveSessionPrincipal) — serving tenant data from this fallback + // is exactly the wrong-org connection-list bug (2026-07). With a + // header (any slugged URL), the URL's org drives the answer so the + // shell reflects the org the tab is viewing. const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; const org = selector ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe( @@ -237,6 +247,52 @@ export const workosAccountProvider: Layer.Layer< return { success: true }; }), + // Org keys read every user in the tenant through the `/admin/*` plane, so + // both paths are admin-gated — unlike personal keys, which any member may + // mint for themselves. + listOrgApiKeys: (headers) => + Effect.gen(function* () { + const { session, org } = yield* requireOrganization(headers); + yield* requireAdmin(session.accountId, org.id); + const keys = yield* apiKeys + .listOrgKeys({ organizationId: org.id }) + .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); + return { apiKeys: keys }; + }), + + createOrgApiKey: (headers, name) => + Effect.gen(function* () { + const { session, org } = yield* requireOrganization(headers); + yield* requireAdmin(session.accountId, org.id); + const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH); + if (!trimmed) { + return yield* new AccountError({ message: "API key name is required" }); + } + return yield* apiKeys + .createOrgKey({ organizationId: org.id, name: trimmed }) + .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); + }), + + // Same admin gate as the mint. Note this deliberately does NOT reuse + // `revokeApiKey`: that path resolves ownership against the caller's + // personal keys, so an org key id is never in its owned set. Ownership + // here is resolved against the ORG's keys inside `revokeOrgKey`, which is + // what keeps a user-scoped (or foreign-org) key id from being destroyed + // through the org route — it surfaces as a 404-shaped AccountError, not a + // silent success and not a 500. + revokeOrgApiKey: (headers, apiKeyId) => + Effect.gen(function* () { + const { session, org } = yield* requireOrganization(headers); + yield* requireAdmin(session.accountId, org.id); + yield* apiKeys.revokeOrgKey({ organizationId: org.id, keyId: apiKeyId }).pipe( + Effect.catchTag("ApiKeyManagementError", toAccountError), + Effect.catchTag("OrgApiKeyNotFound", () => + Effect.fail(new AccountError({ message: "Organization API key not found" })), + ), + ); + return { success: true }; + }), + listMembers: (headers) => Effect.gen(function* () { const { session, org } = yield* requireOrganization(headers); @@ -309,6 +365,7 @@ export const workosAccountProvider: Layer.Layer< yield* workos .deleteOrgMembership(membershipId) .pipe(Effect.catchTag("WorkOSError", toAccountError)); + yield* forkReportMemberSeats(org.id).pipe(Effect.provideContext(ctx)); return { success: true }; }), @@ -331,7 +388,9 @@ export const workosAccountProvider: Layer.Layer< .updateOrganization(org.id, name) .pipe(Effect.catchTag("WorkOSError", toAccountError)); yield* users - .use((s) => s.upsertOrganization({ id: updated.id, name: updated.name })) + .use("upsertOrganization", (s) => + s.upsertOrganization({ id: updated.id, name: updated.name }), + ) .pipe(Effect.catchTag("UserStoreError", toAccountError)); return { name: updated.name }; }), diff --git a/apps/cloud/src/admin/admin-users-api.ts b/apps/cloud/src/admin/admin-users-api.ts new file mode 100644 index 0000000000..cae66fcb5f --- /dev/null +++ b/apps/cloud/src/admin/admin-users-api.ts @@ -0,0 +1,357 @@ +// --------------------------------------------------------------------------- +// Cloud admin users API — the shared, provider-neutral `AdminUsersHandlers` +// backed by a WorkOS-authorized platform view, mounted at `/api/admin/users*`. +// +// TWO credentials reach this plane, and they are the two an operator actually +// has: +// 1. an ORG-SCOPED api key -> `PlatformAuth`. The key IS the authority: WorkOS +// validated it and reported which org owns it, and there is no member +// behind it to check membership for. This is the machine credential +// (a customer's backend calling us). +// 2. an admin SESSION member -> the console. Requires a live `getUserOrgMembership` +// whose role slug is `admin` AND whose status is `active`, matching the +// strictest existing cloud guard (`auth/handlers.ts`'s org-delete check) — +// a pending admin invite is not an admin. +// A plain member session, or a USER-scoped api key, is refused: both name one +// acting member, and this plane deliberately serves the whole tenant. +// +// The executor is built by `makePlatformExecutor` — `{ tenant, subject: +// undefined, platformView: true }` — so the reads are tenant-wide and read-only +// by storage policy, and no `subject` row is minted for the caller. +// +// Cross-tenant isolation is structural, not a check in this file: the tenant is +// taken from the resolved credential (the key's own org, or the session's +// authorized org), never from client input, and `reach: "tenant"` still filters +// every query by that tenant. +// --------------------------------------------------------------------------- + +import { env } from "cloudflare:workers"; +import { HttpRouter } from "effect/unstable/http"; +import { Context, Effect, Layer, Option } from "effect"; + +import { + AdminUsersProvider, + DbProvider, + HostConfig, + PluginsProvider, + getAdminUser, + listAdminUserConnections, + listAdminUsers, + listAdminUsersWithConnections, + makeAdminUsersApiLayer, + makePlatformExecutor, + normalizeAdminUserEmail, + platformViewOf, + requestScopedMiddleware, + type AdminEmailResolver, + type AdminIdentityDirectory, + type AdminUserDirectory, + type AdminUserIdentity, + type AdminUsersHeaders, +} from "@executor-js/api/server"; +import { + AdminUserNotFound, + AdminUsersError, + AdminUsersForbidden, + AdminUsersUnauthorized, +} from "@executor-js/api"; +import type { Executor } from "@executor-js/sdk"; + +import { ApiKeyService } from "../auth/api-keys"; +import { UserStoreService } from "../auth/context"; +import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider"; +import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization"; +import { WorkOSClient } from "../auth/workos"; +import { DbService } from "../db/db"; +import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; + +/** + * Resolve the tenant this request may read, or fail with the neutral 401/403. + * + * Returns only the organization id: nothing downstream needs to know WHICH of + * the two credentials got the caller here, and keeping the acting member out of + * the return value means no admin read can accidentally become subject-scoped. + */ +const authorizeTenant = ( + request: Request, +): Effect.Effect< + string, + AdminUsersUnauthorized | AdminUsersForbidden, + WorkOSClient | ApiKeyService | UserStoreService +> => + Effect.gen(function* () { + // (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`, + // which rejects org keys for the product plane) is what distinguishes an + // org key from a user key. + const bearer = yield* resolveBearerAuth(request).pipe( + // Every rejected-credential and infra failure collapses to one refusal: + // this plane must not report whether a key exists, belongs to another + // org, or merely lacks privilege. + Effect.catchCause(() => Effect.succeed(null)), + ); + if (bearer !== null) { + if (isPlatformAuth(bearer)) return bearer.organizationId; + // A user-scoped key authenticated fine but names one member; the platform + // plane has no honest way to serve it. + return yield* new AdminUsersForbidden(); + } + + // (2) The session path: a live admin membership in the selected org. + const workos = yield* WorkOSClient; + const session = yield* workos + .authenticateRequest(request) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (!session) return yield* new AdminUsersUnauthorized(); + + const selector = orgSelectorFromRequest(request) ?? session.organizationId; + if (!selector) return yield* new AdminUsersForbidden(); + // Re-checks live membership, so the org selector header can only ever name + // an org the caller already belongs to. + const org = yield* authorizeOrganizationSelector(session.userId, selector).pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (!org) return yield* new AdminUsersForbidden(); + + const membership = yield* workos + .getUserOrgMembership(org.id, session.userId) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + // A pending admin invite is not an active admin — require both. + if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") { + return yield* new AdminUsersForbidden(); + } + return org.id; + }); + +/** + * How many user-detail reads run at once. Matches the account plane's own + * member listing (`workos-account-service.ts`), which fans out the same way for + * the same reason. + */ +const IDENTITY_CONCURRENCY = 5; + +/** + * Cloud's member directory: `externalId` → email/name. + * + * THE JOIN KEY is the membership's `userId` — the WorkOS `user_...` that + * `workos-auth-provider.ts` binds as `accountId` on every credential path, and + * therefore what the subject table records in `external_id`. The membership's + * own `id` is an `om_...` row id and joins to nothing. + * + * WHY THIS IS TWO CALLS AND NOT ONE. The membership list is read once per + * request and is the authority on who belongs to the org, but WorkOS's + * `listOrganizationMemberships` carries no user detail and offers no + * include/expand — email and name only exist on the user resource. The SDK does + * expose a batched `listUsers({ organizationId })`, but the pinned + * `@executor-js/emulate` WorkOS emulator serves only `GET + * /user_management/users/:id`, so taking that path would leave every cloud e2e + * user unnamed. So: ONE membership read per request, then user detail fetched + * only for the ids ON THIS PAGE — never for the whole org, and never once per + * row of some larger list. An id that is not an active/pending member is not + * fetched at all and reports absent identity, which is the honest answer for a + * member who left while their connections remain. + */ +const identityDirectory = + (organizationId: string, context: Context.Context): AdminIdentityDirectory => + (externalIds) => + Effect.gen(function* () { + const workos = yield* WorkOSClient; + const memberships = yield* workos.listOrgMembers(organizationId); + const wanted = new Set(externalIds); + const memberIds = memberships.data + .map((membership) => membership.userId) + .filter((userId) => wanted.has(userId)); + + const resolved = yield* Effect.all( + memberIds.map((userId) => + workos.getUser(userId).pipe( + Effect.map( + (user) => + [ + userId, + { + email: user.email, + displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, + }, + ] as const, + ), + // One unreadable user must not cost the whole page its names. + Effect.catchCause(() => Effect.succeed(null)), + ), + ), + { concurrency: IDENTITY_CONCURRENCY }, + ); + + const identities = new Map(); + for (const entry of resolved) if (entry) identities.set(entry[0], entry[1]); + return identities; + }).pipe(Effect.provideContext(context)); + +/** + * Cloud's REVERSE directory lookup: email → the WorkOS `user_...` id. + * + * Production asks WorkOS for the email AND organization in one request. Both + * filters matter: email makes the lookup indexed rather than one `getUser` + * request per member, while organization keeps the reverse lookup bound to the + * same tenant as the platform view. + * + * The pinned `@executor-js/emulate` WorkOS emulator has no list-users route. + * `WORKOS_API_URL` is the explicit test/dev emulator override, so that path + * retains the membership scan until the emulator supports the production + * query. The fallback still starts from the tenant's membership list and can + * never return a user from another organization. + * + * CASING: WorkOS preserves whatever casing an email was created with (and the + * emulator compares byte-exact), so the directory value is normalized here + * before comparison, against an argument the seam already normalized. + */ +export const emailResolver = + (organizationId: string, context: Context.Context): AdminEmailResolver => + (email) => + Effect.gen(function* () { + const workos = yield* WorkOSClient; + + if (!env.WORKOS_API_URL) { + const users = yield* workos.listUsers({ email, organizationId }); + return users.data[0]?.id ?? null; + } + + const memberships = yield* workos.listOrgMembers(organizationId); + const userIds = memberships.data.map((membership) => membership.userId); + + // Emulator compatibility only. Short-circuit once the normalized email + // matches so the fallback makes as few unsupported-detail reads as it can. + const match = yield* Effect.findFirst(userIds, (userId) => + workos.getUser(userId).pipe( + Effect.map((user) => normalizeAdminUserEmail(user.email ?? "") === email), + // One unreadable user must not fail the whole lookup — it simply + // cannot be the match. + Effect.catchCause(() => Effect.succeed(false)), + ), + ); + return Option.getOrNull(match); + }).pipe(Effect.provideContext(context)); + +/** Both directions of cloud's directory, built once per authorized request. */ +const userDirectory = ( + organizationId: string, + context: Context.Context, +): AdminUserDirectory => ({ + identities: identityDirectory(organizationId, context), + resolveEmail: emailResolver(organizationId, context), +}); + +/** + * Authorize, then run `body` against the tenant's platform view. + * + * The executor is built per request and closed after, like every other cloud + * execution stack: it holds the per-request postgres socket, which Cloudflare's + * I/O isolation forbids sharing across requests. + */ +const withPlatformView = ( + headers: AdminUsersHeaders, + body: (executor: Executor, organizationId: string) => Effect.Effect, +): Effect.Effect< + A, + // `AdminUsersError` unconditionally: opening the platform view can fail that + // way regardless of what `body` itself raises. + E | AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden, + WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig +> => + Effect.gen(function* () { + const organizationId = yield* authorizeTenant( + new Request("https://admin.invalid", { headers }), + ); + const executor = yield* makePlatformExecutor(organizationId).pipe( + Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })), + ); + // The authorized tenant is handed to the body so an identity join reads the + // SAME org the reads are scoped to — never one named by client input. + return yield* Effect.ensuring( + body(executor, organizationId), + executor.close().pipe(Effect.ignore), + ); + }); + +/** + * Cloud's `AdminUsersProvider`, built per request so the platform executor + * closes over the per-request postgres socket. + */ +export const workosAdminUsersProvider: Layer.Layer< + AdminUsersProvider, + never, + WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig +> = Layer.effect(AdminUsersProvider)( + Effect.gen(function* () { + const context = yield* Effect.context< + WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig + >(); + return AdminUsersProvider.of({ + listUsers: (headers, options) => + withPlatformView(headers, (executor, organizationId) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => + listAdminUsers(admin, options, userDirectory(organizationId, context)), + ), + ), + ).pipe(Effect.provideContext(context)), + listUsersWithConnections: (headers, options) => + withPlatformView(headers, (executor, organizationId) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => + listAdminUsersWithConnections(admin, options, userDirectory(organizationId, context)), + ), + ), + ).pipe(Effect.provideContext(context)), + listUserConnections: (headers, externalId) => + withPlatformView(headers, (executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => listAdminUserConnections(admin, externalId)), + ), + ).pipe(Effect.provideContext(context)), + getUser: (headers, identifier) => + withPlatformView(headers, (executor, organizationId) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => + getAdminUser(admin, identifier, userDirectory(organizationId, context)), + ), + ), + ).pipe(Effect.provideContext(context)), + }); + }), +); + +// Builds the provider per request, providing it to the handlers. Long-lived +// `WorkOSClient | ApiKeyService` come from the surrounding boot context; the +// per-request `DbService`/`UserStoreService` (and the execution seams built +// over them) are supplied by the combined `requestScopedMiddleware`. +const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()( + Effect.gen(function* () { + const longLived = yield* Effect.context(); + return (httpEffect) => + Effect.gen(function* () { + // Built inside the request body so the execution seams close over the + // per-request postgres socket. `local` keeps that promise: the + // `longLived` context re-applied below carries the boot `CurrentMemoMap`, + // so a shared build would hand overlapping requests one another's socket. + const provider = yield* Effect.provide( + AdminUsersProvider.asEffect(), + workosAdminUsersProvider.pipe(Layer.provide(CloudExecutionSeamsLayer)), + { local: true }, + ); + return yield* Effect.provideService(httpEffect, AdminUsersProvider, provider); + }).pipe(Effect.provideContext(longLived)); + }), +); + +/** + * The cloud admin-users route layer, mounted as an app extension under the same + * `/api` prefix as the rest of the cloud router. + */ +export const makeCloudAdminUsersRoutes = ( + rsLive: Layer.Layer, + options: Parameters[1] = {}, +) => + makeAdminUsersApiLayer( + AdminUsersProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer, + options, + ); diff --git a/apps/cloud/src/admin/admin-users-email.node.test.ts b/apps/cloud/src/admin/admin-users-email.node.test.ts new file mode 100644 index 0000000000..463c83df09 --- /dev/null +++ b/apps/cloud/src/admin/admin-users-email.node.test.ts @@ -0,0 +1,127 @@ +import { env } from "cloudflare:workers"; +import { expect, it } from "@effect/vitest"; +import { Data, Effect, Layer } from "effect"; + +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { emailResolver } from "./admin-users-api"; + +// Cloud's REVERSE directory lookup: email -> the WorkOS `user_...` id that the +// subject table records in `external_id`. Production resolves it with one +// tenant-scoped list-users query. The WorkOS emulator lacks that route, so +// tests/dev retain the membership-backed scan exercised below. + +const ORG = "org_placeholder"; +const OTHER_ORG = "org_other"; + +class WorkOSUnavailable extends Data.TaggedError("WorkOSUnavailable")<{ + readonly userId: string; +}> {} + +const DIRECTORY = [ + // Same email in another tenant must never win either lookup path. + { id: "user_foreign", email: "ada@placeholder.test", organizationId: OTHER_ORG }, + // WorkOS preserves submitted casing, while the resolver seam is normalized. + { id: "user_ada", email: "Ada@Placeholder.test", organizationId: ORG }, + { id: "user_grace", email: "grace@placeholder.test", organizationId: ORG }, + { id: "user_nameless", email: null, organizationId: ORG }, +] as const; + +const stubWorkOS = (calls: string[], unreadableUserIds: ReadonlySet) => + Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => { + if (prop === "listUsers") { + return (params: { email: string; organizationId: string }) => { + calls.push(`listUsers:${params.organizationId}:${params.email}`); + return Effect.succeed({ + data: DIRECTORY.filter( + (user) => + user.organizationId === params.organizationId && + user.email?.toLowerCase() === params.email, + ), + }); + }; + } + if (prop === "listOrgMembers") { + return (organizationId: string) => { + calls.push(`listOrgMembers:${organizationId}`); + return Effect.succeed({ + data: DIRECTORY.filter((user) => user.organizationId === organizationId).map( + (user) => ({ userId: user.id, organizationId }), + ), + }); + }; + } + if (prop === "getUser") { + return (userId: string) => { + calls.push(`getUser:${userId}`); + if (unreadableUserIds.has(userId)) { + return Effect.fail(new WorkOSUnavailable({ userId })); + } + const user = DIRECTORY.find((candidate) => candidate.id === userId); + if (!user) return Effect.die(`unexpected user ${userId}`); + return Effect.succeed(user); + }; + } + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); + }, + }), + ); + +const resolve = ( + email: string, + calls: string[], + emulator = false, + unreadableUserIds: ReadonlySet = new Set(), +) => { + const previousApiUrl = env.WORKOS_API_URL; + return Effect.gen(function* () { + yield* Effect.sync(() => + Object.assign(env, { + WORKOS_API_URL: emulator ? "http://workos-emulator.invalid" : undefined, + }), + ); + const context = yield* Effect.context(); + return yield* emailResolver(ORG, context)(email); + }).pipe( + Effect.provide(stubWorkOS(calls, unreadableUserIds)), + Effect.ensuring(Effect.sync(() => Object.assign(env, { WORKOS_API_URL: previousApiUrl }))), + ); +}; + +it.effect("resolves an email with one tenant-scoped WorkOS query", () => + Effect.gen(function* () { + const calls: string[] = []; + expect(yield* resolve("ada@placeholder.test", calls)).toBe("user_ada"); + expect(calls).toEqual([`listUsers:${ORG}:ada@placeholder.test`]); + }), +); + +it.effect("returns null from one query when the organization has no matching email", () => + Effect.gen(function* () { + const calls: string[] = []; + expect(yield* resolve("nobody@placeholder.test", calls)).toBeNull(); + expect(calls).toEqual([`listUsers:${ORG}:nobody@placeholder.test`]); + }), +); + +it.effect("keeps the emulator fallback tenant-scoped and case-insensitive", () => + Effect.gen(function* () { + const calls: string[] = []; + expect(yield* resolve("ada@placeholder.test", calls, true)).toBe("user_ada"); + expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada"]); + expect(calls).not.toContain("getUser:user_foreign"); + expect(calls.some((call) => call.startsWith("listUsers:"))).toBe(false); + }), +); + +it.effect("lets the emulator fallback continue past one unreadable member", () => + Effect.gen(function* () { + const calls: string[] = []; + expect(yield* resolve("grace@placeholder.test", calls, true, new Set(["user_ada"]))).toBe( + "user_grace", + ); + expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada", "getUser:user_grace"]); + }), +); diff --git a/apps/cloud/src/analytics/ema-rollout.test.ts b/apps/cloud/src/analytics/ema-rollout.test.ts new file mode 100644 index 0000000000..c62321b050 --- /dev/null +++ b/apps/cloud/src/analytics/ema-rollout.test.ts @@ -0,0 +1,394 @@ +// --------------------------------------------------------------------------- +// Cloud's PostHog-backed enterprise-managed rollout gate. +// +// The gate is exercised through its production seam: the real +// `makePostHogEnterpriseManagedRollout` with an injected `fetch` and an +// injected `waitUntil`, exactly as `auth/jwks-cache.node.test.ts` drives the +// JWKS client. Nothing is module-mocked, so the request these tests read is the +// request PostHog would receive. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import type { EnterpriseManagedRolloutContext } from "@executor-js/sdk"; + +import { + ENTERPRISE_MANAGED_AUTH_FLAG_KEY, + cloudEnterpriseManagedRollout, + makePostHogEnterpriseManagedRollout, +} from "./ema-rollout"; + +const HOST = "https://us.i.posthog.com"; +const PROJECT_KEY = "phc_test_project_key"; + +const CONTEXT: EnterpriseManagedRolloutContext = { + userId: "user_01ABC", + organizationId: "org_01XYZ", + integration: "slack" as EnterpriseManagedRolloutContext["integration"], +}; + +interface CapturedRequest { + readonly url: string; + readonly body: Record; + /** The bound the gate put on this call, when it set one. */ + readonly signal: AbortSignal | null; +} + +interface FetchHarness { + readonly fetch: typeof globalThis.fetch; + readonly requests: () => readonly CapturedRequest[]; + /** Resolve every request the gate detached, so an assertion cannot race it. */ + readonly settle: () => Promise; + readonly waitUntil: (work: Promise) => void; +} + +/** + * Stands in for PostHog. `respond` decides what each call returns; throwing + * from it models a transport failure, and an aborted signal models the timeout. + */ +const makeFetchHarness = ( + respond: (request: CapturedRequest) => Response | Promise, +): FetchHarness => { + const requests: CapturedRequest[] = []; + const detached: Promise[] = []; + return { + requests: () => requests, + settle: async () => { + await Promise.all(detached); + }, + waitUntil: (work) => { + detached.push(work); + }, + fetch: async (input, init) => { + const raw = typeof init?.body === "string" ? init.body : "{}"; + const captured: CapturedRequest = { + url: String(input), + // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture reads back the exact JSON body the gate serialized for PostHog + body: JSON.parse(raw) as Record, + signal: init?.signal instanceof AbortSignal ? init.signal : null, + }; + requests.push(captured); + return respond(captured); + }, + }; +}; + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const flagsBody = (enabled: boolean) => ({ + flags: { + [ENTERPRISE_MANAGED_AUTH_FLAG_KEY]: { + key: ENTERPRISE_MANAGED_AUTH_FLAG_KEY, + enabled, + reason: { code: "condition_match" }, + }, + }, + errorsWhileComputingFlags: false, +}); + +const gate = (harness: FetchHarness, timeoutMs?: number) => + makePostHogEnterpriseManagedRollout({ + projectKey: PROJECT_KEY, + host: HOST, + fetch: harness.fetch, + waitUntil: harness.waitUntil, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); + +describe("flag evaluation", () => { + it.effect("keys the rollout on the user and carries the org as group context", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => jsonResponse(flagsBody(true))); + + const decision = yield* gate(harness).decide(CONTEXT); + + expect(decision).toEqual({ kind: "enabled" }); + const [request] = harness.requests(); + expect(request?.url).toBe(`${HOST}/flags?v=2`); + expect( + request?.body.distinct_id, + "the rollout unit is the user, matching posthog.identify(user.id) in the browser", + ).toBe(CONTEXT.userId); + expect( + request?.body.groups, + "the org rides along so group targeting stays available without changing the rollout unit", + ).toEqual({ organization: CONTEXT.organizationId }); + expect(request?.body.api_key).toBe(PROJECT_KEY); + expect(request?.signal, "the evaluation is always bounded").not.toBeNull(); + }), + ); + + it.effect("omits group context when the host named no organization", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => jsonResponse(flagsBody(true))); + + yield* gate(harness).decide({ ...CONTEXT, organizationId: null }); + + expect(harness.requests()[0]?.body.groups).toBeUndefined(); + }), + ); + + it.effect("withholds when the flag is off for this user", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => jsonResponse(flagsBody(false))); + + expect(yield* gate(harness).decide(CONTEXT)).toEqual({ + kind: "withheld", + reason: "disabled", + }); + }), + ); + + it.effect("withholds when PostHog returned no verdict for the flag at all", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => + // What a quota-limited project answers. + jsonResponse({ + flags: {}, + errorsWhileComputingFlags: false, + quotaLimited: ["feature_flags"], + }), + ); + + expect(yield* gate(harness).decide(CONTEXT)).toEqual({ + kind: "withheld", + reason: "disabled", + }); + }), + ); +}); + +describe("failing closed", () => { + it.effect("withholds on a non-2xx answer", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => new Response("nope", { status: 503 })); + + expect(yield* gate(harness).decide(CONTEXT)).toEqual({ + kind: "withheld", + reason: "evaluation-unavailable", + }); + }), + ); + + it.effect("withholds on a transport failure", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: models `fetch` rejecting with a TypeError, which is exactly how a Worker sees a dead upstream + throw new TypeError("network error"); + }); + + expect(yield* gate(harness).decide(CONTEXT)).toEqual({ + kind: "withheld", + reason: "evaluation-unavailable", + }); + }), + ); + + it.effect("withholds when the evaluation times out", () => + Effect.gen(function* () { + // Never answers. Only the gate's own AbortSignal ends this call, so the + // test fails by hanging if the gate ever stops bounding the evaluation. + const harness = makeFetchHarness( + (request) => + new Promise((_resolve, reject) => { + request.signal?.addEventListener("abort", () => + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fetch-compatible fixture mirrors the platform's abort rejection semantics + reject(new DOMException("aborted", "AbortError")), + ); + }), + ); + + expect( + yield* gate(harness, 1).decide(CONTEXT), + "a slow flag service must degrade the rollout, never hold up a connect", + ).toEqual({ kind: "withheld", reason: "evaluation-unavailable" }); + }), + ); + + it.effect("withholds on a body that is not a flags response", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => jsonResponse({ flags: "not-an-object" })); + + expect(yield* gate(harness).decide(CONTEXT)).toEqual({ + kind: "withheld", + reason: "evaluation-unavailable", + }); + }), + ); + + it.effect("withholds when there is no acting user to roll out by", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => jsonResponse(flagsBody(true))); + + expect(yield* gate(harness).decide({ ...CONTEXT, userId: null })).toEqual({ + kind: "withheld", + reason: "evaluation-unavailable", + }); + expect(harness.requests().length, "and asks PostHog nothing").toBe(0); + }), + ); + + it.effect("withholds when the deployment has no PostHog configuration", () => + Effect.gen(function* () { + // The cloud test env carries no VITE_PUBLIC_POSTHOG_KEY, so this builds + // the misconfigured-deployment gate — which is deliberately NOT the same + // as a host that injects no gate at all. + expect(yield* cloudEnterpriseManagedRollout().decide(CONTEXT)).toEqual({ + kind: "withheld", + reason: "evaluation-unavailable", + }); + }), + ); +}); + +describe("rollout events", () => { + it.effect("captures the connect outcome with the flag decision attached", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => jsonResponse({ status: 1 })); + const rollout = gate(harness); + + yield* rollout.record({ + kind: "connected", + context: CONTEXT, + decision: { kind: "enabled" }, + }); + yield* Effect.promise(() => harness.settle()); + + const [request] = harness.requests(); + expect(request?.url).toBe(`${HOST}/i/v0/e/`); + expect(request?.body.event).toBe("ema_connect_connected"); + expect(request?.body.distinct_id).toBe(CONTEXT.userId); + const properties = request?.body.properties as Record; + expect(properties.ema_flag_enabled).toBe(true); + expect(properties[`$feature/${ENTERPRISE_MANAGED_AUTH_FLAG_KEY}`]).toBe(true); + expect(properties.$groups).toEqual({ + organization: CONTEXT.organizationId, + }); + expect(properties.integration_slug).toBe("slack"); + }), + ); + + it.effect("carries the withheld reason on the attempt that never ran", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => jsonResponse({ status: 1 })); + + yield* gate(harness).record({ + kind: "attempted", + context: CONTEXT, + decision: { kind: "withheld", reason: "evaluation-unavailable" }, + }); + yield* Effect.promise(() => harness.settle()); + + const properties = harness.requests()[0]?.body.properties as Record; + expect(harness.requests()[0]?.body.event).toBe("ema_connect_attempted"); + expect(properties.ema_flag_enabled).toBe(false); + expect(properties.ema_flag_withheld_reason).toBe("evaluation-unavailable"); + }), + ); + + it.effect("carries the identity provider's own error code on a blocked connect", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => jsonResponse({ status: 1 })); + + yield* gate(harness).record({ + kind: "blocked-by-admin", + context: CONTEXT, + decision: { kind: "enabled" }, + oauthErrorCode: "unauthorized_client", + }); + yield* Effect.promise(() => harness.settle()); + + const [request] = harness.requests(); + expect(request?.body.event).toBe("ema_connect_blocked_by_admin"); + const properties = request?.body.properties as Record | undefined; + expect(properties?.oauth_error_code).toBe("unauthorized_client"); + }), + ); + + it.effect("sends nothing beyond identity, the integration and the flag decision", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => jsonResponse({ status: 1 })); + + yield* gate(harness).record({ + kind: "blocked-by-admin", + context: CONTEXT, + decision: { kind: "enabled" }, + oauthErrorCode: "access_denied", + }); + yield* Effect.promise(() => harness.settle()); + + const properties = harness.requests()[0]?.body.properties as Record; + expect( + Object.keys(properties).sort(), + "a closed property set is what keeps a token or an assertion from ever being added by accident", + ).toEqual( + [ + "$feature/mcp-enterprise-managed-auth", + "$groups", + "ema_flag_enabled", + "integration_slug", + "oauth_error_code", + ].sort(), + ); + }), + ); + + it.effect("hands the capture to the platform instead of awaiting it", () => + Effect.gen(function* () { + let resolveCapture: (() => void) | undefined; + const harness = makeFetchHarness( + () => + new Promise((resolve) => { + resolveCapture = () => resolve(jsonResponse({ status: 1 })); + }), + ); + + // Returns while the capture is still in flight: an analytics call can + // never be on the critical path of a connect. + yield* gate(harness).record({ + kind: "attempted", + context: CONTEXT, + decision: { kind: "enabled" }, + }); + + expect(harness.requests().length).toBe(1); + resolveCapture?.(); + yield* Effect.promise(() => harness.settle()); + }), + ); + + it.effect("survives a capture that fails outright", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: models the ingest endpoint being unreachable + throw new TypeError("network error"); + }); + + yield* gate(harness).record({ + kind: "connected", + context: CONTEXT, + decision: { kind: "enabled" }, + }); + yield* Effect.promise(() => harness.settle()); + }), + ); + + it.effect("records nothing when there is no person to attach the event to", () => + Effect.gen(function* () { + const harness = makeFetchHarness(() => jsonResponse({ status: 1 })); + + yield* gate(harness).record({ + kind: "attempted", + context: { ...CONTEXT, userId: null }, + decision: { kind: "withheld", reason: "evaluation-unavailable" }, + }); + + expect(harness.requests().length).toBe(0); + }), + ); +}); diff --git a/apps/cloud/src/analytics/ema-rollout.ts b/apps/cloud/src/analytics/ema-rollout.ts new file mode 100644 index 0000000000..88d10e8e94 --- /dev/null +++ b/apps/cloud/src/analytics/ema-rollout.ts @@ -0,0 +1,255 @@ +// --------------------------------------------------------------------------- +// Cloud's rollout gate for MCP Enterprise-Managed Authorization. +// +// Implements the vendor-free `EnterpriseManagedRollout` port from +// `@executor-js/sdk` against PostHog, which is the only flag/analytics service +// this deployment operates. Two deliberate shapes: +// +// 1. NO SDK. This is a hand-written `fetch` against two documented PostHog +// endpoints, not `posthog-node`. Cloud already ate an unexplained 3-5s +// page-load regression from adding one dependency to the worker bundle and +// the mechanism was never identified (see the notes on the MCP SDK v2 +// revert). Until that is understood, a new runtime dependency in this bundle +// is a cost we are not willing to pay for a boolean, and the whole client we +// would be importing is two POSTs wide. +// +// 2. FAIL CLOSED, ALWAYS. Every way of not getting an answer — timeout, +// transport failure, non-2xx, a body we cannot parse, no PostHog key +// configured, no acting user to key the rollout on — withholds the +// enterprise-managed path and falls back to ordinary interactive OAuth. A +// PostHog outage therefore degrades the rollout and never fails a connect, +// and (because the SDK freezes the verdict onto the connection) never +// touches an enterprise-managed connection that already exists. +// +// The rollout UNIT is the user: `distinct_id` is the acting user's id, the same +// id `posthog.identify(user.id, …)` uses in the browser, so a percentage +// rollout here means the same population it means everywhere else in the +// project. The organization rides along as group context so org-level targeting +// stays available in the PostHog UI without changing that unit. +// --------------------------------------------------------------------------- + +import { env } from "cloudflare:workers"; +import { Effect, Schema } from "effect"; + +import type { + EnterpriseManagedRollout, + EnterpriseManagedRolloutContext, + EnterpriseManagedRolloutDecision, + EnterpriseManagedRolloutEvent, +} from "@executor-js/sdk"; + +import { POSTHOG_INGEST_HOST } from "../edge/passthrough"; + +/** The flag that gates enterprise-managed authorization. */ +export const ENTERPRISE_MANAGED_AUTH_FLAG_KEY = "mcp-enterprise-managed-auth"; + +/** PostHog group type for organizations — the same one + * `posthog.group("organization", …)` establishes in the browser. */ +const ORGANIZATION_GROUP_TYPE = "organization"; + +/** + * How long a flag evaluation may take before the gate gives up and withholds. + * Short on purpose: this call sits in front of an interactive connect, and the + * safe answer is already known, so waiting is strictly worse than answering. + */ +export const DEFAULT_FLAG_EVALUATION_TIMEOUT_MS = 1_000; + +/** Wire names of the rollout events, in the product's `object_verb` style. */ +const EVENT_NAMES = { + attempted: "ema_connect_attempted", + connected: "ema_connect_connected", + "blocked-by-admin": "ema_connect_blocked_by_admin", +} as const satisfies Record; + +// --------------------------------------------------------------------------- +// Boundary parsing +// --------------------------------------------------------------------------- + +/** The slice of PostHog's `POST /flags?v=2` response this gate reads. Unknown + * keys are ignored, so PostHog adding fields cannot make the gate fail closed; + * a body that is not this shape at all can, which is the intent. */ +const FlagsResponse = Schema.Struct({ + flags: Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })), +}); + +const decodeFlagsResponse = Schema.decodeUnknownEffect(FlagsResponse); + +// --------------------------------------------------------------------------- +// Detached work +// --------------------------------------------------------------------------- + +/** + * Hand a request to the platform and stop caring about it. + * + * `waitUntil` is the correct owner when the caller has an `ExecutionContext`; + * the executor composition seam this gate is installed through does not receive + * one, so the default owner attaches a terminal rejection handler and lets the + * in-flight request ride out the rest of the response. Either way the promise + * is owned by something that cannot report back, which is what makes an event + * structurally unable to fail or delay a connect. + */ +const detach = ( + work: Promise, + waitUntil: ((work: Promise) => void) | undefined, +): void => { + const settled = work.then( + () => undefined, + () => undefined, + ); + if (waitUntil) waitUntil(settled); +}; + +// --------------------------------------------------------------------------- +// The gate +// --------------------------------------------------------------------------- + +export interface PostHogRolloutConfig { + /** Public project key (`phc_…`). Public by design — it is already shipped to + * every browser — so this is a var, not a secret. */ + readonly projectKey: string; + /** PostHog API origin (no trailing slash), e.g. `https://us.i.posthog.com`. */ + readonly host: string; + readonly fetch: typeof globalThis.fetch; + readonly timeoutMs?: number; + /** Platform post-response hook, when the caller has one. */ + readonly waitUntil?: (work: Promise) => void; +} + +const withheld = ( + reason: "disabled" | "evaluation-unavailable", +): EnterpriseManagedRolloutDecision => ({ kind: "withheld", reason }); + +const ENABLED: EnterpriseManagedRolloutDecision = { kind: "enabled" }; + +/** Group context for a connect whose organization the host named. */ +const groupsFor = (context: EnterpriseManagedRolloutContext): Record | undefined => + context.organizationId === null + ? undefined + : { [ORGANIZATION_GROUP_TYPE]: context.organizationId }; + +/** + * Build cloud's enterprise-managed rollout gate over an explicit PostHog + * configuration. Everything environmental is a parameter so the composition + * root parses it once and tests drive the real code path with an injected + * `fetch`. + */ +export const makePostHogEnterpriseManagedRollout = ( + config: PostHogRolloutConfig, +): EnterpriseManagedRollout => { + const timeoutMs = config.timeoutMs ?? DEFAULT_FLAG_EVALUATION_TIMEOUT_MS; + + const evaluate = Effect.fn("Cloud.EnterpriseManagedRollout.decide")(function* ( + context: EnterpriseManagedRolloutContext, + ) { + // The rollout unit is the user. With no acting user there is no + // `distinct_id` to key it on, so there is no answer to be had — withhold + // rather than invent a rollout bucket. + const distinctId = context.userId; + if (distinctId === null) return withheld("evaluation-unavailable"); + + const groups = groupsFor(context); + const response = yield* Effect.tryPromise(() => + config.fetch(`${config.host}/flags?v=2`, { + method: "POST", + headers: { + "content-type": "application/json", + // Declares this a server-side evaluation, which is what selects + // server-runtime flags in PostHog's runtime detection. + "user-agent": "executor-cloud", + }, + body: JSON.stringify({ + api_key: config.projectKey, + distinct_id: distinctId, + ...(groups === undefined ? {} : { groups }), + }), + signal: AbortSignal.timeout(timeoutMs), + }), + ); + if (!response.ok) return withheld("evaluation-unavailable"); + + const body = yield* Effect.tryPromise(() => response.json() as Promise); + const decoded = yield* decodeFlagsResponse(body); + const flag = decoded.flags[ENTERPRISE_MANAGED_AUTH_FLAG_KEY]; + // A flag PostHog did not return is a flag that is not on for this user + // (it may not exist yet, or the project may be quota-limited). That is a + // real "no", not a failure to answer. + if (flag === undefined || !flag.enabled) return withheld("disabled"); + return ENABLED; + }); + + return { + decide: (context) => + evaluate(context).pipe( + // Timeout, transport failure and an unparseable body all land here and + // all mean the same thing: no answer, so no enterprise-managed attempt. + // `catchCause` rather than `catchAll` because a defect in this adapter + // must not become a failed connect either. + Effect.catchCause(() => Effect.succeed(withheld("evaluation-unavailable"))), + ), + record: (event) => + Effect.sync(() => { + const distinctId = event.context.userId; + // Same reason `decide` withholds: an event with no person to attach to + // would only pollute the project with anonymous rows. + if (distinctId === null) return; + const groups = groupsFor(event.context); + const decision = event.decision; + detach( + config.fetch(`${config.host}/i/v0/e/`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + api_key: config.projectKey, + event: EVENT_NAMES[event.kind], + distinct_id: distinctId, + properties: { + // Product metadata only. There is no field on + // `EnterpriseManagedRolloutEvent` that can carry a token, an + // identity assertion, a client secret or a scope, and none is + // synthesized here. + integration_slug: String(event.context.integration), + ema_flag_enabled: decision.kind === "enabled", + ...(decision.kind === "withheld" + ? { ema_flag_withheld_reason: decision.reason } + : {}), + // PostHog's own convention, so these events can be filtered by + // flag value in the UI alongside every other flag. + [`$feature/${ENTERPRISE_MANAGED_AUTH_FLAG_KEY}`]: decision.kind === "enabled", + ...(event.kind === "blocked-by-admin" && event.oauthErrorCode !== undefined + ? { oauth_error_code: event.oauthErrorCode } + : {}), + ...(groups === undefined ? {} : { $groups: groups }), + }, + }), + }), + config.waitUntil, + ); + }), + }; +}; + +/** A gate that answers "no" to everything, for a deployment that has no PostHog + * configuration to evaluate against. Cloud is SUPPOSED to have one, so a + * missing key is a misconfiguration, and failing closed is the same choice the + * outage paths make. This is NOT the same as injecting no gate at all, which + * is how the hosts with no flag service (local, desktop, CLI, self-host) keep + * their existing behavior. */ +export const unavailableEnterpriseManagedRollout: EnterpriseManagedRollout = { + decide: () => Effect.succeed(withheld("evaluation-unavailable")), + record: () => Effect.void, +}; + +/** + * The gate as the cloud worker composes it: the public project key already + * shipped in `wrangler.jsonc`, and the same PostHog ingest origin the + * adblock-dodging passthrough proxies to. + */ +export const cloudEnterpriseManagedRollout = (): EnterpriseManagedRollout => { + const projectKey = env.VITE_PUBLIC_POSTHOG_KEY; + if (!projectKey) return unavailableEnterpriseManagedRollout; + return makePostHogEnterpriseManagedRollout({ + projectKey, + host: env.VITE_PUBLIC_POSTHOG_HOST ?? `https://${POSTHOG_INGEST_HOST}`, + fetch: (input, init) => globalThis.fetch(input, init), + }); +}; diff --git a/apps/cloud/src/analytics/google-oauth-listing.test.ts b/apps/cloud/src/analytics/google-oauth-listing.test.ts new file mode 100644 index 0000000000..8b85ec0a49 --- /dev/null +++ b/apps/cloud/src/analytics/google-oauth-listing.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { GOOGLE_OAUTH_REVIEW_FLAG, makeGoogleOAuthListing } from "./google-oauth-listing"; + +const context = { userId: "review-user", organizationId: "review-org" }; + +describe("Google OAuth review listing", () => { + it.effect("targets the authenticated user and re-evaluates after revocation", () => + Effect.gen(function* () { + let enabled = true; + const policy = makeGoogleOAuthListing({ + projectKey: "public-project-key", + host: "https://flags.example.com", + fetch: async (url, init) => { + expect(url).toBe("https://flags.example.com/flags?v=2"); + expect(await new Response(String(init?.body)).json()).toMatchObject({ + distinct_id: context.userId, + person_properties: { executor_user_id: context.userId }, + flag_keys_to_evaluate: [GOOGLE_OAUTH_REVIEW_FLAG], + }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + return Response.json({ flags: { [GOOGLE_OAUTH_REVIEW_FLAG]: { enabled } } }); + }, + }); + expect(yield* policy(context)).toBe(true); + enabled = false; + expect(yield* policy(context)).toBe(false); + }), + ); + + for (const body of [ + { flags: {} }, + { flags: { [GOOGLE_OAUTH_REVIEW_FLAG]: { enabled: "true" } } }, + {}, + ]) { + it.effect(`withholds an absent or invalid flag: ${JSON.stringify(body)}`, () => + Effect.gen(function* () { + const policy = makeGoogleOAuthListing({ + projectKey: "key", + host: "https://flags.example.com", + fetch: async () => Response.json(body), + }); + expect(yield* policy(context)).toBe(false); + }), + ); + } + + it.effect("withholds on HTTP and transport failures", () => + Effect.gen(function* () { + const unavailable = makeGoogleOAuthListing({ + projectKey: "key", + host: "https://flags.example.com", + fetch: async () => new Response(null, { status: 503 }), + }); + const failed = makeGoogleOAuthListing({ + projectKey: "key", + host: "https://flags.example.com", + fetch: () => + Effect.runPromise(Effect.fail(new DOMException("Network unavailable", "NetworkError"))), + }); + expect(yield* unavailable(context)).toBe(false); + expect(yield* failed(context)).toBe(false); + }), + ); + + it.effect("does not evaluate without a user or project key", () => + Effect.gen(function* () { + let requests = 0; + const fetch: typeof globalThis.fetch = async () => { + requests += 1; + return Response.json({ flags: {} }); + }; + const configured = makeGoogleOAuthListing({ + projectKey: "key", + host: "https://flags.example.com", + fetch, + }); + const unconfigured = makeGoogleOAuthListing({ + projectKey: undefined, + host: "https://flags.example.com", + fetch, + }); + expect(yield* configured({ ...context, userId: null })).toBe(false); + expect(yield* unconfigured(context)).toBe(false); + expect(requests).toBe(0); + }), + ); +}); diff --git a/apps/cloud/src/analytics/google-oauth-listing.ts b/apps/cloud/src/analytics/google-oauth-listing.ts new file mode 100644 index 0000000000..abe775a613 --- /dev/null +++ b/apps/cloud/src/analytics/google-oauth-listing.ts @@ -0,0 +1,44 @@ +import { Effect, Schema } from "effect"; + +import type { FirstPartyOAuthClientConfig } from "@executor-js/sdk"; + +/** Server-side rollout for offering the built-in Google OAuth app. Configure + * the PostHog flag's runtime as "all": direct HTTP clients are not recognized + * as server SDKs. The caller identity still comes only from the server. */ +export const GOOGLE_OAUTH_REVIEW_FLAG = "google-oauth-review"; + +const FlagsResponse = Schema.Struct({ + flags: Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })), +}); + +/** Evaluate each listing against the authenticated user. Missing configuration, + * timeouts, and invalid responses withhold the app. No verdict is cached. */ +export const makeGoogleOAuthListing = + (config: { + readonly projectKey: string | undefined; + readonly host: string; + readonly fetch: typeof globalThis.fetch; + }): NonNullable => + (context) => + Effect.gen(function* () { + if (!config.projectKey || context.userId === null) return false; + const response = yield* Effect.tryPromise(() => + config.fetch(`${config.host}/flags?v=2`, { + method: "POST", + headers: { "content-type": "application/json", "user-agent": "executor-cloud" }, + body: JSON.stringify({ + api_key: config.projectKey, + distinct_id: context.userId, + // Supplied by the server so targeting needs no prior browser identify. + person_properties: { executor_user_id: context.userId }, + groups: { organization: context.organizationId }, + flag_keys_to_evaluate: [GOOGLE_OAUTH_REVIEW_FLAG], + }), + signal: AbortSignal.timeout(1_000), + }), + ); + if (!response.ok) return false; + const body: unknown = yield* Effect.tryPromise(() => response.json()); + const parsed = yield* Schema.decodeUnknownEffect(FlagsResponse)(body); + return parsed.flags[GOOGLE_OAUTH_REVIEW_FLAG]?.enabled === true; + }).pipe(Effect.catch(() => Effect.succeed(false))); diff --git a/apps/cloud/src/api.request-scope.node.test.ts b/apps/cloud/src/api.request-scope.node.test.ts index 1efc5bf864..e2bac9143e 100644 --- a/apps/cloud/src/api.request-scope.node.test.ts +++ b/apps/cloud/src/api.request-scope.node.test.ts @@ -21,11 +21,32 @@ // for `acquireRelease`. // --------------------------------------------------------------------------- -import { describe, it, expect } from "@effect/vitest"; +import { describe, it, expect, beforeEach } from "@effect/vitest"; import { Context, Effect, Layer } from "effect"; -import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"; +import { + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http"; -import { requestScopedMiddleware } from "@executor-js/api/server"; +import { + CodeExecutorProvider, + DbProvider, + EngineDecoratorNoop, + ExecutorService, + HostConfig, + PluginsProvider, + makeExecutionStackMiddleware, + requestScopedMiddleware, + textFailureStrategy, + type CodeExecutor, + type IdentityFailure, + type Principal, +} from "@executor-js/api/server"; +import { collectTables } from "@executor-js/sdk"; +import { resetSubjectTouchCache } from "@executor-js/sdk/host-internal"; +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing"; import { RequestScopedServicesLive } from "./api/layers"; import { makeApiLive } from "./api/router"; @@ -195,3 +216,290 @@ describe("makeApiLive (prod handler factory) request scoping", () => { expect(counts.releases).toBe(2); }); }); + +// --------------------------------------------------------------------------- +// `ExecutionStackMiddleware` request scoping. +// +// `requestScopedMiddleware` gives every request a fresh `MemoMap`, so the +// per-request DB handle is genuinely per request (the suites above). But +// `makeExecutionStackMiddleware` captures the BOOT fiber's context once, at +// layer-construction time, and re-applies it to every request with +// `Effect.provideContext`. That captured context carries Effect's +// `CurrentMemoMap`, and `provideContext`'s merge lets the BOOT map overwrite +// the fresh per-request one. The per-request `Effect.provide(stackLayer)` then +// memoizes its build in the boot map, which every in-flight request fiber in +// the isolate shares. +// +// Sequential requests still rebuild (the memo entry is refcounted by observer +// and drops to zero when the request scope closes), so only OVERLAPPING +// requests are affected: the second request reuses the first request's stack +// build, and therefore the first request's database handle. On Cloudflare +// Workers that is a cross-request I/O violation; and when the owning request +// finishes, its scope finalizer closes the connection out from under the +// borrower, whose next query fails. +// +// These tests stand the real middleware up over per-request in-memory +// databases and pin all three symptoms. +// --------------------------------------------------------------------------- + +interface TestDbHandle { + readonly id: number; + readonly db: SqliteTestFumaDb; + closed: boolean; +} + +class TestDb extends Context.Service()("test/TestDb") {} + +interface StackProbeState { + /** + * Databases created up front, one per request the case will make. Handing a + * ready database to the request-scoped layer keeps its acquire synchronous, + * so the first request to arrive is reliably the one that owns handle 1 — + * which is what makes the borrow direction deterministic below. + */ + readonly pool: readonly SqliteTestFumaDb[]; + /** Handles acquired by `requestScopedMiddleware` — one per request. */ + readonly handles: TestDbHandle[]; + /** How many times the stack layer's `DbProvider` was actually built. */ + builds: number; + /** The handle id each stack build read. One entry per build. */ + readonly builtWithHandleId: number[]; + /** + * What each request could see about connection ownership at the moment it + * performed its read-after-await, keyed by that request's await duration. + */ + readonly lateReadOwnership: Map< + number, + { + readonly ownHandleId: number; + readonly ownClosed: boolean; + readonly otherClosed: readonly boolean[]; + } + >; +} + +const noopCodeExecutor: CodeExecutor = { + execute: () => Effect.succeed({ result: undefined, logs: [] }), +}; + +const stackProbePrincipal: Principal = { + kind: "member", + accountId: "user_request_scope", + organizationId: "org_request_scope", + organizationName: "Request Scope Test Org", + email: "request-scope@test.local", + name: "Request Scope", + avatarUrl: null, + roles: ["admin"], + orgRoleModel: "organization", + orgRole: "admin", +}; + +/** + * The per-request DB handle, in the slot cloud's postgres.js socket occupies: + * acquired when the request fiber's scope opens, closed when it closes. + */ +const makeTestDbLive = (state: StackProbeState) => + Layer.effect(TestDb)( + Effect.acquireRelease( + Effect.sync(() => { + const index = state.handles.length; + const handle: TestDbHandle = { + id: index + 1, + db: state.pool[index] as SqliteTestFumaDb, + closed: false, + }; + state.handles.push(handle); + return handle; + }), + (handle) => + // The request's own scope ends its connection, exactly as the host + // closes the postgres socket when the request finishes. + Effect.promise(async () => { + handle.closed = true; + await handle.db.close(); + }), + ), + ); + +/** + * The host's `stackLayer` seam, reduced to the parts that matter: a + * `DbProvider` derived from the REQUEST-scoped handle (exactly cloud's + * arrangement) plus inert stand-ins for the rest. The 5ms sleep widens the + * window in which two request fibers overlap on the layer build, which is what + * an isolate serving concurrent requests does on its own. + */ +const makeTestStackLayer = (state: StackProbeState) => + Layer.mergeAll( + Layer.effect(DbProvider)( + Effect.gen(function* () { + const handle = yield* TestDb; + yield* Effect.sleep("5 millis"); + state.builds += 1; + state.builtWithHandleId.push(handle.id); + return handle.db; + }), + ), + Layer.succeed(PluginsProvider)({ plugins: () => [] }), + Layer.succeed(HostConfig)({ allowLocalNetwork: false, oauthCallbackPath: "/oauth/callback" }), + Layer.succeed(CodeExecutorProvider)(noopCodeExecutor), + EngineDecoratorNoop, + ); + +/** + * `GET /probe?delay=N`: one cheap read, a pause standing in for a slow + * outbound call, then a second read. That is the production shape — an early + * query succeeds, the request awaits something slow, and by the time it queries + * again the connection it borrowed has been closed by its real owner. + */ +const makeStackProbeRoutes = (state: StackProbeState) => + HttpRouter.add( + "GET", + "/probe", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const delayMs = Number( + new URL(request.url, "http://test.local").searchParams.get("delay") ?? "0", + ); + const executor = yield* ExecutorService; + const read = () => + executor.connections.list().pipe( + Effect.as("ok" as const), + Effect.catchCause(() => Effect.succeed("failed" as const)), + ); + + // The handle this request acquired for itself, whatever order the + // request fibers happened to acquire in. + const own = yield* TestDb; + + const early = yield* read(); + yield* Effect.sleep(`${delayMs} millis`); + state.lateReadOwnership.set(delayMs, { + ownHandleId: own.id, + ownClosed: own.closed, + otherClosed: state.handles.filter((handle) => handle.id !== own.id).map((h) => h.closed), + }); + const late = yield* read(); + return HttpServerResponse.jsonUnsafe({ early, late }); + }), + ); + +const makeStackProbeHandler = (state: StackProbeState) => { + const ExecutionStackMiddleware = makeExecutionStackMiddleware< + readonly [], + IdentityFailure, + never, + TestDb, + never, + never + >({ + plugins: [], + authenticate: () => Effect.succeed(stackProbePrincipal), + strategy: textFailureStrategy, + stackLayer: makeTestStackLayer(state), + }); + + const App = makeStackProbeRoutes(state).pipe( + // Exactly cloud's composition: the stack middleware with the per-request + // DB layer combined in, so `DbProvider` is built over a handle owned by the + // request fiber's scope. + Layer.provide( + ExecutionStackMiddleware.combine(requestScopedMiddleware(makeTestDbLive(state))).layer, + ), + Layer.provideMerge(HttpServer.layerServices), + ); + return HttpRouter.toWebHandler(App, { disableLogger: true }).handler; +}; + +const makeStackProbeState = async (requestCount: number): Promise => { + const pool: SqliteTestFumaDb[] = []; + for (let index = 0; index < requestCount; index += 1) { + pool.push(await createSqliteTestFumaDb({ tables: collectTables() })); + } + return { + pool, + handles: [], + builds: 0, + builtWithHandleId: [], + lateReadOwnership: new Map(), + }; +}; + +describe("ExecutionStackMiddleware request scoping", () => { + beforeEach(() => { + // `makeExecutionStack` touches the subject once per build, behind a + // process-local throttle. Reset it so every case does the same work. + resetSubjectTouchCache(); + }); + + it("builds the execution stack once per concurrent request", async () => { + const state = await makeStackProbeState(3); + const handler = makeStackProbeHandler(state); + + const responses = await Promise.all([ + handler(new Request("http://test.local/probe"), Context.empty()), + handler(new Request("http://test.local/probe"), Context.empty()), + handler(new Request("http://test.local/probe"), Context.empty()), + ]); + + expect(responses.map((response) => response.status)).toEqual([200, 200, 200]); + // Three requests, three request-scoped handles, three stack builds. A lower + // build count means some request served its work on a stack built for a + // different request. + expect(state.handles.length).toBe(3); + expect(state.builds).toBe(3); + }); + + it("never builds a request's stack over another request's database handle", async () => { + const state = await makeStackProbeState(3); + const handler = makeStackProbeHandler(state); + + await Promise.all([ + handler(new Request("http://test.local/probe"), Context.empty()), + handler(new Request("http://test.local/probe"), Context.empty()), + handler(new Request("http://test.local/probe"), Context.empty()), + ]); + + // Every acquired handle must be the one its own request's stack was built + // over: three builds reading three distinct handles, covering all three. + const acquired = state.handles.map((handle) => handle.id); + expect(acquired).toEqual([1, 2, 3]); + expect([...state.builtWithHandleId].sort()).toEqual(acquired); + expect(new Set(state.builtWithHandleId).size).toBe(state.handles.length); + }); + + it("keeps a slow request's connection usable after a faster overlapping request ends", async () => { + const state = await makeStackProbeState(2); + const handler = makeStackProbeHandler(state); + + // The fast request is served first, so it acquires handle 1. It finishes + // while the slow request is still awaiting, which is the window in which a + // borrowed connection gets pulled out from under its borrower. + const [fast, slow] = await Promise.all([ + handler(new Request("http://test.local/probe?delay=0"), Context.empty()), + handler(new Request("http://test.local/probe?delay=80"), Context.empty()), + ]); + + // Read, slow await, read again — both reads succeed for both requests. + expect(await fast.json()).toEqual({ early: "ok", late: "ok" }); + expect(await slow.json()).toEqual({ early: "ok", late: "ok" }); + + // Ownership at the read-after-await is the real assertion. The fast + // request has ended and released its connection by then, so the slow + // request is reading in exactly the window that breaks in production — + // and its OWN connection is still open, because that is the one its stack + // was built over. + // + // Sharing one build inverts this: the slow request's own connection is + // released while it is still running, and it keeps reading on the fast + // request's instead. + const slowView = state.lateReadOwnership.get(80); + expect(slowView?.ownClosed).toBe(false); + expect(slowView?.otherClosed).toEqual([true]); + + // Each request built its stack over its own connection. + expect(state.builds).toBe(2); + expect([...state.builtWithHandleId].sort()).toEqual([1, 2]); + expect(state.lateReadOwnership.get(0)?.ownHandleId).not.toBe(slowView?.ownHandleId); + }); +}); diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index 9d9334bf98..d92ffe723e 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -13,6 +13,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ Effect.succeed( value === "valid_user_key" ? { + scope: "user" as const, accountId: "user_123", organizationId: "org_123", keyId: "api_key_123", @@ -22,6 +23,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ listUserKeys: () => Effect.succeed([]), createUserKey: () => Effect.die("protected API auth test does not create API keys"), revokeUserKey: () => Effect.void, + listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), + createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), + revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), }); const stubWorkOS = Layer.succeed( @@ -43,7 +47,7 @@ const stubWorkOS = Layer.succeed( ); const stubUsers = Layer.succeed(UserStoreService)({ - use: (fn) => + use: (_op, fn) => Effect.promise(() => fn({ ensureAccount: async (id: string) => ({ id, createdAt }), @@ -85,6 +89,7 @@ describe("protected API key auth", () => { ); expect(identity).toEqual({ + kind: "member", accountId: "user_123", organizationId: "org_123", organizationName: "Org org_123", @@ -93,6 +98,10 @@ describe("protected API key auth", () => { name: null, avatarUrl: null, roles: [], + // The stub membership carries no role slug — normalization FAILS + // CLOSED to plain member, so the executor binds workspace writes off. + orgRoleModel: "organization", + orgRole: "member", }); }), ); diff --git a/apps/cloud/src/api/protected-jwt-auth.node.test.ts b/apps/cloud/src/api/protected-jwt-auth.node.test.ts index b8d81b334e..b330ecdd70 100644 --- a/apps/cloud/src/api/protected-jwt-auth.node.test.ts +++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts @@ -40,6 +40,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ listUserKeys: () => Effect.succeed([]), createUserKey: () => Effect.die("JWT auth test does not create API keys"), revokeUserKey: () => Effect.void, + listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), + createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), + revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), }); const stubWorkOS = Layer.succeed( @@ -61,7 +64,7 @@ const stubWorkOS = Layer.succeed( ); const stubUsers = Layer.succeed(UserStoreService)({ - use: (fn) => + use: (_op, fn) => Effect.promise(() => fn({ ensureAccount: async (id: string) => ({ id, createdAt }), @@ -107,6 +110,7 @@ describe("protected JWT (device-login) auth", () => { const identity = yield* run(request(token), config); expect(identity).toEqual({ + kind: "member", accountId: "user_123", organizationId: "org_123", organizationName: "Org org_123", @@ -115,6 +119,10 @@ describe("protected JWT (device-login) auth", () => { name: null, avatarUrl: null, roles: [], + // The stub membership carries no role slug — normalization FAILS + // CLOSED to plain member, so the executor binds workspace writes off. + orgRoleModel: "organization", + orgRole: "member", }); }), ); diff --git a/apps/cloud/src/api/protected.test.ts b/apps/cloud/src/api/protected.test.ts index 858f9710f4..f5a81f0ae5 100644 --- a/apps/cloud/src/api/protected.test.ts +++ b/apps/cloud/src/api/protected.test.ts @@ -20,6 +20,8 @@ const makeBaseEngine = (): ExecutionEngine => pausedExecutionCount: () => Effect.succeed(0), hasPausedExecutions: () => Effect.succeed(false), getDescription: Effect.succeed("desc"), + // The fake forks nothing, so there is no sandbox fiber to end. + shutdown: Effect.void, }) as ExecutionEngine; describe("withExecutionUsageTracking", () => { diff --git a/apps/cloud/src/app-paths.test.ts b/apps/cloud/src/app-paths.test.ts index 98aeff3bdc..dd9ff1af3c 100644 --- a/apps/cloud/src/app-paths.test.ts +++ b/apps/cloud/src/app-paths.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { isAppOwnedPath } from "./app-paths"; +import { isAppOwnedPath, servedByAppPlane } from "./app-paths"; // Guards the start.ts dispatch decision: every surface the unified app handler // serves must be classified app-owned (forwarded to `app.handler`), and Start's @@ -62,3 +62,39 @@ describe("isAppOwnedPath", () => { }); } }); + +describe("app-plane dispatch", () => { + // These two are the whole risk of dispatching `/api` before Start: both still + // return a response if routed early, just the wrong one, so nothing else would + // catch a regression here. + it("leaves the Sentry tunnel POST to Start's middleware", () => { + expect(servedByAppPlane("/api/sentry-tunnel", "POST")).toBe(false); + // Only the POST is claimed; anything else under that path is ordinary API. + expect(servedByAppPlane("/api/sentry-tunnel", "GET")).toBe(true); + }); + + it("leaves the OAuth callback to Start, for the signed-out redirect", () => { + expect(servedByAppPlane("/api/oauth/callback", "GET")).toBe(false); + expect(servedByAppPlane("/api/oauth/callback", "POST")).toBe(false); + }); + + const appPlane = [ + "/api/connections", + "/api/tools", + "/api/integrations", + "/api/account/members", + "/api/docs", + "/api/billing/checkout", + ]; + for (const pathname of appPlane) { + it(`serves ${pathname} without entering Start`, () => { + expect(servedByAppPlane(pathname, "GET")).toBe(true); + }); + } + + it("never claims a non-API path, however app-owned", () => { + expect(servedByAppPlane("/mcp", "POST")).toBe(false); + expect(servedByAppPlane("/", "GET")).toBe(false); + expect(servedByAppPlane("/.well-known/oauth-authorization-server", "GET")).toBe(false); + }); +}); diff --git a/apps/cloud/src/app-paths.ts b/apps/cloud/src/app-paths.ts index 6dd6fc56f5..48e70a8c3a 100644 --- a/apps/cloud/src/app-paths.ts +++ b/apps/cloud/src/app-paths.ts @@ -17,3 +17,31 @@ export const isApiPath = (pathname: string) => pathname === "/api" || pathname.s export const isAppOwnedPath = (pathname: string) => isApiPath(pathname) || classifyMcpPath(pathname) !== null; + +// --------------------------------------------------------------------------- +// Which plane serves an app-owned path: the Effect app directly, or TanStack +// Start's middleware chain. +// +// Everything under `/api` is pure Effect and touches no part of the router, +// React, or SSR — so `server.ts` dispatches it at the Worker entry and skips +// Start's lazy `loadEntries` import entirely. Two paths must NOT take that +// shortcut, because Start's request middleware claims them BEFORE the app +// handler would ever see them: +// +// POST /api/sentry-tunnel - `sentryTunnelMiddleware` forwards the envelope +// to Sentry; the app has no such route. +// /api/oauth/callback - `oauthCallbackSignInMiddleware` redirects a +// signed-out visitor to /login, and start.ts +// rewrites the org-scoped `state` before handing +// off. Routing it early would drop both. +// +// Getting this wrong is silent: the request still gets a response, just the +// wrong one, which is why it is classified here and tested rather than being +// an inline condition at the dispatch site. +// --------------------------------------------------------------------------- + +export const isStartOwnedApiPath = (pathname: string, method: string): boolean => + (pathname === "/api/sentry-tunnel" && method === "POST") || pathname === "/api/oauth/callback"; + +export const servedByAppPlane = (pathname: string, method: string): boolean => + isApiPath(pathname) && !isStartOwnedApiPath(pathname, method); diff --git a/apps/cloud/src/app.ts b/apps/cloud/src/app.ts index 77afa9c7f9..19b0213ead 100644 --- a/apps/cloud/src/app.ts +++ b/apps/cloud/src/app.ts @@ -13,7 +13,6 @@ import { ApiKeyService } from "./auth/api-keys"; import { cloudIdentityFailureStrategy, workosIdentityLayer } from "./auth/workos-auth-provider"; import { DbService } from "./db/db"; import { cloudMcpAuth } from "./mcp"; -import { McpSessionDOSqlite } from "./mcp/session-durable-object"; import { ErrorCaptureLive } from "./observability"; import { AutumnService } from "./extensions/billing/service"; import { @@ -31,7 +30,7 @@ import { WorkerTelemetryLive } from "./observability/telemetry"; // The whole scenario in 60 seconds: WorkOS identity (api-key Bearer OR sealed- // session cookie, api-key wins) over a per-request Hyperdrive→Postgres socket, // the Cloudflare dynamic-worker code substrate, MCP served by a Durable-Object -// session store (the DO surfaced via `config.mcpExport`), console+Sentry error +// session store (the DO class is exported straight from server.ts), console+Sentry error // capture — and Autumn BILLING entering ONLY as extensions: the engine // metering decorator, the account seat-gate, the `/api/billing/*` proxy route, // and the createOrganization free-limit gate. `diff` against @@ -66,7 +65,7 @@ const controlPlane = Layer.mergeAll(CoreSharedServices, apiKeyService); // satisfied by `boot`, `DbService` per request via `requestScoped`). const cloudDb: Layer.Layer = CloudDbProvider; -const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ +const { appLayer, toWebHandler } = ExecutorApp.make({ plugins: cloudPlugins, providers: { // Identity: the NEUTRAL `IdentityProvider`. WorkOS api-key Bearer BEATS @@ -112,7 +111,6 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ failure: cloudIdentityFailureStrategy, // The MCP session Durable Object class — a top-level Workers export a Layer // can't return; surfaced so `server.ts` can re-export it. - mcpExport: McpSessionDOSqlite, }, // The long-lived (boot-scoped) context provideMerge'd under everything: the // WorkOS control plane (the raw `WorkOSClient` + `ApiKeyService` the per-request @@ -135,10 +133,7 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ requestScoped: RequestScopedServicesLive, }); -export { McpSessionDOSqlite }; - export const CloudAppLayer = appLayer; -export const cloudMcpExport = mcpExport; // The unified cloud web handler: serves /api/* (incl. /api/billing/*, /api/docs), // /mcp, /.well-known/* — everything the worker dispatches. diff --git a/apps/cloud/src/auth/api-keys.node.test.ts b/apps/cloud/src/auth/api-keys.node.test.ts index 6208dae37d..c55a2f90e6 100644 --- a/apps/cloud/src/auth/api-keys.node.test.ts +++ b/apps/cloud/src/auth/api-keys.node.test.ts @@ -42,6 +42,7 @@ describe("ApiKeyService.WorkOS", () => { }); expect(principal).toEqual({ + scope: "user", accountId: "user_123", organizationId: "org_123", keyId: "api_key_123", @@ -66,15 +67,9 @@ describe("ApiKeyService.WorkOS", () => { }), ); - it.effect("rejects missing, organization-owned, and org-less keys", () => + it.effect("rejects missing and org-less keys", () => Effect.gen(function* () { const missing = yield* validate({ apiKey: null }); - const orgOwned = yield* validate({ - apiKey: { - id: "api_key_org", - owner: { type: "organization", id: "org_123" }, - }, - }); const orgLess = yield* validate({ apiKey: { id: "api_key_no_org", @@ -83,11 +78,31 @@ describe("ApiKeyService.WorkOS", () => { }); expect(missing).toBeNull(); - expect(orgOwned).toBeNull(); expect(orgLess).toBeNull(); }), ); + it.effect("resolves organization-owned keys to the org scope with no account", () => + Effect.gen(function* () { + // An org key USED to fail the decode and come back null — indistinguishable + // from an invalid key. It now resolves explicitly: `owner.id` IS the + // organization, and there is no member behind it. + const principal = yield* validate({ + apiKey: { + id: "api_key_org", + owner: { type: "organization", id: "org_123" }, + }, + }); + + expect(principal).toEqual({ + scope: "org", + accountId: null, + organizationId: "org_123", + keyId: "api_key_org", + }); + }), + ); + it.effect("lists and creates user-owned keys", () => Effect.gen(function* () { const program = Effect.gen(function* () { diff --git a/apps/cloud/src/auth/api-keys.ts b/apps/cloud/src/auth/api-keys.ts index 2984f59873..0effa2609e 100644 --- a/apps/cloud/src/auth/api-keys.ts +++ b/apps/cloud/src/auth/api-keys.ts @@ -3,10 +3,24 @@ import { Context, Data, Effect, Layer, Option, Schema } from "effect"; import { ApiKeyManagementError } from "./errors"; import { WorkOSClient } from "./workos"; +/** + * Which view a validated key resolves to. + * - `"user"` — the product view. The key belongs to a member; the request + * binds to that member as the acting subject. Every key today is this. + * - `"org"` — the PLATFORM view. The key belongs to the organization itself, + * so there is no acting member: the request gets tenant-wide READ-ONLY + * reach and no bound subject. Privileged; minted separately (PR 1.5). + */ +export type ApiKeyScope = "user" | "org"; + /** The owner an api key resolves to — NOT a full {@link Principal} (no email / * name / roles), so it carries an honest, distinct name. */ export type ApiKeyOwner = { - readonly accountId: string; + readonly scope: ApiKeyScope; + /** The acting member for a `"user"` key; `null` for an `"org"` key, which + * has no member behind it. Nullable rather than a sentinel so nothing can + * accidentally bind an org key to a subject. */ + readonly accountId: string | null; readonly organizationId: string; readonly keyId: string; }; @@ -28,6 +42,21 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr readonly cause: unknown; }> {} +/** + * The requested key id is not an org-owned key of this organization — it does + * not exist, belongs to a member rather than the org, or belongs to a different + * org in the same WorkOS workspace. Distinct from `ApiKeyManagementError` (an + * upstream failure) so callers can answer "not found" without a 500. + */ +export class OrgApiKeyNotFound extends Data.TaggedError("OrgApiKeyNotFound")<{ + readonly keyId: string; +}> {} + +// WorkOS keys are owned by either a user or an organization. The user shape +// carries the member id in `id` and the org in `organization_id`; the org shape +// carries the ORG in `id` and no member at all. Both decode here — the +// distinction is a branch (`ownerFromApiKey`), not a decode failure, so an +// org-owned key is no longer indistinguishable from an invalid one. const UserApiKeyOwner = Schema.Struct({ type: Schema.Literal("user"), id: Schema.String, @@ -35,6 +64,13 @@ const UserApiKeyOwner = Schema.Struct({ organization_id: Schema.optional(Schema.String), }); +const OrgApiKeyOwner = Schema.Struct({ + type: Schema.Literal("organization"), + id: Schema.String, +}); + +const WorkOSApiKeyOwner = Schema.Union([UserApiKeyOwner, OrgApiKeyOwner]); + const ApiKey = Schema.Struct({ id: Schema.String, owner: UserApiKeyOwner, @@ -49,8 +85,17 @@ const ApiKey = Schema.Struct({ last_used_at: Schema.optional(Schema.NullOr(Schema.String)), }); +// Validation accepts BOTH owner shapes. The user list/create paths below stay +// user-only on purpose (they are the console's personal-key CRUD); the ORG +// list/create paths are the separate, privileged surface beside them, gated to +// admins at the account-provider boundary. +const ValidatedApiKey = Schema.Struct({ + id: Schema.String, + owner: WorkOSApiKeyOwner, +}); + const ValidateApiKeyResponse = Schema.Struct({ - apiKey: Schema.NullOr(ApiKey), + apiKey: Schema.NullOr(ValidatedApiKey), }); const RawCreatedApiKey = Schema.Struct({ @@ -72,6 +117,45 @@ const ListApiKeysResponse = Schema.Struct({ data: Schema.Array(ApiKey), }); +// The ORG-owned mirror of `ApiKey` / `RawCreatedApiKey`. A separate shape +// rather than a widened one: `summaryFromApiKey` reads the user shape's +// `organization_id` to prove the key belongs to the caller's org, and an +// org-owned row carries the org in `owner.id` instead. Merging them would mean +// one nullable field standing for two different things. +const OrgApiKey = Schema.Struct({ + id: Schema.String, + owner: OrgApiKeyOwner, + name: Schema.optional(Schema.String), + obfuscatedValue: Schema.optional(Schema.String), + obfuscated_value: Schema.optional(Schema.String), + createdAt: Schema.optional(Schema.String), + created_at: Schema.optional(Schema.String), + updatedAt: Schema.optional(Schema.String), + updated_at: Schema.optional(Schema.String), + lastUsedAt: Schema.optional(Schema.NullOr(Schema.String)), + last_used_at: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCreatedOrgApiKey = Schema.Struct({ + ...OrgApiKey.fields, + value: Schema.String, +}); + +// `data` is decoded element-by-element, NOT as `Schema.Array(OrgApiKey)`: WorkOS +// may return user-owned keys alongside org-owned ones, and a whole-array decode +// fails on the first non-org element — blanking the entire list rather than +// dropping the one row that doesn't belong. Same per-row tolerance the user-key +// path gets from `summaryFromApiKey` returning null. +const ListOrgApiKeysResponse = Schema.Struct({ + data: Schema.Array(Schema.Unknown), +}); + +const CreateOrgApiKeyResponse = Schema.Union([ + RawCreatedOrgApiKey, + Schema.Struct({ apiKey: RawCreatedOrgApiKey }), + Schema.Struct({ api_key: RawCreatedOrgApiKey }), +]); + const CreateApiKeyResponse = Schema.Union([ RawCreatedApiKey, Schema.Struct({ apiKey: RawCreatedApiKey }), @@ -81,20 +165,35 @@ const CreateApiKeyResponse = Schema.Union([ const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse); const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse); const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse); +const decodeListOrgApiKeysResponse = Schema.decodeUnknownOption(ListOrgApiKeysResponse); +const decodeOrgApiKey = Schema.decodeUnknownOption(OrgApiKey); +const decodeCreateOrgApiKeyResponse = Schema.decodeUnknownOption(CreateOrgApiKeyResponse); + +const ownerFromApiKey = (apiKey: typeof ValidatedApiKey.Type): ApiKeyOwner | null => { + if (apiKey.owner.type === "organization") { + // An org-owned key IS the org: `owner.id` is the organization, and there + // is no member to act as. This resolves to the platform view. + return { + scope: "org", + accountId: null, + organizationId: apiKey.owner.id, + keyId: apiKey.id, + }; + } + const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id; + if (!organizationId) return null; + return { + scope: "user", + accountId: apiKey.owner.id, + organizationId, + keyId: apiKey.id, + }; +}; const ownerFromResponse = (value: unknown): ApiKeyOwner | null => Option.match(decodeValidateApiKeyResponse(value), { onNone: () => null, - onSome: ({ apiKey }) => { - if (!apiKey) return null; - const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id; - if (!organizationId) return null; - return { - accountId: apiKey.owner.id, - organizationId, - keyId: apiKey.id, - }; - }, + onSome: ({ apiKey }) => (apiKey ? ownerFromApiKey(apiKey) : null), }); const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => { @@ -120,6 +219,53 @@ const listFromResponse = (value: unknown): readonly ApiKeySummary[] => }), }); +// The org-owned mirror of `summaryFromApiKey`. `organizationId` is checked by +// the CALLER against the org it asked for: WorkOS's workspace api key is +// workspace-wide, so a response naming a different org than the one requested +// must not be surfaced as that org's key. +const summaryFromOrgApiKey = ( + apiKey: typeof OrgApiKey.Type, + organizationId: string, +): ApiKeySummary | null => { + if (apiKey.owner.id !== organizationId) return null; + return { + id: apiKey.id, + name: apiKey.name ?? "API key", + obfuscatedValue: apiKey.obfuscatedValue ?? apiKey.obfuscated_value ?? "", + createdAt: apiKey.createdAt ?? apiKey.created_at ?? "", + updatedAt: apiKey.updatedAt ?? apiKey.updated_at ?? "", + lastUsedAt: apiKey.lastUsedAt ?? apiKey.last_used_at ?? null, + }; +}; + +const orgListFromResponse = (value: unknown, organizationId: string): readonly ApiKeySummary[] => + Option.match(decodeListOrgApiKeysResponse(value), { + onNone: () => [], + onSome: ({ data }) => + data.flatMap((entry) => + Option.match(decodeOrgApiKey(entry), { + // Not an org-owned key (a user key sharing the listing, or a shape we + // don't recognize) — skip the row, keep the rest. + onNone: () => [], + onSome: (apiKey) => { + const summary = summaryFromOrgApiKey(apiKey, organizationId); + return summary ? [summary] : []; + }, + }), + ), + }); + +const orgCreatedFromResponse = (value: unknown, organizationId: string): CreatedApiKey | null => + Option.match(decodeCreateOrgApiKeyResponse(value), { + onNone: () => null, + onSome: (response) => { + const apiKey = + "value" in response ? response : "apiKey" in response ? response.apiKey : response.api_key; + const summary = summaryFromOrgApiKey(apiKey, organizationId); + return summary ? { ...summary, value: apiKey.value } : null; + }, + }); + const createdFromResponse = (value: unknown): CreatedApiKey | null => Option.match(decodeCreateApiKeyResponse(value), { onNone: () => null, @@ -147,6 +293,28 @@ export class ApiKeyService extends Context.Service< readonly revokeUserKey: (input: { readonly keyId: string; }) => Effect.Effect; + /** + * The org-owned keys of an organization — the credentials that resolve to + * the read-only platform view. Privileged: callers gate on admin membership + * before reaching this. + */ + readonly listOrgKeys: (input: { + readonly organizationId: string; + }) => Effect.Effect; + readonly createOrgKey: (input: { + readonly organizationId: string; + readonly name: string; + }) => Effect.Effect; + /** + * Revoke an org-owned key. Ownership is resolved against the ORG's own key + * listing — never the caller's personal keys — so a member's user key id + * (or another org's key id, since the WorkOS workspace key is + * workspace-wide) cannot be destroyed through this path. + */ + readonly revokeOrgKey: (input: { + readonly organizationId: string; + readonly keyId: string; + }) => Effect.Effect; } >()("@executor-js/cloud/ApiKeyService") { static WorkOS = Layer.effect(this)( @@ -177,6 +345,40 @@ export class ApiKeyService extends Context.Service< workos .deleteApiKey(keyId) .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))), + listOrgKeys: ({ organizationId }) => + workos.listOrgApiKeys(organizationId).pipe( + Effect.map((response) => orgListFromResponse(response, organizationId)), + Effect.mapError((cause) => new ApiKeyManagementError({ cause })), + ), + createOrgKey: ({ organizationId, name }) => + workos.createOrgApiKey({ organizationId, name }).pipe( + Effect.mapError((cause) => new ApiKeyManagementError({ cause })), + Effect.flatMap((response) => { + const created = orgCreatedFromResponse(response, organizationId); + return created + ? Effect.succeed(created) + : Effect.fail(new ApiKeyManagementError({ cause: "invalid_create_response" })); + }), + ), + // Ownership is checked against the ORG's decoded key listing before the + // delete goes out. `deleteApiKey` is scope-blind — handed any key id it + // would happily destroy a member's personal credential, or a key + // belonging to a sibling org in the same workspace. The listing already + // drops both of those (`orgListFromResponse` filters on owner type AND + // organization), so filtering through it is the whole authorization. + revokeOrgKey: ({ organizationId, keyId }) => + Effect.gen(function* () { + const response = yield* workos + .listOrgApiKeys(organizationId) + .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))); + const owned = orgListFromResponse(response, organizationId); + if (!owned.some((key) => key.id === keyId)) { + return yield* new OrgApiKeyNotFound({ keyId }); + } + yield* workos + .deleteApiKey(keyId) + .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))); + }), }; }), ); diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 9d14c8d643..5c64be3c5c 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -181,6 +181,15 @@ const McpApprovalErrors = [ /** Public auth endpoints — no authentication required */ export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") .add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch })) + // Sign-out is PUBLIC on purpose. The console posts it as a top-level form + // navigation (the WorkOS hop is cross-origin, so it can't be a fetch), which + // means an error response is rendered as the page — and a browser whose + // session has already ended is exactly the browser most likely to click it + // (a second tab, a re-submit from history, an expired or revoked session). + // Behind SessionAuth all of those got a raw `{"_tag":"Unauthorized"}` screen + // instead of being signed out. There is nothing to authorize here anyway: + // the request can only end the session whose cookie it presents. + .add(HttpApiEndpoint.post("logout", "/auth/logout")) .add( HttpApiEndpoint.get("callback", "/auth/callback", { query: AuthCallbackSearch, @@ -201,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") error: AuthErrors, }), ) - .add(HttpApiEndpoint.post("logout", "/auth/logout")) .add( HttpApiEndpoint.get("organizations", "/auth/organizations", { success: AuthOrganizationsResponse, diff --git a/apps/cloud/src/auth/context.ts b/apps/cloud/src/auth/context.ts index bfd3ea25c0..ce8aa1804f 100644 --- a/apps/cloud/src/auth/context.ts +++ b/apps/cloud/src/auth/context.ts @@ -1,7 +1,7 @@ import { Context, Effect, Layer } from "effect"; import { makeUserStore } from "../auth/user-store"; import { DbService } from "../db/db"; -import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors"; +import { tryPromiseService, userStoreErrorFromFailure, withServiceLogging } from "./errors"; // --------------------------------------------------------------------------- // UserStoreService — wraps the Drizzle-backed user store with Effect @@ -9,11 +9,16 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors" type RawStore = ReturnType; +// `op` names the store call so every span reads `user_store.` +// instead of one undifferentiated "user_store" bucket, failures log which +// query actually failed, and — because the same `op` is threaded onto the +// public error alongside the classified driver reason — an error report is +// diagnosable without the trace. const makeService = (store: RawStore) => ({ - use: (fn: (s: RawStore) => Promise) => + use: (op: string, fn: (s: RawStore) => Promise) => withServiceLogging( - "user_store", - () => new UserStoreError(), + `user_store.${op}`, + (failure) => userStoreErrorFromFailure(op, failure), tryPromiseService(() => fn(store)), ), }); diff --git a/apps/cloud/src/auth/ssr-gate.ts b/apps/cloud/src/auth/doc-gate.ts similarity index 72% rename from apps/cloud/src/auth/ssr-gate.ts rename to apps/cloud/src/auth/doc-gate.ts index c329ac8cbd..16d6145bc9 100644 --- a/apps/cloud/src/auth/ssr-gate.ts +++ b/apps/cloud/src/auth/doc-gate.ts @@ -1,19 +1,23 @@ // --------------------------------------------------------------------------- -// SSR auth gate — the server-side session check for DOCUMENT requests. +// Document auth gate — the server-side session check for DOCUMENT requests. // // The sealed `wos-session` cookie is verified right here in the worker // (unseal + JWT check against cached JWKS — no per-request WorkOS round trip -// except token refresh), so by the time the SPA is served the server KNOWS -// who it's serving: +// except token refresh), so by the time the SPA shell is served the server +// KNOWS who it's serving: // // - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists // - org-less → 302 /create-org (onboarding owns those sessions) -// - signed in → the document is served WITH the verified identity: the -// auth-hint travels to the SSR render via request-middleware context (the -// root loader picks it up), and is minted as a cookie when the browser -// doesn't hold a current one — so the very first paint is the real app -// shell, never a skeleton. The hint is display-only; /account/me remains -// the authority and the client keeps it fresh from then on. +// - signed in → the prerendered shell is served, and the auth-hint cookie is +// minted when the browser doesn't hold a current one — the client seeds its +// auth state from that cookie right after mount (there is no per-request +// render to dehydrate it into). The hint is display-only; /account/me +// remains the authority and the client keeps it fresh from then on. +// +// Beyond redirects, this gate is load-bearing for SESSION LIFETIME: WorkOS +// refresh tokens are single-use, and a verify that rotates the sealed session +// must deliver the new cookie to the browser or the next expiry logs the user +// out. Document navigations are where that rotation lands. // // Scope: GET/HEAD requests that are document navigations (sec-fetch-dest / // accept), excluding app-owned paths (/api, /mcp — they answer for themselves @@ -23,6 +27,7 @@ import { createMiddleware } from "@tanstack/react-start"; import { Effect, Exit, Layer, ManagedRuntime } from "effect"; +import { isValidOrgSlug } from "@executor-js/api"; import { AUTH_HINT_COOKIE, AUTH_HINT_MAX_AGE_SECONDS, @@ -35,8 +40,9 @@ import { isAppOwnedPath } from "../app-paths"; import { makeDbLayer } from "../db/db"; import { makeUserStoreLayer, UserStoreService } from "./context"; import { parseCookie } from "./cookies"; +import { LAST_ORG_COOKIE } from "./last-org-cookie"; import { sealedSessionDisplayName } from "./middleware"; -import { browserOriginFromRequest } from "./request-origin"; +import { authorizeOrganizationSelector } from "./organization"; import { loginPath, safeReturnTo } from "./return-to"; import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths"; import { WorkOSClient } from "./workos"; @@ -150,7 +156,7 @@ const organizationDisplay = async ( ): Promise<{ name: string; slug: string }> => { const exit = await getRuntime().runPromiseExit( Effect.flatMap(UserStoreService.asEffect(), (users) => - users.use((store) => store.getOrganization(organizationId)), + users.use("getOrganization", (store) => store.getOrganization(organizationId)), ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))), ); return Exit.isSuccess(exit) @@ -158,6 +164,23 @@ const organizationDisplay = async ( : { name: "", slug: "" }; }; +// Live membership check for the last-org cookie's slug. Same authorize path +// as any org selector — the cookie is a preference, so a slug the user can't +// access (stale after removal/deletion, or forged) resolves to null and the +// bare path falls through to today's canonicalize-onto-session-org behavior. +// Per-request store layers for the same reason as organizationDisplay. +const authorizeLastOrgSlug = async ( + userId: string, + slug: string, +): Promise<{ readonly id: string } | null> => { + const exit = await getRuntime().runPromiseExit( + authorizeOrganizationSelector(userId, slug).pipe( + Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer())), + ), + ); + return Exit.isSuccess(exit) ? exit.value : null; +}; + const hintSetCookie = (hint: AuthHint) => `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`; @@ -226,19 +249,44 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( return redirect("/create-org", { refreshedSession: session.refreshedSession }); } - // Serve the document WITH the verified identity: the hint rides to the - // SSR render through middleware context (the root loader reads it), so - // the server paints the real authenticated shell — no loading state, no - // skeleton. The request origin rides along too: it's what the connect - // card's MCP URL is built from, and the server knows it (the SPA only - // learns `window.location.origin` after mount), so passing it here lets - // SSR render the real `https://…//mcp` instead of the client-side - // `http://127.0.0.1:4000` default — which would otherwise flash until - // hydration corrected it. Set-cookie writes ride on the rendered response. + // A BARE console path (no org slug in the URL) canonicalizes onto the org + // this browser last worked in (the last-org cookie), not the org pinned in + // the session at login time — a multi-org user re-entering at `/` lands + // where they left off. Slugged URLs never enter here (the URL names its + // own scope), so this costs nothing on the steady state; the not-found + // contract is untouched because an unknown-but-valid slug in the URL reads + // as slugged, not bare. When the cookie matches the session's own org (the + // overwhelmingly common single-org case) the client-side OrgSlugGate + // already canonicalizes onto it, so skip the live membership check and the + // redirect entirely. + const lastOrgSlug = parseCookie(cookieHeader, LAST_ORG_COOKIE); + const firstSegment = pathname.split("/")[1] ?? ""; + if ( + session.organizationId && + lastOrgSlug && + isValidOrgSlug(lastOrgSlug) && + !isValidOrgSlug(firstSegment) && + !ONBOARDING_PATHS.has(pathname) + ) { + const sessionOrgSlug = (await organizationDisplay(session.organizationId)).slug; + if (lastOrgSlug !== sessionOrgSlug) { + // The cookie is a preference, not an authority: only redirect onto an + // org the caller actively belongs to (stale/forged values fall through + // to today's session-org canonicalization). + const lastOrg = await authorizeLastOrgSlug(session.userId, lastOrgSlug); + if (lastOrg) { + return redirect(`/${lastOrgSlug}${pathname === "/" ? "" : pathname}${url.search}`, { + refreshedSession: session.refreshedSession, + }); + } + } + } + + // Serve the shell, minting the auth-hint cookie when the browser lacks a + // current one so the client's post-mount cookie read seeds the verified + // identity. Set-cookie writes ride on the shell response. const { hint, mint } = await resolveAuthHint(session, cookieHeader); - const result = await next({ - context: { authHint: hint, origin: browserOriginFromRequest(request) }, - }); + const result = await next(); if (!mint && !session.refreshedSession) return result; const response = new Response(result.response.body, result.response); diff --git a/apps/cloud/src/auth/errors.ts b/apps/cloud/src/auth/errors.ts index 927ae59f64..debf0b0635 100644 --- a/apps/cloud/src/auth/errors.ts +++ b/apps/cloud/src/auth/errors.ts @@ -1,10 +1,103 @@ import { Data, Effect, Option, Predicate, Schema } from "effect"; +// How a user-store call failed, classified from the driver cause. Safe to put +// on the wire and on a Sentry tag: it names a failure MODE, never a query, a +// value, or a customer. +export const USER_STORE_FAILURE_REASONS = [ + "connect_timeout", + "connection_closed", + "query", + "unknown", +] as const; + +export type UserStoreFailureReason = (typeof USER_STORE_FAILURE_REASONS)[number]; + +/** + * The public failure of every cloud user-store call. + * + * It carries the two fields that make an issue diagnosable from the error + * alone: which store call failed, and how. Before those existed the error had + * an empty field set, so Sentry showed a titleless, messageless issue and the + * only cause detail (the pretty-printed Effect cause in a Sentry `extra`) is + * scrubbed server-side — the failing operation and the driver reason existed + * only in the trace store. Same shape as `WorkOSError.status`: a small, safe + * classification field threaded at the service boundary. + */ export class UserStoreError extends Schema.TaggedErrorClass()( "UserStoreError", - {}, + { + /** The store call that failed, e.g. `getOrganization`. */ + operation: Schema.String, + /** How it failed, classified from the driver cause chain. */ + reason: Schema.Literals(USER_STORE_FAILURE_REASONS), + }, { httpApiStatus: 500 }, -) {} +) { + override get message(): string { + return `user store ${this.operation} failed: ${this.reason}`; + } +} + +/** Reasons a retry can plausibly clear: the query never reached a healthy + * server. A `query` failure is deterministic and must not be retried. */ +export const isTransientUserStoreReason = (reason: UserStoreFailureReason): boolean => + reason === "connect_timeout" || reason === "connection_closed"; + +// postgres.js tags its connection failures with a string `code` +// (`CONNECT_TIMEOUT`, `CONNECTION_CLOSED`, …) and its query failures with the +// SQLSTATE. Drizzle re-throws both wrapped in its own "Failed query" error with +// the driver error in `.cause`, and the service adapter wraps that again, so +// the classification walks the chain rather than inspecting one level. +const MAX_CAUSE_DEPTH = 8; + +const REASON_BY_DRIVER_CODE: Readonly> = { + CONNECT_TIMEOUT: "connect_timeout", + ETIMEDOUT: "connect_timeout", + CONNECTION_CLOSED: "connection_closed", + CONNECTION_ENDED: "connection_closed", + CONNECTION_DESTROYED: "connection_closed", + ECONNREFUSED: "connection_closed", + ECONNRESET: "connection_closed", +}; + +const stringCodeOf = (value: unknown): string | undefined => { + if (typeof value !== "object" || value === null) return undefined; + const code = (value as { readonly code?: unknown }).code; + return typeof code === "string" ? code : undefined; +}; + +const driverCodesOf = (failure: unknown): readonly string[] => { + const codes: string[] = []; + let current: unknown = isServiceAdapterError(failure) ? failure.cause : failure; + for ( + let depth = 0; + depth < MAX_CAUSE_DEPTH && current !== undefined && current !== null; + depth++ + ) { + const code = stringCodeOf(current); + if (code !== undefined) codes.push(code); + current = + typeof current === "object" ? (current as { readonly cause?: unknown }).cause : undefined; + } + return codes; +}; + +/** Classify a raw store failure. A recognised connection code wins; any other + * driver code (a SQLSTATE) is a deterministic query failure; nothing at all is + * `unknown`. */ +export const userStoreReasonFromCause = (failure: unknown): UserStoreFailureReason => { + const codes = driverCodesOf(failure); + for (const code of codes) { + const reason = REASON_BY_DRIVER_CODE[code]; + if (reason !== undefined) return reason; + } + return codes.length > 0 ? "query" : "unknown"; +}; + +/** Build the public `UserStoreError` for a store-adapter failure, naming the + * operation the call site already knows and classifying the driver cause. */ +export const userStoreErrorFromFailure = (operation: string, failure: unknown): UserStoreError => + new UserStoreError({ operation, reason: userStoreReasonFromCause(failure) }); export class WorkOSError extends Schema.TaggedErrorClass()( "WorkOSError", diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index cd198c58fd..ae91bd35a4 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -22,11 +22,14 @@ import { env } from "cloudflare:workers"; import { WorkOSError } from "./errors"; import { WorkOSClient } from "./workos"; import { AutumnService } from "../extensions/billing/service"; +import { forkReportMemberSeats } from "../extensions/billing/member-seats"; +import { captureCauseEffect } from "../observability"; import { hasPaidOrganizationSubscription, isOverFreeOrganizationLimit, shouldApplyFreeOrganizationLimit, } from "../extensions/billing/plans"; +import { LAST_ORG_COOKIE } from "./last-org-cookie"; import { ORG_SELECTOR_HEADER, authorizeOrganization, @@ -117,6 +120,7 @@ const requireSelectedOrganization = Effect.gen(function* () { return { ...session, organizationId: org.id, + memberRole: org.memberRole, }; }); @@ -143,8 +147,8 @@ const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, n HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); // --------------------------------------------------------------------------- -// Single non-protected API surface — public (login/callback) + session -// (me/logout/organizations/switch-organization). The session group has SessionAuth on it. +// Single non-protected API surface — public (login/callback/logout) + session +// (me/organizations/switch-organization). The session group has SessionAuth on it. // --------------------------------------------------------------------------- export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi); @@ -201,7 +205,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( const result = yield* workos.authenticateWithCode(query.code); // Mirror the account locally - yield* users.use((s) => s.ensureAccount(result.user.id)); + yield* users.use("ensureAccount", (s) => s.ensureAccount(result.user.id)); let sealedSession = result.sealedSession; @@ -218,17 +222,40 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( : null; // Prefer the org in the URL that sent the user to login. If the URL - // is bare, or not an org route, fall back to WorkOS's org and then to - // the first active membership for org-less sessions. Pending - // memberships are skipped because refreshing into one 400s and would - // bypass invite consent. - let targetOrganizationId = requestedOrg?.id ?? result.organizationId ?? null; + // is bare, or not an org route, prefer the org this browser last + // worked in (the last-org cookie — it outlives the session precisely + // so a fresh login lands where the user left off), then WorkOS's + // org, then the first active membership for org-less sessions. + // Pending memberships are skipped because refreshing into one 400s + // and would bypass invite consent. The cookie is membership-checked + // like any selector, so a stale one just falls through. + let targetOrganizationId = requestedOrg?.id ?? null; + if (!targetOrganizationId && !requestedOrgSelector) { + const lastOrgSlug = request.cookies[LAST_ORG_COOKIE]; + const lastOrg = + lastOrgSlug && isValidOrgSlug(lastOrgSlug) + ? yield* authorizeOrganizationSelector(result.user.id, lastOrgSlug).pipe( + Effect.orElseSucceed(() => null), + ) + : null; + targetOrganizationId = lastOrg?.id ?? null; + } + targetOrganizationId ??= result.organizationId ?? null; if (!targetOrganizationId && !requestedOrgSelector) { const memberships = yield* workos.listUserMemberships(result.user.id); const existingActive = memberships.data.find((m) => m.status === "active"); targetOrganizationId = existingActive?.organizationId ?? null; } + // Seat changes the app never sees a mutation for (invitation + // acceptance in AuthKit, SSO JIT provisioning, join by domain, + // WorkOS dashboard edits) all end in a sign-in, so every login + // reconciles the landed org's billed seat count. Forked: billing + // must not delay the login. + if (targetOrganizationId) { + yield* forkReportMemberSeats(targetOrganizationId); + } + if ( targetOrganizationId && targetOrganizationId !== result.organizationId && @@ -259,6 +286,42 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( ); }), ) + .handleRaw("logout", ({ request }) => + Effect.gen(function* () { + const workos = yield* WorkOSClient; + // The session this browser presents, NOT one the middleware vouched + // for — signing out of a session that has already ended must still + // sign the browser out (see the group declaration in ./api.ts). + const sealedSession = request.cookies["wos-session"] ?? ""; + + // WorkOS's documented sign-out: send the browser through the WorkOS + // logout endpoint, which ends the AuthKit session upstream and then + // redirects to the registered sign-out URL. Without this hop, the + // hosted session survives and the next "Sign in" silently + // re-authenticates (issue #1445). Fail-open when the cookie won't + // unseal — there is then nothing to end upstream, and local sign-out + // must still complete, so fall back to "/". + const origin = env.VITE_PUBLIC_SITE_URL ?? ""; + const logoutUrl = sealedSession + ? yield* workos.logoutUrl(sealedSession, origin ? `${origin}/` : undefined) + : null; + + const response = HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }); + + // Drop only what this browser actually presented. Both cookies are + // SameSite=Lax, so a cross-site form POST carries neither — it gets + // the bare redirect and cannot be used to sign anyone out. + if (!sealedSession && request.cookies[AUTH_HINT_COOKIE] === undefined) return response; + + // The auth-hint travels with the session: leaving it behind would + // make the next page load optimistically paint the app shell for a + // signed-out browser. + return deleteResponseCookie( + deleteResponseCookie(response, "wos-session"), + AUTH_HINT_COOKIE, + ); + }), + ) // CLI device-login discovery. The WorkOS device endpoints live on the // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, // the SAME base the SDK uses, so e2e points the CLI at the emulator with @@ -304,35 +367,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( }; }), ) - .handleRaw("logout", () => - Effect.gen(function* () { - const workos = yield* WorkOSClient; - const session = yield* SessionContext; - - // WorkOS's documented sign-out: send the browser through the WorkOS - // logout endpoint, which ends the AuthKit session upstream and then - // redirects to the registered sign-out URL. Without this hop, the - // hosted session survives and the next "Sign in" silently - // re-authenticates (issue #1445). Fail-open when the cookie won't - // unseal: local sign-out must still complete, so fall back to "/". - const origin = env.VITE_PUBLIC_SITE_URL ?? ""; - const logoutUrl = yield* workos.logoutUrl( - session.sealedSession, - origin ? `${origin}/` : undefined, - ); - - // The auth-hint travels with the session: leaving it behind would - // make the next page load optimistically paint the app shell for a - // signed-out browser. - return deleteResponseCookie( - deleteResponseCookie( - HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }), - "wos-session", - ), - AUTH_HINT_COOKIE, - ); - }), - ) .handle("organizations", () => Effect.gen(function* () { const workos = yield* WorkOSClient; @@ -393,7 +427,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( ), { concurrency: 3 }, ).pipe( - Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())), + // Any Autumn failure here (outage or missing customer) leaves the + // paid/free split unknown, and the limit must fail closed. + Effect.mapError(() => new WorkOSError()), Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), ); @@ -405,10 +441,30 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( const org = yield* workos.createOrganization(name); yield* workos.createMembership(org.id, session.accountId, "admin"); // `upsertOrganization` mints the slug at insert — no separate heal step. - const mirrored = yield* users.use((s) => + const mirrored = yield* users.use("upsertOrganization", (s) => s.upsertOrganization({ id: org.id, name: org.name }), ); + // Provision the org's billing customer while we're the ones creating + // the org. Without this the first billing call an org ever makes is a + // non-creating one (balance check / usage track), which 404s and keeps + // 404ing — unlimited unbilled executions. Non-fatal: a billing blip + // must not block signup, and the billing seam heals a customer that + // is still missing later. + yield* autumn.ensureCustomer(org.id).pipe( + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logWarning( + "createOrganization: could not provision the Autumn customer", + { organizationId: org.id, error }, + ); + yield* captureCauseEffect(error); + }), + ), + ); + // Seed the new org's billed seat count (the creator's seat). + yield* forkReportMemberSeats(org.id); + // Try to attach the new org to the current session. This can fail // (or silently return a session still scoped to the old org) when // the caller's current session is stale — most commonly after the @@ -459,7 +515,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // The typed confirmation must match the org's current name — the same // label the settings page shows. Trimmed on both sides. - const org = yield* users.use((s) => s.getOrganization(organizationId)); + const org = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); if (!org || payload.confirmName.trim() !== org.name.trim()) { return yield* new OrganizationDeletionForbidden(); } @@ -477,7 +533,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // everyone (unreachable) but its secrets/tenant rows linger orphaned — // alert loudly so that window gets swept, then surface the failure. yield* users - .use((s) => s.deleteOrganizationCascade(organizationId)) + .use("deleteOrganizationCascade", (s) => s.deleteOrganizationCascade(organizationId)) .pipe( Effect.tapError((error) => Effect.logError( @@ -493,7 +549,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( yield* autumn .use((client) => client.customers.delete({ customerId: organizationId })) .pipe( - Effect.catchTag("AutumnError", (error) => + // Includes the "customer never existed" answer: nothing to cancel + // is a fine outcome for a deleted org, and it is still worth a line. + Effect.catch((error) => Effect.logWarning("deleteOrganization: failed to delete Autumn customer", { organizationId, error, @@ -578,10 +636,15 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // Mirror the org locally so domain tables can FK against it; the // upsert mints the slug at insert — no separate heal step. const org = yield* workos.getOrganization(invitation.organizationId); - const mirrored = yield* users.use((s) => + const mirrored = yield* users.use("upsertOrganization", (s) => s.upsertOrganization({ id: org.id, name: org.name }), ); + // The membership is active in WorkOS from this point even if + // attaching the session below fails, so reconcile the org's billed + // seat count now. + yield* forkReportMemberSeats(org.id); + // Attach the just-accepted org to the current session. Same shape // as createOrganization: refresh + verify; if we can't pin the // session in-place, clear the cookie and let the user bounce @@ -636,6 +699,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( { accountId: owner.accountId, organizationId: owner.organizationId, + orgRole: owner.memberRole, }, { action: payload.action, diff --git a/apps/cloud/src/auth/jwks-cache.node.test.ts b/apps/cloud/src/auth/jwks-cache.node.test.ts index d1f2b47c4b..f4f3e08a64 100644 --- a/apps/cloud/src/auth/jwks-cache.node.test.ts +++ b/apps/cloud/src/auth/jwks-cache.node.test.ts @@ -9,7 +9,7 @@ import { type KeyLike, } from "jose"; -import { createCachedRemoteJWKSet } from "./jwks-cache"; +import { createCachedRemoteJWKSet, type JwksStore, type StoredJwks } from "./jwks-cache"; const issuer = "https://test-authkit.example.com"; const audience = "client_test_fixture"; @@ -65,6 +65,40 @@ const makeFetchHarness = (initialKeys: ReadonlyArray): FetchHarness => { }; }; +interface StoreHarness extends JwksStore { + readonly seed: (stored: StoredJwks) => void; + readonly reads: () => number; + readonly writes: () => number; +} + +/** Stands in for the Workers Cache API: shared across "isolates", in memory. */ +const makeStoreHarness = (): StoreHarness => { + const entries = new Map(); + let reads = 0; + let writes = 0; + return { + get: async (url) => { + reads++; + return entries.get(url.toString()) ?? null; + }, + put: async (url, stored) => { + writes++; + entries.set(url.toString(), stored); + }, + seed: (stored) => { + entries.set(jwksUrl.toString(), stored); + }, + reads: () => reads, + writes: () => writes, + }; +}; + +/** A fetch that always fails, standing in for a slow/down key server. */ +const failingFetch: typeof globalThis.fetch = async () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test double: `fetch` signals an unreachable key server by rejecting + throw new Error("JWKS endpoint unreachable"); +}; + describe("createCachedRemoteJWKSet", () => { it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => { const kp = await generateRotatableKeypair("k1"); @@ -168,4 +202,151 @@ describe("createCachedRemoteJWKSet", () => { await jwtVerify(t1, jwks, { issuer, audience }); expect(harness.callCount()).toBe(2); }); + + // ------------------------------------------------------------------------- + // Cross-isolate store — the cold-isolate path that caused the 2026-08-18 + // 500s: no module-scope entry, so every verify paid an upstream fetch. + // ------------------------------------------------------------------------- + + it("a cold isolate serves from the store instead of going upstream", async () => { + const kp = await generateRotatableKeypair("k1"); + const store = makeStoreHarness(); + + // First isolate: cold everywhere, so it fetches and populates the store. + const warm = makeFetchHarness([kp.publicJwk]); + const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store }); + const t1 = await sign(kp); + await jwtVerify(t1, first, { issuer, audience }); + expect(warm.callCount()).toBe(1); + expect(store.writes()).toBe(1); + + // A brand-new isolate (fresh module scope). If it reaches upstream at all + // the fetch fails, so verifying proves the store answered. + const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store }); + const { payload } = await jwtVerify(t1, second, { issuer, audience }); + expect(payload.sub).toBe("user_test"); + expect(store.reads()).toBeGreaterThan(0); + }); + + it("keeps serving the last good keys when the key server is down", async () => { + const kp = await generateRotatableKeypair("k1"); + const harness = makeFetchHarness([kp.publicJwk]); + let upstreamUp = true; + const flaky: typeof globalThis.fetch = (...args) => + upstreamUp ? harness.fetch(...args) : failingFetch(...args); + + // ttl short enough that the next call is past it, stale window generous. + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: flaky, + store: null, + ttlMs: 10, + staleMaxMs: 60_000, + }); + + const token = await sign(kp); + await jwtVerify(token, jwks, { issuer, audience }); + + upstreamUp = false; + await new Promise((r) => setTimeout(r, 20)); + + // Past the TTL with a dead upstream: the cached keys still verify. + const { payload } = await jwtVerify(token, jwks, { issuer, audience }); + expect(payload.sub).toBe("user_test"); + }); + + it("stops serving stale keys once the stale window closes", async () => { + const kp = await generateRotatableKeypair("k1"); + const harness = makeFetchHarness([kp.publicJwk]); + let upstreamUp = true; + const flaky: typeof globalThis.fetch = (...args) => + upstreamUp ? harness.fetch(...args) : failingFetch(...args); + + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: flaky, + store: null, + ttlMs: 5, + staleMaxMs: 10, + }); + + const token = await sign(kp); + await jwtVerify(token, jwks, { issuer, audience }); + + upstreamUp = false; + await new Promise((r) => setTimeout(r, 30)); + + // Beyond staleMaxMs the keys are no longer trustworthy — fail, don't + // silently honour a key set we can no longer confirm. + await expect(jwtVerify(token, jwks, { issuer, audience })).rejects.toThrow(); + }); + + it("attributes background revalidation to fetchCount but not blockingFetchCount", async () => { + const kp = await generateRotatableKeypair("k1"); + const harness = makeFetchHarness([kp.publicJwk]); + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: harness.fetch, + store: null, + ttlMs: 10, + staleMaxMs: 60_000, + }); + + const token = await sign(kp); + await jwtVerify(token, jwks, { issuer, audience }); + expect(jwks.inspect().blockingFetchCount).toBe(1); + + await new Promise((r) => setTimeout(r, 20)); + + // Past the TTL: served from the stale entry, revalidated behind it. The + // caller waited on nothing, so latency must not be attributed to it. + const before = jwks.inspect(); + await jwtVerify(token, jwks, { issuer, audience }); + const after = jwks.inspect(); + + expect(after.blockingFetchCount).toBe(before.blockingFetchCount); + expect(after.fetchCount).toBeGreaterThan(before.fetchCount); + }); + + it("records a cold-isolate store read as a store hit, not a fetch", async () => { + const kp = await generateRotatableKeypair("k1"); + const store = makeStoreHarness(); + const warm = makeFetchHarness([kp.publicJwk]); + + const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store }); + const token = await sign(kp); + await jwtVerify(token, first, { issuer, audience }); + + const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store }); + await jwtVerify(token, second, { issuer, audience }); + + const stats = second.inspect(); + expect(stats.storeHitCount).toBe(1); + expect(stats.blockingFetchCount).toBe(0); + }); + + it("does not block a request on revalidation once keys are cached", async () => { + const kp = await generateRotatableKeypair("k1"); + const harness = makeFetchHarness([kp.publicJwk]); + let hang = false; + const slowAfterFirst: typeof globalThis.fetch = async (...args) => { + if (hang) await new Promise((r) => setTimeout(r, 5_000)); + return harness.fetch(...args); + }; + + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: slowAfterFirst, + store: null, + ttlMs: 10, + staleMaxMs: 60_000, + }); + + const token = await sign(kp); + await jwtVerify(token, jwks, { issuer, audience }); + + hang = true; + await new Promise((r) => setTimeout(r, 20)); + + // The revalidation behind this call hangs for 5s; the verify must not. + const startedAt = Date.now(); + await jwtVerify(token, jwks, { issuer, audience }); + expect(Date.now() - startedAt).toBeLessThan(1_000); + }); }); diff --git a/apps/cloud/src/auth/jwks-cache.ts b/apps/cloud/src/auth/jwks-cache.ts index d04e80187c..3cd1d963d1 100644 --- a/apps/cloud/src/auth/jwks-cache.ts +++ b/apps/cloud/src/auth/jwks-cache.ts @@ -1,5 +1,5 @@ // --------------------------------------------------------------------------- -// In-memory JWKS cache for MCP JWT verification. +// JWKS cache for JWT verification (session + MCP auth). // --------------------------------------------------------------------------- // // Cloudflare Workers boot many short-lived isolates. `createRemoteJWKSet`'s @@ -7,14 +7,32 @@ // fetches per hour because each new isolate starts cold. Production p99 for // `mcp.auth.jwt_verify` was 1.7s — almost entirely the JWKS fetch. // -// This module offers a drop-in `createCachedRemoteJWKSet` that: +// Module-scope memory alone only helps while an isolate stays warm. When +// `workos.session.local_verify` started missing ~92% of the time (2026-08-18), +// every miss paid a fresh upstream fetch — p50 3s, and the tail crossed the 5s +// request timeout, turning a slow key-server into `AbortError` → 500 on every +// authenticated API route and 503 on `/mcp`. A transient key-server blip must +// never read as an auth failure, and a cold isolate must not have to go +// upstream at all. // -// * Caches the JSON Web Key Set in module-scope memory for a configurable -// TTL (default 1 hour). -// * Single-flights concurrent fetches so a stampede of verifies during a -// cache miss only fires one upstream request. -// * Force-refreshes once when verification fails with a cached key, so -// genuine key rotation isn't blocked by the TTL. +// So this module layers three caches, fastest first: +// +// 1. Module-scope memory — free, but dies with the isolate. +// 2. A cross-isolate store (the Workers Cache API by default) — colo-local, +// survives isolate recycling, so a cold isolate reads keys in ~1ms +// instead of paying an upstream round trip. +// 3. The upstream JWKS endpoint. +// +// On top of that it is stale-while-revalidate: once past `ttlMs` a usable key +// set is served immediately and refreshed in the background, and if a refresh +// fails we keep serving the last good keys until `staleMaxMs`. Only a fully +// cold path (no memory, no store) ever blocks on the network. +// +// Serving stale keys is safe in a way that serving a stale *token* would not +// be: key sets rotate on the order of days, tokens are still signature- and +// expiry-checked against them, and a token whose `kid` is absent forces a real +// refresh before it is rejected (see `get`). `staleMaxMs` bounds how long a +// retired key can still be honoured. // // The returned function is a `JWTVerifyGetKey` and slots directly into // `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can @@ -32,6 +50,21 @@ import { import { Schema } from "effect"; import { JWKSNoMatchingKey } from "jose/errors"; +/** + * A cross-isolate key-set store. Defaults to the Workers Cache API; tests and + * non-Workers hosts pass their own, or `null` to stay memory-only. + */ +export interface JwksStore { + readonly get: (url: URL) => Promise; + readonly put: (url: URL, stored: StoredJwks) => Promise; +} + +export interface StoredJwks { + readonly jwks: JSONWebKeySet; + /** Epoch ms of the upstream fetch these keys came from. */ + readonly fetchedAt: number; +} + export interface CachedRemoteJWKSetOptions { /** * How long a successful fetch is considered fresh. Defaults to 1 hour — @@ -39,20 +72,56 @@ export interface CachedRemoteJWKSetOptions { * failure handles unscheduled rotations. */ readonly ttlMs?: number; + /** + * How long past `ttlMs` a key set may still be served while refreshes are + * failing. Defaults to 24h — long enough to ride out a key-server outage, + * short enough to bound how long a retired key stays honoured. + */ + readonly staleMaxMs?: number; /** Override the fetch implementation for tests. */ readonly fetch?: typeof globalThis.fetch; /** HTTP request timeout. Defaults to 5s, matching jose. */ readonly timeoutMs?: number; + /** + * Cross-isolate store. Defaults to the Workers Cache API when available, + * `null` disables the layer (memory-only). + */ + readonly store?: JwksStore | null; } export interface CachedRemoteJWKSet extends JWTVerifyGetKey { /** Drop the cached JWKS so the next call refetches. */ readonly forceRefresh: () => void; - /** Inspect the current cache state (testing/diagnostics). */ - readonly inspect: () => { fetchedAt: number | null; hasJwks: boolean }; + /** + * Inspect the current cache state (testing/diagnostics/span annotation). + * `fetchCount`/`fetchFailureCount` are lifetime counters for this resolver + * instance; callers snapshot them around a verify to tell a cache hit from + * a live upstream fetch (the Aug 2026 latency regression was exactly this + * cache silently missing on ~92% of verifies, invisible in traces). + * + * `blockingFetchCount` counts only fetches a verify actually WAITED on. + * Under stale-while-revalidate `fetchCount` also moves for background + * revalidation the caller never paid for, so latency attribution wants + * `blockingFetchCount`. `storeHitCount` counts cold-isolate reads answered + * by the cross-isolate store instead of going upstream. + */ + readonly inspect: () => { + fetchedAt: number | null; + hasJwks: boolean; + fetchCount: number; + fetchFailureCount: number; + blockingFetchCount: number; + storeHitCount: number; + lastFetchDurationMs: number | null; + /** Wall-clock of the last cross-isolate store read (I/O). */ + lastStoreReadMs: number | null; + /** Wall-clock of the last key resolution — WebCrypto `importKey`. */ + lastResolveMs: number | null; + }; } const DEFAULT_TTL_MS = 60 * 60 * 1000; +const DEFAULT_STALE_MAX_MS = 24 * 60 * 60 * 1000; const DEFAULT_TIMEOUT_MS = 5000; const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown); @@ -73,6 +142,26 @@ interface CacheEntry { resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise; } +const entryFrom = (stored: StoredJwks): CacheEntry => ({ + jwks: stored.jwks, + fetchedAt: stored.fetchedAt, + resolver: createLocalJWKSet(stored.jwks), +}); + +/** + * Cache upkeep (store writes, background revalidation) is best effort: it must + * never fail or delay the verify that happened to trigger it. + */ +const ignoreFailure = async (work: Promise): Promise => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort cache upkeep must not surface as a verify failure + try { + await work; + } catch { + // Deliberately swallowed — the caller already has usable keys, or will + // take the upstream path on its next miss. + } +}; + const fetchJwksOnce = async ( url: URL, fetchImpl: typeof globalThis.fetch, @@ -107,17 +196,74 @@ const fetchJwksOnce = async ( } }; +// --------------------------------------------------------------------------- +// Workers Cache API store +// --------------------------------------------------------------------------- +// +// Keyed by the JWKS URL itself. We store our own Response rather than the +// upstream one so the body is already-validated JSON and the cache headers are +// ours: `max-age` covers the stale window, because an entry past `ttlMs` is +// still useful to us as a stale fallback. + +const STORE_HEADER_FETCHED_AT = "x-jwks-fetched-at"; + +/** `caches.default` is a Workers extension the standard lib type omits. */ +type WorkersCacheStorage = CacheStorage & { readonly default?: Cache }; + +const workersCacheStore = (staleMaxMs: number): JwksStore | null => { + if (typeof caches === "undefined") return null; + const open = (): Cache | null => (caches as WorkersCacheStorage).default ?? null; + + return { + get: async (url) => { + const cache = open(); + if (!cache) return null; + const hit = await cache.match(url.toString()); + if (!hit) return null; + const fetchedAtHeader = hit.headers.get(STORE_HEADER_FETCHED_AT); + const fetchedAt = fetchedAtHeader === null ? Number.NaN : Number(fetchedAtHeader); + if (!Number.isFinite(fetchedAt)) return null; + const body = await hit.json(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a corrupt cache entry must degrade to a miss, never fail the verify + try { + await decodeJsonWebKeySetPayload(body); + } catch { + return null; + } + return { jwks: body as JSONWebKeySet, fetchedAt }; + }, + put: async (url, stored) => { + const cache = open(); + if (!cache) return; + const maxAgeSeconds = Math.max(1, Math.floor(staleMaxMs / 1000)); + await cache.put( + url.toString(), + new Response(JSON.stringify(stored.jwks), { + status: 200, + headers: { + "content-type": "application/json", + "cache-control": `max-age=${maxAgeSeconds}`, + [STORE_HEADER_FETCHED_AT]: String(stored.fetchedAt), + }, + }), + ); + }, + }; +}; + /** * Creates a cached, single-flight, force-refreshable JWKS resolver compatible * with `jose.jwtVerify`. Drop-in replacement for `createRemoteJWKSet` for the - * MCP auth path — see module header for why we don't just use jose's built-in. + * auth paths — see module header for why we don't just use jose's built-in. */ export const createCachedRemoteJWKSet = ( url: URL, options: CachedRemoteJWKSetOptions = {}, ): CachedRemoteJWKSet => { const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + const staleMaxMs = Math.max(options.staleMaxMs ?? DEFAULT_STALE_MAX_MS, ttlMs); const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const store = options.store === undefined ? workersCacheStore(staleMaxMs) : options.store; // Capture the fetch impl lazily so consumers can swap globalThis.fetch // (tests do this) without us snapshotting a stale reference. const fetchImpl = (): typeof globalThis.fetch => @@ -125,39 +271,116 @@ export const createCachedRemoteJWKSet = ( let entry: CacheEntry | null = null; let inflight: Promise | null = null; + let fetchCount = 0; + let fetchFailureCount = 0; + let blockingFetchCount = 0; + let storeHitCount = 0; + let lastFetchDurationMs: number | null = null; + let lastStoreReadMs: number | null = null; + let lastResolveMs: number | null = null; + + const isFresh = (candidate: CacheEntry): boolean => Date.now() - candidate.fetchedAt < ttlMs; + const isUsable = (candidate: CacheEntry): boolean => + Date.now() - candidate.fetchedAt < staleMaxMs; const refresh = (): Promise => { if (inflight) return inflight; + const startedAt = Date.now(); + fetchCount += 1; inflight = (async () => { const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); - const next: CacheEntry = { - jwks, - fetchedAt: Date.now(), - resolver: createLocalJWKSet(jwks), - }; + const next = entryFrom({ jwks, fetchedAt: Date.now() }); entry = next; + if (store) { + await ignoreFailure(store.put(url, { jwks: next.jwks, fetchedAt: next.fetchedAt })); + } return next; - })().finally(() => { - inflight = null; - }); + })() + .then( + (next) => next, + (error: unknown) => { + fetchFailureCount += 1; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: counting a fetch failure must preserve the original rejection for jose + throw error; + }, + ) + .finally(() => { + lastFetchDurationMs = Date.now() - startedAt; + inflight = null; + }); return inflight; }; - const ensureFresh = async (forceRefresh: boolean): Promise => { - if (forceRefresh) return refresh(); - if (entry && Date.now() - entry.fetchedAt < ttlMs) return entry; + /** Fire-and-forget revalidation behind a stale hit. */ + const refreshInBackground = (): void => { + void ignoreFailure(refresh()); + }; + + const loadFromStore = async (): Promise => { + if (!store) return null; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the L2 store is an optimization; any failure degrades to an upstream fetch + try { + const storeReadStartedAt = Date.now(); + const stored = await store.get(url); + lastStoreReadMs = Date.now() - storeReadStartedAt; + if (!stored) return null; + const candidate = entryFrom(stored); + if (!isUsable(candidate)) return null; + entry = candidate; + storeHitCount += 1; + return candidate; + } catch { + return null; + } + }; + + /** A refresh the caller waits on — the only kind that costs it latency. */ + const refreshBlocking = (): Promise => { + blockingFetchCount += 1; return refresh(); }; + const ensureFresh = async (forceRefresh: boolean): Promise => { + if (forceRefresh) return refreshBlocking(); + if (entry && isFresh(entry)) return entry; + + // Memory is stale but still usable: serve it now, revalidate behind it. + if (entry && isUsable(entry)) { + refreshInBackground(); + return entry; + } + + // Cold isolate — the L2 store saves us the upstream round trip. + const stored = await loadFromStore(); + if (stored) { + if (!isFresh(stored)) refreshInBackground(); + return stored; + } + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a failed refresh must fall back to stale keys rather than fail the verify + try { + return await refreshBlocking(); + } catch (error) { + // Upstream is slow or down. Last good keys beat failing every request. + if (entry && isUsable(entry)) return entry; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: nothing usable is cached, so the upstream failure is the real answer + throw error; + } + }; + const get: JWTVerifyGetKey = async (protectedHeader, token) => { const current = await ensureFresh(false); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures try { - return await current.resolver(protectedHeader, token); + const resolveStartedAt = Date.now(); + const key = await current.resolver(protectedHeader, token); + lastResolveMs = Date.now() - resolveStartedAt; + return key; } catch (error) { - // Likely cause: keys rotated upstream after our TTL window started. - // Refetch once and try again. Anything still failing bubbles up so - // jose can classify it (we do not silently swallow real failures). + // Likely cause: keys rotated upstream after our TTL window started, or + // we answered from a stale entry. Refetch once and try again. Anything + // still failing bubbles up so jose can classify it (we do not silently + // swallow real failures). if (!isJwksNoMatchingKey(error)) { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection throw error; @@ -177,6 +400,13 @@ export const createCachedRemoteJWKSet = ( value: () => ({ fetchedAt: entry?.fetchedAt ?? null, hasJwks: entry !== null, + fetchCount, + fetchFailureCount, + blockingFetchCount, + storeHitCount, + lastFetchDurationMs, + lastStoreReadMs, + lastResolveMs, }), }); return result; diff --git a/apps/cloud/src/auth/last-org-cookie.ts b/apps/cloud/src/auth/last-org-cookie.ts new file mode 100644 index 0000000000..ef6242604f --- /dev/null +++ b/apps/cloud/src/auth/last-org-cookie.ts @@ -0,0 +1,32 @@ +// --------------------------------------------------------------------------- +// Last-visited-org cookie — the browser's "which org was I working in" memory. +// +// Org switching is stateless by design (the URL slug scopes every request; see +// organization.ts), which leaves nothing that remembers the last org across +// entries: the sealed session still carries the org pinned at LOGIN time, so a +// bare URL (`executor.sh/`) would always canonicalize onto that first org. This +// cookie fills the gap: the client records the slug of the org it's verifiably +// viewing, and the two bare-entry deciders honor it — +// +// - the document auth gate redirects bare document paths onto it (doc-gate.ts) +// - the login callback prefers it when picking the org for a fresh session +// with a bare returnTo (handlers.ts) +// +// It is a PREFERENCE, never an authority: both readers re-check live membership +// through the same authorize path as any org selector, so a stale or forged +// value at worst falls back to today's behavior. Not HttpOnly — the client is +// the writer. Deliberately NOT cleared on logout: surviving the session is what +// lets the next login land on the last-used org. +// --------------------------------------------------------------------------- + +export const LAST_ORG_COOKIE = "executor-last-org"; + +/** Outlives the 7d session on purpose — it spans logins. */ +const LAST_ORG_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; + +/** Browser-side write. Slugs are `[a-z0-9-]` by grammar, so no encoding. */ +export const writeLastOrgCookie = (slug: string): void => { + document.cookie = `${LAST_ORG_COOKIE}=${slug}; Path=/; Max-Age=${LAST_ORG_MAX_AGE_SECONDS}; SameSite=Lax${ + window.location.protocol === "https:" ? "; Secure" : "" + }`; +}; diff --git a/apps/cloud/src/auth/org-api-key-auth.node.test.ts b/apps/cloud/src/auth/org-api-key-auth.node.test.ts new file mode 100644 index 0000000000..ec87511c3b --- /dev/null +++ b/apps/cloud/src/auth/org-api-key-auth.node.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { ApiKeyService } from "./api-keys"; +import { UserStoreService } from "./context"; +import { WorkOSClient, type WorkOSClientService } from "./workos"; +import { isPlatformAuth, resolveApiKeyPrincipal, resolveBearerAuth } from "./workos-auth-provider"; + +// Groundwork for the PRIVILEGED, org-level API key: it resolves to the platform +// view (tenant-wide, read-only, NO acting member) rather than to a member +// `Principal`. The product surfaces must reject it outright rather than +// inventing a subject for it — that is the property these tests pin. + +const createdAt = new Date("2026-01-01T00:00:00.000Z"); + +const stubApiKeys = Layer.succeed(ApiKeyService)({ + validate: (value: string) => { + if (value === "valid_org_key") { + return Effect.succeed({ + scope: "org" as const, + accountId: null, + organizationId: "org_123", + keyId: "api_key_org", + }); + } + if (value === "valid_user_key") { + return Effect.succeed({ + scope: "user" as const, + accountId: "user_123", + organizationId: "org_123", + keyId: "api_key_123", + }); + } + return Effect.succeed(null); + }, + listUserKeys: () => Effect.succeed([]), + createUserKey: () => Effect.die("org api key test does not create API keys"), + revokeUserKey: () => Effect.void, + listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), + createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), + revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), +}); + +const stubWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => { + if (prop === "listUserMemberships") { + return (userId: string) => + Effect.succeed({ + data: + userId === "user_123" + ? [{ userId, organizationId: "org_123", status: "active" }] + : [], + }); + } + // An org key must NOT trigger a membership check — there is no user to + // check. Any such call dies here, which is the assertion. + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); + }, + }), +); + +const stubUsers = Layer.succeed(UserStoreService)({ + use: (_op, fn) => + Effect.promise(() => + fn({ + ensureAccount: async (id: string) => ({ id, createdAt }), + getAccount: async (id: string) => ({ id, createdAt }), + upsertOrganization: async (org: { id: string; name: string }) => ({ + ...org, + slug: `org-slug-${org.id}`, + createdAt, + }), + getOrganization: async (id: string) => ({ + id, + name: `Org ${id}`, + slug: `org-slug-${id}`, + createdAt, + }), + getOrganizationBySlug: async (slug: string) => ({ + id: "org_by_slug", + name: `Org ${slug}`, + slug, + createdAt, + }), + deleteOrganizationCascade: async () => {}, + }), + ), +}); + +const layers = Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers); + +const bearer = (token: string) => + new Request("https://executor.test/api/tools", { + headers: { authorization: `Bearer ${token}` }, + }); + +describe("org-level API keys", () => { + it.effect("resolve to the platform view with no acting member", () => + Effect.gen(function* () { + const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers)); + + expect(isPlatformAuth(auth)).toBe(true); + expect(auth).toEqual({ + kind: "platform", + organizationId: "org_123", + organizationName: "Org org_123", + organizationSlug: "org-slug-org_123", + keyId: "api_key_org", + }); + // No `accountId` anywhere in the shape: nothing downstream can bind a + // subject from it by accident. + expect(auth).not.toHaveProperty("accountId"); + }), + ); + + it.effect("user keys still resolve to a bound member principal", () => + Effect.gen(function* () { + const auth = yield* resolveBearerAuth(bearer("valid_user_key")).pipe(Effect.provide(layers)); + + expect(isPlatformAuth(auth)).toBe(false); + expect(auth).toMatchObject({ accountId: "user_123", organizationId: "org_123" }); + }), + ); + + it.effect("resolve on the product surface as a platform principal, never a subject", () => + Effect.gen(function* () { + // THE security property of the api-key half, updated for platform reads: + // a product request carrying an org key resolves to the NEUTRAL platform + // shape — which the shared middleware routes to the subject-less, + // GET-only platform executor — and must never carry an accountId a + // handler could bind as an acting member. + const principal = yield* resolveApiKeyPrincipal(bearer("valid_org_key")).pipe( + Effect.provide(layers), + ); + + expect(principal).toEqual({ + kind: "platform", + organizationId: "org_123", + organizationName: "Org org_123", + organizationSlug: "org-slug-org_123", + keyId: "api_key_org", + }); + expect(principal).not.toHaveProperty("accountId"); + }), + ); + + it.effect("do not trigger a user membership check", () => + Effect.gen(function* () { + // `authorizeOrganization` checks a USER's live membership; there is no + // user here. The WorkOS stub dies on any call other than the user path, + // so a clean resolution proves the org branch never took it. + const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers)); + + expect(isPlatformAuth(auth)).toBe(true); + }), + ); +}); diff --git a/apps/cloud/src/auth/org-api-key-mint.node.test.ts b/apps/cloud/src/auth/org-api-key-mint.node.test.ts new file mode 100644 index 0000000000..82b581e716 --- /dev/null +++ b/apps/cloud/src/auth/org-api-key-mint.node.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys"; +import { ApiKeyManagementError } from "./errors"; +import { WorkOSClient, type WorkOSClientService } from "./workos"; + +// --------------------------------------------------------------------------- +// Minting, listing, and revoking ORGANIZATION-owned api keys — the privileged +// credential that resolves to the read-only platform view (`/admin/*`). +// +// The decode boundary is what these pin. WorkOS returns an org-owned key with +// the ORG in `owner.id` and no member at all, which is a different shape from +// the user-owned keys the console's personal-key CRUD handles. The workspace +// api key is also workspace-WIDE, so a response naming a different org than the +// one asked for must not be surfaced as that org's key. +// +// NOTE (emulator gap): the WorkOS emulator's `serializeApiKey` hardcodes +// `owner.type: "user"` and exposes no org-key mint route, so this path cannot +// be exercised over the wire in e2e today. These stubs encode the REAL WorkOS +// API shape (`organizations.createOrganizationApiKey` / +// `listOrganizationApiKeys`, per the installed SDK's types). +// --------------------------------------------------------------------------- + +const ORG = "org_123"; + +const orgKeyResponse = (organizationId: string, id: string, name: string) => ({ + object: "api_key", + id, + owner: { type: "organization", id: organizationId }, + name, + obfuscatedValue: "sk_org…a1b2", + lastUsedAt: null, + permissions: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +const stubWorkOS = (overrides: Record = {}) => + Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => { + const override = overrides[String(prop)]; + if (override) return override; + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); + }, + }), + ); + +const serviceWith = (overrides: Record) => + ApiKeyService.asEffect().pipe( + Effect.provide(ApiKeyService.WorkOS.pipe(Layer.provide(stubWorkOS(overrides)))), + ); + +describe("organization API keys", () => { + it.effect("mints an org-owned key and returns the one-time secret", () => + Effect.gen(function* () { + const apiKeys = yield* serviceWith({ + createOrgApiKey: (params: { organizationId: string; name: string }) => + Effect.succeed({ + ...orgKeyResponse(params.organizationId, "key_org_1", params.name), + value: "sk_org_plaintext", + }), + }); + + const created = yield* apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }); + + expect(created.id).toBe("key_org_1"); + expect(created.name).toBe("acme-backend"); + expect(created.value, "create returns the one-time plaintext secret").toBe( + "sk_org_plaintext", + ); + expect(created.obfuscatedValue, "the display value is masked, not the secret").not.toBe( + created.value, + ); + }), + ); + + it.effect("lists the organization's own keys, masked", () => + Effect.gen(function* () { + const apiKeys = yield* serviceWith({ + listOrgApiKeys: (organizationId: string) => + Effect.succeed({ + object: "list", + data: [ + orgKeyResponse(organizationId, "key_org_1", "acme-backend"), + orgKeyResponse(organizationId, "key_org_2", "ci"), + ], + listMetadata: { before: null, after: null }, + }), + }); + + const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); + + expect(keys.map((key) => key.name)).toEqual(["acme-backend", "ci"]); + expect(JSON.stringify(keys), "the list never carries a plaintext secret").not.toContain( + "sk_org_plaintext", + ); + }), + ); + + it.effect("drops a key owned by a DIFFERENT organization", () => + Effect.gen(function* () { + // The WorkOS workspace api key is workspace-wide, so a response naming + // another org must never be surfaced as this org's key. + const apiKeys = yield* serviceWith({ + listOrgApiKeys: () => + Effect.succeed({ + object: "list", + data: [ + orgKeyResponse(ORG, "key_mine", "mine"), + orgKeyResponse("org_other", "key_theirs", "theirs"), + ], + listMetadata: { before: null, after: null }, + }), + }); + + const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); + + expect( + keys.map((key) => key.id), + "only this org's key survives", + ).toEqual(["key_mine"]); + }), + ); + + it.effect("refuses a create response that names another organization", () => + Effect.gen(function* () { + const apiKeys = yield* serviceWith({ + createOrgApiKey: () => + Effect.succeed({ + ...orgKeyResponse("org_other", "key_theirs", "theirs"), + value: "sk_org_plaintext", + }), + }); + + const error = yield* Effect.flip( + apiKeys.createOrgKey({ organizationId: ORG, name: "acme-backend" }), + ); + + expect(error).toBeInstanceOf(ApiKeyManagementError); + }), + ); + + it.effect("ignores a user-owned key in an org-key listing", () => + Effect.gen(function* () { + // The org and user shapes are decoded separately on purpose; a user key + // appearing here would mean the caller could mistake a member's personal + // credential for a workspace one. + const apiKeys = yield* serviceWith({ + listOrgApiKeys: () => + Effect.succeed({ + object: "list", + data: [ + orgKeyResponse(ORG, "key_mine", "mine"), + { + object: "api_key", + id: "key_user", + owner: { type: "user", id: "user_1", organization_id: ORG }, + name: "personal", + obfuscatedValue: "sk_user…c3d4", + lastUsedAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + listMetadata: { before: null, after: null }, + }), + }); + + const keys = yield* apiKeys.listOrgKeys({ organizationId: ORG }); + + expect( + keys.map((key) => key.id), + "a user-owned key is not an org key", + ).toEqual(["key_mine"]); + }), + ); +}); + +// --------------------------------------------------------------------------- +// Revoking an org key. The mint is only half a credential lifecycle: an org key +// reads every user and connection in the tenant, so it has to be destroyable. +// +// Revoke cannot go through `revokeUserKey`/`revokeApiKey` — that path resolves +// ownership against the CALLER's personal keys, so an org key id is never in +// its owned set. It also cannot just call `deleteApiKey` directly: that route is +// scope-blind and would happily destroy a member's personal key, or a key +// belonging to a sibling org in the same WorkOS workspace. So ownership is +// resolved against the ORG's decoded listing first, and that filter IS the +// authorization. These pin that filter. +// --------------------------------------------------------------------------- + +describe("revoking an organization API key", () => { + const listing = (...keys: ReadonlyArray) => ({ + object: "list", + data: keys, + listMetadata: { before: null, after: null }, + }); + + it.effect("revokes a key the organization owns", () => + Effect.gen(function* () { + const deleted: string[] = []; + const apiKeys = yield* serviceWith({ + listOrgApiKeys: (organizationId: string) => + Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))), + deleteApiKey: (id: string) => + Effect.sync(() => { + deleted.push(id); + }), + }); + + yield* apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }); + + expect(deleted, "the delete reaches WorkOS exactly once, for that key").toEqual([ + "key_org_1", + ]); + }), + ); + + it.effect("refuses a USER-owned key id, without deleting anything", () => + Effect.gen(function* () { + // The regression this guards: a member's personal credential must not be + // destroyable through the org route just because an admin can call it. + const deleted: string[] = []; + const apiKeys = yield* serviceWith({ + listOrgApiKeys: () => + Effect.succeed( + listing(orgKeyResponse(ORG, "key_org_1", "acme-backend"), { + object: "api_key", + id: "key_user", + owner: { type: "user", id: "user_1", organization_id: ORG }, + name: "personal", + obfuscatedValue: "sk_user…c3d4", + lastUsedAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ), + deleteApiKey: (id: string) => + Effect.sync(() => { + deleted.push(id); + }), + }); + + const error = yield* Effect.flip( + apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_user" }), + ); + + expect(error).toBeInstanceOf(OrgApiKeyNotFound); + expect(deleted, "no delete is issued for a key the org does not own").toEqual([]); + }), + ); + + it.effect("refuses a key belonging to ANOTHER organization", () => + Effect.gen(function* () { + // The WorkOS workspace key is workspace-wide, so an admin of one org must + // not be able to revoke a sibling org's credential by id. + const deleted: string[] = []; + const apiKeys = yield* serviceWith({ + listOrgApiKeys: () => + Effect.succeed( + listing( + orgKeyResponse(ORG, "key_mine", "mine"), + orgKeyResponse("org_other", "key_theirs", "theirs"), + ), + ), + deleteApiKey: (id: string) => + Effect.sync(() => { + deleted.push(id); + }), + }); + + const error = yield* Effect.flip( + apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_theirs" }), + ); + + expect(error).toBeInstanceOf(OrgApiKeyNotFound); + expect(deleted).toEqual([]); + }), + ); + + it.effect("refuses an unknown key id", () => + Effect.gen(function* () { + const apiKeys = yield* serviceWith({ + listOrgApiKeys: () => Effect.succeed(listing()), + deleteApiKey: () => Effect.die("nothing should be deleted"), + }); + + const error = yield* Effect.flip( + apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_nope" }), + ); + + expect(error).toBeInstanceOf(OrgApiKeyNotFound); + }), + ); + + it.effect("surfaces an upstream delete failure as a management error", () => + Effect.gen(function* () { + // A failed delete must not read as a successful revoke — the console + // would tell an admin a live credential is gone. + const apiKeys = yield* serviceWith({ + listOrgApiKeys: (organizationId: string) => + Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))), + deleteApiKey: () => Effect.fail("workos_unavailable"), + }); + + const error = yield* Effect.flip( + apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }), + ); + + expect(error).toBeInstanceOf(ApiKeyManagementError); + }), + ); +}); diff --git a/apps/cloud/src/auth/org-selector-auth.node.test.ts b/apps/cloud/src/auth/org-selector-auth.node.test.ts index c469780a71..ead56fb893 100644 --- a/apps/cloud/src/auth/org-selector-auth.node.test.ts +++ b/apps/cloud/src/auth/org-selector-auth.node.test.ts @@ -7,9 +7,11 @@ import { resolveSessionPrincipal } from "./workos-auth-provider"; import { WorkOSClient, type WorkOSClientService } from "./workos"; // The org a console request resolves to is the URL's org (sent in the -// `x-executor-organization` selector header), not the session's stored org — -// with the session org as the fallback for non-console callers, and live -// membership re-checked either way. This is what makes two browser tabs on +// `x-executor-organization` selector header) — NEVER the session's stored org. +// The sealed cookie's org is a browser-global pinned to whichever org WorkOS +// last touched, so a fallback to it silently scopes a multi-org user's request +// to the wrong org; a header-less request fails closed instead. Live +// membership is re-checked either way. This is what makes two browser tabs on // different orgs independent. const createdAt = new Date("2026-01-01T00:00:00.000Z"); @@ -27,6 +29,9 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ listUserKeys: () => Effect.succeed([]), createUserKey: () => Effect.die("not used"), revokeUserKey: () => Effect.void, + listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), + createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), + revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), }); const stubWorkOS = Layer.succeed( @@ -59,7 +64,7 @@ const stubWorkOS = Layer.succeed( ); const stubUsers = Layer.succeed(UserStoreService)({ - use: (fn) => + use: (_op, fn) => Effect.promise(() => fn({ ensureAccount: async (id: string) => ({ id, createdAt }), @@ -95,10 +100,15 @@ const run = (headers: Record) => ); describe("resolveSessionPrincipal · URL org selector", () => { - it.effect("falls back to the session org when no selector header is sent", () => + it.effect("fails closed when no selector header is sent", () => Effect.gen(function* () { - const principal = yield* run({ cookie: "wos-session=x" }); - expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG); + // No fallback to the session org: the cookie's org is browser-global + // and can name a different org than the tab's URL for a multi-org user. + const error = yield* Effect.flip(run({ cookie: "wos-session=x" })); + expect(error, "rejects instead of scoping to the session org").toMatchObject({ + _tag: "NoOrganization", + code: "no_organization", + }); }), ); diff --git a/apps/cloud/src/auth/organization.ts b/apps/cloud/src/auth/organization.ts index 51944cfbbe..5aceb2ab1a 100644 --- a/apps/cloud/src/auth/organization.ts +++ b/apps/cloud/src/auth/organization.ts @@ -38,12 +38,14 @@ import { WorkOSClient } from "./workos"; export const resolveOrganization = (organizationId: string) => Effect.gen(function* () { const users = yield* UserStoreService; - const existing = yield* users.use((s) => s.getOrganization(organizationId)); + const existing = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); if (existing) return existing; const workos = yield* WorkOSClient; const fresh = yield* workos.getOrganization(organizationId); - return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name })); + return yield* users.use("upsertOrganization", (s) => + s.upsertOrganization({ id: fresh.id, name: fresh.name }), + ); }); // --------------------------------------------------------------------------- @@ -86,7 +88,14 @@ export const authorizeOrganization = (userId: string, organizationId: string) => ); if (!active) return null; - return yield* resolveOrganization(organizationId); + const org = yield* resolveOrganization(organizationId); + // The membership row already names the caller's role — surface it + // normalized so identity resolution can bind the executor's workspace + // write permission without a second WorkOS call. WorkOS issues + // `admin` / `member`; anything unrecognized stays a plain member. + const roleSlug = (active as { readonly role?: { readonly slug?: string } }).role?.slug; + const memberRole: "admin" | "member" = roleSlug === "admin" ? "admin" : "member"; + return { ...org, memberRole }; }); // --------------------------------------------------------------------------- @@ -125,7 +134,7 @@ export const authorizeOrganizationSelector = (userId: string, selector: string) return yield* authorizeOrganization(userId, selector); } const users = yield* UserStoreService; - const org = yield* users.use((s) => s.getOrganizationBySlug(selector)); + const org = yield* users.use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(selector)); if (!org) return null; return yield* authorizeOrganization(userId, org.id); }); diff --git a/apps/cloud/src/auth/user-store-error.node.test.ts b/apps/cloud/src/auth/user-store-error.node.test.ts new file mode 100644 index 0000000000..880c6fa37d --- /dev/null +++ b/apps/cloud/src/auth/user-store-error.node.test.ts @@ -0,0 +1,128 @@ +// `UserStoreError` is the public failure of every cloud user-store call, and it +// used to carry NOTHING: no operation, no reason, no message. Sentry grouped +// every store failure — a connect timeout, a missing table, a constraint +// violation — into one titleless issue, and the only cause detail (the +// pretty-printed Effect cause stuffed into a Sentry `extra`) is scrubbed +// server-side, so the issue was undiagnosable from Sentry alone. +// +// This pins the two safe classification fields it must carry instead: +// `operation` (already in hand at the call site) and `reason` (classified from +// the driver cause the way `statusFromWorkOSCause` classifies WorkOS causes). +import { createServer, type Server, type Socket } from "node:net"; + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Result } from "effect"; +import postgres from "postgres"; + +import { UserStoreService } from "./context"; +import { ServiceAdapterError, userStoreReasonFromCause, type UserStoreError } from "./errors"; +import { DbService } from "../db/db"; + +// A socket that completes the TCP handshake and then says nothing — exactly +// what a wedged Hyperdrive/Postgres endpoint looks like to postgres.js, and the +// only way to obtain the driver's REAL connect-timeout error object rather than +// a hand-written imitation of it. +const blackHolePort = async (): Promise<{ + readonly port: number; + readonly close: () => Promise; +}> => { + const sockets = new Set(); + const server: Server = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the fixture cannot run without a bound port + if (address === null || typeof address === "string") throw new Error("no port"); + return { + port: address.port, + close: () => + new Promise((resolve) => { + // The timed-out client leaves its half-open socket attached; without + // dropping it first, `close` waits for a peer that will never speak. + for (const socket of sockets) socket.destroy(); + server.close(() => resolve()); + }), + }; +}; + +const realConnectTimeoutError = async (): Promise => { + const hole = await blackHolePort(); + const sql = postgres(`postgresql://postgres:postgres@127.0.0.1:${hole.port}/postgres`, { + max: 1, + connect_timeout: 1, + fetch_types: false, + onnotice: () => undefined, + }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: capturing the driver's own thrown error IS the fixture + try { + await sql`select 1`; + // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the fixture is unusable if the socket answered + throw new Error("expected the connect to time out"); + } catch (error) { + return error; + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort teardown of a connection that never opened + await sql.end({ timeout: 0 }).catch(() => undefined); + await hole.close(); + } +}; + +// Drizzle re-throws driver failures wrapped in its own error with the failing +// SQL in the message and the driver error in `.cause` — the shape production +// actually reports (`Failed query: select … -> write CONNECT_TIMEOUT …`). +const wrappedLikeDrizzle = (cause: unknown): Error => + // oxlint-disable-next-line executor/no-error-constructor -- boundary: reproducing the driver wrapper shape the store really fails with + Object.assign(new Error('Failed query: select "id" from "organizations" where "id" = $1'), { + cause, + }); + +const stubDb = Layer.succeed(DbService)({ db: {} as never }); + +const failingStoreCall = ( + operation: string, + failure: unknown, +): Effect.Effect> => + Effect.gen(function* () { + const users = yield* UserStoreService; + // oxlint-disable-next-line executor/no-promise-reject -- boundary: the store adapter lifts a REJECTING promise; rejecting is the fixture + return yield* users.use(operation, () => Promise.reject(failure)); + }).pipe( + Effect.provide(UserStoreService.Live.pipe(Layer.provide(stubDb))), + Effect.result, + ) as Effect.Effect>; + +describe("UserStoreError classification", () => { + it("classifies the driver cause chain", async () => { + const driverError = await realConnectTimeoutError(); + + expect(userStoreReasonFromCause(wrappedLikeDrizzle(driverError))).toBe("connect_timeout"); + expect( + userStoreReasonFromCause(new ServiceAdapterError({ cause: wrappedLikeDrizzle(driverError) })), + ).toBe("connect_timeout"); + expect( + userStoreReasonFromCause( + // oxlint-disable-next-line executor/no-error-constructor -- boundary: a SQLSTATE failure shape + Object.assign(new Error('relation "organizations" does not exist'), { code: "42P01" }), + ), + ).toBe("query"); + // oxlint-disable-next-line executor/no-error-constructor -- boundary: a failure with nothing to classify + expect(userStoreReasonFromCause(new Error("something else"))).toBe("unknown"); + }, 20_000); + + it("carries operation, reason and a stable message on the public error", async () => { + const driverError = await realConnectTimeoutError(); + + const result = await Effect.runPromise( + failingStoreCall("getOrganization", wrappedLikeDrizzle(driverError)), + ); + + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(result.failure.operation).toBe("getOrganization"); + expect(result.failure.reason).toBe("connect_timeout"); + expect(result.failure.message).toContain("getOrganization"); + expect(result.failure.message).toContain("connect_timeout"); + }, 20_000); +}); diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index 0c9477c385..6abbffb7cb 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -16,6 +16,7 @@ // - invalid api key -> Unauthorized 401 invalid_api_key // - api-key org not authorized -> NoOrganization 403 no_organization // - no/invalid session -> NoOrganization 403 no_organization +// - session without org header -> NoOrganization 403 no_organization (fail closed) // - session org not authorized -> NoOrganization 403 no_organization // - no auth header -> falls through to the sealed-session path // The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are @@ -37,7 +38,13 @@ import { Unauthorized, Unavailable, } from "@executor-js/api/server"; -import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server"; +import type { + FailureRenderingStrategy, + IdentityFailure, + PlatformPrincipal, + Principal, + ResolvedPrincipal, +} from "@executor-js/api/server"; import { ApiKeyService } from "./api-keys"; import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; @@ -46,6 +53,7 @@ import { authorizeOrganization, authorizeOrganizationSelector, orgSelectorFromRequest, + resolveOrganization, } from "./organization"; import { UserStoreService } from "./context"; import { sealedSessionDisplayName } from "./middleware"; @@ -86,6 +94,10 @@ const NO_ORGANIZATION_IN_SESSION = { code: "no_organization", message: "No organization in session", }; +const NO_ORGANIZATION_SELECTOR = { + code: "no_organization", + message: "No organization selector in request", +}; const INVALID_ACCESS_TOKEN = { code: "invalid_access_token", message: "Invalid or expired access token", @@ -98,7 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { code: "no_organization", message: "No organization in access token", }; - // A bearer value with three dot-separated segments is a JWT (a WorkOS access // token from the CLI device-login); anything else is treated as an API key. // Same discriminator the MCP plane uses (`mcp/auth.ts`). @@ -134,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN); return { + kind: "member", accountId: verified.accountId, organizationId: org.id, organizationName: org.name, @@ -142,17 +154,55 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => name: null, avatarUrl: null, roles: [], + orgRoleModel: "organization", + orgRole: org.memberRole, } satisfies Principal; }); /** - * Resolve a `Bearer` credential into a `Principal`: a WorkOS access-token JWT - * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS - * API key. Returns `null` when there is no `Authorization` header, so the - * caller falls through to the sealed-session path. (Kept the historical name, - * the re-export and resolver tests reference it.) + * What an ORG-level API key resolves to: the platform view. Deliberately NOT a + * `Principal` — a `Principal` names an acting member (`accountId: string`), and + * an org key has none. Keeping it a separate shape means no product code path + * can accidentally treat it as a member, and the executor built from it binds + * `subject: null` + the read-only tenant reach rather than inventing a subject. + * + * The `/admin/*` mount turns this into an executor with `{ tenant: + * organizationId, subject: undefined, platformView: true }`. + */ +export interface PlatformAuth { + readonly kind: "platform"; + readonly organizationId: string; + readonly organizationName: string; + readonly organizationSlug?: string; + readonly keyId: string; +} + +/** A resolved bearer credential: an acting member, or the org-level platform + * view. `null` means no `Authorization` header at all. */ +export type BearerAuth = Principal | PlatformAuth | null; + +export const isPlatformAuth = (value: BearerAuth): value is PlatformAuth => + value !== null && "kind" in value && value.kind === "platform"; + +/** + * Resolve a `Bearer` credential into either a member `Principal` or the + * org-level {@link PlatformAuth}. Returns `null` when there is no + * `Authorization` header, so the caller falls through to the sealed-session + * path. + * + * The org branch does NOT call `authorizeOrganization`: that checks a USER's + * live membership, and there is no user here. The key itself is the authority — + * WorkOS validated it and reported which org owns it — so the org row is merely + * resolved (mirrored on first read) for its name and slug. */ -export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) => +export const resolveBearerAuth = ( + request: Request, + jwt: JwtBearerConfig | null = null, +): Effect.Effect< + BearerAuth, + Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, + WorkOSClient | ApiKeyService | UserStoreService +> => Effect.gen(function* () { const authHeader = request.headers.get("authorization"); if (!authHeader) return null; @@ -167,7 +217,7 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt); const apiKeys = yield* ApiKeyService; - const principal = yield* apiKeys + const owner = yield* apiKeys .validate(value) .pipe( Effect.catchTag("ApiKeyValidationError", () => @@ -175,13 +225,29 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | ), ); - if (!principal) return yield* new Unauthorized(INVALID_API_KEY); + if (!owner) return yield* new Unauthorized(INVALID_API_KEY); - const org = yield* authorizeOrganization(principal.accountId, principal.organizationId); + if (owner.scope === "org") { + const org = yield* resolveOrganization(owner.organizationId); + return { + kind: "platform", + organizationId: org.id, + organizationName: org.name, + ...(org.slug === undefined || org.slug === null ? {} : { organizationSlug: org.slug }), + keyId: owner.keyId, + } satisfies PlatformAuth; + } + + // A `"user"` key always carries an accountId (see `ownerFromApiKey`); the + // guard keeps the narrowing honest rather than asserting. + if (owner.accountId == null) return yield* new Unauthorized(INVALID_API_KEY); + + const org = yield* authorizeOrganization(owner.accountId, owner.organizationId); if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); return { - accountId: principal.accountId, + kind: "member", + accountId: owner.accountId, organizationId: org.id, organizationName: org.name, organizationSlug: org.slug, @@ -189,9 +255,45 @@ export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | name: null, avatarUrl: null, roles: [], + orgRoleModel: "organization", + orgRole: org.memberRole, } satisfies Principal; }); +/** + * The PRODUCT-plane bearer resolver. An org-level key resolves to the neutral + * seam's {@link PlatformPrincipal} — NOT a member `Principal` — and the shared + * middleware routes it to the subject-less, read-only platform executor + * (refusing non-GET up front). Previously the product plane rejected org keys + * outright; serving tenant-level READS to them is deliberate: the catalog, + * tools, policies, and org-owned connection listings are tenant-shared answers + * a machine credential can honestly receive, while everything subject-bound + * stays structurally out of reach (a platform executor binds no subject, so no + * member's personal rows resolve). (Kept the historical name; the re-export and + * resolver tests reference it.) + */ +export const resolveApiKeyPrincipal = ( + request: Request, + jwt: JwtBearerConfig | null = null, +): Effect.Effect< + ResolvedPrincipal | null, + Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, + WorkOSClient | ApiKeyService | UserStoreService +> => + Effect.gen(function* () { + const auth = yield* resolveBearerAuth(request, jwt); + if (isPlatformAuth(auth)) { + return { + kind: "platform", + organizationId: auth.organizationId, + organizationName: auth.organizationName, + ...(auth.organizationSlug === undefined ? {} : { organizationSlug: auth.organizationSlug }), + keyId: auth.keyId, + } satisfies PlatformPrincipal; + } + return auth; + }); + export const resolveSessionPrincipal = (request: Request) => Effect.gen(function* () { const workos = yield* WorkOSClient; @@ -199,17 +301,27 @@ export const resolveSessionPrincipal = (request: Request) => if (!session) { return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); } - // The console URL's org is the scope authority (sent as a header); the - // session's own org is the fallback for non-console callers. Membership is - // re-checked live either way — the header is a selector, not a trust - // boundary (see organization.ts). - const selector = orgSelectorFromRequest(request) ?? session.organizationId; + // The console URL's org is the scope authority, sent as a header on every + // request. FAIL CLOSED when it's missing: the sealed cookie's org is + // browser-global and pinned to whichever org WorkOS last touched, so + // falling back to it silently serves ANOTHER org's data to a multi-org + // user (the wrong-tenant connection-list bug, 2026-07). A header-less + // session call gets a clear 403 instead. Membership is re-checked live — + // the header is a selector, not a trust boundary (see organization.ts). + // A bare-URL first paint (no org in the path yet) may 403 here; that's + // the safe outcome — OrgSlugGate immediately canonicalizes the URL onto + // an org slug, the org-keyed atom registry remounts, and everything + // refetches with the header. `/account/me` — which deliberately DOES fall + // back to the session org to pick that landing org — lives on the account + // plane, not here. + const selector = orgSelectorFromRequest(request); if (!selector) { - return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); + return yield* new NoOrganization(NO_ORGANIZATION_SELECTOR); } const org = yield* authorizeOrganizationSelector(session.userId, selector); if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); return { + kind: "member", accountId: session.userId, organizationId: org.id, organizationName: org.name, @@ -218,6 +330,8 @@ export const resolveSessionPrincipal = (request: Request) => name: sealedSessionDisplayName(session), avatarUrl: session.avatarUrl ?? null, roles: [], + orgRoleModel: "organization", + orgRole: org.memberRole, } satisfies Principal; }); @@ -236,7 +350,7 @@ export const resolveProtectedPrincipal = ( request: Request, jwt: JwtBearerConfig | null = null, ): Effect.Effect< - Principal, + ResolvedPrincipal, Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, WorkOSClient | ApiKeyService | UserStoreService > => @@ -317,6 +431,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy => Effect.gen(function* () { + // Phase timings, not just child spans. `local_verify` is a leaf in + // production traces, so a ~3.3s verify has nothing under it to blame — + // and it stayed 3.3s after the JWKS fetch was eliminated entirely + // (jwks.fetch_count == 0), so the cost is one of the phases below. Under + // workerd `Date.now()` only advances at I/O boundaries, which is exactly + // what makes a raw span duration misleading here: recording each phase + // explicitly says which await the wall-clock actually crossed. + const verifyStartedAt = Date.now(); + + const unsealStartedAt = Date.now(); const unsealed = yield* Effect.tryPromise({ try: () => unsealWorkOSSession(sessionData, cookiePassword), catch: (cause) => new LocalSessionCookieError({ cause }), }).pipe( Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null as unknown | null)), + Effect.withSpan("workos.session.unseal"), ); + const unsealMs = Date.now() - unsealStartedAt; + yield* Effect.annotateCurrentSpan({ "verify.unseal_ms": unsealMs }); if (!unsealed) return { _tag: "InvalidCookie" }; + const decodeStartedAt = Date.now(); const session = Option.match(decodeSealedSessionPayload(unsealed), { onNone: (): SealedSessionPayload | null => null, onSome: (payload) => payload, }); + yield* Effect.annotateCurrentSpan({ "verify.decode_ms": Date.now() - decodeStartedAt }); if (!session) return { _tag: "InvalidCookie" }; - const verified = yield* verifyJwtWithRefreshRetry(session.accessToken, jwks); + // Snapshot the JWKS cache around the verify so the `local_verify` span + // says whether THIS verify was a warm-cache signature check or paid for a + // live upstream JWKS fetch. The Aug 2026 latency regression was the cache + // silently missing on most verifies, and no span attribute distinguished + // the two paths. + const jwksBefore = jwks.inspect(); + // Entry-state annotation goes on BEFORE the verify so a failing verify + // (the case worth debugging) still records whether the cache was warm. + yield* Effect.annotateCurrentSpan({ + "jwks.cache_populated_at_start": jwksBefore.hasJwks, + ...(jwksBefore.fetchedAt === null + ? {} + : { "jwks.cache_age_ms": Date.now() - jwksBefore.fetchedAt }), + }); + const jwtStartedAt = Date.now(); + const verified = yield* verifyJwtWithRefreshRetry(session.accessToken, jwks).pipe( + Effect.withSpan("workos.session.jwt_verify"), + Effect.onExit(() => { + const jwksAfter = jwks.inspect(); + const finishedAt = Date.now(); + return Effect.annotateCurrentSpan({ + "verify.jwt_ms": finishedAt - jwtStartedAt, + "verify.total_ms": finishedAt - verifyStartedAt, + // Blocking, not total: under stale-while-revalidate a background + // refresh moves `fetchCount` without costing this verify anything. + // Attribute latency to what the caller actually waited on. + "jwks.fetched_during_verify": + jwksAfter.blockingFetchCount > jwksBefore.blockingFetchCount, + "jwks.served_from_store": jwksAfter.storeHitCount > jwksBefore.storeHitCount, + "jwks.fetch_count": jwksAfter.fetchCount, + "jwks.blocking_fetch_count": jwksAfter.blockingFetchCount, + "jwks.fetch_failure_count": jwksAfter.fetchFailureCount, + ...(jwksAfter.lastFetchDurationMs === null + ? {} + : { "jwks.last_fetch_ms": jwksAfter.lastFetchDurationMs }), + // Splits the ~3.4s that sits inside jwt_verify with zero upstream + // fetches: the cross-isolate store read (I/O) vs WebCrypto key + // import vs the signature check itself. + ...(jwksAfter.lastStoreReadMs === null + ? {} + : { "jwks.store_read_ms": jwksAfter.lastStoreReadMs }), + ...(jwksAfter.lastResolveMs === null + ? {} + : { "jwks.key_resolve_ms": jwksAfter.lastResolveMs }), + }); + }), + ); if (!verified) return { _tag: "Refresh" }; const claims = Option.getOrNull(decodeJwtClaims(decodeJwt(session.accessToken))); @@ -338,9 +399,12 @@ const make = Effect.gen(function* () { // exception had one (all its typed exceptions do), so consumers can tell a // definitive WorkOS denial (401/403/404 — fail closed) from a transient // failure (429/5xx/network — retryable). - const use = (fn: (wos: WorkOS) => Promise) => + // `op` names the SDK call (mirroring its `namespace.method` path) so every + // span reads `workos.` instead of one undifferentiated "workos" + // bucket, and failures log which call actually failed. + const use = (op: string, fn: (wos: WorkOS) => Promise) => withServiceLogging( - "workos", + `workos.${op}`, workosErrorFromFailure, tryPromiseService(() => fn(workos)), ); @@ -376,7 +440,7 @@ const make = Effect.gen(function* () { if (isLocalSessionInvalidCookie(local)) return null; // Try refreshing - const refreshed = yield* use(() => session.refresh()).pipe( + const refreshed = yield* use("session.refresh", () => session.refresh()).pipe( Effect.orElseSucceed(() => ({ authenticated: false as const })), ); @@ -405,7 +469,7 @@ const make = Effect.gen(function* () { }), authenticateWithCode: (code: string) => - use((wos) => + use("userManagement.authenticateWithCode", (wos) => wos.userManagement.authenticateWithCode({ code, clientId, @@ -415,11 +479,13 @@ const make = Effect.gen(function* () { /** Create a new organization in WorkOS. */ createOrganization: (name: string) => - use((wos) => wos.organizations.createOrganization({ name })), + use("organizations.createOrganization", (wos) => + wos.organizations.createOrganization({ name }), + ), /** Add a user to an organization. */ createMembership: (organizationId: string, userId: string, roleSlug?: string) => - use((wos) => + use("userManagement.createOrganizationMembership", (wos) => wos.userManagement.createOrganizationMembership({ organizationId, userId, @@ -429,7 +495,7 @@ const make = Effect.gen(function* () { /** List organization memberships for a user. */ listUserMemberships: (userId: string) => - use(async (wos) => + use("userManagement.listOrganizationMemberships", async (wos) => collectWorkOSList( await wos.userManagement.listOrganizationMemberships({ userId, @@ -448,7 +514,7 @@ const make = Effect.gen(function* () { sessionData, cookiePassword, }); - const refreshed = yield* use(() => + const refreshed = yield* use("session.refresh", () => session.refresh(organizationId ? { organizationId } : undefined), ); if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null; @@ -517,10 +583,13 @@ const make = Effect.gen(function* () { * auth/api-keys.ts. */ validateApiKey: (value: string) => - use((wos) => wos.apiKeys.validateApiKey({ value }) as Promise), + use( + "apiKeys.validateApiKey", + (wos) => wos.apiKeys.validateApiKey({ value }) as Promise, + ), listUserApiKeys: (userId: string, organizationId: string) => - use(async (wos) => { + use("userManagement.listUserApiKeys", async (wos) => { const raw = wos as RawWorkOS; return collectRawWorkOSList(async (after) => { const response = await raw.get(`/user_management/users/${userId}/api_keys`, { @@ -535,7 +604,7 @@ const make = Effect.gen(function* () { }), createUserApiKey: (params: { userId: string; organizationId: string; name: string }) => - use(async (wos) => { + use("userManagement.createUserApiKey", async (wos) => { const raw = wos as RawWorkOS; const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, { name: params.name, @@ -544,11 +613,40 @@ const make = Effect.gen(function* () { return response.data; }), - deleteApiKey: (id: string) => use((wos) => wos.apiKeys.deleteApiKey(id)), + /** + * List the ORGANIZATION-owned api keys of an org. Unlike the user-key + * paths above, org keys are first-class in the installed SDK + * (`organizations.listOrganizationApiKeys`), so no raw escape hatch is + * needed. Returned unknown and decoded in `auth/api-keys.ts` like every + * other key response. + */ + listOrgApiKeys: (organizationId: string) => + use("organizations.listOrganizationApiKeys", async (wos) => + collectWorkOSList(await wos.organizations.listOrganizationApiKeys({ organizationId })), + ), + + /** + * Mint an ORGANIZATION-owned api key: the credential that resolves to the + * read-only platform view (`PlatformAuth`) instead of an acting member. + * Privileged — the caller must be an admin of the org, which is enforced at + * the account-provider boundary, not here. + */ + createOrgApiKey: (params: { organizationId: string; name: string }) => + use( + "organizations.createOrganizationApiKey", + (wos) => + wos.organizations.createOrganizationApiKey({ + organizationId: params.organizationId, + name: params.name, + }) as Promise, + ), + + deleteApiKey: (id: string) => + use("apiKeys.deleteApiKey", (wos) => wos.apiKeys.deleteApiKey(id)), /** List organization memberships with user details. */ listOrgMembers: (organizationId: string) => - use(async (wos) => + use("userManagement.listOrganizationMemberships", async (wos) => collectWorkOSList( await wos.userManagement.listOrganizationMemberships({ organizationId, @@ -559,7 +657,7 @@ const make = Effect.gen(function* () { /** Get a user's membership in an organization. */ getUserOrgMembership: (organizationId: string, userId: string) => - use(async (wos) => { + use("userManagement.listOrganizationMemberships", async (wos) => { const response = await wos.userManagement.listOrganizationMemberships({ organizationId, userId, @@ -569,11 +667,23 @@ const make = Effect.gen(function* () { }), /** Get a user by ID. */ - getUser: (userId: string) => use((wos) => wos.userManagement.getUser(userId)), + getUser: (userId: string) => + use("userManagement.getUser", (wos) => wos.userManagement.getUser(userId)), + + /** List users matching an email within one organization. */ + listUsers: (params: { email: string; organizationId: string }) => + use("userManagement.listUsers", async (wos) => + collectWorkOSList( + await wos.userManagement.listUsers({ + email: params.email, + organizationId: params.organizationId, + }), + ), + ), /** Send an organization invitation. */ sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => - use((wos) => + use("userManagement.sendInvitation", (wos) => wos.userManagement.sendInvitation({ email: params.email, organizationId: params.organizationId, @@ -587,7 +697,7 @@ const make = Effect.gen(function* () { * API level, so we filter after. */ listPendingInvitations: (organizationId: string) => - use(async (wos) => + use("userManagement.listInvitations", async (wos) => collectWorkOSList( await wos.userManagement.listInvitations({ organizationId, @@ -602,7 +712,7 @@ const make = Effect.gen(function* () { /** List invitations for an email address (across all orgs). */ listInvitationsByEmail: (email: string) => - use(async (wos) => + use("userManagement.listInvitations", async (wos) => collectWorkOSList( await wos.userManagement.listInvitations({ email, @@ -612,19 +722,25 @@ const make = Effect.gen(function* () { /** Accept an invitation; returns the (now accepted) invitation. */ acceptInvitation: (invitationId: string) => - use((wos) => wos.userManagement.acceptInvitation(invitationId)), + use("userManagement.acceptInvitation", (wos) => + wos.userManagement.acceptInvitation(invitationId), + ), /** Remove an organization membership. */ deleteOrgMembership: (membershipId: string) => - use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)), + use("userManagement.deleteOrganizationMembership", (wos) => + wos.userManagement.deleteOrganizationMembership(membershipId), + ), /** Get the role for a membership. */ getOrgMembership: (membershipId: string) => - use((wos) => wos.userManagement.getOrganizationMembership(membershipId)), + use("userManagement.getOrganizationMembership", (wos) => + wos.userManagement.getOrganizationMembership(membershipId), + ), /** Update a membership's role. */ updateOrgMembershipRole: (membershipId: string, roleSlug: string) => - use((wos) => + use("userManagement.updateOrganizationMembership", (wos) => wos.userManagement.updateOrganizationMembership(membershipId, { roleSlug, }), @@ -632,15 +748,19 @@ const make = Effect.gen(function* () { /** List available roles for an organization. */ listOrgRoles: (organizationId: string) => - use((wos) => wos.organizations.listOrganizationRoles({ organizationId })), + use("organizations.listOrganizationRoles", (wos) => + wos.organizations.listOrganizationRoles({ organizationId }), + ), /** Get an organization (includes domains). */ getOrganization: (organizationId: string) => - use((wos) => wos.organizations.getOrganization(organizationId)), + use("organizations.getOrganization", (wos) => + wos.organizations.getOrganization(organizationId), + ), /** Update an organization. */ updateOrganization: (organizationId: string, name: string) => - use((wos) => + use("organizations.updateOrganization", (wos) => wos.organizations.updateOrganization({ organization: organizationId, name, @@ -652,11 +772,13 @@ const make = Effect.gen(function* () { * invitations, and domains go with it, so every member loses access. */ deleteOrganization: (organizationId: string) => - use((wos) => wos.organizations.deleteOrganization(organizationId)), + use("organizations.deleteOrganization", (wos) => + wos.organizations.deleteOrganization(organizationId), + ), /** Generate an Admin Portal link for domain verification. */ generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) => - use((wos) => + use("portal.generateLink", (wos) => wos.portal.generateLink({ organization: organizationId, intent: GeneratePortalLinkIntent.DomainVerification, @@ -666,11 +788,11 @@ const make = Effect.gen(function* () { /** Get a domain by ID. */ getOrganizationDomain: (domainId: string) => - use((wos) => wos.organizationDomains.get(domainId)), + use("organizationDomains.get", (wos) => wos.organizationDomains.get(domainId)), /** Delete a domain claim. */ deleteOrganizationDomain: (domainId: string) => - use((wos) => wos.organizationDomains.delete(domainId)), + use("organizationDomains.delete", (wos) => wos.organizationDomains.delete(domainId)), }; }); @@ -679,9 +801,10 @@ export type WorkOSClientService = Effect.Success; export class WorkOSClient extends Context.Service()( "@executor-js/cloud/WorkOSClient", ) { - static Default = Layer.effect(this)(make).pipe( - Layer.withSpan("WorkOSClient", { attributes: { module: "WorkOSClient" } }), - ); + // Deliberately unspanned: client construction is synchronous and ran per + // layer build, which produced one of the highest-volume zero-duration span + // names in the whole trace corpus while telling us nothing. + static Default = Layer.effect(this)(make); } // The boot-scoped WorkOS client root — the one neutral service the stateless diff --git a/apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts b/apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts index cbbec59fcf..94c6228071 100644 --- a/apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts +++ b/apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts @@ -21,15 +21,138 @@ // with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through // one PGLiteSocketServer and asserts zero protocol corruption. +import { setTimeout as sleep } from "node:timers/promises"; +import { connect, createServer, type Socket } from "node:net"; import { describe, expect, it } from "@effect/vitest"; import { PGlite } from "@electric-sql/pglite"; import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; import postgres from "postgres"; -const PORT = 45998; const CLIENTS = 6; const QUERIES_PER_CLIENT = 40; +/** + * Start a server bound to an OS-assigned port and return that port. + * + * Every server in this file binds port 0. The fixed ports this file used to + * bind (45993-45998) sit inside the default Linux ephemeral port range + * (32768-60999): on a busy CI runner any other socket — an outbound connection + * from a sibling suite in the same turbo shard, or a leaked e2e server, which + * squat exactly this block — can hold one of them at bind time. The stock + * server then swallowed the EADDRINUSE (start() rejected only when `active` + * was false, and start() sets `active` true before listen), so + * `await server.start()` never settled and the test died as a bare vitest + * timeout with zero diagnostics — the CI signature of every wedge in this + * family. macOS assigns ephemeral ports from 49152, which is why hundreds of + * local replays never reproduced it. + */ +const startOnOsPort = async (server: PGLiteSocketServer): Promise => { + const listening = new Promise((resolve) => { + server.addEventListener( + "listening", + (event) => resolve((event as CustomEvent<{ readonly port: number }>).detail.port), + { once: true }, + ); + }); + await server.start(); + return await listening; +}; + +const makeClient = (port: number, connectTimeout = 5) => + postgres(`postgres://postgres:postgres@127.0.0.1:${port}/postgres`, { + max: 1, + idle_timeout: 0, + connect_timeout: connectTimeout, + fetch_types: false, + prepare: true, + onnotice: () => undefined, + }); + +// Hand-rolled wire client: connect and complete the trust-auth startup, so a +// test can then speak raw protocol frames (e.g. a lone Parse) that postgres.js +// would never emit on its own. Resolves after ReadyForQuery so the next write +// is its own data event — and its own queue entry — on the server. +const openWireClient = async (port: number): Promise => { + const socket: Socket = connect(port, "127.0.0.1"); + await new Promise((res, rej) => { + socket.once("connect", res); + socket.once("error", rej); + }); + const startupBody = Buffer.concat([ + Buffer.from([0, 3, 0, 0]), + Buffer.from("user\0postgres\0database\0postgres\0\0"), + ]); + const startup = Buffer.concat([Buffer.alloc(4), startupBody]); + startup.writeInt32BE(startup.length, 0); + socket.write(startup); + await new Promise((res) => { + socket.on("data", (chunk: Buffer) => { + if (chunk.includes(0x5a)) res(); // 'Z' = ReadyForQuery + }); + }); + return socket; +}; + +/** + * Run a bystander's query with a bounded deadline and, on the deadline, fail + * with the server's internals instead of vitest's bare 30s timeout. + * + * The reap/ghost scenarios each wedged in CI (runs 32933818134 and + * 33019527020) while ~800 replays of the isolated scenarios on macOS and + * Linux, idle and CPU-starved, never reproduced it. Those bare timeouts are + * now attributed: a bind conflict on this file's old fixed ephemeral-range + * ports left `server.start()` pending forever (see startOnOsPort), which a + * bare vitest timeout cannot distinguish from a queue wedge. The wrapper + * stays so that any FUTURE wedge that really is in the queue carries its own + * diagnosis: the queue/handler stats at wedge time, plus whether a FRESH + * connection still completes startup (a latched queue serves nobody; + * per-handler affinity pinning still answers new startups). + */ +const diagnoseWedge = async ( + run: () => Promise, + context: { readonly server: PGLiteSocketServer; readonly port: number }, + deadlineMs = 20_000, +): Promise => { + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + void (async () => { + const stats = JSON.stringify(context.server.getStats()); + // oxlint-disable-next-line executor/no-promise-catch -- test boundary: the probe outcome is diagnostic text, never a failure path + const freshStartup = await Promise.race([ + openWireClient(context.port).then((socket) => { + socket.destroy(); + return "completes"; + }), + sleep(3_000).then(() => "hangs"), + ]).catch(() => "errors"); + // oxlint-disable-next-line executor/no-promise-reject -- test boundary: adapt the deadline to the assertion path with the diagnosis attached + reject( + // oxlint-disable-next-line executor/no-error-constructor -- test boundary: the diagnosis rides the assertion failure + new Error( + `bystander wedged for ${deadlineMs}ms; server stats=${stats}; fresh startup ${freshStartup}`, + ), + ); + })(); + }, deadlineMs); + }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: the deadline timer must be cleared on every path + try { + return await Promise.race([run(), deadline]); + } finally { + clearTimeout(timer); + } +}; + +// A Parse frame for an unnamed statement: opens an extended-protocol pipeline +// that only a later Sync (or the server's recovery) closes. +const parseFrame = (query: string): Buffer => { + const body = Buffer.concat([Buffer.from(`\0${query}\0`), Buffer.from([0, 0])]); + const frame = Buffer.concat([Buffer.from("P"), Buffer.alloc(4), body]); + frame.writeInt32BE(4 + body.length, 1); + return frame; +}; + describe("dev-db PGlite socket under concurrent connections", () => { it( "serves interleaved multi-connection pipelines without protocol corruption", @@ -38,24 +161,17 @@ describe("dev-db PGlite socket under concurrent connections", () => { const db = await PGlite.create(); const server = new PGLiteSocketServer({ db, - port: PORT, + port: 0, host: "127.0.0.1", maxConnections: 100, }); - await server.start(); + const port = await startOnOsPort(server); let ok = 0; const errors: string[] = []; const worker = async (id: number) => { - const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, { - max: 1, - idle_timeout: 0, - connect_timeout: 10, - fetch_types: false, - prepare: true, - onnotice: () => undefined, - }); + const sql = makeClient(port, 10); // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: postgres.js is promise-native and the socket must be closed on every path try { for (let q = 0; q < QUERIES_PER_CLIENT; q++) { @@ -91,4 +207,272 @@ describe("dev-db PGlite socket under concurrent connections", () => { expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT); }, ); + + // Regression for the CI e2e "cloud signIn: callback set no session (500)" + // cascade: QueryQueueManager.processQueue used to `return` out of its drain + // loop when a query REJECTED (as opposed to returning a wire-level + // ErrorResponse), leaving `processing` latched true. From then on every + // enqueue — including brand-new connections' startup packets — sat in the + // queue forever: in-flight requests hung, postgres.js reconnects died with + // CONNECT_TIMEOUT, and the whole dev stack was bricked until restart. The + // patch rejects the one entry, drops pipeline affinity, and keeps draining. + it( + "a rejected query fails one client, not the whole socket server", + { timeout: 30_000 }, + async () => { + const db = await PGlite.create(); + const server = new PGLiteSocketServer({ + db, + port: 0, + host: "127.0.0.1", + maxConnections: 100, + }); + const port = await startOnOsPort(server); + + const first = makeClient(port); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path + try { + expect((await first.unsafe(`select 1 as one`))[0]).toEqual({ one: 1 }); + + // Force the NEXT protocol exchange to reject at the JS level, the shape + // PGlite produces when the shared session is broken mid-run. + const real = db.execProtocolRawStream.bind(db); + let arm = true; + (db as { execProtocolRawStream: typeof real }).execProtocolRawStream = (...args) => { + if (arm) { + arm = false; + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: simulating a PGlite internal failure requires a raw throw + throw new Error("synthetic PGlite failure"); + } + return real(...args); + }; + + await expect(first.unsafe(`select 2 as two`)).rejects.toThrow(); + + // The poisoned entry must take down only its own connection: a fresh + // client (new socket, full startup handshake) still gets served. + const second = makeClient(port); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path + try { + expect((await second.unsafe(`select 3 as three`))[0]).toEqual({ three: 3 }); + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion + await second.end({ timeout: 5 }).catch(() => {}); + } + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion + await first.end({ timeout: 5 }).catch(() => {}); + await server.stop(); + await db.close(); + } + }, + ); + + // Regression for the sporadic `write CONNECTION_ENDED` 500s: the server's + // idleTimeout backstop used to kill ANY connection with no traffic for the + // window, which is the resting state of every healthy postgres.js pool + // connection (idle_timeout: 0) held by a long-lived scope. The backstop now + // only fires on a connection that is actually blocking the shared session — + // an open pipeline or an open transaction. + it("an idle-at-rest connection outlives the idle backstop", { timeout: 30_000 }, async () => { + const db = await PGlite.create(); + const server = new PGLiteSocketServer({ + db, + port: 0, + host: "127.0.0.1", + maxConnections: 100, + idleTimeout: 250, + }); + const port = await startOnOsPort(server); + + const sql = makeClient(port); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path + try { + expect((await sql.unsafe(`select 1 as one`))[0]).toEqual({ one: 1 }); + await sleep(900); + expect((await sql.unsafe(`select 2 as two`))[0]).toEqual({ two: 2 }); + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion + await sql.end({ timeout: 5 }).catch(() => {}); + await server.stop(); + await db.close(); + } + }); + + // The backstop's actual job still works: a client that opens a pipeline + // (Parse sent, never Sync) and goes silent holds queue affinity, which + // starves every other connection. The idle timer must reap exactly that + // client and hand the queue back. + it( + "a client stalled mid-pipeline is reaped and the queue recovers", + { timeout: 30_000 }, + async () => { + const db = await PGlite.create(); + const server = new PGLiteSocketServer({ + db, + port: 0, + host: "127.0.0.1", + maxConnections: 100, + idleTimeout: 250, + }); + const port = await startOnOsPort(server); + + // Hand-rolled wire client: complete the trust-auth startup, then send a + // lone Parse. Its last frame type ('P') marks the pipeline open, so the + // handler takes affinity and every other connection queues behind it. + const staller = await openWireClient(port); + staller.write(parseFrame("select 1")); + + const bystander = makeClient(port, 10); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path + try { + // Connects and queries only once the staller is reaped (~250ms). + expect( + (await diagnoseWedge(() => bystander.unsafe(`select 4 as four`), { server, port }))[0], + ).toEqual({ four: 4 }); + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion + await bystander.end({ timeout: 5 }).catch(() => {}); + staller.destroy(); + await server.stop(); + await db.close(); + } + }, + ); + + // Regression for the reap SLOT LEAK: detach(true) removes the socket's + // listeners before destroying it, so a server-initiated teardown (the idle + // backstop) never fired the server's 'close' bookkeeping — the reaped + // handler stayed in the server's handlers set forever, burning one + // maxConnections slot per reap. Enough reaps over a long run and the server + // answers every NEW connection with "Too many connections" while the + // process, the port, and PGlite are all healthy — postgres.js surfaces that + // as the same CONNECT_TIMEOUT cascade as the queue wedges. The server now + // drops the handler when it dispatches its terminal error. + it("reaped handlers release their connection slots", { timeout: 30_000 }, async () => { + const db = await PGlite.create(); + const server = new PGLiteSocketServer({ + db, + port: 0, + host: "127.0.0.1", + maxConnections: 2, + idleTimeout: 250, + }); + const port = await startOnOsPort(server); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path + try { + // Burn through more reaps than there are slots: each staller opens a + // pipeline and goes silent, so the idle backstop reaps it (the server + // destroys the socket — its 'close' marks that reap complete). + for (let i = 0; i < 3; i++) { + const staller = await openWireClient(port); + staller.write(parseFrame(`select ${i + 1}`)); + await new Promise((res) => staller.once("close", res)); + } + + expect( + server.getStats().activeConnections, + "reaped handlers stay counted against maxConnections", + ).toBe(0); + + const sql = makeClient(port); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path + try { + expect((await sql.unsafe(`select 6 as six`))[0]).toEqual({ six: 6 }); + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion + await sql.end({ timeout: 5 }).catch(() => {}); + } + } finally { + await server.stop(); + await db.close(); + } + }); + + // Regression for the second wedge mode behind the same CI cascade: a client + // whose socket dies WHILE its pipeline-opening entry is executing. detach() + // clears pipeline affinity before the entry finishes, so the queue then + // assigned affinity to the already-dead handler — and nothing ever cleared + // it: the dead handler has no timers left, and every other connection + // (including fresh startups) queued behind the ghost forever. The queue now + // tracks detached handlers and repairs affinity they can no longer release. + it( + "a client that dies mid-execution does not leave the queue pinned to its ghost", + { timeout: 30_000 }, + async () => { + const db = await PGlite.create(); + + // Hold the marker query in flight long enough that the disconnect below + // reliably lands while the entry is EXECUTING (after detach's cleanup, + // before the queue takes affinity for it). + const real = db.execProtocolRawStream.bind(db); + (db as { execProtocolRawStream: typeof real }).execProtocolRawStream = async (...args) => { + if (Buffer.from(args[0]).includes("ghost_marker")) await sleep(300); + return real(...args); + }; + + const server = new PGLiteSocketServer({ + db, + port: 0, + host: "127.0.0.1", + maxConnections: 100, + }); + const port = await startOnOsPort(server); + + const ghost = await openWireClient(port); + ghost.write(parseFrame("select 'ghost_marker'")); + // Give the data event time to reach the queue and start executing, then + // die without a trace mid-flight. + await sleep(100); + ghost.destroy(); + + const bystander = makeClient(port); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path + try { + expect( + (await diagnoseWedge(() => bystander.unsafe(`select 5 as five`), { server, port }))[0], + ).toEqual({ five: 5 }); + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion + await bystander.end({ timeout: 5 }).catch(() => {}); + await server.stop(); + await db.close(); + } + }, + ); + + // Regression for the CI wedge behind every bare-timeout flake in this file: + // stock pglite-socket start() only rejected its promise while `active` was + // false, but start() sets `active` true BEFORE listen(), so a bind failure + // (EADDRINUSE — this file used to bind fixed ports inside the Linux + // ephemeral range, where any concurrent suite's outbound socket or a leaked + // e2e server can sit) dispatched an 'error' event nobody listened to and + // left `await server.start()` pending forever. The test then died as a raw + // vitest timeout with zero diagnostics. The patch rejects start() on server + // errors; a settled promise ignores later rejects, so post-listen errors + // still only surface through the 'error' event. + it( + "a bind conflict rejects start() instead of hanging forever", + { timeout: 30_000 }, + async () => { + const squatter = createServer(); + await new Promise((resolve) => squatter.listen(0, "127.0.0.1", () => resolve())); + const address = squatter.address(); + if (address === null || typeof address !== "object") { + expect.unreachable("squatter listener has no bound address"); + } + + const db = await PGlite.create(); + const server = new PGLiteSocketServer({ db, port: address.port, host: "127.0.0.1" }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: the squatter and PGlite must be released on every path + try { + await expect(server.start()).rejects.toThrow(/EADDRINUSE/); + } finally { + await server.stop(); + await db.close(); + await new Promise((resolve) => squatter.close(() => resolve())); + } + }, + ); }); diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 350b0c18b3..0db709b884 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -33,6 +33,22 @@ export const integration = pgTable( (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)], ); +export const subject = pgTable( + "subject", + { + external_id: varchar("external_id", { length: 255 }).notNull(), + created_at: timestamp("created_at").notNull(), + last_seen_at: bigint("last_seen_at", { mode: "bigint" }), + status: text("status"), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("subject_uidx").on(table.tenant, table.external_id)], +); + export const connection = pgTable( "connection", { @@ -41,6 +57,7 @@ export const connection = pgTable( template: text("template").notNull(), provider: text("provider").notNull(), item_ids: json("item_ids").notNull(), + credential_write: json("credential_write"), identity_label: text("identity_label"), description: text("description"), last_health: json("last_health"), @@ -82,6 +99,8 @@ export const oauth_client = pgTable( grant: text("grant").notNull(), client_id: text("client_id").notNull(), client_secret_item_id: text("client_secret_item_id"), + credential_write: json("credential_write"), + token_endpoint_auth_method: text("token_endpoint_auth_method"), resource: text("resource"), origin_kind: text("origin_kind"), origin_integration: text("origin_integration"), @@ -165,7 +184,7 @@ export const definition = pgTable( integration: varchar("integration", { length: 255 }).notNull(), connection: varchar("connection", { length: 255 }).notNull(), plugin_id: text("plugin_id").notNull(), - name: varchar("name", { length: 255 }).notNull(), + name: text("name").notNull(), schema: json("schema").notNull(), created_at: timestamp("created_at").notNull(), row_id: varchar("row_id", { length: 255 }) @@ -210,6 +229,28 @@ export const tool_policy = pgTable( ], ); +export const artifact = pgTable( + "artifact", + { + id: varchar("id", { length: 255 }).notNull(), + title: text("title").notNull(), + description: text("description"), + code: text("code").notNull(), + bindings: json("bindings"), + preview: text("preview"), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("artifact_uidx").on(table.tenant, table.owner, table.subject, table.id)], +); + export const plugin_storage = pgTable( "plugin_storage", { diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index f05f5234ec..86faa45bb9 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -11,16 +11,24 @@ // - a second org's data is completely untouched // - the blob prefix match escapes LIKE wildcards (a `_` in the org id must // not widen the match to a look-alike namespace) +// +// Plus a SCHEMA-DRIFT guard (see "purge coverage" below): the purge list is +// hand-written, so a table added to the schema without being added to it would +// leave that org's rows behind forever, silently. The guard derives the set of +// org-owned tables from the schema itself rather than restating it. import { describe, it, expect } from "@effect/vitest"; import { Effect } from "effect"; -import { eq, inArray } from "drizzle-orm"; +import { eq, getTableColumns, getTableName, inArray } from "drizzle-orm"; import { DbService } from "./db"; import type { DrizzleDb } from "./db"; import { makeUserStore } from "../auth/user-store"; +import * as cloudSchema from "./schema"; +import * as executorSchema from "./executor-schema"; import { memberships, accounts } from "./schema"; import { + artifact, blob, connection, definition, @@ -28,6 +36,7 @@ import { oauth_client, oauth_session, plugin_storage, + subject, tool, tool_policy, } from "./executor-schema"; @@ -131,6 +140,24 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { subject: "s", }); + await db.insert(subject).values({ + external_id: `acct-${tag}`, + created_at: now, + last_seen_at: BigInt(now.getTime()), + tenant, + }); + + await db.insert(artifact).values({ + id: `art-${tag}`, + title: "Dashboard", + code: "export default function App() { return null; }", + created_at: now, + updated_at: now, + tenant, + owner: "o", + subject: "s", + }); + const orgNs = `o:${tenant}/plugin`; const userNs = `u:${tenant}:subject/plugin`; await db.insert(blob).values({ @@ -156,8 +183,26 @@ const TENANT_TABLES = [ definition, tool_policy, plugin_storage, + subject, + artifact, ] as const; +// Tables that are NOT purged by org id, each with the reason it is exempt. Any +// schema table not carrying a `tenant` column must be listed here — that is +// what makes the drift guard below total rather than a spot check. +const NOT_ORG_OWNED: Record = { + // Purged, but by NAMESPACE PREFIX (`o:/…` / `u::…`) rather than a + // tenant column, so it cannot be checked by the column rule. + blob: "org-scoped by namespace prefix, purged via the LIKE match", + // Instance-wide, not owned by any org. + private_executor_cloud_settings: "singleton instance settings, not org-scoped", + // Identity mirror. `organizations` is deleted directly and `memberships` + // cascades from its FK; `accounts` deliberately outlives the org. + organizations: "the identity row itself, deleted directly", + memberships: "cascades from the organizations FK", + accounts: "shared across orgs — deliberately survives", +}; + const countTenantRows = async (db: DrizzleDb, tenant: string): Promise => { let total = 0; for (const table of TENANT_TABLES) { @@ -249,3 +294,70 @@ describe("purgeOrganizationData", () => { ); }, 20_000); }); + +// --------------------------------------------------------------------------- +// Purge coverage vs the schema +// --------------------------------------------------------------------------- +// +// The purge list in `org-deletion.ts` is hand-written, one `tx.delete(...)` per +// table. Adding a tenant table to the schema and forgetting that line leaves +// every deleted org's rows in it forever — no error, no failing test, and the +// data is exactly what a deletion was supposed to remove. +// +// So the expected set is DERIVED: a `tenant` column is what makes a table +// org-owned, and every table without one must be named in `NOT_ORG_OWNED` with +// its reason. A new table lands in one of the two buckets automatically, and +// both buckets fail loudly until it is handled. +describe("purge coverage", () => { + // Every drizzle table the cloud DB is built from (the same two modules + // `combinedSchema` spreads), by table name. + const allTables = Object.values({ ...cloudSchema, ...executorSchema }); + + const orgOwnedTables = allTables.filter((table) => "tenant" in getTableColumns(table)); + const orgOwnedNames = orgOwnedTables.map(getTableName).sort(); + const seededNames = TENANT_TABLES.map(getTableName).sort(); + + it("classifies every schema table as org-owned or explicitly exempt", () => { + const unclassified = allTables + .map(getTableName) + .filter((name) => !orgOwnedNames.includes(name) && !(name in NOT_ORG_OWNED)); + expect( + unclassified, + "a new table must either carry `tenant` (and be purged) or be listed in NOT_ORG_OWNED with a reason", + ).toEqual([]); + }); + + it("purges every org-owned table in the schema", () => { + // `TENANT_TABLES` is what this suite seeds and then asserts is empty after + // the purge, so keeping it equal to the schema-derived set is what makes + // the cascade test above cover the WHOLE schema rather than a snapshot of + // it. A tenant table added tomorrow fails here first — the fix is to seed + // it, which then fails the cascade test until `purgeOrganizationData` + // deletes it too. + expect(seededNames, "every org-owned table is seeded and checked by the cascade test").toEqual( + orgOwnedNames, + ); + }); + + it("seeds a row in every org-owned table, so the cascade assertions are not vacuous", async () => { + // Guards the other half: a table listed in TENANT_TABLES but never seeded + // would count zero rows before AND after the purge, passing while proving + // nothing. Assert the seed actually populates each one. + const tenant = `org_seed_${crypto.randomUUID().slice(0, 8)}`; + const empty = await program( + Effect.gen(function* () { + const { db } = yield* DbService; + return yield* Effect.promise(async () => { + await seedTenant(db, tenant, `seed${crypto.randomUUID().slice(0, 6)}`); + const missing: Array = []; + for (const table of TENANT_TABLES) { + const rows = await db.select().from(table).where(eq(table.tenant, tenant)); + if (rows.length === 0) missing.push(getTableName(table)); + } + return missing; + }); + }), + ); + expect(empty, "seedTenant must insert a row into every table the cascade checks").toEqual([]); + }, 20_000); +}); diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts index c86afd63e8..b2a922a3fd 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -16,6 +16,7 @@ import { eq, or, sql } from "drizzle-orm"; import type { DrizzleDb } from "./db"; import { organizations } from "./schema"; import { + artifact, blob, connection, definition, @@ -23,6 +24,7 @@ import { oauth_client, oauth_session, plugin_storage, + subject, tool, tool_policy, } from "./executor-schema"; @@ -48,6 +50,8 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId)); await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); + await tx.delete(subject).where(eq(subject.tenant, organizationId)); + await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by // owner: `o:/` (org scope) and `u::/` diff --git a/apps/cloud/src/edge/docs.ts b/apps/cloud/src/edge/docs.ts index d9f18433fa..fabf3773e0 100644 --- a/apps/cloud/src/edge/docs.ts +++ b/apps/cloud/src/edge/docs.ts @@ -6,43 +6,23 @@ // base path, so the pathname is forwarded UNCHANGED — only the host/proto swap // to the upstream origin (unlike the PostHog proxy, which strips its prefix). // -// Like the PostHog/Sentry tunnels (and unlike the marketing proxy, which needs -// the prod-only `env.MARKETING` service binding), this is a plain external -// `fetch`, so it runs on every host — `/docs` previews against live Mintlify in -// local dev too. `/docs` is distinct from the app-owned `/api/docs` (Swagger), -// so this never shadows an Effect-served route. +// The matching, upstream construction, and client span live in `./passthrough`, +// which server.ts dispatches BEFORE Start loads: forwarding a docs page must +// not pay for the whole server graph. This middleware stays registered so hosts +// that reach Start by another entry (local dev) keep identical behavior; in the +// deployed Worker it is unreachable. `/docs` is distinct from the app-owned +// `/api/docs` (Swagger), so this never shadows an Effect-served route. // --------------------------------------------------------------------------- import { createMiddleware } from "@tanstack/react-start"; -const DOCS_UPSTREAM_HOST = "executor.mintlify.dev"; +import { docsProxyResponse, isDocsPath } from "./passthrough"; -export const isDocsPath = (pathname: string) => - pathname === "/docs" || pathname.startsWith("/docs/"); - -// Build the upstream request for an already-classified `/docs` path. Caller -// guarantees `isDocsPath(pathname)` — we only swap the origin and fix up the -// forwarding headers, preserving method, body, path, and query. -export const buildDocsUpstream = (request: Request): Request => { - const url = new URL(request.url); - const forwardedHost = url.host; - - url.hostname = DOCS_UPSTREAM_HOST; - url.protocol = "https:"; - url.port = ""; - - const upstream = new Request(url, request); - // Mintlify keys canonical links off the public host; tell it the real one. - upstream.headers.set("X-Forwarded-Host", forwardedHost); - upstream.headers.set("X-Forwarded-Proto", "https"); - // Never leak the executor.sh session cookie to the docs origin. - upstream.headers.delete("cookie"); - return upstream; -}; +export { buildDocsUpstream, isDocsPath } from "./passthrough"; export const docsProxyMiddleware = createMiddleware({ type: "request" }).server( ({ pathname, request, next }) => { if (!isDocsPath(pathname)) return next(); - return fetch(buildDocsUpstream(request)); + return docsProxyResponse(request, pathname); }, ); diff --git a/apps/cloud/src/edge/index.ts b/apps/cloud/src/edge/index.ts index 1b1cbe9ad8..6b58c2eda5 100644 --- a/apps/cloud/src/edge/index.ts +++ b/apps/cloud/src/edge/index.ts @@ -1,11 +1,10 @@ // --------------------------------------------------------------------------- -// Edge concerns — the analytics/marketing/docs request middlewares that run at -// the worker edge BEFORE the app's own mcp + api dispatch. None of these touch -// the Effect app layer; they proxy or tunnel to external services (the -// marketing worker, Sentry, PostHog, Mintlify docs). +// Edge concerns — request middleware that runs before the app's own mcp + api +// dispatch. These proxy or tunnel to external services without touching the +// Effect app layer. Marketing is dispatched even earlier, from server.ts, so a +// public page never loads the TanStack Start graph. // --------------------------------------------------------------------------- -export { marketingMiddleware } from "./marketing"; export { sentryTunnelMiddleware } from "./sentry-tunnel"; export { posthogProxyMiddleware } from "./posthog"; export { docsProxyMiddleware } from "./docs"; diff --git a/apps/cloud/src/edge/marketing.test.ts b/apps/cloud/src/edge/marketing.test.ts index 38019ce9b9..6e788bf2ac 100644 --- a/apps/cloud/src/edge/marketing.test.ts +++ b/apps/cloud/src/edge/marketing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { isMarketingPath } from "./marketing"; +import { isMarketingPath, marketingProxyRequest } from "./marketing"; // On executor.sh the marketing middleware proxies an allow-list of paths to the // `executor-marketing` worker; everything else falls through to the auth-gated @@ -12,6 +12,10 @@ describe("isMarketingPath", () => { "/home", "/privacy", "/terms", + "/pricing", + "/about-executor", + "/google-oauth", + "/google-workspace", "/blog", "/blog/", "/blog/some-post", @@ -37,3 +41,60 @@ describe("isMarketingPath", () => { }); } }); + +describe("marketingProxyRequest", () => { + it("routes a signed-out homepage request", () => { + const request = new Request("https://executor.sh/?source=test"); + + const proxied = marketingProxyRequest(request); + + expect(proxied?.url).toBe("https://executor.sh/?source=test"); + }); + + it("leaves the signed-in homepage with the cloud application", () => { + const request = new Request("https://executor.sh/", { + headers: { cookie: "other=value; wos-session=sealed" }, + }); + + expect(marketingProxyRequest(request)).toBeNull(); + }); + + it("routes public content even when a session cookie is present", () => { + const request = new Request("https://executor.sh/blog/post", { + headers: { cookie: "wos-session=sealed" }, + }); + + expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/blog/post"); + }); + + it("routes /pricing to the marketing worker", () => { + const request = new Request("https://executor.sh/pricing"); + + expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/pricing"); + }); + + it("rewrites the public home alias to the marketing root", () => { + const request = new Request("https://executor.sh/home?source=test"); + + expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/?source=test"); + }); + + it("preserves the request method, headers, and body", async () => { + const request = new Request("https://executor.sh/_astro/_ph/capture", { + method: "POST", + headers: { "content-type": "application/json", "x-request-id": "request-1" }, + body: JSON.stringify({ event: "test" }), + }); + + const proxied = marketingProxyRequest(request); + + expect(proxied?.method).toBe("POST"); + expect(proxied?.headers.get("x-request-id")).toBe("request-1"); + await expect(proxied?.json()).resolves.toEqual({ event: "test" }); + }); + + it("does not proxy non-production hosts or app-owned paths", () => { + expect(marketingProxyRequest(new Request("http://executor-cloud.localhost/"))).toBeNull(); + expect(marketingProxyRequest(new Request("https://executor.sh/login"))).toBeNull(); + }); +}); diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts index ee473ce688..5199f0cb41 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -3,14 +3,10 @@ // // On the production domain (`executor.sh`), marketing paths and the // unauthenticated landing page are served by the separate `executor-marketing` -// worker (bound as `env.MARKETING`). In local dev that worker isn't running, so -// unauthenticated visits fall through to the cloud app's routes (the sign-in -// page). +// worker. This module deliberately has no TanStack Start or cloud application +// imports: the Worker entry calls it before loading the Start server graph. // --------------------------------------------------------------------------- -import { env } from "cloudflare:workers"; -import { createMiddleware } from "@tanstack/react-start"; - import { parseCookie } from "../auth/cookies"; const MARKETING_PATHS = [ @@ -18,6 +14,10 @@ const MARKETING_PATHS = [ "/setup", "/privacy", "/terms", + "/pricing", + "/about-executor", + "/google-oauth", + "/google-workspace", "/blog", "/llms.txt", "/api/detect", @@ -27,33 +27,25 @@ const MARKETING_PATHS = [ "/pattern-graph-paper.svg", ]; -export const isMarketingPath = (pathname: string) => - MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); - -const getMarketingWorker = () => env.MARKETING as { fetch: typeof fetch } | undefined; - -export const marketingMiddleware = createMiddleware({ type: "request" }).server( - async ({ pathname, request, next }) => { - // Only proxy to the marketing worker on the production domain. In local - // dev we don't run `executor-marketing`, so unauthenticated visits fall - // through to the cloud app's routes (which show the sign-in page). - const host = new URL(request.url).hostname; - if (host !== "executor.sh") return next(); +const SESSION_COOKIE = "wos-session"; - const shouldProxyToMarketing = - isMarketingPath(pathname) || - (pathname === "/" && !parseCookie(request.headers.get("cookie"), "wos-session")); - - if (!shouldProxyToMarketing) return next(); - - const marketing = getMarketingWorker(); - if (!marketing) return next(); +/** Whether an exact pathname belongs to the public marketing worker. */ +export const isMarketingPath = (pathname: string): boolean => + MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); - const url = new URL(request.url); - // Rewrite /home to / so marketing worker serves its homepage - if (pathname === "/home") { - url.pathname = "/"; - } - return marketing.fetch(new Request(url, request)); - }, -); +/** + * Project a production request onto the marketing service-binding request. + * Returns `null` when the cloud application owns the request instead. + */ +export const marketingProxyRequest = (request: Request): Request | null => { + const url = new URL(request.url); + if (url.hostname !== "executor.sh") return null; + + const shouldProxy = + isMarketingPath(url.pathname) || + (url.pathname === "/" && !parseCookie(request.headers.get("cookie"), SESSION_COOKIE)); + if (!shouldProxy) return null; + + if (url.pathname === "/home") url.pathname = "/"; + return new Request(url, request); +}; diff --git a/apps/cloud/src/edge/passthrough.test.ts b/apps/cloud/src/edge/passthrough.test.ts new file mode 100644 index 0000000000..2f5bbd35ad --- /dev/null +++ b/apps/cloud/src/edge/passthrough.test.ts @@ -0,0 +1,85 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "@effect/vitest"; + +import { + buildDocsUpstream, + buildPosthogUpstream, + isDocsPath, + isPosthogPath, + passthroughResponse, + POSTHOG_PROXY_PATH, +} from "./passthrough"; + +describe("passthrough matching", () => { + it("claims /docs and everything under it, but not /api/docs", () => { + expect(isDocsPath("/docs")).toBe(true); + expect(isDocsPath("/docs/concepts/policies")).toBe(true); + // The app-owned Swagger route must keep reaching the Effect app. + expect(isDocsPath("/api/docs")).toBe(false); + expect(isDocsPath("/docsearch")).toBe(false); + }); + + it("claims the PostHog proxy path and its subtree only", () => { + expect(isPosthogPath(POSTHOG_PROXY_PATH)).toBe(true); + expect(isPosthogPath(`${POSTHOG_PROXY_PATH}/i/v0/e/`)).toBe(true); + expect(isPosthogPath(`${POSTHOG_PROXY_PATH}extra`)).toBe(false); + expect(isPosthogPath("/api/connections")).toBe(false); + }); + + it("returns null for app paths so they fall through to normal dispatch", () => { + expect(passthroughResponse(new Request("https://executor.sh/"), "/")).toBeNull(); + expect( + passthroughResponse(new Request("https://executor.sh/api/connections"), "/api/connections"), + ).toBeNull(); + expect(passthroughResponse(new Request("https://executor.sh/mcp"), "/mcp")).toBeNull(); + }); +}); + +describe("upstream construction", () => { + it("forwards the docs path unchanged and strips the session cookie", () => { + const upstream = buildDocsUpstream( + new Request("https://executor.sh/docs/concepts/policies?x=1", { + headers: { cookie: "wos-session=secret" }, + }), + ); + const url = new URL(upstream.url); + expect(url.hostname).toBe("executor.mintlify.dev"); + expect(url.pathname).toBe("/docs/concepts/policies"); + expect(url.search).toBe("?x=1"); + expect(upstream.headers.get("X-Forwarded-Host")).toBe("executor.sh"); + expect(upstream.headers.get("cookie")).toBeNull(); + }); + + it("strips the proxy prefix for PostHog and splits ingest from assets", () => { + const ingest = buildPosthogUpstream( + new Request(`https://executor.sh${POSTHOG_PROXY_PATH}/i/v0/e/`), + `${POSTHOG_PROXY_PATH}/i/v0/e/`, + ); + expect(new URL(ingest.url).hostname).toBe("us.i.posthog.com"); + expect(new URL(ingest.url).pathname).toBe("/i/v0/e/"); + + const assets = buildPosthogUpstream( + new Request(`https://executor.sh${POSTHOG_PROXY_PATH}/static/array.js`), + `${POSTHOG_PROXY_PATH}/static/array.js`, + ); + expect(new URL(assets.url).hostname).toBe("us-assets.i.posthog.com"); + expect(new URL(assets.url).pathname).toBe("/static/array.js"); + }); +}); + +describe("Start-graph independence", () => { + // The entire point of this module is that server.ts can answer a proxy + // request WITHOUT importing TanStack Start. An import of the app or of + // `@tanstack/react-start` here silently reintroduces the ~3.1s cold-isolate + // `loadEntries` cost this module exists to avoid, and nothing else would + // catch it — the behavior stays correct, only slow. + it("imports neither TanStack Start nor an app module", () => { + const source = readFileSync(new URL("./passthrough.ts", import.meta.url), "utf8"); + const imports = [...source.matchAll(/^\s*import[^"']*["']([^"']+)["']/gm)].map((m) => m[1]); + expect(imports.length).toBeGreaterThan(0); + for (const specifier of imports) { + expect(specifier).not.toMatch(/@tanstack/); + expect(specifier).not.toMatch(/^\.\.\//); + } + }); +}); diff --git a/apps/cloud/src/edge/passthrough.ts b/apps/cloud/src/edge/passthrough.ts new file mode 100644 index 0000000000..76049436c6 --- /dev/null +++ b/apps/cloud/src/edge/passthrough.ts @@ -0,0 +1,138 @@ +// --------------------------------------------------------------------------- +// Pure passthrough proxies — dispatched from the Worker entry, before Start. +// --------------------------------------------------------------------------- +// +// `/docs` and the PostHog proxy forward to an external origin and never touch +// the router, React, or the Effect app. They lived in Start's request +// middleware, which meant each one still paid Start's lazy `loadEntries` +// import of the whole server graph before it could forward a request. +// +// Measured on production (2026-08-18), splitting the two costs apart on a cold +// isolate: the graph import is p50 **3.1s** while the request's own work is +// p50 **33ms**. And essentially every request is cold — `worker.dispatch` ran +// 1,666 requests across 1,608 isolates (1.04 req/isolate), because `/mcp` +// dispatches before `fetchHandler` and so never warms the graph. A `/docs` +// page took 3-6s through the Worker against 0.098s straight from the upstream. +// +// So these move to the Worker entry, exactly as marketing did: classify and +// forward before anything imports Start. This module therefore must NOT import +// `@tanstack/react-start` or any app module — that import is the cost it +// exists to avoid. +// +// The middleware wrappers in `./docs` and `./posthog` stay registered. In the +// deployed Worker they become unreachable (server.ts answers first), but they +// keep the behavior identical on any host that reaches Start by another entry +// (local dev), and they source their matching from here so the two can't drift. +// --------------------------------------------------------------------------- + +import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; + +const DOCS_UPSTREAM_HOST = "executor.mintlify.dev"; +/** PostHog's US ingest origin. Exported because the server-side feature-flag + * gate (`../analytics/ema-rollout`) calls the same origin directly, and the + * two must not be able to drift onto different PostHog regions. */ +export const POSTHOG_INGEST_HOST = "us.i.posthog.com"; +const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com"; + +export const POSTHOG_PROXY_PATH = `/api/${( + import.meta.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a" +).replace(/^\/+|\/+$/g, "")}`; + +// The proxy fetch gets its own client span: `/docs` requests otherwise render +// as a single opaque server span, and during the Aug 2026 regression there +// was no way to tell upstream (Mintlify/Vercel) latency from worker-side +// dispatch cost. The noop tracer applies when no provider is installed +// (local dev without AXIOM_TOKEN), so this is free there. +const tracer = trace.getTracer("executor-cloud-docs-proxy"); + +export const isDocsPath = (pathname: string): boolean => + pathname === "/docs" || pathname.startsWith("/docs/"); + +export const isPosthogPath = (pathname: string): boolean => + pathname === POSTHOG_PROXY_PATH || pathname.startsWith(`${POSTHOG_PROXY_PATH}/`); + +/** + * Build the upstream request for an already-classified `/docs` path. Caller + * guarantees `isDocsPath(pathname)` — we only swap the origin and fix up the + * forwarding headers, preserving method, body, path, and query. + */ +export const buildDocsUpstream = (request: Request): Request => { + const url = new URL(request.url); + const forwardedHost = url.host; + + url.hostname = DOCS_UPSTREAM_HOST; + url.protocol = "https:"; + url.port = ""; + + const upstream = new Request(url, request); + // Mintlify keys canonical links off the public host; tell it the real one. + upstream.headers.set("X-Forwarded-Host", forwardedHost); + upstream.headers.set("X-Forwarded-Proto", "https"); + // Never leak the executor.sh session cookie to the docs origin. + upstream.headers.delete("cookie"); + return upstream; +}; + +/** Build the upstream request for an already-classified PostHog proxy path. */ +export const buildPosthogUpstream = (request: Request, pathname: string): Request => { + const url = new URL(request.url); + url.hostname = pathname.startsWith(`${POSTHOG_PROXY_PATH}/static/`) + ? POSTHOG_ASSETS_HOST + : POSTHOG_INGEST_HOST; + url.protocol = "https:"; + url.port = ""; + url.pathname = pathname.slice(POSTHOG_PROXY_PATH.length) || "/"; + + const upstream = new Request(url, request); + upstream.headers.delete("cookie"); + return upstream; +}; + +export const docsProxyResponse = (request: Request, pathname: string): Promise => + tracer.startActiveSpan( + `http.client ${request.method}`, + { + kind: SpanKind.CLIENT, + attributes: { + "server.address": DOCS_UPSTREAM_HOST, + "url.path": pathname, + "http.request.method": request.method, + }, + }, + async (span) => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe upstream response/error for span status, then pass both through unchanged + try { + const response = await fetch(buildDocsUpstream(request)); + span.setAttribute("http.response.status_code", response.status); + if (response.status >= 500) { + span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); + } + return response; + } catch (err) { + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: fetch rejects untyped; normalized only for the OTel span record, the original error is rethrown below + const cause = err instanceof Error ? err : String(err); + span.recordException(cause); + // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above + const message = typeof cause === "string" ? cause : cause.message; + span.setStatus({ code: SpanStatusCode.ERROR, message }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve the original rejection for the platform handler + throw err; + } finally { + span.end(); + } + }, + ); + +/** + * Answer a pure passthrough request without loading the Start server graph. + * Returns `null` when the request belongs to the app, so the caller falls + * through to normal dispatch. + */ +export const passthroughResponse = ( + request: Request, + pathname: string, +): Promise | null => { + if (isDocsPath(pathname)) return docsProxyResponse(request, pathname); + if (isPosthogPath(pathname)) return fetch(buildPosthogUpstream(request, pathname)); + return null; +}; diff --git a/apps/cloud/src/edge/posthog.ts b/apps/cloud/src/edge/posthog.ts index 6badd574f9..a9a035b4d1 100644 --- a/apps/cloud/src/edge/posthog.ts +++ b/apps/cloud/src/edge/posthog.ts @@ -3,33 +3,20 @@ // first-party path and we forward to PostHog's ingest + asset hosts. Keeps // events flowing past adblockers that match *.posthog.com. See // https://posthog.com/docs/advanced/proxy/cloudflare +// +// The matching and forwarding live in `./passthrough`, which server.ts +// dispatches BEFORE Start loads (a proxy must not pay for the server graph). +// This middleware stays registered so hosts that reach Start by another entry +// keep identical behavior; in the deployed Worker it is unreachable. // --------------------------------------------------------------------------- import { createMiddleware } from "@tanstack/react-start"; -const POSTHOG_INGEST_HOST = "us.i.posthog.com"; -const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com"; -const POSTHOG_PROXY_PATH = `/api/${(import.meta.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a").replace( - /^\/+|\/+$/g, - "", -)}`; +import { buildPosthogUpstream, isPosthogPath } from "./passthrough"; export const posthogProxyMiddleware = createMiddleware({ type: "request" }).server( ({ pathname, request, next }) => { - if (pathname !== POSTHOG_PROXY_PATH && !pathname.startsWith(`${POSTHOG_PROXY_PATH}/`)) { - return next(); - } - - const url = new URL(request.url); - url.hostname = pathname.startsWith(`${POSTHOG_PROXY_PATH}/static/`) - ? POSTHOG_ASSETS_HOST - : POSTHOG_INGEST_HOST; - url.protocol = "https:"; - url.port = ""; - url.pathname = pathname.slice(POSTHOG_PROXY_PATH.length) || "/"; - - const upstream = new Request(url, request); - upstream.headers.delete("cookie"); - return fetch(upstream); + if (!isPosthogPath(pathname)) return next(); + return fetch(buildPosthogUpstream(request, pathname)); }, ); diff --git a/apps/cloud/src/engine/execution-gate.ts b/apps/cloud/src/engine/execution-gate.ts index 6c12d53ef3..60c102663f 100644 --- a/apps/cloud/src/engine/execution-gate.ts +++ b/apps/cloud/src/engine/execution-gate.ts @@ -100,10 +100,17 @@ export const withPreExecutionGate = ( ), // resume is never gated: paused executions must be able to complete. resume: (executionId, response) => engine.resume(executionId, response), + // Optional member, so it must be forwarded explicitly — a decorator that + // rebuilds the object literal drops it, and the host then reads every settled + // execution as "never existed". + isExecutionSettled: engine.isExecutionSettled, getPausedExecution: (executionId) => engine.getPausedExecution(executionId), pausedExecutionCount: () => engine.pausedExecutionCount(), hasPausedExecutions: () => engine.hasPausedExecutions(), getDescription: engine.getDescription, + // Forwarded, not re-implemented: the wrapped engine owns the sandbox fibers, + // so the host's request-scope teardown has to reach through this decorator. + shutdown: engine.shutdown, }); // --------------------------------------------------------------------------- diff --git a/apps/cloud/src/engine/execution-rate-limit.node.test.ts b/apps/cloud/src/engine/execution-rate-limit.node.test.ts new file mode 100644 index 0000000000..4cefbdbf85 --- /dev/null +++ b/apps/cloud/src/engine/execution-rate-limit.node.test.ts @@ -0,0 +1,435 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { env } from "cloudflare:workers"; +import { Data, Effect } from "effect"; +import type * as Tracer from "effect/Tracer"; + +import type { ExecutionEngine } from "@executor-js/execution"; + +import { + ExecutionRateLimiterDO, + makeCloudExecutionRateLimiter, + makeExecutionRateLimiter, + RateLimitCounterError, +} from "./execution-rate-limit"; +import { RATE_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages"; + +const ORG = "org_test"; + +/** Stands in for whatever the real lookups fail with (Autumn down, DO down). */ +class UpstreamDownError extends Data.TaggedError("UpstreamDownError")<{ + readonly which: string; +}> {} + +// A stand-in engine: `execute` resolves to a marker, so a test tells an allowed +// execution (marker) from a blocked one (the gate's error result) by which of +// the two came back. A blocked decision never reaches the engine at all. +const engineStub: ExecutionEngine = { + execute: () => Effect.succeed({ result: "ran" }), + executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "ran" } }), + resume: () => Effect.succeed(null), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("stub"), + // The stub forks nothing, so there is no sandbox fiber to end. + shutdown: Effect.void, +}; + +/** Counter that hands out a caller-controlled sequence of counts. */ +const countingIncrement = (counts: ReadonlyArray) => { + let calls = 0; + return () => Effect.succeed(counts[Math.min(calls++, counts.length - 1)] ?? 0); +}; + +const runExecute = (limiter: ReturnType) => + Effect.runPromise( + limiter.decorate(ORG, engineStub).execute("code", { + // Never invoked: the stub ignores it, and a blocked execution never runs. + onElicitation: () => Effect.die("elicitation is not exercised here"), + }), + ); + +describe("execution rate limiter — paid exemption", () => { + it("allows executions under the cap without consulting the exemption", async () => { + let exemptionCalls = 0; + const limiter = makeExecutionRateLimiter(countingIncrement([1]), { + limit: 10, + isExempt: () => { + exemptionCalls += 1; + return Effect.succeed(false); + }, + }); + + expect(await runExecute(limiter)).toMatchObject({ result: "ran" }); + // The whole point of resolving lazily: the common path costs no lookup. + expect(exemptionCalls).toBe(0); + }); + + it("blocks a non-exempt org over the cap", async () => { + const limiter = makeExecutionRateLimiter(countingIncrement([11]), { + limit: 10, + isExempt: () => Effect.succeed(false), + }); + + expect(await runExecute(limiter)).toMatchObject({ + result: null, + error: RATE_LIMIT_BLOCKED_MESSAGE, + }); + }); + + it("allows an exempt org over the cap", async () => { + const limiter = makeExecutionRateLimiter(countingIncrement([11]), { + limit: 10, + isExempt: () => Effect.succeed(true), + }); + + expect(await runExecute(limiter)).toMatchObject({ result: "ran" }); + }); + + it("caches the exemption so a paid org past the cap looks it up once", async () => { + let exemptionCalls = 0; + const limiter = makeExecutionRateLimiter(countingIncrement([11, 12, 13]), { + limit: 10, + exemptionTtlMs: 60_000, + now: () => 1_000, + isExempt: () => { + exemptionCalls += 1; + return Effect.succeed(true); + }, + }); + + for (let i = 0; i < 3; i += 1) + expect(await runExecute(limiter)).toMatchObject({ result: "ran" }); + expect(exemptionCalls).toBe(1); + }); + + it("re-resolves once the cached exemption expires", async () => { + let exemptionCalls = 0; + let clock = 1_000; + const limiter = makeExecutionRateLimiter(countingIncrement([11, 12]), { + limit: 10, + exemptionTtlMs: 1_000, + now: () => clock, + isExempt: () => { + exemptionCalls += 1; + return Effect.succeed(true); + }, + }); + + await runExecute(limiter); + clock += 5_000; + await runExecute(limiter); + expect(exemptionCalls).toBe(2); + }); + + it("blocks when the exemption cannot be resolved and nothing is cached", async () => { + // Deliberately NOT fail-open: an unresolvable exemption during an Autumn + // outage must not switch the backstop off, which is the one scenario it + // exists to cover. + const limiter = makeExecutionRateLimiter(countingIncrement([11]), { + limit: 10, + isExempt: () => Effect.fail(new UpstreamDownError({ which: "autumn" })), + }); + + expect(await runExecute(limiter)).toMatchObject({ + result: null, + error: RATE_LIMIT_BLOCKED_MESSAGE, + }); + }); + + it("honours a stale exemption when a later lookup fails", async () => { + let clock = 1_000; + let shouldFail = false; + const limiter = makeExecutionRateLimiter(countingIncrement([11, 12]), { + limit: 10, + exemptionTtlMs: 1_000, + now: () => clock, + isExempt: () => + shouldFail ? Effect.fail(new UpstreamDownError({ which: "autumn" })) : Effect.succeed(true), + }); + + expect(await runExecute(limiter)).toMatchObject({ result: "ran" }); + + // Cache expires and Autumn is now unreachable: the known-paid org keeps + // running rather than getting blocked mid-workload by a blip. + clock += 5_000; + shouldFail = true; + expect(await runExecute(limiter)).toMatchObject({ result: "ran" }); + }); + + it("fails open when the counter itself is unreachable", async () => { + const limiter = makeExecutionRateLimiter( + (organizationId) => + Effect.fail( + new RateLimitCounterError({ + organizationId, + code: "unknown", + reason: "counter DO unreachable", + cause: null, + }), + ), + { + limit: 10, + isExempt: () => Effect.succeed(false), + }, + ); + + expect(await runExecute(limiter)).toMatchObject({ result: "ran" }); + }); + + it("applies the cap when no exemption predicate is wired", async () => { + const limiter = makeExecutionRateLimiter(countingIncrement([11]), { limit: 10 }); + + expect(await runExecute(limiter)).toMatchObject({ + result: null, + error: RATE_LIMIT_BLOCKED_MESSAGE, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Counter observability — the production DO wiring. +// +// The counter increment used to be a bare one-argument `Effect.tryPromise` +// with no span, so a Durable Object fault reached error reporting as +// `UnknownError: An error occurred in Effect.tryPromise` with no application +// frames, and the 2s check budget was invisible in traces. These tests pin +// the named spans, the typed/classified failure, and the timeout override — +// all read through the REAL `makeCloudExecutionRateLimiter` wiring against a +// fake `EXECUTION_RATE_LIMITER` binding. +// --------------------------------------------------------------------------- + +type RecordedSpan = { + readonly name: string; + readonly attributes: Map; +}; + +/** A tracer that keeps every span it is asked to open, with its attributes. */ +const recordingTracer = (recorded: Array): Tracer.Tracer => { + let nextId = 1; + return { + span: (options) => { + let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; + const attributes = new Map(); + recorded.push({ name: options.name, attributes }); + const id = String(nextId++).padStart(16, "0"); + return { + _tag: "Span", + name: options.name, + spanId: id, + traceId: "00000000000000000000000000000001", + parent: options.parent, + annotations: options.annotations, + get status() { + return status; + }, + attributes, + links: options.links, + sampled: options.sampled, + kind: options.kind, + end: (endTime, exit) => { + status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; + }, + attribute: (key, value) => { + attributes.set(key, value); + }, + event: () => undefined, + addLinks: () => undefined, + }; + }, + }; +}; + +const spanNamed = (recorded: ReadonlyArray, name: string): RecordedSpan => { + const span = recorded.find((candidate) => candidate.name === name); + expect( + span, + `a span named ${name} is recorded (got: ${recorded.map((s) => s.name).join(", ") || "none"})`, + ).toBeDefined(); + return span ?? { name, attributes: new Map() }; +}; + +/** A counter-DO namespace whose single RPC method behaves as the test says. */ +const namespaceReturning = (increment: () => Promise) => ({ + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ increment }), +}); + +/** The three worker vars the production limiter reads at construction. */ +type CounterEnv = { + EXECUTION_RATE_LIMITER?: unknown; + EXECUTION_RATE_LIMIT_PER_HOUR?: string; + EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS?: string; +}; + +const cloudEnv: CounterEnv = env; +const savedEnv: CounterEnv = { ...cloudEnv }; + +// Restore rather than leak: the limiter is built from the worker env, and a +// stale binding or budget would silently retune a later test. +afterEach(() => { + delete cloudEnv.EXECUTION_RATE_LIMITER; + delete cloudEnv.EXECUTION_RATE_LIMIT_PER_HOUR; + delete cloudEnv.EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS; + Object.assign(cloudEnv, savedEnv); +}); + +// The exact platform fault behind the production issue: Cloudflare resets the +// object and the RPC rejects with a plain Error. The reference id is synthetic. +const DO_STORAGE_RESET = + "Internal error in Durable Object storage caused object to be reset; reference = 0000000000000000"; + +// oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: the Durable Object RPC is a promise that rejects with a platform Error; the test double has to fail the same way for the classification to mean anything +const rejectStorageReset = (): Promise => Promise.reject(new Error(DO_STORAGE_RESET)); + +/** Build the production limiter against a fake binding and worker env. */ +const cloudLimiter = (options: { + readonly namespace: ReturnType; + readonly limit: string; + readonly timeoutMs?: string; +}): ReturnType => { + cloudEnv.EXECUTION_RATE_LIMITER = options.namespace; + cloudEnv.EXECUTION_RATE_LIMIT_PER_HOUR = options.limit; + if (options.timeoutMs !== undefined) + cloudEnv.EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS = options.timeoutMs; + return makeCloudExecutionRateLimiter(() => Effect.succeed(false)); +}; + +const runExecuteTraced = ( + limiter: ReturnType, + recorded: Array, +) => + Effect.runPromise( + limiter + .decorate(ORG, engineStub) + .execute("code", { onElicitation: () => Effect.die("elicitation is not exercised here") }) + .pipe(Effect.withTracer(recordingTracer(recorded))), + ); + +describe("execution rate limiter — counter observability", () => { + it("reports a counter-DO fault as a typed, classified error on a named span", async () => { + const recorded: Array = []; + const limiter = cloudLimiter({ + namespace: namespaceReturning(rejectStorageReset), + limit: "10", + }); + const result = await runExecuteTraced(limiter, recorded); + + // Fail-open semantics are unchanged: a broken counter never blocks. + expect(result).toMatchObject({ result: "ran" }); + + const increment = spanNamed(recorded, "rate_limit.increment"); + expect(increment.attributes.get("rate_limit.counter.error_tag")).toBe("RateLimitCounterError"); + expect(increment.attributes.get("rate_limit.counter.error_code")).toBe("storage_reset"); + + const check = spanNamed(recorded, "rate_limit.check"); + expect(check.attributes.get("rate_limit.check.failed_open")).toBe(true); + expect(check.attributes.get("rate_limit.check.timed_out")).toBe(false); + expect(check.attributes.get("rate_limit.check.error_tag")).toBe("RateLimitCounterError"); + }); + + it("honours the EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS override", async () => { + const recorded: Array = []; + const limiter = cloudLimiter({ + // Answers well past the tiny budget, with a count far over the cap. If + // the override were ignored the default 2s budget would let that count + // through and BLOCK the execution; honouring it times the check out and + // fails open instead, so the two outcomes are distinguishable without + // measuring wall time. + namespace: namespaceReturning( + () => new Promise((resolve) => setTimeout(() => resolve(999), 200)), + ), + limit: "10", + timeoutMs: "5", + }); + const result = await runExecuteTraced(limiter, recorded); + + expect(result).toMatchObject({ result: "ran" }); + + const check = spanNamed(recorded, "rate_limit.check"); + expect(check.attributes.get("rate_limit.check.timed_out")).toBe(true); + expect(check.attributes.get("rate_limit.check.error_tag")).toBe("RateLimitCheckTimeoutError"); + }); + + // The healthy check's span (count / limit / blocked / failed_open) is NOT + // asserted here: the cloud e2e scenario "the rate-limit counter check is + // visible in the exported spans" pins it on the real workerd + Durable + // Object topology, against the spans the worker actually exports. The two + // cases above stay because neither is reachable from the e2e harness — it + // has no fault seam for a Durable Object RPC, and the check budget is a + // process-wide worker var that the shared cloud boot cannot vary per + // scenario without disabling the backstop for the whole run. +}); + +// --------------------------------------------------------------------------- +// Counter Durable Object +// --------------------------------------------------------------------------- + +/** Minimal DO storage that counts the calls the increment path makes. */ +const fakeStorage = () => { + const values = new Map(); + const calls = { put: 0, setAlarm: 0, deleteAll: 0 }; + return { + calls, + storage: { + get: (key: string) => Promise.resolve(values.get(key)), + put: (key: string, value: unknown) => { + calls.put += 1; + values.set(key, value); + return Promise.resolve(); + }, + setAlarm: () => { + calls.setAlarm += 1; + return Promise.resolve(); + }, + deleteAll: () => { + calls.deleteAll += 1; + values.clear(); + return Promise.resolve(); + }, + }, + }; +}; + +const makeCounter = (storage: ReturnType["storage"]) => + // oxlint-disable-next-line executor/no-double-cast -- test double: only the four storage methods the counter uses are implemented + new ExecutionRateLimiterDO({ storage } as unknown as DurableObjectState, {} as Env); + +describe("execution rate-limit counter DO", () => { + it("writes the purge alarm once per window instead of on every increment", async () => { + // The alarm only has to outlive the window; rewriting it on every call put + // a second durable write on the hot path with the input gate closed. + const fake = fakeStorage(); + const counter = makeCounter(fake.storage); + + expect(await counter.increment(7)).toBe(1); + expect(await counter.increment(7)).toBe(2); + expect(await counter.increment(7)).toBe(3); + + expect(fake.calls.put, "every increment still persists the count").toBe(3); + expect(fake.calls.setAlarm, "the purge alarm is written once for the window").toBe(1); + }); + + it("moves the purge alarm when the window rolls", async () => { + const fake = fakeStorage(); + const counter = makeCounter(fake.storage); + + await counter.increment(7); + await counter.increment(7); + expect(await counter.increment(8), "a new window restarts the count").toBe(1); + + expect(fake.calls.setAlarm, "one alarm write per window, not per increment").toBe(2); + }); + + it("re-arms the purge alarm after it has fired", async () => { + const fake = fakeStorage(); + const counter = makeCounter(fake.storage); + + await counter.increment(7); + await counter.alarm(); + expect(fake.calls.deleteAll, "the alarm purges the counter's storage").toBe(1); + + expect(await counter.increment(7), "the purge reset the window's count").toBe(1); + expect(fake.calls.setAlarm, "a purged counter schedules a fresh purge").toBe(2); + }); +}); diff --git a/apps/cloud/src/engine/execution-rate-limit.ts b/apps/cloud/src/engine/execution-rate-limit.ts index b68d4c044e..04c35f5774 100644 --- a/apps/cloud/src/engine/execution-rate-limit.ts +++ b/apps/cloud/src/engine/execution-rate-limit.ts @@ -1,5 +1,5 @@ // --------------------------------------------------------------------------- -// Per-org execution rate limit — an abuse backstop independent of billing. +// Per-org execution rate limit — a free-tier abuse backstop. // // The balance gate (execution-gate.ts) depends on Autumn and fails open, so a // billing outage plus runaway automation could still run unbounded executions. @@ -8,13 +8,28 @@ // each MCP session lives in its own DO instance, so an in-memory counter // would be per-session and trivially bypassed by opening more sessions). // -// Like the balance gate it FAILS OPEN: an unreachable counter DO, a missing +// Every organization on a plan other than Free is EXEMPT. The cap was sized +// for free-tier abuse but applied to everyone, and on 2026-08-18 it blocked a +// paying customer mid-workload — their agent gave up on Executor and routed +// around it. The first fix exempted only the plans sold today, and on +// 2026-09-03 that capped an org on a grandfathered plan. Non-free usage is +// what the balance gate and metered overage are for; this backstop has no +// business capping it. +// +// The exemption is resolved ONLY once the counter reports an org over the cap, +// so the common path (under the cap) costs the counter increment and nothing +// else. `isExempt` is an opaque predicate: this module still names no billing +// concept, and the Autumn coupling lives in `execution-stack-metered.ts`, +// which already owns that dependency. +// +// FAIL OPEN applies to the COUNTER: an unreachable counter DO, a missing // binding, or a slow call allows the execution (warn + Sentry). The backstop -// must never take executions down with it. +// must never take executions down with it. An unresolved EXEMPTION is the one +// thing that does not fail open — see `resolveExemption`. // --------------------------------------------------------------------------- import { DurableObject, env } from "cloudflare:workers"; -import { Data, Effect } from "effect"; +import { Data, Effect, Predicate } from "effect"; import type * as Cause from "effect/Cause"; import type { ExecutionEngine } from "@executor-js/execution"; @@ -25,14 +40,26 @@ import { RATE_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages"; // Fixed window: all executions in the same clock hour share one counter. export const RATE_LIMIT_WINDOW_MS = 3_600_000; -// Calibration: the heaviest legitimate org runs ~1.1k executions per MONTH, -// so 1000 per HOUR is far above any human-driven usage and only trips on -// runaway automation (the incident this backstops: ~18k in 30 days would -// still pass, which is fine — that class of overrun is the balance gate's -// job; this catches tight loops). -export const EXECUTIONS_PER_ORG_PER_HOUR = 1000; +// The cap for organizations WITHOUT a paid subscription. +// +// The original calibration ("the heaviest legitimate org runs ~1.1k executions +// per MONTH, so 1000 per HOUR is far above any human-driven usage") went stale +// inside six weeks: by 2026-08-18 a paying org was sustaining ~5.3k executions +// per DAY and crossed this cap in a single hour. Sizing a shared number +// against the largest customer is a losing game, so the number no longer tries +// to describe them — paid orgs are exempt below, and this now has only +// free-tier abuse to cover, which is what it was picked for. +export const EXECUTIONS_PER_ORG_PER_HOUR = 10_000; // Counter DO slower than this => fail open rather than stall executions. const RATE_LIMIT_CHECK_TIMEOUT_MS = 2_000; +// Exemption lookup slower than this => treat as unresolved. +const EXEMPTION_CHECK_TIMEOUT_MS = 2_000; +// An org over the cap is checked at most once per TTL rather than once per +// execution, so a paid org running far past it doesn't hammer the lookup. +const EXEMPTION_CACHE_TTL_MS = 60_000; +// Sweep guard, mirroring the balance gate's cache: one long-lived isolate can +// serve many orgs. +const EXEMPTION_CACHE_MAX_ENTRIES = 10_000; // The DO purges its storage this long after the last increment, so idle orgs // cost nothing. Two windows: long enough that an active window never purges. const COUNTER_PURGE_AFTER_MS = 2 * RATE_LIMIT_WINDOW_MS; @@ -51,6 +78,80 @@ class RateLimitCheckTimeoutError extends Data.TaggedError("RateLimitCheckTimeout readonly timeoutMs: number; }> {} +/** + * Why a counter DO call failed, as a small closed vocabulary. + * + * The counter's failures are overwhelmingly transient Cloudflare platform + * faults, and they used to arrive at error reporting as one untyped + * `UnknownError: An error occurred in Effect.tryPromise` with no application + * frames — a group that says nothing and would eventually swallow a real + * misconfiguration too. The code is what makes a storage reset (retryable, + * expected) distinguishable from an overload or an outright unknown fault. + */ +export type RateLimitCounterErrorCode = + | "storage_reset" + | "overloaded" + | "exceeded_memory" + | "network" + | "unknown"; + +/** A counter DO call that failed, carrying the classification and the org. */ +export class RateLimitCounterError extends Data.TaggedError("RateLimitCounterError")<{ + readonly organizationId: string; + readonly code: RateLimitCounterErrorCode; + readonly reason: string; + readonly cause: unknown; +}> {} + +// Cloudflare surfaces these as plain `Error`s with documented message text; +// there is no structured code to read, so the message is the only signal. +// (A shared `classifyDurableObjectError` would be the right home for this once +// one exists.) +const counterErrorCode = (reason: string): RateLimitCounterErrorCode => { + if (/caused object to be reset/i.test(reason)) return "storage_reset"; + if (/overloaded/i.test(reason)) return "overloaded"; + if (/exceeded (its )?memory|out of memory/i.test(reason)) return "exceeded_memory"; + if (/network connection lost|connection.*(lost|reset)/i.test(reason)) return "network"; + return "unknown"; +}; + +/** + * The fail-open landing: record the outcome on the check span, warn, and allow + * the execution. Only failures that are NOT deliberate degradation reach the + * error reporter — see the call sites in `decide`. + */ +const failOpen = ( + error: unknown, + outcome: { readonly errorTag: string; readonly timedOut: boolean }, +): Effect.Effect => + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "rate_limit.blocked": false, + "rate_limit.check.failed_open": true, + "rate_limit.check.timed_out": outcome.timedOut, + "rate_limit.check.error_tag": outcome.errorTag, + }); + yield* Effect.sync(() => { + console.warn("[rate-limit] execution rate limit check failed open:", error); + }); + if (!outcome.timedOut) yield* captureCauseEffect(error); + return { blocked: false } as const satisfies GateDecision; + }); + +/** Internal sentinel for an exemption lookup that exceeded its time budget. */ +class ExemptionCheckTimeoutError extends Data.TaggedError("ExemptionCheckTimeoutError")<{ + readonly timeoutMs: number; +}> {} + +/** + * Whether an organization is exempt from the cap. Production passes a paid- + * subscription check; keeping it an opaque predicate is what lets this module + * stay free of any billing import. + */ +export type ExecutionRateLimitExemption = ( + organizationId: string, +) => Effect.Effect; + // --------------------------------------------------------------------------- // Counter Durable Object — one instance per organization (idFromName(orgId)). // Stores a single { windowId, count } record: an increment in a new window @@ -67,6 +168,12 @@ type WindowRecord = { export class ExecutionRateLimiterDO extends DurableObject { private readonly counterStorage: DurableObjectState["storage"]; + /** + * The window this instance has already armed the purge alarm for. In-memory + * on purpose: it costs no storage read, and a fresh instance (eviction, cold + * start) simply re-arms on its first increment. + */ + private purgeArmedForWindow: number | null = null; constructor(ctx: DurableObjectState, doEnv: Env) { super(ctx, doEnv); @@ -80,11 +187,21 @@ export class ExecutionRateLimiterDO extends DurableObject { const stored = await this.counterStorage.get(WINDOW_RECORD_KEY); const count = stored && stored.windowId === windowId ? stored.count + 1 : 1; await this.counterStorage.put(WINDOW_RECORD_KEY, { windowId, count }); - await this.counterStorage.setAlarm(Date.now() + COUNTER_PURGE_AFTER_MS); + // The alarm only has to outlive the window, and it is set two windows out, + // so once per window is enough — rewriting it on every increment put a + // second durable write and an alarm-manager update on the hot path of + // every execution, with the input gate closed across all three. `count` + // back at 1 means the window rolled (or a purge already ran), so the + // deadline moves with it. + if (count === 1 || this.purgeArmedForWindow !== windowId) { + await this.counterStorage.setAlarm(Date.now() + COUNTER_PURGE_AFTER_MS); + this.purgeArmedForWindow = windowId; + } return count; } async alarm(): Promise { + this.purgeArmedForWindow = null; await this.counterStorage.deleteAll(); } } @@ -93,11 +210,17 @@ export class ExecutionRateLimiterDO extends DurableObject { // Client // --------------------------------------------------------------------------- -/** Count one execution for (organizationId, windowId); returns the new count. */ +/** + * Count one execution for (organizationId, windowId); returns the new count. + * + * The failure channel is typed rather than `unknown` so the fail-open path can + * tell a counter fault from a blown budget by tag, and so error reporting + * groups by cause instead of by one opaque `UnknownError`. + */ export type RateLimitIncrement = ( organizationId: string, windowId: number, -) => Effect.Effect; +) => Effect.Effect; export type ExecutionRateLimiter = { readonly decorate: ( @@ -119,12 +242,76 @@ export const makeExecutionRateLimiter = ( readonly windowMs?: number; readonly timeoutMs?: number; readonly now?: () => number; + readonly isExempt?: ExecutionRateLimitExemption; + readonly exemptionTtlMs?: number; }, ): ExecutionRateLimiter => { const limit = options?.limit ?? EXECUTIONS_PER_ORG_PER_HOUR; const windowMs = options?.windowMs ?? RATE_LIMIT_WINDOW_MS; const timeoutMs = options?.timeoutMs ?? RATE_LIMIT_CHECK_TIMEOUT_MS; const now = options?.now ?? Date.now; + const isExempt = options?.isExempt; + const exemptionTtlMs = options?.exemptionTtlMs ?? EXEMPTION_CACHE_TTL_MS; + + const exemptionCache = new Map< + string, + { readonly exempt: boolean; readonly expiresAtMs: number } + >([]); + + const writeExemptionCache = (organizationId: string, exempt: boolean, nowMs: number): void => { + if (exemptionCache.size >= EXEMPTION_CACHE_MAX_ENTRIES) { + for (const [key, entry] of exemptionCache) { + if (entry.expiresAtMs <= nowMs) exemptionCache.delete(key); + } + // Still saturated after dropping expired entries: reset rather than grow. + if (exemptionCache.size >= EXEMPTION_CACHE_MAX_ENTRIES) exemptionCache.clear(); + } + exemptionCache.set(organizationId, { exempt, expiresAtMs: nowMs + exemptionTtlMs }); + }; + + /** + * Resolved only for orgs already over the cap, so the lookup never touches + * the common path. + * + * This is the one place that does NOT fail open. The balance gate already + * allows executions when Autumn is unreachable; if the exemption did too, + * an Autumn outage would switch this backstop off entirely — precisely the + * "billing outage plus runaway automation" case it exists to cover. A stale + * positive is honoured ahead of that fallback, so a blip can't flip a + * known-paid org into a block mid-workload. + */ + const resolveExemption = (organizationId: string): Effect.Effect => + Effect.suspend(() => { + if (!isExempt) return Effect.succeed(false); + const nowMs = now(); + const cached = exemptionCache.get(organizationId); + if (cached && cached.expiresAtMs > nowMs) return Effect.succeed(cached.exempt); + return isExempt(organizationId).pipe( + Effect.timeoutOrElse({ + duration: `${EXEMPTION_CHECK_TIMEOUT_MS} millis`, + orElse: () => + Effect.fail(new ExemptionCheckTimeoutError({ timeoutMs: EXEMPTION_CHECK_TIMEOUT_MS })), + }), + Effect.map((exempt) => { + writeExemptionCache(organizationId, exempt, nowMs); + return exempt; + }), + Effect.catch((error: unknown) => + Effect.gen(function* () { + yield* Effect.sync(() => { + console.warn( + `[rate-limit] exemption lookup failed for ${organizationId}; treating as ${ + cached ? "last known" : "not exempt" + }:`, + error, + ); + }); + yield* captureCauseEffect(error); + return cached?.exempt ?? false; + }), + ), + ); + }); const decide = (organizationId: string): Effect.Effect => Effect.suspend(() => { @@ -134,29 +321,86 @@ export const makeExecutionRateLimiter = ( duration: `${timeoutMs} millis`, orElse: () => Effect.fail(new RateLimitCheckTimeoutError({ timeoutMs })), }), - Effect.map( - (count): GateDecision => - count > limit - ? { - blocked: true, - error: new ExecutionRateLimitExceededError({ - organizationId, - message: RATE_LIMIT_BLOCKED_MESSAGE, - }), - } - : { blocked: false }, - ), + Effect.flatMap((count): Effect.Effect => { + // Under the cap: no exemption lookup, no extra I/O. + if (count <= limit) + return Effect.as( + Effect.annotateCurrentSpan({ + "rate_limit.count": count, + "rate_limit.blocked": false, + "rate_limit.check.failed_open": false, + }), + { blocked: false }, + ); + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "rate_limit.count": count, + "rate_limit.check.failed_open": false, + }); + if (yield* resolveExemption(organizationId)) { + yield* Effect.annotateCurrentSpan({ + "rate_limit.blocked": false, + "rate_limit.exempt": true, + }); + return { blocked: false } as const satisfies GateDecision; + } + // The only record that the backstop fired. A blocked execution is + // never usage-tracked (the gate short-circuits before the tracker) + // and deliberately not sent to Sentry — a backstop stopping + // runaway automation is expected, not exceptional — so without + // this line a blocked org is invisible outside a bug report, which + // is how the 2026-08-18 block went unnoticed until a customer + // sent a screenshot. + yield* Effect.sync(() => { + console.warn( + `[rate-limit] blocked execution for ${organizationId}: ${count} > ${limit} in window ${windowId}`, + ); + }); + yield* Effect.annotateCurrentSpan({ + "rate_limit.blocked": true, + "rate_limit.exempt": false, + }); + return { + blocked: true, + error: new ExecutionRateLimitExceededError({ + organizationId, + message: RATE_LIMIT_BLOCKED_MESSAGE, + }), + } as const satisfies GateDecision; + }); + }), // FAIL OPEN: the backstop must never block executions because its // counter is unreachable or slow. + // + // A check that blew its own budget is DELIBERATE degradation, not an + // exception — the timeout exists precisely so a slow counter can't + // stall a user-facing execution. It is measured on the span + // (`rate_limit.check.timed_out`), where a step change in the rate is + // alertable, rather than paged per occurrence, which is what buried + // real counter failures under one opaque group. Everything else (RPC + // faults, a missing binding) still reports. + Effect.catchTag("RateLimitCheckTimeoutError", (error) => + failOpen(error, { errorTag: "RateLimitCheckTimeoutError", timedOut: true }), + ), + // A catch-all rather than a second `catchTag`: fail-open is a hard + // requirement and must not depend on the failure being one the types + // predicted. Effect.catch((error: unknown) => - Effect.gen(function* () { - yield* Effect.sync(() => { - console.warn("[rate-limit] execution rate limit check failed open:", error); - }); - yield* captureCauseEffect(error); - return { blocked: false } as const satisfies GateDecision; + failOpen(error, { + errorTag: Predicate.isTagged(error, "RateLimitCounterError") + ? "RateLimitCounterError" + : "unknown", + timedOut: false, }), ), + Effect.withSpan("rate_limit.check", { + attributes: { + "rate_limit.organization_id": organizationId, + "rate_limit.window_id": windowId, + "rate_limit.limit": limit, + "rate_limit.check.timeout_ms": timeoutMs, + }, + }), ); }); @@ -185,8 +429,13 @@ type RateLimiterNamespace = { * Production rate limiter backed by the `EXECUTION_RATE_LIMITER` counter DO. * When the binding is absent (unit-test workers, older local setups) the * limiter is disabled: every check passes, logged once at construction. + * + * `isExempt` decides which orgs the cap skips; production passes a paid- + * subscription check from `execution-stack-metered.ts`. */ -export const makeCloudExecutionRateLimiter = (): ExecutionRateLimiter => { +export const makeCloudExecutionRateLimiter = ( + isExempt: ExecutionRateLimitExemption, +): ExecutionRateLimiter => { const limit = resolveRateLimit(); const namespace = (env as { EXECUTION_RATE_LIMITER?: RateLimiterNamespace }) .EXECUTION_RATE_LIMITER; @@ -196,17 +445,53 @@ export const makeCloudExecutionRateLimiter = (): ExecutionRateLimiter => { ); return makeExecutionRateLimiter(() => Effect.succeed(0)); } - return makeExecutionRateLimiter( - (organizationId, windowId) => - Effect.tryPromise(() => { + return makeExecutionRateLimiter(counterIncrement(namespace), { + limit, + timeoutMs: resolveCheckTimeoutMs(), + isExempt, + }); +}; + +/** + * The counter DO RPC, as a traced and typed increment. + * + * The span is the whole point: this call is a blocking, cold-startable hop on + * the execute hot path, and until it had one nothing about its duration was + * measurable — the only evidence it was slow was the fail-open warning 2s + * later. The typed error replaces Effect's generic `UnknownError`, which + * reported no org, no window, and no hint that a Durable Object was involved. + */ +const counterIncrement = + (namespace: RateLimiterNamespace): RateLimitIncrement => + (organizationId, windowId) => + Effect.tryPromise({ + try: () => { const stub = namespace.get( namespace.idFromName(organizationId), ) as ExecutionRateLimiterStub; return stub.increment(windowId); + }, + catch: (cause) => { + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: the Durable Object RPC rejects with a plain platform Error whose message text is the only classification signal Cloudflare gives + const reason = cause instanceof Error ? cause.message : String(cause); + return new RateLimitCounterError({ + organizationId, + code: counterErrorCode(reason), + reason, + cause, + }); + }, + }).pipe( + Effect.tapError((error) => + Effect.annotateCurrentSpan({ + "rate_limit.counter.error_tag": "RateLimitCounterError", + "rate_limit.counter.error_code": error.code, + }), + ), + Effect.withSpan("rate_limit.increment", { + attributes: { "rate_limit.window_id": windowId }, }), - { limit }, - ); -}; + ); /** * The per-org hourly cap: the `EXECUTION_RATE_LIMIT_PER_HOUR` env override @@ -220,3 +505,18 @@ const resolveRateLimit = (): number => { const parsed = Number.parseInt(raw, 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : EXECUTIONS_PER_ORG_PER_HOUR; }; + +/** + * The counter's time budget: the `EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS` env + * override or `RATE_LIMIT_CHECK_TIMEOUT_MS` when it's unset or unparseable. + * Same precedent and same purpose as `EXECUTION_RATE_LIMIT_PER_HOUR`: the + * production 2s budget can't be blown on demand, so tests set a tiny one to + * drive the fail-open path deterministically. Production leaves it unset. + */ +const resolveCheckTimeoutMs = (): number => { + const raw = (env as { EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS?: string }) + .EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS; + if (raw === undefined) return RATE_LIMIT_CHECK_TIMEOUT_MS; + const parsed = Number.parseInt(raw, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : RATE_LIMIT_CHECK_TIMEOUT_MS; +}; diff --git a/apps/cloud/src/engine/execution-stack-metered.ts b/apps/cloud/src/engine/execution-stack-metered.ts index 4ea70b6d43..f3a66dd228 100644 --- a/apps/cloud/src/engine/execution-stack-metered.ts +++ b/apps/cloud/src/engine/execution-stack-metered.ts @@ -26,6 +26,7 @@ import { } from "@executor-js/api/server"; import { AutumnService } from "../extensions/billing/service"; +import { hasNonFreeOrganizationSubscription } from "../extensions/billing/plans"; import type { DbService } from "../db/db"; import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; import { makeExecutionLimitGate } from "./execution-gate"; @@ -34,7 +35,8 @@ import { withExecutionUsageTracking } from "./execution-usage"; // Usage-metering decorator bound to the billing service, plus the two // pre-execution guards this layer owns, ordered cheapest first: -// 1. rate-limit backstop (counter DO, independent of billing) +// 1. rate-limit backstop (counter DO; free-tier abuse only — any org on a +// plan other than Free is exempt via the subscription lookup below) // 2. execution balance gate (Autumn check, cached 60s, fails open) // 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing // call can't stall a user-facing execution. @@ -48,7 +50,19 @@ export const CloudMeteringEngineDecorator: Layer.Layer autumn.checkExecutionBalance(organizationId), ); - const rateLimiter = makeCloudExecutionRateLimiter(); + // The limiter's exemption. This is the billing coupling the limiter + // module deliberately avoids owning. Any active subscription other than + // Free exempts the org: the backstop is for free-tier abuse, and the + // narrower "is on a plan we sell today" predicate the org-creation gate + // uses capped grandfathered and pay-as-you-go customers. The limiter + // calls this only for orgs already over the cap and caches the answer, + // so the extra Autumn round trip stays off the hot path. + const rateLimiter = makeCloudExecutionRateLimiter((organizationId) => + Effect.map( + autumn.use((client) => client.customers.getOrCreate({ customerId: organizationId })), + (customer) => hasNonFreeOrganizationSubscription(customer.subscriptions), + ), + ); return { decorate: (engine, identity: EngineStackIdentity) => rateLimiter.decorate( diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index deba6b7e4d..ef13ed5636 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -32,7 +32,7 @@ // seams module; the decorator is composed on top. // --------------------------------------------------------------------------- -import { env } from "cloudflare:workers"; +import { env, waitUntil } from "cloudflare:workers"; import { Layer } from "effect"; import { @@ -43,11 +43,13 @@ import { collectTables, } from "@executor-js/api/server"; import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; -import type { AnyPlugin } from "@executor-js/sdk"; +import { type AnyPlugin } from "@executor-js/sdk"; import executorConfig from "../../executor.config"; +import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout"; import { DbService } from "../db/db"; import { cloudDbProviderLayer } from "../db/fuma"; +import { firstPartyOAuthClientsFor } from "./first-party-oauth-clients"; export { makeExecutionStack } from "@executor-js/api/server"; @@ -62,9 +64,6 @@ const cloudPluginFactory = executorConfig.plugins as (deps: { readonly apiUrl?: string; }; readonly activeToolkitSlug?: string; - readonly allowLocalNetwork?: boolean; - readonly workerLoader?: WorkerLoader; - readonly workerAssets?: { readonly fetch: (request: Request) => Promise }; }) => readonly AnyPlugin[]; // Fresh plugin instances per request, carrying the Worker env's WorkOS Vault @@ -79,9 +78,6 @@ export const CloudPluginsProvider: Layer.Layer = Layer.succeed( }, activeToolkitSlug: context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined, - allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", - workerLoader: env.LOADER, - workerAssets: env.ASSETS, }), }); @@ -105,6 +101,16 @@ export const CloudHostConfig: Layer.Layer = Layer.sync(HostConfig, ( // WorkOS Vault is cloud's credential storage implementation detail, not a // user-selectable provider surface. exposeCredentialProviders: false, + firstPartyOAuthClients: firstPartyOAuthClientsFor(env), + // Workers cancel request-scoped I/O once the response settles; the ambient + // `waitUntil` binds to the in-flight invocation (HTTP request or DO call), + // so stale tool-catalog rebuilds that outlive a read still converge. + waitUntil, + // Enterprise-managed authorization ships behind a PostHog flag. Cloud is the + // one host with a flag service, so cloud is the one host that installs a + // gate; everywhere else the seam stays empty and the profile is attempted as + // before. Gating happens at connect only — see the SDK contract. + enterpriseManagedRollout: cloudEnterpriseManagedRollout(), })); export const CloudCodeExecutorProvider: Layer.Layer = Layer.sync( diff --git a/apps/cloud/src/engine/execution-usage.ts b/apps/cloud/src/engine/execution-usage.ts index 50ba3fc345..d5b2bc8e99 100644 --- a/apps/cloud/src/engine/execution-usage.ts +++ b/apps/cloud/src/engine/execution-usage.ts @@ -12,14 +12,20 @@ export const withExecutionUsageTracking = ( engine .execute(code, options) .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), - executeWithPause: (code) => + // `options` MUST be forwarded: it carries `autoApprove`, and dropping it + // silently turned every auto-approved execution back into a paused one. + executeWithPause: (code, options) => engine - .executeWithPause(code) + .executeWithPause(code, options) .pipe(Effect.tap(() => Effect.sync(() => trackUsage(organizationId)))), // resume doesn't count as usage resume: (executionId, response) => engine.resume(executionId, response), + isExecutionSettled: engine.isExecutionSettled, getPausedExecution: (executionId) => engine.getPausedExecution(executionId), pausedExecutionCount: () => engine.pausedExecutionCount(), hasPausedExecutions: () => engine.hasPausedExecutions(), getDescription: engine.getDescription, + // Forwarded, not re-implemented: the wrapped engine owns the sandbox fibers, + // so the host's request-scope teardown has to reach through this decorator. + shutdown: engine.shutdown, }); diff --git a/apps/cloud/src/engine/first-party-oauth-clients.test.ts b/apps/cloud/src/engine/first-party-oauth-clients.test.ts new file mode 100644 index 0000000000..1ca3962d6b --- /dev/null +++ b/apps/cloud/src/engine/first-party-oauth-clients.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + firstPartyOAuthClientsFor, + type FirstPartyOAuthClientEnv, +} from "./first-party-oauth-clients"; + +const completeEnv: FirstPartyOAuthClientEnv = { + FIRST_PARTY_AIRTABLE_CLIENT_ID: "airtable-id", + FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "airtable-secret", + FIRST_PARTY_ATLASSIAN_CLIENT_ID: "atlassian-id", + FIRST_PARTY_ATLASSIAN_CLIENT_SECRET: "atlassian-secret", + FIRST_PARTY_BOX_CLIENT_ID: "box-id", + FIRST_PARTY_BOX_CLIENT_SECRET: "box-secret", + FIRST_PARTY_CLICKUP_CLIENT_ID: "clickup-id", + FIRST_PARTY_CLICKUP_CLIENT_SECRET: "clickup-secret", + FIRST_PARTY_FIGMA_CLIENT_ID: "figma-id", + FIRST_PARTY_FIGMA_CLIENT_SECRET: "figma-secret", + FIRST_PARTY_GITHUB_CLIENT_ID: "github-id", + FIRST_PARTY_GITHUB_CLIENT_SECRET: "github-secret", + FIRST_PARTY_GITLAB_CLIENT_ID: "gitlab-id", + FIRST_PARTY_GITLAB_CLIENT_SECRET: "gitlab-secret", + FIRST_PARTY_GOOGLE_CLIENT_ID: "google-id", + FIRST_PARTY_GOOGLE_CLIENT_SECRET: "google-secret", + FIRST_PARTY_HUBSPOT_CLIENT_ID: "hubspot-id", + FIRST_PARTY_HUBSPOT_CLIENT_SECRET: "hubspot-secret", + FIRST_PARTY_LINEAR_CLIENT_ID: "linear-id", + FIRST_PARTY_LINEAR_CLIENT_SECRET: "linear-secret", + FIRST_PARTY_MICROSOFT_CLIENT_ID: "microsoft-id", + FIRST_PARTY_MICROSOFT_CLIENT_SECRET: "microsoft-secret", + FIRST_PARTY_NOTION_CLIENT_ID: "notion-id", + FIRST_PARTY_NOTION_CLIENT_SECRET: "notion-secret", + FIRST_PARTY_SLACK_CLIENT_ID: "slack-id", + FIRST_PARTY_SLACK_CLIENT_SECRET: "slack-secret", +}; + +describe("cloud first-party OAuth clients", () => { + it("enables every registered OAuth 2 provider from complete secret pairs", () => { + const clients = firstPartyOAuthClientsFor(completeEnv); + + expect(clients.map((client) => client.name)).toEqual([ + "airtable", + "atlassian", + "box", + "clickup", + "figma", + "github", + "gitlab", + "google", + "hubspot", + "linear", + "microsoft", + "notion", + "slack", + ]); + }); + + it("fails closed when either half of a provider secret pair is absent", () => { + expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_ID: "id" })).toEqual([]); + expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "secret" })).toEqual([]); + }); + + it("carries provider-specific authorization and token contracts", () => { + const byName = new Map( + firstPartyOAuthClientsFor(completeEnv).map((client) => [client.name, client]), + ); + + expect(byName.get("airtable")).toMatchObject({ + tokenEndpointAuthMethod: "basic", + }); + expect(byName.get("atlassian")).toMatchObject({ + tokenRequestFormat: "json", + authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" }, + }); + expect(byName.get("figma")).toMatchObject({ + tokenEndpointAuthMethod: "basic", + allowedScopes: expect.arrayContaining(["folder_metadata:read", "folders:read"]), + }); + expect(byName.get("hubspot")).toMatchObject({ + tokenUrl: "https://api.hubapi.com/oauth/v3/token", + authorizationExtraParams: { + optional_scope: "content crm.objects.custom.read crm.schemas.custom.read", + }, + }); + expect(byName.get("linear")).toMatchObject({ authorizationScopeSeparator: "," }); + expect(byName.get("microsoft")).toMatchObject({ + additionalAuthorizationScopes: ["offline_access"], + allowedScopes: expect.arrayContaining(["Mail.ReadWrite", "Files.ReadWrite.All"]), + }); + expect(byName.get("notion")).toMatchObject({ + authorizationScopes: [], + authorizationExtraParams: { owner: "user" }, + tokenEndpointAuthMethod: "basic", + tokenRequestFormat: "json", + }); + }); +}); + +// The reviewed consumer scope boundary of the Executor-owned Google app. +// +// These assertions used to live in `e2e/scenarios/first-party-oauth.test.ts`, +// read off `listClients`. The app is now gated, so it has no public read surface +// to introspect — the bundle is only observable on the config it is built from, +// which is here. The e2e still owns the BEHAVIOUR the boundary produces (which +// scopes an `oauth.start` requests, and that admin scopes are refused). +const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`; + +describe("cloud first-party Google app", () => { + const google = () => + firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google"); + + it("declares the Google app but withholds it without a configured rollout", async () => { + const client = google(); + expect(client, "the env-declared first-party Google app is configured").toBeDefined(); + // The entry MUST stay declared: `loadClient` resolves it by slug for every + // existing connection's refresh and reconnect. The listing policy stops it + // being offered for new connections. + expect(client?.isListed).toBeDefined(); + if (client?.isListed === undefined) return; + expect( + await Effect.runPromise(client.isListed({ userId: "test-user", organizationId: "test-org" })), + ).toBe(false); + }); + + it("covers the reviewed consumer bundle", () => { + const allowed = google()?.allowedScopes; + expect(allowed).toBeDefined(); + for (const scope of [ + "calendar", + "meetings.space.readonly", + "spreadsheets", + "drive.file", + "drive", + "documents", + "presentations", + "forms.body", + "forms.responses.readonly", + "tasks", + "contacts", + "contacts.other.readonly", + "directory.readonly", + "user.addresses.read", + "user.birthday.read", + "user.emails.read", + "user.gender.read", + "user.organization.read", + "user.phonenumbers.read", + "photoslibrary.appendonly", + "photoslibrary.edit.appcreateddata", + "photospicker.mediaitems.readonly", + "webmasters", + "gmail.settings.basic", + ]) { + expect(allowed).toContain(GOOGLE_SCOPE(scope)); + } + // `gmail.modify` stays in the host-enforced allowlist on purpose: a + // connection created before the full-Gmail review still declares it, and + // `resolveFirstPartyScopes` filters discovered scopes through this list, so + // dropping it would break those reconnects — as the legacy-spec case in the + // e2e asserts. The invariant that new Gmail presets request + // `mail.google.com` instead lives in the preset unit tests + // (packages/plugins/openapi/.../presets.test.ts), which is where the + // request-side scope choice is actually decided. + expect(allowed).toContain("https://mail.google.com/"); + expect(allowed).toContain(GOOGLE_SCOPE("gmail.modify")); + }); + + it("excludes the scopes held back from consumer review", () => { + const allowed = google()?.allowedScopes; + expect(allowed).toBeDefined(); + for (const scope of [ + "gmail.settings.sharing", + "admin.directory.user", + "youtube", + "cloud-platform", + ]) { + expect(allowed).not.toContain(GOOGLE_SCOPE(scope)); + } + }); +}); diff --git a/apps/cloud/src/engine/first-party-oauth-clients.ts b/apps/cloud/src/engine/first-party-oauth-clients.ts new file mode 100644 index 0000000000..9c646e5d58 --- /dev/null +++ b/apps/cloud/src/engine/first-party-oauth-clients.ts @@ -0,0 +1,342 @@ +import { FIGMA_SUPPORTED_OAUTH_SCOPES } from "@executor-js/plugin-openapi/presets"; +import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google"; +import { + MICROSOFT_AUTHORIZATION_URL, + MICROSOFT_TOKEN_URL, +} from "@executor-js/plugin-openapi/providers/microsoft"; +import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; +import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk"; + +import { makeGoogleOAuthListing } from "../analytics/google-oauth-listing"; +import { POSTHOG_INGEST_HOST } from "../edge/passthrough"; + +/** Cloud secret bindings that enable host-operated OAuth clients. A provider + * is absent unless both values in its pair are present. */ +export interface FirstPartyOAuthClientEnv { + readonly VITE_PUBLIC_POSTHOG_KEY?: string; + readonly VITE_PUBLIC_POSTHOG_HOST?: string; + readonly FIRST_PARTY_AIRTABLE_CLIENT_ID?: string; + readonly FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string; + readonly FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string; + readonly FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string; + readonly FIRST_PARTY_BOX_CLIENT_ID?: string; + readonly FIRST_PARTY_BOX_CLIENT_SECRET?: string; + readonly FIRST_PARTY_CLICKUP_CLIENT_ID?: string; + readonly FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string; + readonly FIRST_PARTY_FIGMA_CLIENT_ID?: string; + readonly FIRST_PARTY_FIGMA_CLIENT_SECRET?: string; + readonly FIRST_PARTY_GITHUB_CLIENT_ID?: string; + readonly FIRST_PARTY_GITHUB_CLIENT_SECRET?: string; + readonly FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string; + readonly FIRST_PARTY_GITHUB_TOKEN_URL?: string; + readonly FIRST_PARTY_GITLAB_CLIENT_ID?: string; + readonly FIRST_PARTY_GITLAB_CLIENT_SECRET?: string; + readonly FIRST_PARTY_GOOGLE_CLIENT_ID?: string; + readonly FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string; + readonly FIRST_PARTY_HUBSPOT_CLIENT_ID?: string; + readonly FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string; + readonly FIRST_PARTY_LINEAR_CLIENT_ID?: string; + readonly FIRST_PARTY_LINEAR_CLIENT_SECRET?: string; + readonly FIRST_PARTY_MICROSOFT_CLIENT_ID?: string; + readonly FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string; + readonly FIRST_PARTY_NOTION_CLIENT_ID?: string; + readonly FIRST_PARTY_NOTION_CLIENT_SECRET?: string; + readonly FIRST_PARTY_SLACK_CLIENT_ID?: string; + readonly FIRST_PARTY_SLACK_CLIENT_SECRET?: string; +} + +const AIRTABLE_SCOPES = [ + "data.recordComments:read", + "data.recordComments:write", + "data.records:read", + "data.records:write", + "data.records:manage", + "schema.bases:read", + "schema.bases:write", + "user.email:read", + "workspacesAndBases:read", + "workspacesAndBases:write", + "workspacesAndBases.shares:manage", + "webhook:manage", +] as const; + +const ATLASSIAN_SCOPES = [ + "read:me", + "read:account", + "read:jira-work", + "manage:jira-project", + "manage:jira-configuration", + "read:jira-user", + "write:jira-work", + "manage:jira-webhook", + "read:servicedesk-request", + "manage:servicedesk-customer", + "write:servicedesk-request", + "write:confluence-content", + "read:confluence-space.summary", + "write:confluence-space", + "write:confluence-file", + "read:confluence-props", + "write:confluence-props", + "manage:confluence-configuration", + "read:confluence-content.all", + "read:confluence-content.summary", + "search:confluence", + "read:confluence-content.permission", + "read:confluence-user", + "read:confluence-groups", + "write:confluence-groups", + "offline_access", +] as const; + +const BOX_SCOPES = [ + "root_readonly", + "root_readwrite", + "sign_requests.readwrite", + "ai.readwrite", + "manage_webhook", + "manage_triggers", +] as const; + +const GITLAB_SCOPES = [ + "api", + "read_api", + "read_user", + "create_runner", + "manage_runner", + "k8s_proxy", + "mcp", + "mcp_orbit", + "read_repository", + "write_repository", + "read_registry", + "write_registry", + "read_virtual_registry", + "write_virtual_registry", + "read_observability", + "write_observability", + "ai_features", + "openid", + "profile", + "email", +] as const; + +const HUBSPOT_REQUIRED_SCOPES = [ + "oauth", + "account-info.security.read", + "cms.domains.read", + "cms.domains.write", + "crm.export", + "crm.import", + "crm.lists.read", + "crm.lists.write", + "crm.objects.companies.read", + "crm.objects.companies.write", + "crm.objects.contacts.read", + "crm.objects.contacts.write", + "crm.objects.deals.read", + "crm.objects.deals.write", + "crm.objects.marketing_events.read", + "crm.objects.marketing_events.write", + "crm.objects.owners.read", + "crm.objects.quotes.read", + "crm.objects.quotes.write", + "crm.schemas.companies.read", + "crm.schemas.companies.write", + "crm.schemas.contacts.read", + "crm.schemas.contacts.write", + "sales-email-read", + "settings.users.read", + "settings.users.write", + "tickets", + "timeline", +] as const; + +const HUBSPOT_OPTIONAL_SCOPES = [ + "content", + "crm.objects.custom.read", + "crm.schemas.custom.read", +] as const; + +const MICROSOFT_SCOPES = [ + "User.Read", + "Calendars.ReadWrite", + "Channel.ReadBasic.All", + "ChannelMessage.Read.All", + "ChannelMessage.Send", + "Chat.ReadWrite", + "Files.ReadWrite.All", + "Mail.ReadWrite", + "Mail.Send", + "MailboxSettings.ReadWrite", + "OnlineMeetings.ReadWrite", + "Sites.ReadWrite.All", + "Team.ReadBasic.All", + "offline_access", +] as const; + +// Consumer Google launch boundary. Keep this list aligned with the scopes +// submitted for the Executor-owned production app: ordinary Workspace services +// plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script, +// BigQuery, and Cloud Resource Manager have materially different audiences or +// provider requirements and remain BYO OAuth. The same scope source builds each +// catalog auth template, preventing picker/start drift. +const GOOGLE_FIRST_PARTY_PRESET_IDS = [ + "google-calendar", + "google-meet", + "google-gmail", + "google-sheets", + "google-drive", + "google-docs", + "google-slides", + "google-forms", + "google-tasks", + "google-people", + "google-photos-library", + "google-photos-picker", + "google-search-console", +] as const; + +const GOOGLE_ALLOWED_SCOPES: readonly string[] = [ + ...new Set([ + ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset), + // Connections created before the full-Gmail review retain this declared + // scope on reconnect. New Gmail presets request `mail.google.com`. + "https://www.googleapis.com/auth/gmail.modify", + ]), +]; + +const client = ( + clientId: string | undefined, + clientSecret: string | undefined, + config: Omit, +): readonly FirstPartyOAuthClientConfig[] => + clientId && clientSecret ? [{ ...config, clientId, clientSecret }] : []; + +/** Build the enabled first-party registry from secret bindings. Provider + * protocol details and scope ceilings live here so the cloud composition root + * cannot drift from the registered production clients. + * + * Each provider-side registration must list + * `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug + * travels inside OAuth `state`, so the single static callback serves every + * org. + * + * The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` / + * `_TOKEN_URL` overrides exist so tests and dev instances can point the app at + * an emulated provider (`@executor-js/emulate`) and run the complete flow. + * Production leaves them unset. */ +export const firstPartyOAuthClientsFor = ( + env: FirstPartyOAuthClientEnv, +): readonly FirstPartyOAuthClientConfig[] => [ + ...client(env.FIRST_PARTY_AIRTABLE_CLIENT_ID, env.FIRST_PARTY_AIRTABLE_CLIENT_SECRET, { + name: "airtable", + authorizationUrl: "https://airtable.com/oauth2/v1/authorize", + tokenUrl: "https://airtable.com/oauth2/v1/token", + tokenEndpointAuthMethod: "basic", + authorizationScopes: AIRTABLE_SCOPES, + allowedScopes: AIRTABLE_SCOPES, + }), + ...client(env.FIRST_PARTY_ATLASSIAN_CLIENT_ID, env.FIRST_PARTY_ATLASSIAN_CLIENT_SECRET, { + name: "atlassian", + authorizationUrl: "https://auth.atlassian.com/authorize", + tokenUrl: "https://auth.atlassian.com/oauth/token", + tokenRequestFormat: "json", + authorizationScopes: ATLASSIAN_SCOPES, + allowedScopes: ATLASSIAN_SCOPES, + authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" }, + }), + ...client(env.FIRST_PARTY_BOX_CLIENT_ID, env.FIRST_PARTY_BOX_CLIENT_SECRET, { + name: "box", + authorizationUrl: "https://account.box.com/api/oauth2/authorize", + tokenUrl: "https://api.box.com/oauth2/token", + authorizationScopes: BOX_SCOPES, + allowedScopes: BOX_SCOPES, + }), + ...client(env.FIRST_PARTY_CLICKUP_CLIENT_ID, env.FIRST_PARTY_CLICKUP_CLIENT_SECRET, { + name: "clickup", + authorizationUrl: "https://app.clickup.com/api", + tokenUrl: "https://api.clickup.com/api/v2/oauth/token", + tokenRequestFormat: "json", + authorizationScopes: [], + }), + ...client(env.FIRST_PARTY_FIGMA_CLIENT_ID, env.FIRST_PARTY_FIGMA_CLIENT_SECRET, { + name: "figma", + authorizationUrl: "https://www.figma.com/oauth", + tokenUrl: "https://api.figma.com/v1/oauth/token", + tokenEndpointAuthMethod: "basic", + integrations: [IntegrationSlug.make("figma_api")], + authorizationScopes: FIGMA_SUPPORTED_OAUTH_SCOPES, + allowedScopes: FIGMA_SUPPORTED_OAUTH_SCOPES, + }), + ...client(env.FIRST_PARTY_GITHUB_CLIENT_ID, env.FIRST_PARTY_GITHUB_CLIENT_SECRET, { + name: "github", + authorizationUrl: + env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize", + tokenUrl: env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token", + integrations: [IntegrationSlug.make("github_rest")], + // GitHub App user access tokens do not use classic OAuth scopes; their + // capabilities come from the app's registered permissions. + authorizationScopes: [], + }), + ...client(env.FIRST_PARTY_GITLAB_CLIENT_ID, env.FIRST_PARTY_GITLAB_CLIENT_SECRET, { + name: "gitlab", + authorizationUrl: "https://gitlab.com/oauth/authorize", + tokenUrl: "https://gitlab.com/oauth/token", + authorizationScopes: GITLAB_SCOPES, + allowedScopes: GITLAB_SCOPES, + }), + ...client(env.FIRST_PARTY_GOOGLE_CLIENT_ID, env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, { + name: "google", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + allowedScopes: GOOGLE_ALLOWED_SCOPES, + // Offer the app only to the review rollout. Resolution remains available + // for existing connections regardless of the current listing decision. + isListed: makeGoogleOAuthListing({ + projectKey: env.VITE_PUBLIC_POSTHOG_KEY, + host: env.VITE_PUBLIC_POSTHOG_HOST ?? `https://${POSTHOG_INGEST_HOST}`, + fetch: (input, init) => globalThis.fetch(input, init), + }), + }), + ...client(env.FIRST_PARTY_HUBSPOT_CLIENT_ID, env.FIRST_PARTY_HUBSPOT_CLIENT_SECRET, { + name: "hubspot", + authorizationUrl: "https://app.hubspot.com/oauth/authorize", + tokenUrl: "https://api.hubapi.com/oauth/v3/token", + authorizationScopes: HUBSPOT_REQUIRED_SCOPES, + allowedScopes: [...HUBSPOT_REQUIRED_SCOPES, ...HUBSPOT_OPTIONAL_SCOPES], + authorizationExtraParams: { optional_scope: HUBSPOT_OPTIONAL_SCOPES.join(" ") }, + }), + ...client(env.FIRST_PARTY_LINEAR_CLIENT_ID, env.FIRST_PARTY_LINEAR_CLIENT_SECRET, { + name: "linear", + authorizationUrl: "https://linear.app/oauth/authorize", + tokenUrl: "https://api.linear.app/oauth/token", + authorizationScopes: ["read", "write"], + authorizationScopeSeparator: ",", + allowedScopes: ["read", "write"], + }), + ...client(env.FIRST_PARTY_MICROSOFT_CLIENT_ID, env.FIRST_PARTY_MICROSOFT_CLIENT_SECRET, { + name: "microsoft", + authorizationUrl: MICROSOFT_AUTHORIZATION_URL, + tokenUrl: MICROSOFT_TOKEN_URL, + allowedScopes: MICROSOFT_SCOPES, + additionalAuthorizationScopes: ["offline_access"], + }), + ...client(env.FIRST_PARTY_NOTION_CLIENT_ID, env.FIRST_PARTY_NOTION_CLIENT_SECRET, { + name: "notion", + authorizationUrl: "https://api.notion.com/v1/oauth/authorize", + tokenUrl: "https://api.notion.com/v1/oauth/token", + authorizationExtraParams: { owner: "user" }, + tokenEndpointAuthMethod: "basic", + tokenRequestFormat: "json", + authorizationScopes: [], + }), + ...client(env.FIRST_PARTY_SLACK_CLIENT_ID, env.FIRST_PARTY_SLACK_CLIENT_SECRET, { + name: "slack", + authorizationUrl: "https://slack.com/oauth/v2_user/authorize", + tokenUrl: "https://slack.com/api/oauth.v2.user.access", + resource: "https://mcp.slack.com", + integrations: [IntegrationSlug.make("slack")], + allowedScopes: slackMcpUserScopes, + }), +]; diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 4ef130bfa6..773ca4d468 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -6,6 +6,13 @@ declare global { namespace Cloudflare { interface Env { // Observability + // Worker version metadata binding (wrangler.jsonc `version_metadata`). + // Optional so test workers and local setups without the binding still + // typecheck; spans then carry the "dev" service.version default. + CF_VERSION_METADATA?: WorkerVersionMetadata; + // Commit that produced the running deploy, passed by CI as + // `wrangler deploy --var GIT_COMMIT_SHA:$GITHUB_SHA`. Absent outside CI. + GIT_COMMIT_SHA?: string; AXIOM_TOKEN?: string; AXIOM_DATASET?: string; AXIOM_TRACES_URL?: string; @@ -53,6 +60,50 @@ declare global { // number to drive the backstop. Production leaves it unset. EXECUTION_RATE_LIMIT_PER_HOUR?: string; + // Optional override for the counter DO's check budget in milliseconds + // (defaults to RATE_LIMIT_CHECK_TIMEOUT_MS = 2000 when unset or + // unparseable). Same purpose as the cap override: the production budget + // can't be blown on demand, so tests set a tiny one to exercise the + // fail-open path. Production leaves it unset. + EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS?: string; + + // First-party OAuth apps (executor-owned provider registrations). Each + // pair enables one-click connect through `first-party:`; an + // unset pair simply ships no first-party app for that provider. The + // registered callback on the provider side must be + // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`. + FIRST_PARTY_AIRTABLE_CLIENT_ID?: string; + FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string; + FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string; + FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string; + FIRST_PARTY_BOX_CLIENT_ID?: string; + FIRST_PARTY_BOX_CLIENT_SECRET?: string; + FIRST_PARTY_CLICKUP_CLIENT_ID?: string; + FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string; + FIRST_PARTY_FIGMA_CLIENT_ID?: string; + FIRST_PARTY_FIGMA_CLIENT_SECRET?: string; + FIRST_PARTY_GITHUB_CLIENT_ID?: string; + FIRST_PARTY_GITHUB_CLIENT_SECRET?: string; + // Endpoint overrides for the GitHub first-party app, so tests/dev can + // point it at an emulated provider and complete the whole flow. Unset in + // production (the real github.com endpoints are the defaults). + FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string; + FIRST_PARTY_GITHUB_TOKEN_URL?: string; + FIRST_PARTY_GITLAB_CLIENT_ID?: string; + FIRST_PARTY_GITLAB_CLIENT_SECRET?: string; + FIRST_PARTY_GOOGLE_CLIENT_ID?: string; + FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string; + FIRST_PARTY_HUBSPOT_CLIENT_ID?: string; + FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string; + FIRST_PARTY_LINEAR_CLIENT_ID?: string; + FIRST_PARTY_LINEAR_CLIENT_SECRET?: string; + FIRST_PARTY_MICROSOFT_CLIENT_ID?: string; + FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string; + FIRST_PARTY_NOTION_CLIENT_ID?: string; + FIRST_PARTY_NOTION_CLIENT_SECRET?: string; + FIRST_PARTY_SLACK_CLIENT_ID?: string; + FIRST_PARTY_SLACK_CLIENT_SECRET?: string; + // Billing AUTUMN_SECRET_KEY?: string; /** Optional Autumn base-URL override (Autumn emulator in tests/dev). */ @@ -67,6 +118,9 @@ declare global { MCP_RESOURCE_ORIGIN?: string; MCP_SESSION_TIMEOUT_MS?: string; MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string; + /** Test-only override for the isolate-wide resident-runtime soft cap + * (see `RESIDENT_RUNTIME_SOFT_CAP`). Unset in production. */ + MCP_RESIDENT_RUNTIME_SOFT_CAP?: string; NODE_ENV?: string; // Shared with frontend diff --git a/apps/cloud/src/extensions/billing/member-seats.ts b/apps/cloud/src/extensions/billing/member-seats.ts new file mode 100644 index 0000000000..1c523cfdb7 --- /dev/null +++ b/apps/cloud/src/extensions/billing/member-seats.ts @@ -0,0 +1,53 @@ +// --------------------------------------------------------------------------- +// Seat-count reporting — the WorkOS → Autumn reconciliation for seat billing +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { WorkOSClient } from "../../auth/workos"; +import { AutumnService } from "./service"; + +/** + * Report the organization's billable seat count to Autumn: active members + * only — a pending invite occupies a seat for the plan gate but is not + * billed until the person joins. + * + * Seats change through paths the app never sees a mutation for (invitation + * acceptance in AuthKit, SSO JIT provisioning, join by domain, WorkOS + * dashboard edits), so this reconciles from a full recount rather than + * tracking deltas. It runs after in-app membership mutations AND on every + * login callback, so drift from out-of-band changes heals on the next + * sign-in. Fire-and-forget-safe: errors are logged, never surfaced. + */ +export const reportMemberSeats = ( + organizationId: string, +): Effect.Effect => + Effect.gen(function* () { + const workos = yield* WorkOSClient; + const autumn = yield* AutumnService; + const memberships = yield* workos.listOrgMembers(organizationId); + const seats = memberships.data.filter((m) => m.status === "active").length; + yield* autumn.setMemberSeats(organizationId, seats); + }).pipe( + Effect.catch((error) => + Effect.logWarning("reportMemberSeats: seat recount failed", { organizationId, error }), + ), + Effect.withSpan("billing.reportMemberSeats"), + ); + +/** + * Fork `reportMemberSeats` off the calling request, mirroring how execution + * tracking is forked: billing must never stall or fail a user-facing + * request. Only boot-scoped services are captured (WorkOS + Autumn — no + * request-scoped resources), so the forked fiber cannot outlive anything it + * depends on. + */ +export const forkReportMemberSeats = ( + organizationId: string, +): Effect.Effect => + Effect.gen(function* () { + const ctx = yield* Effect.context(); + yield* Effect.sync(() => { + Effect.runForkWith(ctx)(reportMemberSeats(organizationId)); + }); + }); diff --git a/apps/cloud/src/extensions/billing/plans.ts b/apps/cloud/src/extensions/billing/plans.ts index 66c617acf8..d3f1ce2aab 100644 --- a/apps/cloud/src/extensions/billing/plans.ts +++ b/apps/cloud/src/extensions/billing/plans.ts @@ -38,6 +38,31 @@ export const hasPaidOrganizationSubscription = ( subscriptions: ReadonlyArray, ): boolean => subscriptions.some(isPaidOrganizationSubscription); +// --------------------------------------------------------------------------- +// Execution rate-limit exemption. +// +// The hourly execution backstop exists for FREE-tier abuse, so the only orgs +// it may cap are those with nothing but the Free plan. This is deliberately +// NOT `hasPaidOrganizationSubscription`: that set names only the plans sold +// today, and every other plan an org can legitimately hold — grandfathered +// (`hobby`, `professional`), pay-as-you-go, or one added later and not yet +// listed here — fell through to the cap. A paying customer blocked by an +// abuse backstop is the worse failure, so the exemption is "anything but +// Free" rather than "one of the plans we remembered to list". +// --------------------------------------------------------------------------- + +export const FREE_AUTUMN_PLAN_ID = "free"; + +export const hasNonFreeOrganizationSubscription = ( + subscriptions: ReadonlyArray, +): boolean => + subscriptions.some( + (subscription) => + subscription.planId != null && + subscription.planId !== FREE_AUTUMN_PLAN_ID && + ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""), + ); + export const shouldApplyFreeOrganizationLimit = ( activeMemberships: ReadonlyArray, paidOrganizationIds: ReadonlySet, diff --git a/apps/cloud/src/extensions/billing/route.node.test.ts b/apps/cloud/src/extensions/billing/route.node.test.ts index 52e5d7c03b..dc2a5a7316 100644 --- a/apps/cloud/src/extensions/billing/route.node.test.ts +++ b/apps/cloud/src/extensions/billing/route.node.test.ts @@ -34,7 +34,7 @@ const stubWorkOS = Layer.succeed( ); const stubUsers = Layer.succeed(UserStoreService)({ - use: (fn) => + use: (_op, fn) => Effect.promise(() => fn({ ensureAccount: async (id: string) => ({ id, createdAt }), @@ -61,21 +61,23 @@ const stubUsers = Layer.succeed(UserStoreService)({ ), }); -const run = (headers: Record, organizationId: string | null = SESSION_ORG) => +const run = (headers: Record) => resolveBillingOrganization( new Request("https://executor.test/api/billing/customer", { headers }), - { userId: MEMBER, organizationId }, + { userId: MEMBER }, ).pipe(Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers))); describe("billing route org selector", () => { - it.effect("falls back to the session org when no selector header is sent", () => + it.effect("fails closed when no selector header is sent", () => Effect.gen(function* () { - const org = yield* run({}); - expect(org.id).toBe(SESSION_ORG); + // No fallback to the session org: the cookie's org is browser-global + // and can name a different org than the tab's URL for a multi-org user. + const error = yield* Effect.flip(run({})); + expect(error).toMatchObject({ _tag: "HttpResponseError", status: 401 }); }), ); - it.effect("scopes billing to the URL org selector over the session org", () => + it.effect("scopes billing to the URL org selector", () => Effect.gen(function* () { const org = yield* run({ "x-executor-organization": URL_SLUG }); expect(org.id).toBe(URL_ORG); @@ -88,11 +90,4 @@ describe("billing route org selector", () => { expect(error).toMatchObject({ _tag: "HttpResponseError", status: 403 }); }), ); - - it.effect("requires either a selector header or a session org", () => - Effect.gen(function* () { - const error = yield* Effect.flip(run({}, null)); - expect(error).toMatchObject({ _tag: "HttpResponseError", status: 401 }); - }), - ); }); diff --git a/apps/cloud/src/extensions/billing/route.ts b/apps/cloud/src/extensions/billing/route.ts index 33e3d580e0..78a32323fd 100644 --- a/apps/cloud/src/extensions/billing/route.ts +++ b/apps/cloud/src/extensions/billing/route.ts @@ -13,12 +13,15 @@ import { type BillingSession = { readonly userId: string; - readonly organizationId?: string | null; }; export const resolveBillingOrganization = (request: Request, session: BillingSession) => Effect.gen(function* () { - const selector = request.headers.get(ORG_SELECTOR_HEADER) ?? session.organizationId; + // FAIL CLOSED: no header, no org. The AutumnProvider always sends the + // URL-scoped header (see __root.tsx billingHeaders); the sealed cookie's + // org is a browser-global that can name a DIFFERENT org for a multi-org + // user (see workos-auth-provider.resolveSessionPrincipal). + const selector = request.headers.get(ORG_SELECTOR_HEADER); if (!selector) { return yield* new HttpResponseError({ status: 401, diff --git a/apps/cloud/src/extensions/billing/service.ts b/apps/cloud/src/extensions/billing/service.ts index 1b33b203c6..c4759f269c 100644 --- a/apps/cloud/src/extensions/billing/service.ts +++ b/apps/cloud/src/extensions/billing/service.ts @@ -17,15 +17,61 @@ export class AutumnError extends Data.TaggedError("AutumnError")<{ cause?: unknown; }> {} +/** + * Autumn has no customer record for the organization. Split out from + * `AutumnError` because it is not an outage and must not be treated like one: + * an outage is transient and the right answer is to fail open and page, while + * a missing customer is a PERMANENT provisioning gap — every subsequent + * balance call 404s the same way, so the org runs unbilled and unmetered + * forever. Callers that own an organization id repair it (see + * `withProvisionedCustomer`); everything else still surfaces as `AutumnError`. + */ +export class AutumnCustomerNotFoundError extends Data.TaggedError("AutumnCustomerNotFoundError")<{ + message: string; + cause?: unknown; +}> {} + +export type AutumnFailure = AutumnError | AutumnCustomerNotFoundError; + +// Autumn's own error code for "no such customer", carried in the JSON body of +// a 404 (`{"message":"Customer not found","code":"customer_not_found"}`). +const CUSTOMER_NOT_FOUND_CODE = "customer_not_found"; + +/** + * True when `cause` is Autumn's "no such customer" answer. The autumn-js SDK + * throws an `AutumnError` carrying the raw HTTP `statusCode` and `body`; match + * on the code rather than the status alone so an unrelated 404 (a removed + * endpoint, a proxy) is still reported as a genuine failure. + */ +const isCustomerNotFoundCause = (cause: unknown): boolean => { + if (typeof cause !== "object" || cause === null) return false; + const { statusCode, body } = cause as { readonly statusCode?: unknown; readonly body?: unknown }; + if (statusCode !== 404 || typeof body !== "string") return false; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: classifying a third-party SDK's raw response body; a body that isn't JSON simply isn't this error + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: the autumn-js SDK hands back the response body as an unvalidated string + const parsed: unknown = JSON.parse(body); + if (typeof parsed !== "object" || parsed === null) return false; + return (parsed as { readonly code?: unknown }).code === CUSTOMER_NOT_FOUND_CODE; + } catch { + return false; + } +}; + // --------------------------------------------------------------------------- // Service interface // --------------------------------------------------------------------------- export type IAutumnService = Readonly<{ - use: (fn: (client: Autumn) => Promise) => Effect.Effect; + use: (fn: (client: Autumn) => Promise) => Effect.Effect; + /** + * Provision the organization's Autumn customer, creating it if Autumn has + * never seen it. Idempotent — safe to call on every org creation. + */ + ensureCustomer: (organizationId: string) => Effect.Effect; checkExecutionBalance: ( organizationId: string, - ) => Effect.Effect<{ readonly allowed: boolean }, AutumnError, never>; + ) => Effect.Effect<{ readonly allowed: boolean }, AutumnFailure, never>; /** * Fire-and-forget-safe execution usage tracker. Errors are caught and * logged; the returned Effect never fails. Callers typically @@ -33,6 +79,16 @@ export type IAutumnService = Readonly<{ * user-facing request. */ trackExecution: (organizationId: string) => Effect.Effect; + /** + * Set the organization's billed seat count to an absolute value. Seats are + * a continuous-use feature, so callers recount from the membership source + * of truth and this SETS the usage rather than incrementing it — deltas + * would drift against seat changes the app never sees. Orgs whose plan + * version carries no `members` item (every plan predating seat pricing) + * are skipped. Fire-and-forget-safe: errors are caught and logged; the + * returned Effect never fails. + */ + setMemberSeats: (organizationId: string, seats: number) => Effect.Effect; }>; // --------------------------------------------------------------------------- @@ -48,8 +104,10 @@ const make = Effect.sync(() => { ); return { use: () => notConfigured, + ensureCustomer: () => notConfigured, checkExecutionBalance: () => notConfigured, trackExecution: () => Effect.void, + setMemberSeats: () => Effect.void, } satisfies IAutumnService; } @@ -62,16 +120,53 @@ const make = Effect.sync(() => { const use = (fn: (client: Autumn) => Promise) => Effect.tryPromise({ try: () => fn(client), - catch: (cause) => new AutumnError({ message: "Autumn SDK request failed", cause }), - }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`)); + catch: (cause): AutumnFailure => + isCustomerNotFoundCause(cause) + ? new AutumnCustomerNotFoundError({ + message: "Autumn has no customer for this organization", + cause, + }) + : new AutumnError({ message: "Autumn SDK request failed", cause }), + // An inline arrow's `name` is "" — not nullish — so `??` left every + // Autumn call tracing as the bare span `autumn.`. + }).pipe(Effect.withSpan(`autumn.${fn.name || "use"}`)); + + const ensureCustomer = (organizationId: string) => + Effect.asVoid(use((c) => c.customers.getOrCreate({ customerId: organizationId }))); + + /** + * Run `operation`; if Autumn answers "no such customer", provision the + * organization's customer and run it ONCE more. + * + * This is the seam that closes the provisioning hole. Both billing paths use + * non-creating endpoints, so an organization Autumn never learned about + * 404s here forever: the balance gate fails open (correct for an outage, + * catastrophic as a steady state) and every usage track is lost. Repairing + * the customer makes the retry land the call — and a genuine Autumn outage + * still fails with `AutumnError` and still pages, unretried. + */ + const withProvisionedCustomer = ( + organizationId: string, + operation: Effect.Effect, + ) => + operation.pipe( + Effect.catchTag("AutumnCustomerNotFoundError", () => + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ "autumn.customer.provisioned": true }); + yield* ensureCustomer(organizationId); + return yield* operation; + }), + ), + ); const trackExecution = (organizationId: string) => Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); - yield* use((c) => - c.track({ customerId: organizationId, featureId: "executions", value: 1 }), + yield* withProvisionedCustomer( + organizationId, + use((c) => c.track({ customerId: organizationId, featureId: "executions", value: 1 })), ).pipe( - Effect.catchTag("AutumnError", (error) => + Effect.catch((error) => Effect.gen(function* () { // Silent billing data loss is worth paging on: autumn.trackExecution // is fire-and-forget so the caller doesn't handle it themselves. @@ -85,16 +180,58 @@ const make = Effect.sync(() => { ); }).pipe(Effect.withSpan("autumn.trackExecution")); + const setMemberSeats = (organizationId: string, seats: number) => + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "autumn.customer.id": organizationId, + "autumn.members.seats": seats, + }); + // getOrCreate rather than get: reuses the provisioning repair, so an + // org Autumn has never seen gets its customer minted here instead of + // 404ing forever. + const customer = yield* use((c) => c.customers.getOrCreate({ customerId: organizationId })); + // Plans that predate seat pricing have no members balance to set. + // Existing subscribers stay on those versions deliberately, so this is + // a steady state, not an error. + if (customer.balances["members"] == null) { + yield* Effect.annotateCurrentSpan({ "autumn.members.skipped": true }); + return; + } + yield* use((c) => + c.balances.update({ customerId: organizationId, featureId: "members", usage: seats }), + ); + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + // Silent seat drift means wrong invoices, so failures page just + // like a lost execution track. + yield* Effect.sync(() => { + console.error("[billing] seat sync failed:", error); + }); + yield* captureCauseEffect(error); + yield* Effect.annotateCurrentSpan({ "autumn.members.failed": true }); + }), + ), + Effect.withSpan("autumn.setMemberSeats"), + ); + const checkExecutionBalance = (organizationId: string) => Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); - const check = yield* use((c) => - c.check({ customerId: organizationId, featureId: "executions" }), + const check = yield* withProvisionedCustomer( + organizationId, + use((c) => c.check({ customerId: organizationId, featureId: "executions" })), ); return { allowed: check.allowed }; }).pipe(Effect.withSpan("autumn.checkExecutionBalance")); - return { use, checkExecutionBalance, trackExecution } satisfies IAutumnService; + return { + use, + ensureCustomer, + checkExecutionBalance, + trackExecution, + setMemberSeats, + } satisfies IAutumnService; }); export class AutumnService extends Context.Service()( diff --git a/apps/cloud/src/extensions/docs.ts b/apps/cloud/src/extensions/docs.ts index 6106d49337..5850072e19 100644 --- a/apps/cloud/src/extensions/docs.ts +++ b/apps/cloud/src/extensions/docs.ts @@ -2,12 +2,18 @@ import { Effect, Layer } from "effect"; import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; +import { AccountApi, AdminUsersApi } from "@executor-js/api"; + import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; import { OrgApi } from "../org/api"; import { ProtectedCloudApi } from "../api/layers"; -export const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi).add(CloudAuthApi).add(OrgApi); +export const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi) + .add(CloudAuthApi) + .add(OrgApi) + .add(AccountApi) + .add(AdminUsersApi); const spec = OpenApi.fromApi(CloudOpenApi); diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index 093ed8aad9..4c04bd02cd 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -24,6 +24,7 @@ import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; +import { AccountApi, AdminUsersApi } from "@executor-js/api"; import { requestScopedMiddleware } from "@executor-js/api/server"; import { UserStoreService } from "../auth/context"; @@ -34,6 +35,7 @@ import { } from "../auth/handlers"; import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; import { SessionAuthLive } from "../auth/middleware-live"; +import { makeCloudAdminUsersRoutes } from "../admin/admin-users-api"; import { OrgApi, OrgHttpApi } from "../org/api"; import { orgAuthMiddleware } from "../org/auth-middleware"; import { OrgHandlers } from "../org/handlers"; @@ -55,6 +57,8 @@ const apiPrefixedRouter = Layer.effect(HttpRouter.HttpRouter)( const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi) .add(CloudAuthApi) .add(OrgApi) + .add(AccountApi) + .add(AdminUsersApi) .prefix("/api"); const spec = OpenApi.fromApi(CloudOpenApi); @@ -100,5 +104,18 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer new Response(null, { status: 204, @@ -78,6 +91,41 @@ const renderAuthError = ( }); }; +/** + * A Cloudflare *platform* Durable Object failure happened at one of this + * handler's stub touchpoints. Record what kind it was — on an exported span and + * in a structured log — so the production volume stays countable per cause + * (deploy reset vs storage timeout vs destroyed session) now that it is no + * longer a pile of 500s. + * + * Talking to a session DO means talking to a process the platform can reset out + * from under us: a deploy, a storage timeout, a backend blip, the session's own + * `ctx.abort("destroyed")`. None of those are application defects. An + * unrecognized failure never reaches here and keeps escaping as before. + */ +const recordDurableObjectFailure = ( + failure: DurableObjectFailure, + operation: string, +): Effect.Effect => + Effect.sync(() => { + console.warn( + JSON.stringify({ + event: "mcp_durable_object_platform_failure", + operation, + resetKind: failure.kind, + disposition: failure.disposition, + }), + ); + }).pipe( + Effect.withSpan("mcp.do.platform_failure", { + attributes: { + "mcp.do.reset_kind": failure.kind, + "mcp.do.reset_disposition": failure.disposition, + "mcp.do.reset_operation": operation, + }, + }), + ); + const authenticate = (request: Request) => Effect.gen(function* () { const auth = yield* McpAuthProvider; @@ -129,8 +177,18 @@ const propsForPrincipal = ( return { session: { organizationId: principal.organizationId, + // The org record the live membership check resolved microseconds ago, + // handed to the session DO so it never opens a connection of its own to + // re-read it. An unnamed org (no auth plane could resolve one) is + // omitted rather than sent empty, so the DO can tell "not carried" from + // "carried, and blank". + ...(principal.organizationName ? { organizationName: principal.organizationName } : {}), + ...(principal.organizationSlug ? { organizationSlug: principal.organizationSlug } : {}), + ...sessionOrgRoleMetadata(principal), userId: principal.accountId, elicitationMode: readElicitationMode(request), + artifactsEnabled: readArtifactsEnabled(request), + searchToolsEnabled: readSearchToolsEnabled(request), resource, webOrigin: new URL(request.url).origin, }, @@ -197,10 +255,27 @@ export const makeCloudMcpAgentHandler = () => { } if (sessionId) { - const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ - accountId: outcome.principal.accountId, - organizationId: outcome.principal.organizationId, - }); + let owner: "ok" | "not_found" | "forbidden" | "terminated"; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a Durable Object stub RPC rejects with a plain platform Error, never a typed failure + try { + owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }); + } catch (error) { + // The sibling stub touchpoints in this handler are both guarded — the + // `_cf_scheduleDestroy` call above with `Effect.ignore`, the + // `target.fetch` below with a catch — and this one was not, so a session + // whose DO had been destroyed or reset by the platform 500ed here before + // any of that handling could run. + const failure = classifyDurableObjectError(error); + if (!failure) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: an unrecognized failure is a real defect and must reach the runtime unchanged + throw error; + } + await runTraced(request, recordDurableObjectFailure(failure, "validate_session_owner")); + return durableObjectFailureResponse(failure); + } if (owner === "not_found") { return jsonRpcResponse(404, -32001, "Session not found"); } @@ -218,13 +293,16 @@ export const makeCloudMcpAgentHandler = () => { const resource = resourceFromPath(request); const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource)); (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; - const forwarded = withVerifiedIdentityHeaders( - request, - { - accountId: outcome.principal.accountId, - organizationId: outcome.principal.organizationId, - }, - resource, + const forwarded = withOrgWriteAccess( + withVerifiedIdentityHeaders( + request, + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + resource, + ), + orgWriteAccessForPrincipal(outcome.principal), ); const target = resource.kind === "toolkit" ? serveToolkit : serve; let response: Response; @@ -240,12 +318,35 @@ export const makeCloudMcpAgentHandler = () => { // DO ever getting to answer. Map it to the old envelope's reconnect // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the // client to be told to reconnect, matching a timed-out session). - // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal - if (Predicate.isError(error) && error.message === "destroyed") { - return jsonRpcResponse(404, -32001, "Session timed out, please reconnect"); + // + // The same catch now also covers the rest of the platform's reset + // vocabulary — a deploy, a storage timeout, a cancelled + // blockConcurrencyWhile — which reaches here through the agents SDK's own + // `getServerByName` retry and used to 500 identically. + // The session DO could not reach the organization directory to name the + // org (after its own bounded retry). Transient by construction, so it + // gets the same retryable envelope a WorkOS blip gets on the auth path — + // not an unclassified 500 the agents SDK then retries the whole DO + // operation over, which is what turned a 10s connect timeout into a + // half-minute client hang. + // + // Checked BEFORE the platform classifier: this is an application failure + // that merely escapes through the same seam, and it names its own cause. + // The classifier only recognizes the runtime's own reset vocabulary, so + // the two never contend — the order just keeps it that way if either + // vocabulary grows. + if (isMcpSessionMetaUnavailable(error)) { + return jsonRpcErrorBody(503, -32001, MCP_SESSION_UNAVAILABLE_MESSAGE, { + retryAfterSeconds: UNAVAILABLE_RETRY_AFTER_SECONDS, + }); + } + const failure = classifyDurableObjectError(error); + if (!failure) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't a recognized platform failure to the Workers runtime unchanged + throw error; } - // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged - throw error; + await runTraced(request, recordDurableObjectFailure(failure, "session_fetch")); + return durableObjectFailureResponse(failure); } // The agents SDK answers a bare DELETE with 204; the old envelope's // contract (see above) was 200 — rewrite for consistency. diff --git a/apps/cloud/src/mcp/auth-provider.test.ts b/apps/cloud/src/mcp/auth-provider.test.ts index 454573ade5..1a88793d69 100644 --- a/apps/cloud/src/mcp/auth-provider.test.ts +++ b/apps/cloud/src/mcp/auth-provider.test.ts @@ -22,7 +22,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Data, Effect, Layer, Predicate } from "effect"; -import { McpAuthProvider } from "@executor-js/host-mcp"; +import { McpAuthProvider, orgWriteAccessForPrincipal } from "@executor-js/host-mcp"; import { WorkOSError } from "../auth/errors"; import { cloudMcpAuthProviderLayer } from "./auth-provider"; @@ -70,9 +70,12 @@ const stubOrgAuthNoMembership = Layer.succeed(McpOrganizationAuth)({ authorize: () => Effect.succeed(null), }); -// `authorize` SUCCEEDS with an org id — active membership. +// `authorize` SUCCEEDS with the resolved org record — active membership. The +// record, not just the id: the session props carry the org's name and slug so +// the session DO never re-reads the row. const stubOrgAuthActive = Layer.succeed(McpOrganizationAuth)({ - authorize: () => Effect.succeed(ORG_ID), + authorize: () => + Effect.succeed({ id: ORG_ID, name: "Stub Org", slug: "stub-org", memberRole: "admin" }), }); // A failure that is not a WorkOSError at all (e.g. the per-request DB layer @@ -161,6 +164,14 @@ describe("cloud MCP org-authorization classification", () => { const principal = Predicate.isTagged(outcome, "Authenticated") ? outcome.principal : null; expect(principal?.accountId).toBe(ACCOUNT_ID); expect(principal?.organizationId).toBe(ORG_ID); + expect(principal?.orgRoleModel).toBe("organization"); + expect(principal?.orgRole, "the live membership role reaches the MCP session").toBe("admin"); + const legacyAccess = principal + ? orgWriteAccessForPrincipal( + (({ orgRole: _orgRole, ...legacyMissingRole }) => legacyMissingRole)(principal), + ) + : null; + expect(legacyAccess).toBe("denied"); }), ); diff --git a/apps/cloud/src/mcp/auth-provider.ts b/apps/cloud/src/mcp/auth-provider.ts index 628007a2fd..054054a940 100644 --- a/apps/cloud/src/mcp/auth-provider.ts +++ b/apps/cloud/src/mcp/auth-provider.ts @@ -21,8 +21,9 @@ // clearExistingSession. // - verified + org allowed -> Authenticated(principal) // -// The rich `mcp.request.annotate` client-fingerprint span (cloud-specific, no -// envelope seam) is emitted from here so telemetry parity is preserved. +// The rich client-fingerprint annotations (cloud-specific, no envelope seam) +// are stamped onto the `mcp.request` span from here so telemetry parity is +// preserved. // // The OAuth endpoints (/authorize, /token, /register) are NOT cloud's — they // live at WorkOS/AuthKit (external); only the two discovery docs are mounted. @@ -54,6 +55,7 @@ import { McpAuthLive, McpOrganizationAuth, McpOrganizationAuthLive, + type AuthorizedMcpOrganization, type McpAuthResult, type VerifiedToken, } from "./auth"; @@ -87,16 +89,26 @@ const ORGANIZATION_AUTHORIZE_UNAVAILABLE = /** * Enrich a cloud {@link VerifiedToken} (which carries only accountId + - * organizationId) into the full {@link Principal} the seam validates. The - * envelope only uses `accountId` + `organizationId` for ownership; cloud - * resolves org name/email inside the DO, so the cosmetic identity fields carry - * empty placeholders. `organizationId` is guaranteed non-null here because the - * Forbidden branch already rejected the no-org case before Authenticated. + * organizationId) into the full {@link Principal} the seam validates. + * + * The org name and slug come from the record the live membership check just + * resolved — this is the whole point of `authorize` returning the record rather + * than an id. They used to be dropped here (`organizationName: ""`), which left + * the session Durable Object to re-read the same row over a fresh database + * connection on every cold init. `email` stays a placeholder: the envelope only + * uses `accountId` + `organizationId` for ownership, and nothing downstream + * reads it. */ -const principalFromToken = (token: VerifiedToken, organizationId: string): Principal => ({ +const principalFromToken = ( + token: VerifiedToken, + organization: AuthorizedMcpOrganization, +): Principal => ({ accountId: token.accountId, - organizationId, - organizationName: "", + organizationId: organization.id, + organizationName: organization.name, + ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), + orgRoleModel: "organization", + orgRole: organization.memberRole, email: "", name: null, avatarUrl: null, @@ -222,9 +234,9 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< // caller genuinely holds no active membership (revoked / never a member) // — a real Forbidden, which the handler may act on by condemning the // session. - const organizationId = authorizeResult.success; - if (!organizationId) return forbidden(NO_ORGANIZATION_MESSAGE, -32001); - return authenticated(principalFromToken(token, organizationId)); + const organization = authorizeResult.success; + if (!organization) return forbidden(NO_ORGANIZATION_MESSAGE, -32001); + return authenticated(principalFromToken(token, organization)); }); const toOutcome = (request: Request, result: McpAuthResult): Effect.Effect => { diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index 531fd5cba2..d14e6f4997 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -160,19 +160,33 @@ export class McpAuth extends Context.Service< } >()("@executor-js/cloud/McpAuth") {} +/** + * The organization an MCP request was authorized against. The full record, not + * just its id: the same request later needs the org's display name and slug to + * open a session, and re-reading the row for them (from the session Durable + * Object, on a fresh database connection) is a redundant failure point on a + * request that already has the answer. + */ +export type AuthorizedMcpOrganization = { + readonly id: string; + readonly name: string; + readonly slug?: string; + readonly memberRole: "admin" | "member"; +}; + export class McpOrganizationAuth extends Context.Service< McpOrganizationAuth, { /** * Authorize `accountId` against an org SELECTOR — a WorkOS org id * (`org_…`, from the token or a legacy URL) or the org's URL slug (the - * form the install card prints). Returns the resolved org id when the + * form the install card prints). Returns the resolved organization when the * caller holds an active membership, `null` otherwise. */ readonly authorize: ( accountId: string, organizationSelector: string, - ) => Effect.Effect; + ) => Effect.Effect; } >()("@executor-js/cloud/McpOrganizationAuth") {} @@ -204,7 +218,9 @@ const resolveOrgSelector = (selector: string) => ? Effect.succeed(selector) : Effect.gen(function* () { const users = yield* UserStoreService; - const org = yield* users.use((s) => s.getOrganizationBySlug(selector)); + const org = yield* users.use("getOrganizationBySlug", (s) => + s.getOrganizationBySlug(selector), + ); return org?.id ?? null; }); @@ -214,7 +230,16 @@ export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ Effect.flatMap((organizationId) => organizationId ? authorizeOrganization(accountId, organizationId).pipe( - Effect.map((org) => (org ? org.id : null)), + Effect.map((org) => + org + ? ({ + id: org.id, + name: org.name, + slug: org.slug, + memberRole: org.memberRole, + } as const) + : null, + ), ) : Effect.succeed(null), ), @@ -248,6 +273,17 @@ export const McpAuthLive = Layer.effect( return mcpUnauthorized("invalid_token", "The API key is invalid"); } + // An ORG-level key resolves to the read-only platform view and has no + // acting member. Every MCP session binds to one subject, so there is + // nothing honest to bind here — reject rather than invent a subject. + if (principal.scope === "org" || principal.accountId == null) { + yield* Effect.annotateCurrentSpan({ + "mcp.auth.outcome": "invalid", + "mcp.auth.invalid_reason": "org_api_key", + }); + return mcpUnauthorized("invalid_token", "Organization API keys cannot open an MCP session"); + } + yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "verified", "mcp.auth.credential_type": "api_key", diff --git a/apps/cloud/src/mcp/session-build-semaphore.test.ts b/apps/cloud/src/mcp/session-build-semaphore.test.ts new file mode 100644 index 0000000000..3d4ad76343 --- /dev/null +++ b/apps/cloud/src/mcp/session-build-semaphore.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it, beforeEach } from "@effect/vitest"; + +import { + acquireBuildSlot, + releaseBuildSlot, + resetBuildSlotsForTest, + currentActiveBuildsForTest, + currentQueueLengthForTest, +} from "./session-build-semaphore"; + +describe("session-build-semaphore", () => { + beforeEach(() => { + resetBuildSlotsForTest(); + }); + + it("grants up to the cap immediately, with no wait", async () => { + const results = await Promise.all([ + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + ]); + expect(results).toEqual([ + { acquired: true, waitMs: 0, timedOut: false }, + { acquired: true, waitMs: 0, timedOut: false }, + { acquired: true, waitMs: 0, timedOut: false }, + { acquired: true, waitMs: 0, timedOut: false }, + ]); + expect(currentActiveBuildsForTest()).toBe(4); + expect(currentQueueLengthForTest()).toBe(0); + }); + + it("queues a build past the cap until a slot is released", async () => { + await Promise.all([ + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + ]); + expect(currentActiveBuildsForTest()).toBe(4); + + let fifthResolved = false; + const fifth = acquireBuildSlot().promise.then((result) => { + fifthResolved = true; + return result; + }); + + expect(currentQueueLengthForTest()).toBe(1); + // Nothing releases the pending fifth build without an explicit release — + // it must not resolve on its own. + await Promise.resolve(); + await Promise.resolve(); + expect(fifthResolved).toBe(false); + + releaseBuildSlot(); + const result = await fifth; + expect(fifthResolved).toBe(true); + expect(result.acquired).toBe(true); + expect(result.timedOut).toBe(false); + expect(result.waitMs).toBeGreaterThanOrEqual(0); + // The freed slot went straight to the waiter — total active stays at cap. + expect(currentActiveBuildsForTest()).toBe(4); + expect(currentQueueLengthForTest()).toBe(0); + }); + + it("releases queued waiters in FIFO order", async () => { + await Promise.all([ + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + ]); + + const order: number[] = []; + const second = acquireBuildSlot().promise.then(() => order.push(2)); + const third = acquireBuildSlot().promise.then(() => order.push(3)); + const fourth = acquireBuildSlot().promise.then(() => order.push(4)); + expect(currentQueueLengthForTest()).toBe(3); + + releaseBuildSlot(); + await second; + releaseBuildSlot(); + await third; + releaseBuildSlot(); + await fourth; + + expect(order).toEqual([2, 3, 4]); + }); + + it("never deadlocks: releasing a slot always makes forward progress for the next waiter", async () => { + await Promise.all([ + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + ]); + + // 6 more builds arrive while all 4 slots are held — all 6 queue. + const queued = Array.from({ length: 6 }, () => acquireBuildSlot().promise); + expect(currentQueueLengthForTest()).toBe(6); + + // The 4 in-flight builds finish one at a time; each release must hand its + // slot straight to the next queued waiter rather than sitting idle. + for (let i = 0; i < 4; i++) releaseBuildSlot(); + await Promise.all(queued.slice(0, 4)); + expect(currentActiveBuildsForTest()).toBe(4); + expect(currentQueueLengthForTest()).toBe(2); + + // Those 4 finish too, freeing the last 2 queued waiters. + for (let i = 0; i < 4; i++) releaseBuildSlot(); + await Promise.all(queued.slice(4)); + expect(currentActiveBuildsForTest()).toBe(2); + expect(currentQueueLengthForTest()).toBe(0); + + // And the last 2 finish, draining the semaphore completely. + releaseBuildSlot(); + releaseBuildSlot(); + expect(currentActiveBuildsForTest()).toBe(0); + expect(currentQueueLengthForTest()).toBe(0); + }); + + it("does not go negative when released more times than acquired", () => { + releaseBuildSlot(); + releaseBuildSlot(); + expect(currentActiveBuildsForTest()).toBe(0); + }); + + it("cancelling a queued waiter dequeues it, and a subsequent release goes to the next waiter", async () => { + await Promise.all([ + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + ]); + expect(currentActiveBuildsForTest()).toBe(4); + + const cancelled = acquireBuildSlot(); + let cancelledSettled = false; + void cancelled.promise.then(() => { + cancelledSettled = true; + }); + + const next = acquireBuildSlot(); + let nextResult: Awaited | undefined; + void next.promise.then((r) => { + nextResult = r; + }); + + expect(currentQueueLengthForTest()).toBe(2); + + // Interrupted before it was ever granted a slot (e.g. client disconnect + // while queued) — this must not hand it a slot later, and must not block + // the waiter behind it. + cancelled.cancel(); + expect(currentQueueLengthForTest()).toBe(1); + + releaseBuildSlot(); + await next.promise; + + // The cancelled waiter's promise never resolves and never gets a slot; + // the freed slot went straight to the next waiter instead. + await Promise.resolve(); + await Promise.resolve(); + expect(cancelledSettled).toBe(false); + expect(nextResult).toEqual({ acquired: true, waitMs: expect.any(Number), timedOut: false }); + expect(currentActiveBuildsForTest()).toBe(4); + expect(currentQueueLengthForTest()).toBe(0); + }); + + it("cancel-after-grant releases the slot instead of leaking it", async () => { + await Promise.all([ + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + ]); + expect(currentActiveBuildsForTest()).toBe(4); + + // Queues, then gets granted a slot by the release below. + const grantedThenAbandoned = acquireBuildSlot(); + releaseBuildSlot(); + const result = await grantedThenAbandoned.promise; + expect(result.acquired).toBe(true); + // Still at the cap: the freed slot was handed straight to this waiter. + expect(currentActiveBuildsForTest()).toBe(4); + + // The caller never gets to consume the grant (e.g. its fiber was + // interrupted in the same tick the grant happened) and cancels instead — + // this must give the slot back rather than leaking it forever. + grantedThenAbandoned.cancel(); + expect(currentActiveBuildsForTest()).toBe(3); + + // Idempotent: a second cancel must not release it again. + grantedThenAbandoned.cancel(); + expect(currentActiveBuildsForTest()).toBe(3); + + // And the freed slot is usable by a fresh acquire. + const fresh = await acquireBuildSlot().promise; + expect(fresh).toEqual({ acquired: true, waitMs: 0, timedOut: false }); + expect(currentActiveBuildsForTest()).toBe(4); + }); + + it("cancelling an immediately-granted (never-queued) slot releases it exactly once", async () => { + const handle = acquireBuildSlot(); + await handle.promise; + expect(currentActiveBuildsForTest()).toBe(1); + + handle.cancel(); + expect(currentActiveBuildsForTest()).toBe(0); + + // Idempotent. + handle.cancel(); + expect(currentActiveBuildsForTest()).toBe(0); + }); + + it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => { + await Promise.all([ + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + ]); + expect(currentActiveBuildsForTest()).toBe(4); + + const timedOutHandle = acquireBuildSlot(10); + const result = await timedOutHandle.promise; + + expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true }); + expect(result.waitMs).toBeGreaterThanOrEqual(10); + // A timed-out waiter never counted against the cap. + expect(currentActiveBuildsForTest()).toBe(4); + expect(currentQueueLengthForTest()).toBe(0); + + // A timed-out waiter never held a slot, so cancelling it must not + // release one that was never granted. + timedOutHandle.cancel(); + expect(currentActiveBuildsForTest()).toBe(4); + + // The 4 originally-held slots are still releasable normally. + releaseBuildSlot(); + releaseBuildSlot(); + releaseBuildSlot(); + releaseBuildSlot(); + expect(currentActiveBuildsForTest()).toBe(0); + }); + + it("a waiter that times out is removed from the queue and does not block waiters behind it", async () => { + await Promise.all([ + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + acquireBuildSlot().promise, + ]); + + const timesOut = acquireBuildSlot(10); + const staysQueued = acquireBuildSlot(); + expect(currentQueueLengthForTest()).toBe(2); + + const timedOutResult = await timesOut.promise; + expect(timedOutResult.timedOut).toBe(true); + // Only the timed-out waiter left the queue; the other is still waiting. + expect(currentQueueLengthForTest()).toBe(1); + + releaseBuildSlot(); + const staysQueuedResult = await staysQueued.promise; + expect(staysQueuedResult).toEqual({ + acquired: true, + waitMs: expect.any(Number), + timedOut: false, + }); + expect(currentQueueLengthForTest()).toBe(0); + }); +}); diff --git a/apps/cloud/src/mcp/session-build-semaphore.ts b/apps/cloud/src/mcp/session-build-semaphore.ts new file mode 100644 index 0000000000..4e778deb2f --- /dev/null +++ b/apps/cloud/src/mcp/session-build-semaphore.ts @@ -0,0 +1,181 @@ +/** + * Module-scope (== per-isolate, same reasoning as + * `packages/hosts/cloudflare/src/mcp/session-runtime-residency.ts`) semaphore + * bounding concurrent COLD `buildMcpServer` builds. + * + * A burst of new sessions can land on one isolate at once. Each cold build + * runs execution-stack setup and MCP server construction, which is real CPU + * — concentrating several of those builds at the same instant is what drags + * the isolate's request p95 during a wave. Capping how many builds run at + * once smooths that burst into a short FIFO queue instead of letting every + * arrival pay full concurrent cost. + * + * Deliberately dependency-free: a tiny promise-chain queue, not a library. + */ + +const MAX_CONCURRENT_BUILDS = 4; + +/** + * Max time a build waits in the FIFO queue for a slot before proceeding + * without one. Admission control exists only to smooth bursts into a short + * queue — it must never turn a *slow* build into a *stuck* one. If the + * isolate is wedged behind a hung dependency (e.g. a stalled DB during an + * incident) and a slot never frees up, every waiter behind it would otherwise + * block forever. Past this timeout a waiter gives up on the queue and builds + * uncapped instead, same as if the semaphore did not exist, and reports + * `timedOut: true` so it is visible in telemetry rather than silently + * degrading. + */ +export const MAX_QUEUE_WAIT_MS = 10_000; + +let activeBuilds = 0; + +/** Lifecycle of one queued waiter. See `acquireBuildSlot` for the full state machine. */ +type WaiterState = "queued" | "granted" | "timed-out" | "cancelled"; + +interface Waiter { + state: WaiterState; + readonly grant: () => void; +} + +const waitQueue: Waiter[] = []; + +export interface BuildSlotResult { + /** False only when the wait timed out; the caller must NOT call `releaseBuildSlot` for it. */ + readonly acquired: boolean; + /** Milliseconds spent waiting for a slot (0 when one was immediately free). */ + readonly waitMs: number; + /** True when the wait hit `MAX_QUEUE_WAIT_MS` and proceeded without a slot. */ + readonly timedOut: boolean; +} + +export interface BuildSlotHandle { + /** Resolves exactly once, with the outcome of this acquisition. Never rejects. */ + readonly promise: Promise; + /** + * Cancel this acquisition. Safe to call at any point in its lifecycle, + * including redundantly or after `promise` has already settled — it is + * idempotent and only ever frees a slot once: + * - Still queued: dequeues the waiter. Nothing was ever granted, so there + * is nothing to release. + * - Already granted a slot — this races `releaseBuildSlot` handing the + * slot to this waiter (that happens synchronously inside + * `releaseBuildSlot`) against this `cancel` — releases that slot + * immediately so it is not held with no owner, and wakes the next FIFO + * waiter. + * - Already timed out or already cancelled: no-op. + * + * Callers should call this from a `finally`/`ensuring` unconditionally + * (success, failure, or interruption alike) rather than trying to track + * separately whether a slot was actually granted — idempotency here makes + * that bookkeeping unnecessary and closes the acquire/release race that + * bookkeeping was prone to. + */ + readonly cancel: () => void; +} + +/** + * Reserve a build slot, queueing FIFO when the cap is already held, up to + * `maxQueueWaitMs` (defaults to `MAX_QUEUE_WAIT_MS`; overridable for tests). + * + * Returns a handle rather than a bare promise so a caller that stops waiting + * — most notably an `Effect` fiber interrupted mid-queue on a client + * disconnect — can cancel cleanly instead of leaking the slot a later + * `releaseBuildSlot` would otherwise hand to a waiter nobody is listening to + * anymore. + */ +export const acquireBuildSlot = (maxQueueWaitMs: number = MAX_QUEUE_WAIT_MS): BuildSlotHandle => { + const requestedAt = Date.now(); + + if (activeBuilds < MAX_CONCURRENT_BUILDS) { + activeBuilds += 1; + let released = false; + return { + promise: Promise.resolve({ acquired: true, waitMs: 0, timedOut: false }), + cancel: () => { + if (released) return; + released = true; + releaseBuildSlot(); + }, + }; + } + + let resolvePromise!: (value: BuildSlotResult) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + const waiter: Waiter = { + state: "queued", + grant: () => { + // Guaranteed "queued" here: `releaseBuildSlot` only reaches a waiter by + // shifting it out of `waitQueue`, and both other transitions + // (timeout, cancel) remove the waiter from the queue in the same tick + // they change its state, so a granted waiter can't have been anything + // else. + waiter.state = "granted"; + activeBuilds += 1; + resolvePromise({ acquired: true, waitMs: Date.now() - requestedAt, timedOut: false }); + }, + }; + waitQueue.push(waiter); + + const timer = setTimeout(() => { + if (waiter.state !== "queued") return; + waiter.state = "timed-out"; + const idx = waitQueue.indexOf(waiter); + if (idx !== -1) waitQueue.splice(idx, 1); + resolvePromise({ acquired: false, waitMs: Date.now() - requestedAt, timedOut: true }); + }, maxQueueWaitMs); + + return { + promise, + cancel: () => { + clearTimeout(timer); + if (waiter.state === "queued") { + waiter.state = "cancelled"; + const idx = waitQueue.indexOf(waiter); + if (idx !== -1) waitQueue.splice(idx, 1); + return; + } + if (waiter.state === "granted") { + // Grant already happened (raced with a concurrent `releaseBuildSlot` + // shifting this waiter off the queue) before this cancel ran, and + // the caller never got to consume the slot — hand it back now + // instead of leaking it. + waiter.state = "cancelled"; + releaseBuildSlot(); + return; + } + // "timed-out": never held a slot. "cancelled": a prior call already + // handled this. Either way, nothing to do — idempotent no-op. + }, + }; +}; + +/** + * Release a build slot. Must be called exactly once per slot a caller + * actually acquired (i.e. `acquireBuildSlot`'s `promise` resolved with + * `acquired: true`) — callers release from a `finally`/`ensuring` (typically + * via the returned handle's `cancel`, which is idempotent) so a build that + * throws, or is interrupted, still frees its slot and never deadlocks the + * queue behind it. + * + * Wakes the next FIFO waiter, if any, handing it the freed slot directly + * rather than making it race a fresh `acquireBuildSlot` caller. + */ +export const releaseBuildSlot = (): void => { + activeBuilds = Math.max(0, activeBuilds - 1); + const next = waitQueue.shift(); + if (next) next.grant(); +}; + +/** Test-only: isolate-scoped module state outlives a single test case. */ +export const resetBuildSlotsForTest = (): void => { + activeBuilds = 0; + waitQueue.length = 0; +}; + +export const currentActiveBuildsForTest = (): number => activeBuilds; + +export const currentQueueLengthForTest = (): number => waitQueue.length; diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index c2d249aef9..2646a785a0 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -25,6 +25,9 @@ import { createExecutorMcpServer, } from "@executor-js/host-mcp/tool-server"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; +import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; +import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; +import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import { McpAgentSessionDOBase, type BuiltMcpServer, @@ -40,7 +43,12 @@ import { type McpExecutionOwnerRoute, } from "@executor-js/cloudflare/mcp/execution-owner-directory"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; -import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; +import { + buildExecuteDescription, + parseIntegrationInventory, + type ResumeResponse, +} from "@executor-js/execution"; +import { acquireBuildSlot, type BuildSlotHandle } from "./session-build-semaphore"; // The DO meters executions just like the HTTP `/api/*` plane: it builds its // engine with `CloudMeteredExecutionStackLayer`, so every MCP execution is @@ -55,7 +63,7 @@ import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execu // `SessionAuthLive` instead.) import { CoreSharedServices } from "../auth/workos"; import { UserStoreService } from "../auth/context"; -import { resolveOrganization } from "../auth/organization"; +import { resolveSessionMetaForToken } from "./session-meta"; import { DbService, combinedSchema, @@ -63,13 +71,12 @@ import { type DrizzleDb, type DbServiceShape, } from "../db/db"; -import { makeExecutionStack } from "../engine/execution-stack"; -import { CloudMeteredExecutionStackLayer } from "../engine/execution-stack-metered"; import { AutumnService } from "../extensions/billing/service"; import { DoTelemetryLive, flushTracerProvider } from "../observability/telemetry"; import { captureCause as reportCause, captureCauseEffect as reportCauseEffect, + claimCauseHandledByDurableObject, tagCurrentSentryScopeWithCurrentOtelSpan, } from "../observability"; import { parseTraceparent } from "./traceparent"; @@ -100,15 +107,15 @@ const positiveMilliseconds = (raw: string | undefined): number | undefined => { return Math.floor(parsed); }; +/** Same shape as `positiveMilliseconds`, for a plain count rather than a + * duration — used only by the resident-runtime soft-cap override below. */ +const positiveInteger = positiveMilliseconds; + type CloudSessionDbHandle = DbServiceShape & { readonly sql: Sql; readonly end: () => Promise; }; -class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{ - readonly organizationId: string; -}> {} - class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForwardError")<{ readonly cause: unknown; }> {} @@ -152,11 +159,47 @@ const makeSessionServices = (dbHandle: CloudSessionDbHandle) => { return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices); }; +// The `ui://executor/shell.html` resource, over the ASSETS binding: the +// deployed Worker has no filesystem, so the document is the stable-named +// asset the client build emitted (`mcpAppsShellAsset`), fetched at first +// artifact resource read. Module scope so the fetch-and-verify happens once +// per isolate, not once per session. The dev thunk carries the built shell +// inline under `vite dev`, where no assets exist yet for the binding to find. +const loadAppShellHtml = makeAssetsShellHtmlLoader({ + assets: env.ASSETS, + devShellHtml: () => + import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml), +}); + +// QuickJS-WASM must be loaded before the smoke render asks for a sandbox: the +// default variant cannot fetch its own `.wasm` on Workers. `../quickjs` is +// imported dynamically here, not at module scope, so a session that never +// calls create_artifact/edit_artifact never pays for it — see the comment on +// the dynamic import block in `buildMcpServer` for why that matters on a cold +// isolate. `preloadQuickJs()` itself is memoized per isolate (and resets on +// failure), so concurrent artifact calls, and repeat calls after the first, +// are all free past the first successful load. +const smokeRenderArtifactAfterQuickJsPreload: typeof smokeRenderArtifact = async (code) => { + const { preloadQuickJs } = await import("../quickjs"); + await preloadQuickJs(); + return smokeRenderArtifact(code); +}; + // --------------------------------------------------------------------------- // Durable Object // --------------------------------------------------------------------------- export class McpSessionDOSqlite extends McpAgentSessionDOBase { + // Set once per `buildMcpServer` call, read back by `sessionFootprintAttributes` + // so the counts land on the base's `McpSessionDO.init` span alongside + // `mcp.isolate.*` residency — see the comment at the `parseIntegrationInventory` + // call site below for why this is free to compute. + private lastSessionFootprint: Record = {}; + + protected override sessionFootprintAttributes(): Record { + return this.lastSessionFootprint; + } + protected override sessionTimeoutMs(): number { return positiveMilliseconds(env.MCP_SESSION_TIMEOUT_MS) ?? super.sessionTimeoutMs(); } @@ -171,6 +214,25 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { + // Routed through this session's OWN stub (never a direct in-process call) + // so `requestCapEviction`'s teardown runs under an IoContext scoped to + // this request, not whatever request happened to trigger the eviction + // check — see the base class's `requestSelfEviction` doc comment. + return mcpSessionStub(env.MCP_SESSION, this.sessionId).requestCapEviction(); + } + protected override forwardModelResumeToOwner( owner: McpExecutionOwnerRoute, identity: McpApprovalOwner, @@ -195,26 +257,27 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { + protected override resolveSessionMeta( + token: McpSessionInit, + storedMeta: SessionMeta | null, + ): Effect.Effect { + // The database handle is opened LAZILY: on the props and stored paths — the + // overwhelming majority of inits — nothing here touches Postgres at all, + // which is the whole point. postgres.js only dials on first query, so + // building the handle costs nothing; `ensuring` still closes it. const dbHandle = makeEphemeralDb(); - return Effect.gen(function* () { - const org = yield* resolveOrganization(token.organizationId); - if (!org) { - return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); - } - return { - organizationId: org.id, - organizationName: org.name, - organizationSlug: org.slug, - userId: token.userId, - resource: token.resource, - elicitationMode: token.elicitationMode, - } satisfies SessionMeta; - }).pipe( + return resolveSessionMetaForToken(token, storedMeta).pipe( Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"), Effect.provide(makeSessionServices(dbHandle)), Effect.ensuring(Effect.promise(() => dbHandle.end())), - // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer + // The base's `resolveSessionMeta` seam has no error channel, and a + // Durable Object's `init` can only reject its Promise — so a failure has + // to leave as a defect. What changed is WHAT leaves: an unreachable + // organization directory is now a bounded, classified + // `McpSessionMetaUnavailableError` whose message the worker recognises + // and renders as a retryable 503 (see `agent-handler.ts`), instead of an + // unclassified Postgres cause that produced a 500 and a client hang. + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the DO init seam is Promise-only; the failure is classified before it dies Effect.orDie, ); } @@ -224,12 +287,70 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { const self = this; - return Effect.gen(function* () { + // Set synchronously, inside the `Effect.promise` executor below, before + // any `await` — so there is no window where interruption can land after + // the handle exists but before this is assigned. `cancel` is idempotent + // (see `session-build-semaphore.ts`), so it is safe to invoke from both + // the abort listener below AND the `Effect.ensuring` finalizer without + // risking a double release. + let buildSlot: BuildSlotHandle | undefined; + const build = Effect.gen(function* () { + // A burst of cold sessions landing on one isolate at once used to pay + // full concurrent build cost; this bounds it to `MAX_CONCURRENT_BUILDS` + // at a time and queues the rest FIFO, degrading to old concurrent + // behavior (see `timedOut` below) rather than stalling if the queue + // itself gets stuck. See `session-build-semaphore.ts`. + // + // `Effect.promise` hands its executor an `AbortSignal` tied to this + // fiber's own interruption — firing it (rather than merely walking + // away) is what lets a waiter still sitting in the semaphore's queue + // be dequeued immediately on a client disconnect, instead of getting + // granted a slot later with no one left to release it. + const slot = yield* Effect.promise((signal) => { + const handle = acquireBuildSlot(); + buildSlot = handle; + signal.addEventListener("abort", () => handle.cancel(), { once: true }); + return handle.promise; + }); + if (slot.timedOut) { + yield* Effect.annotateCurrentSpan({ "mcp.init.build_queue_timeout": true }); + } else if (slot.waitMs > 0) { + yield* Effect.annotateCurrentSpan({ "mcp.init.build_queue_ms": slot.waitMs }); + } + + // Imported here rather than at module scope. Cloudflare requires a + // Durable Object class to be exported from the Worker entry, so every + // static import this module makes is evaluated by *every* cold isolate — + // including the ones that only render a page or forward a passthrough + // proxy and never open an MCP session. These two roots pull the whole + // code-execution stack (sucrase, ajv): measured at 1.9 MB of the + // Worker's startup closure, for code only a real session runs. + // `apps/cloud/scripts/start-closure.mjs` reports that number and will + // show it moving back if these become static again. QuickJS-WASM is a + // separate dynamic import off the artifact smoke-render path only (see + // `smokeRenderArtifactAfterQuickJsPreload` above) — it is never needed + // during init, so it no longer lives in this Promise.all at all. + const [{ makeExecutionStack }, { CloudMeteredExecutionStackLayer }] = yield* Effect.promise( + () => + Promise.all([ + import("../engine/execution-stack"), + import("../engine/execution-stack-metered"), + ]) as Promise< + [ + typeof import("../engine/execution-stack"), + typeof import("../engine/execution-stack-metered"), + ] + >, + ); + const { executor, engine } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, sessionMeta.organizationName, - { mcpResource: sessionMeta.resource }, + { + mcpResource: sessionMeta.resource, + orgWrites: "request", + }, ).pipe( // The metered stack tracks each execution to Autumn. It requires // `AutumnService | DbService`; `AutumnService.Default` is provided here @@ -246,10 +367,40 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase self.persistAppsEnabled(appsEnabled), + loadAppShellHtml, + smokeRenderArtifact: smokeRenderArtifactAfterQuickJsPreload, + artifactUrl: artifactUrlFor( + env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", + sessionMeta.organizationSlug, + ), parentSpan: () => self.currentParentSpan(), debug: env.EXECUTOR_MCP_DEBUG === "true", browserApprovalStore: self.browserApprovalStore, @@ -271,8 +422,21 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { + buildSlot?.cancel(); + }), + ), Effect.provide(makeSessionServices(dbHandle)), // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface as the base's tapCause/cleanup defect Effect.orDie, @@ -302,6 +466,12 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase): Effect.Effect { + return claimCauseHandledByDurableObject; + } + // Best-effort export the DO isolate's buffered spans after the RPC settles, // so a dying init/handleRequest can ship its own spans (and the exception + // stack recorded on them) — not just the worker-side `mcp.do.*` span. Keep it diff --git a/apps/cloud/src/mcp/session-meta.node.test.ts b/apps/cloud/src/mcp/session-meta.node.test.ts new file mode 100644 index 0000000000..f745da3ee5 --- /dev/null +++ b/apps/cloud/src/mcp/session-meta.node.test.ts @@ -0,0 +1,205 @@ +// Where an MCP session's organization identity comes from, and what happens +// when the only remaining source — the database — is unreachable. +// +// The production defect: every cold session-DO init read the `organizations` +// row over a brand-new Postgres connection, even though the worker had resolved +// that exact row microseconds earlier on the same request, and even when the DO +// already held the answer in its own storage. A connect timeout on that +// unnecessary connection became an unclassified defect and killed `initialize`. +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Predicate, Result } from "effect"; + +import { defaultMcpResource } from "@executor-js/host-mcp"; +import type { McpSessionInit, SessionMeta } from "@executor-js/cloudflare/mcp/agent-durable-object"; + +import { UserStoreService } from "../auth/context"; +import { UserStoreError } from "../auth/errors"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { + isMcpSessionMetaUnavailable, + resolveSessionMetaForToken, + SESSION_META_DB_RETRIES, +} from "./session-meta"; + +const TOKEN: McpSessionInit = { + organizationId: "org_test", + orgRoleModel: "organization", + orgRole: "member", + userId: "user_test", + elicitationMode: "model", + resource: defaultMcpResource, + artifactsEnabled: true, +}; + +const STORED: SessionMeta = { + organizationId: "org_test", + organizationName: "Stored Org", + organizationSlug: "stored-org", + orgRoleModel: "organization", + orgRole: "admin", + userId: "user_test", + resource: defaultMcpResource, +}; + +/** A user store that always fails the way a wedged Hyperdrive endpoint does, + * counting how many times it was asked. */ +const countingConnectTimeoutStore = (): { + readonly layer: Layer.Layer; + readonly calls: () => number; +} => { + let calls = 0; + return { + calls: () => calls, + layer: Layer.succeed(UserStoreService)({ + use: (operation: string) => + Effect.suspend(() => { + calls += 1; + return Effect.fail(new UserStoreError({ operation, reason: "connect_timeout" })); + }), + } as UserStoreService["Service"]), + }; +}; + +const namingStore = (): { + readonly layer: Layer.Layer; + readonly calls: () => number; +} => { + let calls = 0; + return { + calls: () => calls, + layer: Layer.succeed(UserStoreService)({ + use: (_operation: string, fn: (store: never) => Promise) => + Effect.suspend(() => { + calls += 1; + return Effect.promise(() => + fn({ + getOrganization: async (id: string) => ({ + id, + name: "Database Org", + slug: "database-org", + }), + } as never), + ); + }), + } as UserStoreService["Service"]), + }; +}; + +const unusedWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`), + }), +); + +// The props source — the one the overwhelming majority of inits take — is +// covered black-box by `e2e/cloud/mcp-session-cold-init.test.ts`. What stays +// here is what that scenario cannot reach: the sources it falls back to, and +// what an unreachable database does to them. +describe("resolveSessionMetaForToken", () => { + it("falls back to the meta this session already stored, without touching the store", async () => { + const store = countingConnectTimeoutStore(); + + const meta = await Effect.runPromise( + resolveSessionMetaForToken(TOKEN, STORED).pipe( + Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)), + ), + ); + + expect(meta.organizationName).toBe("Stored Org"); + expect(meta.organizationSlug).toBe("stored-org"); + expect(meta.orgRole).toBe("member"); + expect(store.calls(), "a restore reuses what it persisted").toBe(0); + }); + + it("does not let a stored admin role authorize legacy init props", async () => { + const store = countingConnectTimeoutStore(); + const { orgRole: _orgRole, ...legacyToken } = TOKEN; + + const meta = await Effect.runPromise( + resolveSessionMetaForToken(legacyToken, STORED).pipe( + Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)), + ), + ); + + expect(meta.orgRole).toBeUndefined(); + expect(store.calls()).toBe(0); + }); + + it("uses the fresh role when a warm session's principal is demoted", async () => { + const store = countingConnectTimeoutStore(); + const admin = await Effect.runPromise( + resolveSessionMetaForToken({ ...TOKEN, orgRole: "admin" }, STORED).pipe( + Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)), + ), + ); + const member = await Effect.runPromise( + resolveSessionMetaForToken(TOKEN, admin).pipe( + Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)), + ), + ); + + expect(admin.orgRole).toBe("admin"); + expect(member.orgRole).toBe("member"); + expect(store.calls()).toBe(0); + }); + + it("reads the database only when nothing else names the org", async () => { + const store = namingStore(); + + const meta = await Effect.runPromise( + resolveSessionMetaForToken(TOKEN, null).pipe( + Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)), + ), + ); + + expect(meta.organizationName).toBe("Database Org"); + expect(meta.orgRole).toBe("member"); + expect(store.calls()).toBe(1); + }); + + it("retries a connect timeout a bounded number of times, then fails retryably", async () => { + const store = countingConnectTimeoutStore(); + + const result = await Effect.runPromise( + resolveSessionMetaForToken(TOKEN, null).pipe( + Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)), + Effect.result, + ), + ); + + expect(Result.isFailure(result), "an unreachable directory is a failure, not a defect").toBe( + true, + ); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged(result.failure, "McpSessionMetaUnavailableError")).toBe(true); + expect( + isMcpSessionMetaUnavailable(result.failure), + "the worker can recognise it across the Durable Object boundary", + ).toBe(true); + expect(store.calls(), "bounded: the first attempt plus its retries").toBe( + SESSION_META_DB_RETRIES + 1, + ); + }); + + it("does not retry a deterministic query failure", async () => { + let calls = 0; + const store = Layer.succeed(UserStoreService)({ + use: (operation: string) => + Effect.suspend(() => { + calls += 1; + return Effect.fail(new UserStoreError({ operation, reason: "query" })); + }), + } as UserStoreService["Service"]); + + const result = await Effect.runPromise( + resolveSessionMetaForToken(TOKEN, null).pipe( + Effect.provide(Layer.mergeAll(store, unusedWorkOS)), + Effect.result, + ), + ); + + expect(Result.isFailure(result)).toBe(true); + expect(calls, "a query the server answered will answer the same way again").toBe(1); + }); +}); diff --git a/apps/cloud/src/mcp/session-meta.ts b/apps/cloud/src/mcp/session-meta.ts new file mode 100644 index 0000000000..8ae64e1a69 --- /dev/null +++ b/apps/cloud/src/mcp/session-meta.ts @@ -0,0 +1,216 @@ +// --------------------------------------------------------------------------- +// Where an MCP session's organization identity comes from. +// +// Three sources, in the order they are preferred: +// +// props — the org record the worker resolved while authorizing THIS +// request, carried in the session props. Free, and always the +// freshest answer available. +// stored — the meta this session Durable Object already persisted for the +// same organization on an earlier init. Free, and correct for a +// session that already exists. +// database — an actual read of the `organizations` row. +// +// Only the third can fail, and it is the one that used to run unconditionally. +// Every cold DO init opened a brand-new Postgres connection through Hyperdrive +// purely to re-read a row the worker had loaded microseconds earlier on the +// same request and then discarded; when that connection could not be +// established the read hung for the full connect budget and the failure was +// turned into a defect, killing `initialize` on a database that was, at that +// same moment, answering the worker's own queries in milliseconds. +// +// So: prefer what the request already knows, and when the database genuinely is +// the only source, give it a bounded retry and a CLASSIFIED failure — the same +// transient-vs-definitive split the WorkOS membership check already uses in +// `auth-provider.ts` — instead of an unclassified defect. +// --------------------------------------------------------------------------- + +import { Data, Effect, Predicate, Result, Schedule } from "effect"; + +import type { McpSessionInit, SessionMeta } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { sessionOrgRoleMetadata } from "@executor-js/cloudflare/mcp/role-metadata"; + +import { UserStoreService } from "../auth/context"; +import { WorkOSClient } from "../auth/workos"; +import { + isDefinitiveWorkOSDenial, + isTransientUserStoreReason, + type UserStoreError, +} from "../auth/errors"; +import { resolveOrganization } from "../auth/organization"; + +export type SessionMetaSource = "props" | "stored" | "database"; + +export const SESSION_META_SOURCE_ATTRIBUTE = "mcp.session.meta_source"; + +/** + * The wire marker for "the organization directory was unreachable, try again". + * + * A Durable Object's `init` can only reject its Promise, so this travels to the + * worker as an ordinary error message across the DO boundary — the same + * mechanism the condemned-session `"destroyed"` abort already uses. The worker + * matches on it to answer a retryable 503 instead of letting an unclassified + * 500 (and the agents SDK's own DO-operation retry on top of it) turn a + * transient blip into a half-minute client hang. + */ +export const MCP_SESSION_META_UNAVAILABLE = "mcp_session_meta_unavailable"; + +export class McpSessionMetaUnavailableError extends Data.TaggedError( + "McpSessionMetaUnavailableError", +)<{ + readonly reason: string; + readonly attempts: number; +}> { + override get message(): string { + return `${MCP_SESSION_META_UNAVAILABLE}: organization directory unavailable (${this.reason}) after ${this.attempts} attempts`; + } +} + +export class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{ + readonly organizationId: string; +}> {} + +/** Does this failure, seen at the worker, mean "the session DO could not reach + * the organization directory"? Matches on the message because that is what + * survives the Durable Object RPC boundary. */ +export const isMcpSessionMetaUnavailable = (error: unknown): boolean => + // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: a Durable Object rejection reaches the worker as a plain Error whose message IS the signal (same mechanism as the "destroyed" abort) + Predicate.isError(error) && error.message.includes(MCP_SESSION_META_UNAVAILABLE); + +/** + * Retries for the database path. Deliberately small: the point is to ride out a + * single bad connection attempt, not to sit on a client's `initialize` while a + * database stays down. Exhausting them answers the client quickly and + * retryably, which is strictly better than hanging. + */ +export const SESSION_META_DB_RETRIES = 2; + +const RETRY_SCHEDULE = Schedule.both( + Schedule.exponential("200 millis"), + Schedule.recurs(SESSION_META_DB_RETRIES), +); + +const isUserStoreError = Predicate.isTagged("UserStoreError") as ( + error: unknown, +) => error is UserStoreError; + +/** + * Is this org-lookup failure worth another attempt? A connection that never + * opened is; a query the server answered with an error, or a WorkOS denial, is + * not — retrying those only burns the client's init budget. + */ +export const isRetryableOrganizationLookupFailure = (failure: unknown): boolean => { + if (isUserStoreError(failure)) return isTransientUserStoreReason(failure.reason); + if (isDefinitiveWorkOSDenial(failure)) return false; + // A WorkOS blip (429/5xx/timeout/network) — the same class the MCP auth path + // already treats as retryable. + return Predicate.isTagged(failure, "WorkOSError"); +}; + +const failureReason = (failure: unknown): string => + isUserStoreError(failure) ? failure.reason : "upstream"; + +const metaFromIdentity = ( + token: McpSessionInit, + organization: { + readonly name: string; + readonly slug?: string; + }, +): SessionMeta => { + return { + organizationId: token.organizationId, + organizationName: organization.name, + ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), + ...sessionOrgRoleMetadata(token), + userId: token.userId, + resource: token.resource, + elicitationMode: token.elicitationMode, + artifactsEnabled: token.artifactsEnabled, + searchToolsEnabled: token.searchToolsEnabled, + }; +}; + +/** + * Read the organization row, retrying only failures a retry can clear, and + * surfacing an exhausted retry as a typed, retryable failure rather than a + * defect. + */ +const organizationFromDatabase = ( + organizationId: string, +): Effect.Effect< + { readonly id: string; readonly name: string; readonly slug?: string }, + McpSessionMetaUnavailableError | OrganizationNotFoundError, + UserStoreService | WorkOSClient +> => + Effect.gen(function* () { + let attempts = 0; + // `Effect.retry` retries every failure it sees, so definitive failures are + // lifted OUT of the error channel before the schedule ever runs and only + // the retryable ones are left in it. + const attempt = Effect.suspend(() => { + attempts += 1; + return resolveOrganization(organizationId).pipe( + Effect.result, + Effect.flatMap((outcome) => + Result.isFailure(outcome) && isRetryableOrganizationLookupFailure(outcome.failure) + ? Effect.fail(outcome.failure) + : Effect.succeed(outcome), + ), + ); + }); + + // Two nested Results: the outer one is "the retries ran out", the inner one + // is "the lookup failed definitively on the first look". Both mean the same + // thing to the caller. + const retried = yield* attempt.pipe(Effect.retry(RETRY_SCHEDULE), Effect.result); + const outcome = Result.isFailure(retried) ? Result.fail(retried.failure) : retried.success; + + if (Result.isFailure(outcome)) { + return yield* new McpSessionMetaUnavailableError({ + reason: failureReason(outcome.failure), + attempts, + }); + } + const organization = outcome.success; + if (!organization) return yield* new OrganizationNotFoundError({ organizationId }); + return organization; + }).pipe( + Effect.withSpan("mcp.session.resolve_organization", { + attributes: { "mcp.auth.organization_id": organizationId }, + }), + ); + +/** + * Build the session meta for an init, preferring the org identity the request + * already carries over any read of the organization directory. + * + * `storedMeta` is this DO's own persisted meta for the SAME organization (the + * base Durable Object only offers a matching one), or `null`. + */ +export const resolveSessionMetaForToken = ( + token: McpSessionInit, + storedMeta: SessionMeta | null, +): Effect.Effect< + SessionMeta, + McpSessionMetaUnavailableError | OrganizationNotFoundError, + UserStoreService | WorkOSClient +> => + Effect.gen(function* () { + const fromProps = token.organizationName; + if (fromProps) { + yield* Effect.annotateCurrentSpan(SESSION_META_SOURCE_ATTRIBUTE, "props"); + return metaFromIdentity(token, { name: fromProps, slug: token.organizationSlug }); + } + + if (storedMeta?.organizationName) { + yield* Effect.annotateCurrentSpan(SESSION_META_SOURCE_ATTRIBUTE, "stored"); + return metaFromIdentity(token, { + name: storedMeta.organizationName, + slug: storedMeta.organizationSlug, + }); + } + + yield* Effect.annotateCurrentSpan(SESSION_META_SOURCE_ATTRIBUTE, "database"); + const organization = yield* organizationFromDatabase(token.organizationId); + return metaFromIdentity(token, organization); + }); diff --git a/apps/cloud/src/mcp/telemetry.test.ts b/apps/cloud/src/mcp/telemetry.test.ts new file mode 100644 index 0000000000..df150bc9ed --- /dev/null +++ b/apps/cloud/src/mcp/telemetry.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import type * as Tracer from "effect/Tracer"; + +import { annotateMcpRequest } from "./telemetry"; + +// A tracer that records attributes per span so the test can assert what +// `annotateMcpRequest` stamps on the active request span. One record per span +// creation (never keyed by name) so same-named spans stay distinct. +type RecordedSpan = { + readonly name: string; + readonly attributes: ReadonlyMap; +}; + +const makeRecordingTracer = (): { + tracer: Tracer.Tracer; + attributesOf: (name: string) => ReadonlyMap | undefined; +} => { + const spans: RecordedSpan[] = []; + const tracer: Tracer.Tracer = { + span: (options) => { + const attributes = new Map(); + spans.push({ name: options.name, attributes }); + let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; + return { + _tag: "Span", + name: options.name, + spanId: `span-${spans.length}`, + traceId: "trace-1", + parent: options.parent, + annotations: options.annotations, + get status() { + return status; + }, + attributes, + links: options.links, + sampled: options.sampled, + kind: options.kind, + end: (endTime, exit) => { + status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; + }, + attribute: (key, value) => { + attributes.set(key, value); + }, + event: () => undefined, + addLinks: () => undefined, + }; + }, + }; + return { tracer, attributesOf: (name) => spans.find((span) => span.name === name)?.attributes }; +}; + +const expectDefined: (value: T) => asserts value is NonNullable = (value) => { + expect(value).toBeDefined(); +}; + +const postRequest = (body: unknown): Request => + new Request("https://executor.sh/mcp", { + method: "POST", + headers: { "content-type": "application/json", "mcp-session-id": "sess-123" }, + body: JSON.stringify(body), + }); + +const annotate = (request: Request) => + annotateMcpRequest(request, { token: null, parseBody: true }); + +describe("annotateMcpRequest — cancellation join keys", () => { + it.effect("stamps the cancelled request id and reason from notifications/cancelled", () => { + const { tracer, attributesOf } = makeRecordingTracer(); + return Effect.gen(function* () { + yield* annotate( + postRequest({ + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: 42, reason: "client timeout" }, + }), + ); + const attributes = attributesOf("mcp.request"); + expectDefined(attributes); + expect(attributes.get("mcp.rpc.method")).toBe("notifications/cancelled"); + expect(attributes.get("mcp.rpc.cancelled_id")).toBe("42"); + expect(attributes.get("mcp.rpc.cancelled_reason")).toBe("client timeout"); + expect(attributes.get("mcp.request.session_id")).toBe("sess-123"); + }).pipe(Effect.withSpan("mcp.request"), Effect.withTracer(tracer)); + }); + + it.effect("still stamps mcp.rpc.id on ordinary requests", () => { + const { tracer, attributesOf } = makeRecordingTracer(); + return Effect.gen(function* () { + yield* annotate( + postRequest({ + jsonrpc: "2.0", + id: 7, + method: "tools/call", + params: { name: "execute" }, + }), + ); + const attributes = attributesOf("mcp.request"); + expectDefined(attributes); + expect(attributes.get("mcp.rpc.id")).toBe("7"); + expect(attributes.get("mcp.tool.name")).toBe("execute"); + expect(attributes.get("mcp.rpc.cancelled_id")).toBeUndefined(); + }).pipe(Effect.withSpan("mcp.request"), Effect.withTracer(tracer)); + }); +}); + +describe("annotateMcpRequest — executed-code capture", () => { + it.effect("stamps the script on execute calls", () => { + const { tracer, attributesOf } = makeRecordingTracer(); + return Effect.gen(function* () { + yield* annotate( + postRequest({ + jsonrpc: "2.0", + id: 8, + method: "tools/call", + params: { name: "execute", arguments: { code: "return 6 * 7;" } }, + }), + ); + const attributes = attributesOf("mcp.request"); + expectDefined(attributes); + expect(attributes.get("mcp.execute.code")).toBe("return 6 * 7;"); + }).pipe(Effect.withSpan("mcp.request"), Effect.withTracer(tracer)); + }); + + it.effect("caps oversized scripts with a truncation marker", () => { + const { tracer, attributesOf } = makeRecordingTracer(); + return Effect.gen(function* () { + const code = "x".repeat(12_000); + yield* annotate( + postRequest({ + jsonrpc: "2.0", + id: 9, + method: "tools/call", + params: { name: "execute", arguments: { code } }, + }), + ); + const attributes = attributesOf("mcp.request"); + expectDefined(attributes); + const captured = attributes.get("mcp.execute.code"); + expect(captured).toBe(`${"x".repeat(10_000)}\n… [truncated 2000 chars]`); + }).pipe(Effect.withSpan("mcp.request"), Effect.withTracer(tracer)); + }); + + it.effect("does not capture arguments of other tools", () => { + const { tracer, attributesOf } = makeRecordingTracer(); + return Effect.gen(function* () { + yield* annotate( + postRequest({ + jsonrpc: "2.0", + id: 10, + method: "tools/call", + params: { name: "resume", arguments: { code: "not an execute call" } }, + }), + ); + const attributes = attributesOf("mcp.request"); + expectDefined(attributes); + expect(attributes.get("mcp.execute.code")).toBeUndefined(); + }).pipe(Effect.withSpan("mcp.request"), Effect.withTracer(tracer)); + }); +}); diff --git a/apps/cloud/src/mcp/telemetry.ts b/apps/cloud/src/mcp/telemetry.ts index dc30b8f01b..3f0c300256 100644 --- a/apps/cloud/src/mcp/telemetry.ts +++ b/apps/cloud/src/mcp/telemetry.ts @@ -102,15 +102,29 @@ const InitializeParams = Schema.Struct({ capabilities: Schema.optional(UnknownRecord), }); -const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); +const NamedParams = Schema.Struct({ + name: Schema.optional(Schema.String), + arguments: Schema.optional(UnknownRecord), +}); const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); +// `notifications/cancelled` carries no JSON-RPC id of its own, but its params +// name the request the client gave up on. Surfacing that id as +// `mcp.rpc.cancelled_id` makes "a client gave up" joinable to the exact +// `tools/call` (and the execution spans, which carry `mcp.rpc.id`) that hung — +// the best available proxy for a killed execution. +const CancelledParams = Schema.Struct({ + requestId: Schema.optional(Schema.Union([Schema.String, Schema.Number])), + reason: Schema.optional(Schema.String), +}); + const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( Schema.fromJsonString(JsonRpcEnvelope), ); const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); const decodeUriParams = Schema.decodeUnknownOption(UriParams); +const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams); const decodeElicitationReplyResult = Schema.decodeUnknownOption(ElicitationReplyResult); const readJsonRpcEnvelope = (request: Request): Effect.Effect> => @@ -123,6 +137,29 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect | undefined, +): Record => { + if (name !== "execute" && name !== "execute-action") return {}; + const code = args?.["code"]; + if (typeof code !== "string") return {}; + return { + "mcp.execute.code": + code.length > MAX_CODE_ATTR_CHARS + ? `${code.slice(0, MAX_CODE_ATTR_CHARS)}\n… [truncated ${code.length - MAX_CODE_ATTR_CHARS} chars]` + : code, + }; +}; + const methodAttrs = (envelope: JsonRpcEnvelope): Record => { const params = envelope.params ?? {}; return Match.value(envelope.method).pipe( @@ -143,7 +180,10 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record => { Match.when("tools/call", () => Option.match(decodeNamedParams(params), { onNone: () => ({}) as Record, - onSome: ({ name }) => (name ? { "mcp.tool.name": name } : {}), + onSome: ({ name, arguments: args }) => ({ + ...(name ? { "mcp.tool.name": name } : {}), + ...executeCodeAttrs(name, args), + }), }), ), Match.whenOr("resources/read", "resources/subscribe", () => @@ -158,6 +198,15 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record => { onSome: ({ name }) => (name ? { "mcp.prompt.name": name } : {}), }), ), + Match.when("notifications/cancelled", () => + Option.match(decodeCancelledParams(params), { + onNone: () => ({}) as Record, + onSome: ({ requestId, reason }) => ({ + ...(requestId !== undefined && { "mcp.rpc.cancelled_id": String(requestId) }), + ...(reason && { "mcp.rpc.cancelled_reason": reason }), + }), + }), + ), Match.option, Option.getOrElse(() => ({}) as Record), ); @@ -217,5 +266,4 @@ export const annotateMcpRequest = ( }; yield* Effect.annotateCurrentSpan(attrs); - yield* Effect.annotateCurrentSpan(attrs).pipe(Effect.withSpan("mcp.request.annotate")); }); diff --git a/apps/cloud/src/mcp/traceparent.ts b/apps/cloud/src/mcp/traceparent.ts index 0be604025a..72555df4cf 100644 --- a/apps/cloud/src/mcp/traceparent.ts +++ b/apps/cloud/src/mcp/traceparent.ts @@ -1,13 +1,13 @@ // --------------------------------------------------------------------------- // W3C traceparent parsing shared by the worker edge (server.ts), the MCP agent -// handler, and the session DO. Single-sourced so the producer (the worker span -// stamping traceparent onto the forwarded request) and the consumers (the -// Effect programs joining that span) cannot drift on the header grammar. +// handler, and the session DO. The header grammar itself is single-sourced in +// `@executor-js/cloudflare/mcp/do-headers` beside the producer that stamps the +// header; this module only layers the OTel `tracestate` carrier on top for the +// consumers that join the span via the OTel API. // --------------------------------------------------------------------------- import { createTraceState } from "@opentelemetry/api"; - -const TRACEPARENT_PATTERN = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/; +import { parseTraceparentHeader } from "@executor-js/cloudflare/mcp/do-headers"; export type IncomingSpanContext = { readonly traceId: string; @@ -20,13 +20,10 @@ export const parseTraceparent = ( traceparent: string | null | undefined, tracestate: string | null | undefined, ): IncomingSpanContext | null => { - if (!traceparent) return null; - const match = TRACEPARENT_PATTERN.exec(traceparent); - if (!match) return null; + const parsed = parseTraceparentHeader(traceparent); + if (!parsed) return null; return { - traceId: match[2]!, - spanId: match[3]!, - traceFlags: parseInt(match[4]!, 16), + ...parsed, ...(tracestate ? { traceState: createTraceState(tracestate) } : {}), }; }; diff --git a/apps/cloud/src/observability/browser-traces.test.ts b/apps/cloud/src/observability/browser-traces.test.ts index ce20707b07..f35bf1811a 100644 --- a/apps/cloud/src/observability/browser-traces.test.ts +++ b/apps/cloud/src/observability/browser-traces.test.ts @@ -53,6 +53,103 @@ describe("browserTracesResponse", () => { expect(await response?.text()).toBe(""); }); + it("scrubs credential-bearing URLs from the forwarded payload", async () => { + // The browser's OTLP batch is pre-serialized JSON; the worker must scrub + // the decoded payload before forwarding — the page-side exporter is not a + // trust boundary. + const SECRET = "synthetic-browser-forward-canary"; + const payload = { + resourceSpans: [ + { + resource: { attributes: [] }, + scopeSpans: [ + { + scope: { name: "executor-web" }, + spans: [ + { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + name: "http.client GET", + kind: 3, + startTimeUnixNano: "0", + endTimeUnixNano: "1", + attributes: [ + { + key: "url.full", + value: { + stringValue: `https://app.test/api/oauth/callback?code=${SECRET}-code#access_token=${SECRET}-fragment`, + // Crafted sibling fields: the page-side exporter is not + // a trust boundary, so the URL-keyed special case must + // not exempt the rest of the KeyValue from the scrub. + extraValue: `https://app.test/x?owner=${SECRET}-kv-inner`, + }, + note: `retry of https://app.test/x?owner=${SECRET}-kv-sibling`, + }, + { key: "url.query", value: { stringValue: `code=${SECRET}-code` } }, + ], + events: [ + { + name: "exception", + timeUnixNano: "1", + attributes: [ + { + key: "exception.message", + value: { + stringValue: `GET https://u:${SECRET}-userinfo@api.test/x?key=${SECRET}-key failed`, + }, + }, + ], + }, + ], + status: { + code: 2, + message: `GET https://api.test/x?key=${SECRET}-status failed`, + }, + }, + ], + }, + ], + }, + ], + }; + let forwarded: string | undefined; + const response = await browserTracesResponse( + makeRequest({ + headers: { cookie: "wos-session=abc" }, + body: JSON.stringify(payload), + }), + baseEnv, + (async (_url: RequestInfo | URL, init?: RequestInit) => { + forwarded = await new Response(init?.body as BodyInit).text(); + return new Response(null, { status: 200 }); + }) as typeof fetch, + ); + expect(response?.status).toBe(204); + expect(forwarded).toBeDefined(); + expect(forwarded).not.toContain(SECRET); + // Non-vacuous: the span, its route, and its trace identity survive. + expect(forwarded).toContain("/api/oauth/callback"); + expect(forwarded).toContain("0af7651916cd43dd8448eb211c80319c"); + expect(forwarded).toContain("exception"); + }); + + it("rejects an unparseable batch instead of forwarding it unscrubbed", async () => { + let forwardedCount = 0; + const response = await browserTracesResponse( + makeRequest({ + headers: { cookie: "wos-session=abc" }, + body: `not-json ?token=synthetic-unparsed-secret`, + }), + baseEnv, + (async () => { + forwardedCount += 1; + return new Response(null, { status: 200 }); + }) as typeof fetch, + ); + expect(response?.status).toBe(400); + expect(forwardedCount).toBe(0); + }); + it("reports upstream failure as 502 without leaking detail", async () => { const response = await browserTracesResponse( makeRequest({ headers: { cookie: "wos-session=abc" } }), diff --git a/apps/cloud/src/observability/browser-traces.ts b/apps/cloud/src/observability/browser-traces.ts index 8505678e2c..929663eef3 100644 --- a/apps/cloud/src/observability/browser-traces.ts +++ b/apps/cloud/src/observability/browser-traces.ts @@ -8,11 +8,32 @@ // but it should not be an anonymous internet ingest — a session cookie must // at least be present, and bodies are capped. We deliberately do NOT verify // the session (that would put a WorkOS round-trip on every span batch). +// +// The batch is never forwarded opaquely. The page-side exporter scrubs its +// own spans, but the page is not a trust boundary — anything with the cookie +// can POST here — so the worker decodes the OTLP JSON payload, scrubs every +// credential-bearing URL component server-side, and forwards the +// re-serialized result. A batch that does not parse as JSON is refused: what +// cannot be scrubbed is not forwarded. + +import { redactOtlpTraceExport } from "@executor-js/sdk"; const MAX_BODY_BYTES = 2_000_000; export const BROWSER_TRACES_PATH = "/v1/traces"; +/** The batch re-serialized with every URL scrubbed, or `undefined` when it is + * not valid JSON (or too deep to scrub) and must be refused. */ +const scrubbedBatch = (body: string): string | undefined => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.parse throws on malformed input; the refusal path (400) is the handled outcome + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: the batch is deliberately unknown-shaped (the scrub walks arbitrary JSON); there is no schema to decode into + return JSON.stringify(redactOtlpTraceExport(JSON.parse(body))); + } catch { + return undefined; + } +}; + export const browserTracesResponse = ( request: Request, env: Env, @@ -35,18 +56,34 @@ export const browserTracesResponse = ( if (contentLength > MAX_BODY_BYTES) { return Promise.resolve(new Response(null, { status: 413 })); } - return fetchImpl(env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces", { - method: "POST", - headers: { - "content-type": request.headers.get("content-type") ?? "application/json", - authorization: `Bearer ${env.AXIOM_TOKEN}`, - "x-axiom-dataset": env.AXIOM_DATASET ?? "executor-cloud", + return request.text().then( + (body) => { + // The content-length guard above trusts a header; re-check the bytes + // actually read. + if (body.length > MAX_BODY_BYTES) { + return new Response(null, { status: 413 }); + } + const scrubbed = scrubbedBatch(body); + if (scrubbed === undefined) { + return new Response(null, { status: 400 }); + } + return fetchImpl(env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces", { + method: "POST", + headers: { + // Always JSON: the batch was decoded, scrubbed, and re-serialized. + "content-type": "application/json", + authorization: `Bearer ${env.AXIOM_TOKEN}`, + "x-axiom-dataset": env.AXIOM_DATASET ?? "executor-cloud", + }, + body: scrubbed, + }).then( + // The exporter only needs success/failure; never reflect Axiom's + // response body (or its headers) back to an unauthenticated caller. + (upstream) => new Response(null, { status: upstream.ok ? 204 : 502 }), + () => new Response(null, { status: 502 }), + ); }, - body: request.body, - }).then( - // The exporter only needs success/failure; never reflect Axiom's - // response body (or its headers) back to an unauthenticated caller. - (upstream) => new Response(null, { status: upstream.ok ? 204 : 502 }), - () => new Response(null, { status: 502 }), + // An unreadable body cannot be scrubbed, so it is refused, not forwarded. + () => new Response(null, { status: 400 }), ); }; diff --git a/apps/cloud/src/observability/header-redaction.ts b/apps/cloud/src/observability/header-redaction.ts new file mode 100644 index 0000000000..8c7fd3fc08 --- /dev/null +++ b/apps/cloud/src/observability/header-redaction.ts @@ -0,0 +1,19 @@ +// --------------------------------------------------------------------------- +// Span header redaction for fibers composed under the cloud telemetry layers +// (the Effect HTTP server tracer and any request-scoped client use). The +// allowlist itself lives beside the hosted HTTP client in +// `@executor-js/sdk/host-internal`, which also binds it directly to that +// client: consumers capture the client value at construction, so a context +// layer alone cannot reach their request fibers. This layer is the +// server-side/defense-in-depth half of the same decision; see +// hosted-http-client.ts for the rationale and the allowlist. +// --------------------------------------------------------------------------- + +import { Layer } from "effect"; +import { Headers } from "effect/unstable/http"; +import { spanRedactedHeaderNames } from "@executor-js/sdk/host-internal"; + +export const SpanHeaderRedactionLive: Layer.Layer = Layer.succeed( + Headers.CurrentRedactedNames, + spanRedactedHeaderNames, +); diff --git a/apps/cloud/src/observability/index.ts b/apps/cloud/src/observability/index.ts index 6d1cd7e999..88b88419e5 100644 --- a/apps/cloud/src/observability/index.ts +++ b/apps/cloud/src/observability/index.ts @@ -12,10 +12,12 @@ import * as Sentry from "@sentry/cloudflare"; import type { ErrorEvent, Scope } from "@sentry/cloudflare"; -import { Cause, Effect, Layer } from "effect"; +import { Cause, Effect, Layer, Predicate } from "effect"; import type * as Tracer from "effect/Tracer"; import { ErrorCapture } from "@executor-js/api"; +import { classifyDurableObjectError } from "@executor-js/cloudflare/mcp/durable-object-errors"; +import { withStableGroupingFingerprint } from "@executor-js/sdk/sentry-grouping"; // Drizzle/postgres-js include the failing SQL (params + bound values) in // their error message. For OpenAPI source inserts that's 1MB+ of spec @@ -30,6 +32,21 @@ export const OTEL_TRACE_ID_TAG = "otel_trace_id"; export const OTEL_SPAN_ID_TAG = "otel_span_id"; export const SENTRY_EVENT_ID_ATTRIBUTE = "sentry.event_id"; +/** + * Set by the MCP session Durable Object when it has finished deciding what to + * do about a cause — reported it, or classified it as an expected Cloudflare + * platform reset and deliberately not reported it. + * + * `instrumentDurableObjectWithSentry` wraps the DO's entry points and captures + * the same rejection again as it escapes, which is why one platform reset + * opened two issues for the same event. The DO is the better owner — it has + * the session, the org, the OTEL correlation and the classification — so its + * claim wins and the auto-instrumentation's echo is dropped in `beforeSend`. + * Nothing the DO does not claim is affected. + */ +export const DO_CAUSE_OWNER_TAG = "mcp.do.cause_owner"; +export const DO_CAUSE_OWNER_VALUE = "durable_object"; + export type OtelCorrelationContext = { readonly traceId: string; readonly spanId: string; @@ -99,10 +116,68 @@ export const tagCurrentSentryScopeWithCurrentOtelSpan: Effect.Effect { + if (event.tags?.[DO_CAUSE_OWNER_TAG] !== DO_CAUSE_OWNER_VALUE) return false; + const mechanism = event.exception?.values?.[0]?.mechanism?.type; + return typeof mechanism === "string" && mechanism.startsWith("auto."); +}; + +/** + * The mechanism `instrumentDurableObjectWithSentry` stamps on a rejection it + * catches escaping a Durable Object entry point. + */ +const DO_FAAS_MECHANISM = "auto.faas.cloudflare.durable_object"; + +/** + * True when this event is the Durable Object instrumentation reporting the + * platform tearing its own object down. + * + * The capture-owner scheme above covers every reset a worker seam observed: the + * seam classifies the failure, answers the client with a retryable envelope and + * claims the cause, so the instrumentation's echo is dropped. A deploy has no + * such seam. The runtime resets every live object as the new script rolls out, + * and the instrumentation captures that from INSIDE the dying object, where + * there is no request to own it — so a handful of these file on every deploy + * with nothing behind them. The client-facing side is already handled: the + * in-flight request fails at the worker seam and is rendered as a 503 there. + * + * Both conditions are required, and the classifier is the one the seams use so + * this can never recognize a different set of messages than they do: + * + * - the mechanism must be the DO instrumentation, so a reset message arriving + * through any other path (an ordinary worker capture, the DO's own + * `captureCause` seam) is untouched; + * - the message must classify as a TRANSIENT platform failure, so a real defect + * thrown inside a DO — an alarm crash, a broken handler — still reports. The + * classifier returns null for everything it does not recognize, and this + * drops nothing on a null. + * + * `session_dead` is deliberately not dropped: that disposition means our own + * code called `ctx.abort`, which is an act of the application rather than the + * platform, and how often it happens is worth seeing. + */ +const isDurableObjectPlatformResetNoise = (event: ErrorEvent): boolean => { + const exception = event.exception?.values?.[0]; + if (exception?.mechanism?.type !== DO_FAAS_MECHANISM) return false; + // The exception's `value` is the workerd message verbatim, and the classifier + // already accepts a bare string as its input. + return classifyDurableObjectError(exception.value)?.disposition === "transient"; +}; + export const beforeSendWithOtelCorrelation = ( event: ErrorEvent, options?: { readonly logPayload?: boolean }, -): ErrorEvent => { +): ErrorEvent | null => { + if (isClaimedDurableObjectEcho(event)) return null; + if (isDurableObjectPlatformResetNoise(event)) return null; if (options?.logPayload) { console.info( JSON.stringify({ @@ -116,6 +191,54 @@ export const beforeSendWithOtelCorrelation = ( return event; }; +/** + * The single `beforeSend` the worker and its Durable Objects install. + * + * The worker ships as content-hashed chunks and its frames are not resolved + * back to source, so Sentry's default grouping keys on names like + * `execution-rate-limit-` and re-opens every issue on the next deploy. + * `withStableGroupingFingerprint` pins a key with the hash normalized out; + * events with no hashed grouping input are left on the default algorithm. + * + * The two stages are independent and compose in this order: the reporting pass + * decides WHETHER the event is reported at all (a cause the Durable Object + * already claimed, or a platform reset the DO instrumentation caught from + * inside a dying object, is dropped, and a dropped event is never + * fingerprinted), and the grouping pass then decides HOW whatever survives is + * grouped. + */ +export const beforeSendCloudEvent = ( + event: ErrorEvent, + options?: { readonly logPayload?: boolean }, +): ErrorEvent | null => { + const reported = beforeSendWithOtelCorrelation(event, options); + return reported === null ? null : withStableGroupingFingerprint(reported); +}; + +/** + * The Sentry options the worker and every Durable Object install. It lives + * beside the `beforeSend` it wires so the composition is covered by + * observability.test.ts; `server.ts` only passes this through. + * + * NOTE: do NOT enable `instrumentPrototypeMethods`. It walks the DO prototype + * and reads every property — including accessors — to find methods to wrap, + * which invokes the `sessionId` getter with `this` bound to the prototype + * (where `ctx` is undefined) and throws during construction, 500ing every + * session create / cold restore. The DO captures its own errors via the + * `captureCause` seam (→ Sentry) instead. + */ +export const cloudSentryOptions = (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 0, + enableLogs: true, + sendDefaultPii: true, + skipOpenTelemetrySetup: true, + beforeSend: (event: ErrorEvent) => + beforeSendCloudEvent(event, { + logPayload: !env.SENTRY_DSN || env.SENTRY_OTEL_LOG_PAYLOAD === "true", + }), +}); + export const addCurrentOtelCorrelationTags = < T extends { readonly tags?: Record }, >( @@ -144,14 +267,75 @@ export const sentryPayloadForCause = ( return { primary: input, pretty: null }; }; +// Safe classification fields our tagged errors carry (`UserStoreError.operation` +// / `.reason`, `WorkOSError.status`). They are promoted to Sentry TAGS because +// the pretty cause is only an `extra`, and Sentry's server-side scrubber +// replaces that extra with "[Filtered]" — leaving an issue with no failing +// operation and no reason in it at all. Tags survive, group, and are +// searchable. Values are failure modes and operation names; never a query, a +// value, or anything customer-derived. +const CLASSIFICATION_TAG_FIELDS = ["operation", "reason", "status"] as const; + +/** The errors those fields are read from. An allowlist, because the fields are + * only known to be safe on the errors this app defines. */ +const CLASSIFIED_ERROR_TAGS = [ + "UserStoreError", + "WorkOSError", + "McpSessionMetaUnavailableError", +] as const; + +const MAX_CLASSIFICATION_TAG_CHARS = 120; + +const MAX_CAUSE_NESTING = 3; + +/** Every error value a cause carries, failures and defects alike. A defect can + * itself be a `Cause` (an inner `runPromise` rejecting with its own squashed + * cause), so the walk unwraps a few levels. */ +const errorValuesOf = (input: unknown, depth = 0): readonly unknown[] => { + if (depth >= MAX_CAUSE_NESTING) return []; + if (!Cause.isCause(input)) return [input]; + const values: unknown[] = []; + for (const reason of input.reasons) { + if (Cause.isFailReason(reason)) values.push(reason.error); + else if (Cause.isDieReason(reason)) values.push(...errorValuesOf(reason.defect, depth + 1)); + } + return values; +}; + +/** Read the classification fields off the tagged errors inside a cause. First + * writer wins, so the innermost reported error names the issue. */ +const classificationTagsOf = (input: unknown): Readonly> => { + const tags: Record = {}; + for (const candidate of errorValuesOf(input)) { + if (typeof candidate !== "object" || candidate === null) continue; + // Only the errors whose fields we know are safe classifications. Without + // this, any foreign object in the cause that happens to carry an + // `operation` / `reason` / `status` field would have that value promoted + // onto a tag, and a foreign field is not known to be free of a query, a + // value, or anything customer-derived. + if (!CLASSIFIED_ERROR_TAGS.some((tag) => Predicate.isTagged(candidate, tag))) continue; + const tagged = candidate as Record; + for (const field of CLASSIFICATION_TAG_FIELDS) { + const value = tagged[field]; + if (tags[field] !== undefined) continue; + if (typeof value === "string" || typeof value === "number") { + tags[field] = String(value).slice(0, MAX_CLASSIFICATION_TAG_CHARS); + } + } + } + return tags; +}; + export const captureCause = ( input: unknown, context: OtelCorrelationContext | null = null, ): string | undefined => { const { primary, pretty } = sentryPayloadForCause(input); + const classification = classificationTagsOf(input); tagCurrentSentryScopeWithOtelContext(context); return Sentry.captureException(primary, (scope) => { tagSentryScopeWithOtelContext(scope, context); + for (const [key, value] of Object.entries(classification)) scope.setTag(key, value); if (pretty !== null) scope.setExtra("cause", pretty); return scope; }); @@ -167,6 +351,15 @@ export const captureCauseEffect = (input: unknown): Effect.Effect = Effect.sync(() => { + Sentry.getCurrentScope().setTag(DO_CAUSE_OWNER_TAG, DO_CAUSE_OWNER_VALUE); +}); + export const ErrorCaptureLive: Layer.Layer = Layer.succeed( ErrorCapture, ErrorCapture.of({ diff --git a/apps/cloud/src/observability/oauth-callback-telemetry.test.ts b/apps/cloud/src/observability/oauth-callback-telemetry.test.ts new file mode 100644 index 0000000000..d96e905ef9 --- /dev/null +++ b/apps/cloud/src/observability/oauth-callback-telemetry.test.ts @@ -0,0 +1,114 @@ +// --------------------------------------------------------------------------- +// OAuth callback × telemetry — the authorization code must never be exported. +// +// `/api/oauth/callback` is an app-owned path, so Effect's +// `HttpMiddleware.tracer` opens its `http.server` span and stamps `url.full` +// and `url.query` unconditionally (`effect/unstable/http/HttpMiddleware.ts`). +// It redacts URL userinfo and configured header names — nothing else — so the +// provider's `?code=…&state=…` reached the trace backend on every connect. +// +// This drives a real callback request through the real core API web handler, +// with the real Effect→OTel tracer bridge exporting into an in-memory +// exporter behind the production span-processor chain, and asserts that no +// exported span attribute contains the code or the state. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import * as Resource from "@effect/opentelemetry/Resource"; +import * as OtelTracer from "@effect/opentelemetry/Tracer"; +import { + InMemorySpanExporter, + SimpleSpanProcessor, + type ReadableSpan, +} from "@opentelemetry/sdk-trace-base"; +import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; +import { Context, Effect, Layer } from "effect"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; + +import { ExecutorApi, observabilityMiddleware } from "@executor-js/api"; +import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server"; +import { createExecutor } from "@executor-js/sdk"; +import { makeTestConfig } from "@executor-js/sdk/testing"; + +import { UrlRedactingSpanProcessor } from "./redact-span-urls"; + +// Synthetic placeholders — never a real authorization code or CSRF state. +const CODE = "synthetic-authorization-code-9f2c"; +const STATE = "synthetic-csrf-state-4b7e"; + +const makeTracing = () => { + const exporter = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + // The same wrapper order production installs in `telemetry.ts`: the + // redactor sits outermost, so nothing downstream ever sees the secret. + spanProcessors: [new UrlRedactingSpanProcessor(new SimpleSpanProcessor(exporter))], + }); + const tracerLayer = OtelTracer.layer.pipe( + Layer.provide(Layer.succeed(OtelTracer.OtelTracerProvider)(provider)), + Layer.provide(Resource.layer({ serviceName: "executor-cloud-test" })), + ); + return { exporter, provider, tracerLayer }; +}; + +describe("oauth callback telemetry", () => { + it.effect("exports no span attribute containing the authorization code or state", () => + Effect.gen(function* () { + const { exporter, provider, tracerLayer } = makeTracing(); + const executor = yield* createExecutor(makeTestConfig({})); + + const web = yield* Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(ExecutorApi).pipe( + Layer.provide(CoreHandlers), + Layer.provide(observabilityMiddleware(ExecutorApi)), + Layer.provide(Layer.succeed(ExecutorService)(executor)), + Layer.provide( + Layer.succeed(ExecutionEngineService)({} as ExecutionEngineService["Service"]), + ), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + // The Effect→OTel bridge: HttpMiddleware.tracer's span is created + // by this tracer, so it lands in the exporter below. + Layer.provideMerge(tracerLayer), + ), + { disableLogger: true }, + ), + ), + (handle) => Effect.promise(() => handle.dispose()), + ); + + const context = Context.make(ExecutorService, executor).pipe( + Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]), + ); + + // The provider round-trip: a real callback carries the grant and the + // CSRF state in the query string. + yield* Effect.promise(() => + web.handler( + new Request( + `http://app.test/oauth/callback?code=${CODE}&state=${STATE}&domain=example.test`, + ), + context, + ), + ); + + yield* Effect.promise(() => provider.forceFlush()); + const spans: readonly ReadableSpan[] = exporter.getFinishedSpans(); + + // The middleware must actually have opened a server span — otherwise + // this test would pass vacuously. + const serverSpan = spans.find((span) => span.name.startsWith("http.server")); + expect(serverSpan).toBeDefined(); + + const serialized = JSON.stringify(spans.map((span) => span.attributes)); + expect(serialized).not.toContain(CODE); + expect(serialized).not.toContain(STATE); + + // Route-level visibility survives the scrub. + expect(serverSpan?.attributes["url.path"]).toBe("/oauth/callback"); + expect(String(serverSpan?.attributes["url.full"] ?? "")).toContain("/oauth/callback"); + }), + ); +}); diff --git a/apps/cloud/src/observability/observability.test.ts b/apps/cloud/src/observability/observability.test.ts index b4f69a8d8e..b95ea61d7a 100644 --- a/apps/cloud/src/observability/observability.test.ts +++ b/apps/cloud/src/observability/observability.test.ts @@ -2,8 +2,15 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect } from "effect"; import type * as Tracer from "effect/Tracer"; +import type { ErrorEvent } from "@sentry/cloudflare"; + import { addCurrentOtelCorrelationTags, + beforeSendCloudEvent, + beforeSendWithOtelCorrelation, + cloudSentryOptions, + DO_CAUSE_OWNER_TAG, + DO_CAUSE_OWNER_VALUE, OTEL_SPAN_ID_TAG, OTEL_TRACE_ID_TAG, sentryPayloadForCause, @@ -79,6 +86,83 @@ describe("sentryPayloadForCause", () => { }); }); +// Grouping keys are decided inside the Sentry SDK and never appear on any +// product surface, so the e2e harness cannot observe them. The split is: +// e2e/cloud/sentry-otel-correlation.test.ts proves the worker really installs +// `cloudSentryOptions.beforeSend` (its correlation payload only exists if that +// hook ran), and the tests here prove the hook it installs fingerprints. +describe("Sentry grouping", () => { + // The worker bundle ships as content-hashed chunks, so the only module name + // Sentry ever sees for a given frame changes on every deploy. + const workerEvent = (chunkHash: string): ErrorEvent => ({ + type: undefined, + exception: { + values: [ + { + type: "GateCheckTimeoutError", + value: "balance check timed out", + stacktrace: { + frames: [ + { + filename: `/assets/execution-rate-limit-${chunkHash}.js`, + module: `execution-rate-limit-${chunkHash}`, + function: "timeoutOrElse", + in_app: true, + }, + ], + }, + }, + ], + }, + }); + + it("pins one fingerprint across two deploys of the same chunk", () => { + const before = beforeSendCloudEvent(workerEvent("BAuwphPA"), {}); + const after = beforeSendCloudEvent(workerEvent("DkcPBbWe"), {}); + + expect(before?.fingerprint).toBeDefined(); + expect(before?.fingerprint).toEqual(after?.fingerprint); + }); + + it("leaves unhashed events on Sentry's default grouping", () => { + const event: ErrorEvent = { + type: undefined, + exception: { + values: [ + { + type: "AutumnError", + stacktrace: { + frames: [ + { filename: "/src/engine/execution-gate.ts", function: "checkExecutionBalance" }, + ], + }, + }, + ], + }, + }; + + const sent = beforeSendCloudEvent(event, {}); + + expect(sent).not.toBeNull(); + expect(sent?.fingerprint).toBeUndefined(); + }); + + // The wiring check: this is the exact object handed to `Sentry.withSentry` + // and `instrumentDurableObjectWithSentry` in server.ts. If the normalizer is + // ever dropped from the hook the worker installs, this fails. + it("the options the worker and DOs install carry the fingerprinting hook", () => { + const options = cloudSentryOptions({ SENTRY_DSN: "https://public@example.invalid/1" } as Env); + const sent = options.beforeSend(workerEvent("BAuwphPA")); + + expect(sent?.fingerprint).toEqual([ + "GateCheckTimeoutError", + "timeoutOrElse@execution-rate-limit", + ]); + // Same source, next deploy, new chunk hash — one issue, not two. + expect(options.beforeSend(workerEvent("DkcPBbWe"))?.fingerprint).toEqual(sent?.fingerprint); + }); +}); + describe("Sentry OTel correlation", () => { it.effect("adds tags from the active Effect span", () => Effect.gen(function* () { @@ -91,3 +175,228 @@ describe("Sentry OTel correlation", () => { }).pipe(Effect.withSpan("test.sentry_capture"), Effect.withTracer(makeFixedTracer())), ); }); + +// One Durable Object failure used to open two Sentry issues: the DO's own +// `captureCause` seam reported it (mechanism `generic`), and then +// `instrumentDurableObjectWithSentry` reported the very same rejection again as +// it escaped the method (mechanism `auto.faas.cloudflare.durable_object`). The +// DO is the owner — it has the session, the classification and the OTel +// correlation — so its claim suppresses the echo and nothing else. +describe("Durable Object capture ownership", () => { + const doEcho = (overrides: Partial = {}): ErrorEvent => ({ + type: undefined, + tags: { [DO_CAUSE_OWNER_TAG]: DO_CAUSE_OWNER_VALUE }, + exception: { + values: [ + { + type: "Error", + value: "Durable Object reset because its code was updated.", + mechanism: { type: "auto.faas.cloudflare.durable_object", handled: false }, + }, + ], + }, + ...overrides, + }); + + it("drops the auto-instrumentation's copy of a cause the DO already claimed", () => { + expect(beforeSendWithOtelCorrelation(doEcho())).toBeNull(); + }); + + it("keeps the DO's own report, which carries no auto mechanism", () => { + const own = doEcho({ + exception: { + values: [ + { + type: "Error", + value: "Durable Object reset because its code was updated.", + mechanism: { type: "generic", handled: true }, + }, + ], + }, + }); + expect(beforeSendWithOtelCorrelation(own)).not.toBeNull(); + }); + + // An alarm crash or a transport fault is never claimed by the DO seam, and + // the auto-instrumentation is the ONLY thing that reports it. Dropping those + // would trade duplicate noise for silence. + it("keeps an unclaimed Durable Object failure", () => { + const unclaimed = doEcho({ + tags: {}, + exception: { + values: [ + { + type: "TypeError", + value: "this.scheduleNextSweep is not a function", + mechanism: { type: "auto.faas.cloudflare.durable_object", handled: false }, + }, + ], + }, + }); + expect(beforeSendWithOtelCorrelation(unclaimed)).not.toBeNull(); + }); + + it("keeps ordinary worker events untouched", () => { + const workerEvent: ErrorEvent = { + type: undefined, + tags: { [OTEL_TRACE_ID_TAG]: traceId }, + exception: { + values: [ + { + type: "TypeError", + value: "x is not a function", + mechanism: { type: "auto.http.cloudflare", handled: false }, + }, + ], + }, + }; + expect(beforeSendWithOtelCorrelation(workerEvent)).not.toBeNull(); + }); + + // The two stages of the installed `beforeSend` answer different questions and + // must both keep working: capture ownership decides WHETHER an event is + // reported, stable grouping decides HOW a reported one is grouped. A dropped + // event is never fingerprinted, and a surviving one still is. + describe("composed with stable grouping", () => { + const hashedFrames = (chunkHash: string) => ({ + stacktrace: { + frames: [ + { + filename: `/assets/session-durable-object-${chunkHash}.js`, + module: `session-durable-object-${chunkHash}`, + function: "handleSessionRequest", + in_app: true, + }, + ], + }, + }); + + it("drops a claimed echo rather than fingerprinting it", () => { + const echo = doEcho({ + exception: { + values: [ + { + type: "Error", + value: "Durable Object reset because its code was updated.", + mechanism: { type: "auto.faas.cloudflare.durable_object", handled: false }, + ...hashedFrames("BAuwphPA"), + }, + ], + }, + }); + + expect(beforeSendCloudEvent(echo, {})).toBeNull(); + }); + + it("pins a stable fingerprint on the report the DO itself owns", () => { + const ownReport = (chunkHash: string): ErrorEvent => + doEcho({ + exception: { + values: [ + { + type: "Error", + value: "Durable Object reset because its code was updated.", + mechanism: { type: "generic", handled: true }, + ...hashedFrames(chunkHash), + }, + ], + }, + }); + + const before = beforeSendCloudEvent(ownReport("BAuwphPA"), {}); + const after = beforeSendCloudEvent(ownReport("DkcPBbWe"), {}); + + expect(before?.fingerprint).toBeDefined(); + expect(before?.fingerprint).toEqual(after?.fingerprint); + }); + }); +}); + +// The residue the ownership scheme above cannot reach. A deploy resets every +// live Durable Object, and `instrumentDurableObjectWithSentry` captures that +// from inside the object being torn down — there is no worker seam in that +// isolate to classify the failure and claim the cause, so the event arrives +// unclaimed and files an issue with nothing behind it. The client side is +// already handled: the in-flight request fails at the worker seam and is +// answered there with a retryable 503. +// +// Not reachable from a black-box test. The dropped event and the events kept +// beside it declare the same exception type with the same shape; only the +// mechanism the Sentry SDK stamps and the classifier's verdict on the message +// separate them, and neither is observable from outside the SDK. +describe("Durable Object platform reset noise", () => { + const doInstrumentationEvent = ( + value: string, + mechanismType = "auto.faas.cloudflare.durable_object", + ): ErrorEvent => ({ + type: undefined, + // Unclaimed on purpose: the whole point is that no worker seam existed to + // claim this one. + tags: {}, + exception: { + values: [ + { + type: "Error", + value, + mechanism: { type: mechanismType, handled: false }, + }, + ], + }, + }); + + it("drops a deploy-time code-update reset reported by the DO instrumentation", () => { + const event = doInstrumentationEvent("Durable Object reset because its code was updated."); + expect(beforeSendWithOtelCorrelation(event)).toBeNull(); + }); + + // The classifier is shared with the worker seams, so every transient kind it + // knows is covered here rather than just the deploy message. + it("drops the other transient platform resets the shared classifier recognizes", () => { + for (const value of [ + "Durable Object storage operation exceeded timeout which caused object to be reset.", + "Durable Object exceeded its CPU time limit and was reset.", + "internal error; reference = 0123abcd4567", + ]) { + expect(beforeSendWithOtelCorrelation(doInstrumentationEvent(value))).toBeNull(); + } + }); + + // Same message, different path: a reset seen anywhere other than the DO + // instrumentation is somebody's deliberate report and keeps reporting. + it("keeps a reset message that did not come from the DO instrumentation", () => { + const viaWorker = doInstrumentationEvent( + "Durable Object reset because its code was updated.", + "auto.http.cloudflare", + ); + expect(beforeSendWithOtelCorrelation(viaWorker)).not.toBeNull(); + + const viaCaptureSeam = doInstrumentationEvent( + "Durable Object reset because its code was updated.", + "generic", + ); + expect(beforeSendWithOtelCorrelation(viaCaptureSeam)).not.toBeNull(); + }); + + // The load-bearing limit: a defect thrown inside a DO is reported by the + // instrumentation and by nothing else. The classifier does not recognize it, + // so it must survive. + it("keeps a Durable Object failure the classifier does not recognize", () => { + const defect = doInstrumentationEvent("Cannot read properties of undefined (reading 'meta')"); + expect(beforeSendWithOtelCorrelation(defect)).not.toBeNull(); + }); + + // The memory-limit reset is deliberately absent from the classifier: the + // runtime blames the application for it, so it is a defect, not noise. + it("keeps the memory-limit reset the classifier deliberately excludes", () => { + const memory = doInstrumentationEvent( + "Durable Object's isolate exceeded its memory limit due to overflowing the storage cache. All objects in the isolate were reset.", + ); + expect(beforeSendWithOtelCorrelation(memory)).not.toBeNull(); + }); + + it("the hook the worker and DOs install drops the deploy reset", () => { + const options = cloudSentryOptions({ SENTRY_DSN: "https://public@example.invalid/1" } as Env); + const event = doInstrumentationEvent("Durable Object reset because its code was updated."); + expect(options.beforeSend(event)).toBeNull(); + }); +}); diff --git a/apps/cloud/src/observability/redact-span-urls.test.ts b/apps/cloud/src/observability/redact-span-urls.test.ts new file mode 100644 index 0000000000..288c0f65ce --- /dev/null +++ b/apps/cloud/src/observability/redact-span-urls.test.ts @@ -0,0 +1,286 @@ +// The redaction rules themselves are tested in `@executor-js/sdk` +// (`telemetry-url-redaction.test.ts`). These tests cover the cloud-specific +// adapter: the span-processor seam every isolate span passes through on its +// way to the exporter. + +import * as Resource from "@effect/opentelemetry/Resource"; +import * as OtelTracer from "@effect/opentelemetry/Tracer"; +import { describe, expect, it } from "@effect/vitest"; +import { SpanStatusCode, type Span } from "@opentelemetry/api"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, + type ReadableSpan, +} from "@opentelemetry/sdk-trace-base"; +import { Effect, Exit, Layer } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; + +import { STRIPPED_QUERY_ATTRIBUTE } from "@executor-js/sdk"; + +import { UrlRedactingSpanProcessor } from "./redact-span-urls"; + +// Synthetic placeholders only — never a real authorization code or state. +const CODE = "synthetic-authorization-code"; +const STATE = "synthetic-csrf-state"; + +const callbackUrl = `https://app.test/api/oauth/callback?code=${CODE}&state=${STATE}&domain=example.test`; + +/** Ends one real SDK span carrying `attributes` through the redacting + * processor, and returns what the exporter actually received. Using the real + * provider (rather than a hand-built span) exercises the `onEnding` → `onEnd` + * hook sequence exactly as production does. `configure` runs before the span + * ends, for recording exceptions and setting a status. */ +const exportSpanWith = ( + attributes: Record, + configure?: (span: Span) => void, +): ReadableSpan | undefined => { + const exporter = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new UrlRedactingSpanProcessor(new SimpleSpanProcessor(exporter))], + }); + const span = provider.getTracer("test").startSpan("http.server GET"); + span.setAttributes(attributes); + configure?.(span); + span.end(); + return exporter.getFinishedSpans()[0]; +}; + +describe("UrlRedactingSpanProcessor", () => { + it("scrubs the span before the exporter sees it", () => { + const exported = exportSpanWith({ + "url.full": callbackUrl, + "url.query": `code=${CODE}&state=${STATE}`, + "url.path": "/api/oauth/callback", + }); + + expect(exported).toBeDefined(); + expect(JSON.stringify(exported?.attributes)).not.toContain(CODE); + expect(JSON.stringify(exported?.attributes)).not.toContain(STATE); + expect(exported?.attributes["url.full"]).toBe("https://app.test/api/oauth/callback"); + expect(exported?.attributes["url.path"]).toBe("/api/oauth/callback"); + expect(exported?.attributes[STRIPPED_QUERY_ATTRIBUTE]).toBe("code,domain,state"); + }); + + it("drops a query value regardless of its parameter name", () => { + // Query-auth placement names are arbitrary strings — a key configured as + // `?owner=…` is exactly as much a credential as `?key=…`. + const exported = exportSpanWith({ + "url.full": "https://app.test/api/integrations?owner=synthetic-owner-secret", + "url.query": "owner=synthetic-owner-secret", + }); + + expect(JSON.stringify(exported?.attributes)).not.toContain("synthetic-owner-secret"); + expect(exported?.attributes["url.full"]).toBe("https://app.test/api/integrations"); + expect(exported?.attributes[STRIPPED_QUERY_ATTRIBUTE]).toBe("owner"); + }); + + it("leaves a query-free span unchanged", () => { + const exported = exportSpanWith({ + "url.full": "https://app.test/api/integrations", + "url.path": "/api/integrations", + }); + + expect(exported?.attributes["url.full"]).toBe("https://app.test/api/integrations"); + expect(exported?.attributes[STRIPPED_QUERY_ATTRIBUTE]).toBeUndefined(); + }); + + it("scrubs URL-bearing link attributes", () => { + // Span links carry attributes exactly as spans do — `ReadableSpan.links` + // is a fourth channel to the exporter, and a link stamped with the peer's + // URL must not export the credential the span's own attributes dropped. + const exporter = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new UrlRedactingSpanProcessor(new SimpleSpanProcessor(exporter))], + }); + const tracer = provider.getTracer("test"); + const upstream = tracer.startSpan("upstream"); + const span = tracer.startSpan("http.server GET", { + links: [ + { + context: upstream.spanContext(), + attributes: { + // Malformed on purpose: the free-text regex alone would not match + // it, so the URL-aware attribute path must handle link attributes. + "url.full": "http://exa mple.test/graphql?key=synthetic-link-key-secret", + "peer.note": + "after GET https://canary:synthetic-link-userinfo-secret@api.test/graphql?owner=synthetic-link-owner-secret", + }, + }, + ], + }); + span.end(); + upstream.end(); + + const exported = exporter + .getFinishedSpans() + .find((finished) => finished.name === "http.server GET"); + const links = JSON.stringify(exported?.links); + expect(links).not.toContain("synthetic-link-key-secret"); + expect(links).not.toContain("synthetic-link-userinfo-secret"); + expect(links).not.toContain("synthetic-link-owner-secret"); + // Non-vacuous: the link, its identity, and the scrubbed URLs survive. + expect(exported?.links).toHaveLength(1); + expect(links).toContain(upstream.spanContext().spanId); + expect(links).toContain("https://api.test/graphql"); + }); + + it("scrubs a credential URL inside an array attribute on the span and on a link", () => { + // OTel attributes permit string[] values, so an array element is a fifth + // way a credential-bearing URL reaches the exporter. The arrays are + // planted by direct mutation: `setAttribute` sanitization would drop a + // mixed-type array, but the processor's contract is + // `Record` — upstream bridges and hand-built + // ReadableSpans hand it arbitrary bags. + const SECRET = "synthetic-array-canary-secret"; + const canary = () => [`https://canary:${SECRET}@api.test/graphql?key=${SECRET}`, 42]; + const exporter = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new UrlRedactingSpanProcessor(new SimpleSpanProcessor(exporter))], + }); + const tracer = provider.getTracer("test"); + const upstream = tracer.startSpan("upstream"); + const span = tracer.startSpan("http.server GET", { + // The placeholder attribute keeps the link's bag defined — the SDK + // normalizes an empty bag away, and the canary is planted by mutation. + links: [{ context: upstream.spanContext(), attributes: { "peer.kind": "canary" } }], + }); + // oxlint-disable-next-line executor/no-double-cast -- boundary: planting an out-of-contract attribute bag on the SDK span IS the fixture; no public API accepts a mixed-type array + const bags = span as unknown as { + attributes: Record; + links: ReadonlyArray<{ attributes?: Record }>; + }; + bags.attributes["url.full"] = canary(); + const linkAttributes = bags.links[0]?.attributes; + expect(linkAttributes).toBeDefined(); + if (linkAttributes !== undefined) linkAttributes["peer.urls"] = canary(); + span.end(); + upstream.end(); + + const exported = exporter + .getFinishedSpans() + .find((finished) => finished.name === "http.server GET"); + const serialized = JSON.stringify({ + attributes: exported?.attributes, + links: exported?.links, + }); + expect(serialized).not.toContain(SECRET); + // Non-vacuous: the scrubbed URL and the non-string element survive. + expect(exported?.attributes["url.full"]).toEqual(["https://api.test/graphql", 42]); + expect(exported?.links[0]?.attributes?.["peer.urls"]).toEqual(["https://api.test/graphql", 42]); + expect(exported?.attributes[STRIPPED_QUERY_ATTRIBUTE]).toBe("key,userinfo"); + }); + + it("scrubs a credential URL inside an array attribute on a span event", () => { + // Event attributes permit string[] exactly as span and link attributes do, + // and the event walk redacts free text — so an array element is the same + // channel one level down. Planted by direct mutation for the same reason + // as the span/link array canary: `addEvent` sanitization would drop a + // mixed-type array, but upstream bridges hand the processor arbitrary bags. + const SECRET = "synthetic-event-array-canary-secret"; + const exported = exportSpanWith({}, (span) => { + // The placeholder attribute keeps the event's bag defined; the canary is + // planted by mutation. + span.addEvent("canary", { "event.kind": "canary" }); + // oxlint-disable-next-line executor/no-double-cast -- boundary: planting an out-of-contract attribute bag on the SDK span IS the fixture; no public API accepts a mixed-type array + const bags = span as unknown as { + events: ReadonlyArray<{ attributes?: Record }>; + }; + const eventAttributes = bags.events[0]?.attributes; + expect(eventAttributes).toBeDefined(); + if (eventAttributes !== undefined) { + eventAttributes["event.urls"] = [ + `https://canary:${SECRET}@api.test/graphql?key=${SECRET}`, + 42, + ]; + } + }); + + expect(JSON.stringify(exported?.events)).not.toContain(SECRET); + // Non-vacuous: the scrubbed URL and the non-string element survive. + expect(exported?.events[0]?.attributes?.["event.urls"]).toEqual([ + "https://api.test/graphql", + 42, + ]); + }); + + it("scrubs the URL out of exception events and the status message", () => { + // The shape `@effect/opentelemetry` exports for a failed request: + // `TransportError.message` embeds the raw URL, and the bridge copies it + // into `exception.message`/`exception.stacktrace` event attributes and + // into `status.message`. + const message = `Transport: fetch failed (GET https://canary:synthetic-userinfo-secret@api.test/graphql?key=synthetic-key-secret)`; + const exported = exportSpanWith({}, (span) => { + // oxlint-disable-next-line executor/no-error-constructor -- boundary: OTel's recordException takes a plain JS Error; reproducing the bridge's exception shape IS the fixture + span.recordException(new Error(message)); + span.setStatus({ code: SpanStatusCode.ERROR, message }); + }); + + // Non-vacuous: the exception event exists and kept its scrubbed URL. + const events = JSON.stringify(exported?.events); + expect(events).toContain("exception"); + expect(events).toContain("https://api.test/graphql"); + expect(events).not.toContain("synthetic-userinfo-secret"); + expect(events).not.toContain("synthetic-key-secret"); + expect(exported?.status.message).toBe("Transport: fetch failed (GET https://api.test/graphql)"); + }); +}); + +describe("credential canary — no export channel carries the secret", () => { + const USERINFO_SECRET = "synthetic-canary-userinfo-secret"; + const KEY_SECRET = "synthetic-canary-query-key-secret"; + + it.effect( + "a failed outbound request exports no attribute, event, or status with the secret", + () => { + const exporter = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new UrlRedactingSpanProcessor(new SimpleSpanProcessor(exporter))], + }); + const tracerLayer = OtelTracer.layer.pipe( + Layer.provide(Layer.succeed(OtelTracer.OtelTracerProvider)(provider)), + Layer.provide(Resource.layer({ serviceName: "executor-cloud-test" })), + ); + return Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + + // Port 1 refuses immediately, so both requests fail without any network + // dependency, and the failing error's message embeds the raw URL + // (`TransportError.message` is "Transport: … (GET )"). The secret + // rides in userinfo and in a query parameter named `key` — a real + // GraphQL-integration auth shape no name blocklist would catch. + const withUserinfo = yield* client + .get(`http://canary-user:${USERINFO_SECRET}@127.0.0.1:1/graphql?key=${KEY_SECRET}`) + .pipe(Effect.withSpan("canary.outbound_userinfo"), Effect.exit); + const refused = yield* client + .get(`http://127.0.0.1:1/graphql?key=${KEY_SECRET}`) + .pipe(Effect.withSpan("canary.outbound_refused"), Effect.exit); + expect(Exit.isFailure(withUserinfo)).toBe(true); + expect(Exit.isFailure(refused)).toBe(true); + + yield* Effect.promise(() => provider.forceFlush()); + const spans = exporter.getFinishedSpans(); + + // Non-vacuous: the failures produced ERROR spans that recorded + // exception events and a status message. + const errored = spans.filter((span) => span.status.code === SpanStatusCode.ERROR); + expect(errored.length).toBeGreaterThan(0); + expect(errored.some((span) => span.events.length > 0)).toBe(true); + expect(errored.some((span) => (span.status.message ?? "") !== "")).toBe(true); + + const serialized = JSON.stringify( + spans.map((span) => ({ + name: span.name, + attributes: span.attributes, + events: span.events, + status: span.status, + })), + ); + expect(serialized).not.toContain(USERINFO_SECRET); + expect(serialized).not.toContain(KEY_SECRET); + // The scrub redacts; it does not erase — the host survives for debugging. + expect(serialized).toContain("127.0.0.1"); + }).pipe(Effect.provide(Layer.mergeAll(FetchHttpClient.layer, tracerLayer))); + }, + ); +}); diff --git a/apps/cloud/src/observability/redact-span-urls.ts b/apps/cloud/src/observability/redact-span-urls.ts new file mode 100644 index 0000000000..ced0513214 --- /dev/null +++ b/apps/cloud/src/observability/redact-span-urls.ts @@ -0,0 +1,131 @@ +// --------------------------------------------------------------------------- +// URL redaction at the cloud span-processor seam. +// +// The redaction rules themselves live in `@executor-js/sdk` +// (`telemetry-url-redaction`) and are shared by every exporter path — this +// module only adapts them to the OpenTelemetry SDK's span-processor +// interface, which is the one chokepoint every span in a cloud isolate must +// pass through on its way to the exporter: worker spans, Effect spans, +// Durable Object spans, and any route added later. A per-route middleware or +// a `TracerDisabledWhen` override would only cover the routes someone +// remembered to wire it into. +// +// Four channels are scrubbed: attributes (`url.full` / `url.query` stamped +// unconditionally by Effect's HttpMiddleware.tracer and HttpClient), event +// attributes (`exception.message` / `exception.stacktrace` carry the raw URL +// of a failed request), link attributes (a link to a peer span carries the +// peer's own attribute bag), and the status message. `url.path` is +// deliberately preserved — route-level visibility is what makes these traces +// worth exporting at all. +// --------------------------------------------------------------------------- + +import type { Context } from "@opentelemetry/api"; +import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base"; + +import { + redactSpanUrlAttributes, + redactStringElements, + redactUrlsInText, + STRIPPED_QUERY_ATTRIBUTE, +} from "@executor-js/sdk"; + +/** The mutable surface of a span the redactor needs: the attribute bag, the + * recorded events (exception events carry `exception.message` and + * `exception.stacktrace`), the links (each carries its own attribute bag), + * and the status (whose `message` carries the failing error's message). Both + * `Span` and `ReadableSpan` expose all four as plain mutable objects. */ +interface RedactableSpan { + readonly attributes: Record; + readonly events: ReadonlyArray<{ + name: string; + attributes?: Record | undefined; + }>; + readonly links: ReadonlyArray<{ + attributes?: Record | undefined; + }>; + readonly status: { message?: string | undefined }; +} + +/** Wraps a span processor so every span is scrubbed of credential-bearing URL + * components — in attributes, in event attributes, in link attributes, and + * in the status message — before the inner processor (and therefore the + * exporter) sees it. + * + * The attribute rewrite happens in `onEnding`, the last hook the OTel SDK + * calls while the span is still mutable (`Span.end()` runs `onEnding` before + * setting `_ended`, so `setAttribute` still applies); `onEnd` receives a + * frozen `ReadableSpan`. `onEnding` is optional in the SpanProcessor + * interface, so `onEnd` re-checks the attribute bag and mutates it directly + * as a backstop for any SDK path that skips the earlier hook. Events and + * status have no setter on an ended span in either hook, so they are always + * scrubbed by direct mutation. */ +export class UrlRedactingSpanProcessor implements SpanProcessor { + constructor(private readonly inner: SpanProcessor) {} + + forceFlush(): Promise { + return this.inner.forceFlush(); + } + + onStart(span: Span, parentContext: Context): void { + this.inner.onStart(span, parentContext); + } + + onEnding(span: Span): void { + this.redact(span, (key, value) => span.setAttribute(key, value)); + this.inner.onEnding?.(span); + } + + onEnd(span: ReadableSpan): void { + this.redact(span, (key, value) => { + span.attributes[key] = value; + }); + this.inner.onEnd(span); + } + + shutdown(): Promise { + return this.inner.shutdown(); + } + + private redact(span: RedactableSpan, write: (key: string, value: string) => void): void { + // Work on a copy so the redaction decision is made from the current values + // and applied through the caller's writer (span API vs direct mutation). + const draft: Record = { ...span.attributes }; + const stripped = new Set(redactSpanUrlAttributes(draft)); + for (const [name, value] of Object.entries(draft)) { + if (typeof value === "string" && value !== span.attributes[name]) write(name, value); + } + + // Links have no setter on an ended span, so their attribute bags are + // scrubbed by direct mutation — the same full URL-aware pass the span's + // own attributes get, since a link carries an arbitrary attribute bag. + for (const link of span.links) { + if (link.attributes === undefined) continue; + for (const key of redactSpanUrlAttributes(link.attributes)) stripped.add(key); + } + + if (stripped.size > 0) write(STRIPPED_QUERY_ATTRIBUTE, Array.from(stripped).sort().join(",")); + + for (const event of span.events) { + const name = redactUrlsInText(event.name); + if (name !== event.name) event.name = name; + if (event.attributes === undefined) continue; + for (const [key, value] of Object.entries(event.attributes)) { + // Event attributes permit string[] exactly as span attributes do, so + // array elements get the same free-text scrub, in place. + if (Array.isArray(value)) { + redactStringElements(value, redactUrlsInText); + continue; + } + if (typeof value !== "string") continue; + const redacted = redactUrlsInText(value); + if (redacted !== value) event.attributes[key] = redacted; + } + } + + const message = span.status.message; + if (typeof message === "string") { + const redacted = redactUrlsInText(message); + if (redacted !== message) span.status.message = redacted; + } + } +} diff --git a/apps/cloud/src/observability/telemetry.ts b/apps/cloud/src/observability/telemetry.ts index 7bd5baa589..91731b787e 100644 --- a/apps/cloud/src/observability/telemetry.ts +++ b/apps/cloud/src/observability/telemetry.ts @@ -45,15 +45,46 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic import { env } from "cloudflare:workers"; import { Effect, Layer } from "effect"; +import { SpanHeaderRedactionLive } from "./header-redaction"; import { CountingSpanExporter, CountingSpanProcessor, OTEL_MAX_SPAN_QUEUE_SIZE, recordForceFlush, } from "./memory-metrics"; +import { UrlRedactingSpanProcessor } from "./redact-span-urls"; const SERVICE_NAME = "executor-cloud"; -const SERVICE_VERSION = "1.0.0"; + +// `service.version` is the Cloudflare Worker version id (from the +// `version_metadata` binding) so any span links back to the exact deploy in a +// step-change investigation; "dev" is the documented default for hosts without +// the binding (local dev, older test workers). `executor.commit_sha` rides +// along when CI passed it (`wrangler deploy --var GIT_COMMIT_SHA:...`). +const serviceVersion = (): string => env.CF_VERSION_METADATA?.id ?? "dev"; + +// One id per isolate: distinguishes "many isolates each paying a cold cost" +// from "one isolate is slow", and makes per-isolate cache behavior (JWKS, +// module caches) measurable from Axiom. The Aug 2026 latency investigation +// stalled for lack of exactly this attribute. +// +// Generated LAZILY on first use, not at module scope: workerd forbids random +// generation (and I/O) in global scope and Cloudflare's upload validation +// rejects the whole deploy for it (error 10021). First use is inside +// `installTracerProvider()` / the telemetry layer build, which both run in a +// request handler, so the id is still one-per-isolate. +let isolateInstanceId: string | null = null; +let isolateStartedAt: number | null = null; + +const resourceAttributes = (): Record => { + isolateInstanceId ??= crypto.randomUUID(); + isolateStartedAt ??= Date.now(); + return { + "service.instance.id": isolateInstanceId, + "executor.isolate_started_at": new Date(isolateStartedAt).toISOString(), + ...(env.GIT_COMMIT_SHA === undefined ? {} : { "executor.commit_sha": env.GIT_COMMIT_SHA }), + }; +}; // Module-scope: one provider per isolate, never shut down. The provider holds // the SimpleSpanProcessor + OTLP exporter, so any tracer reference captured by @@ -65,7 +96,8 @@ const ensureGlobalTracerProvider = (): boolean => { provider = new WebTracerProvider({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: SERVICE_NAME, - [ATTR_SERVICE_VERSION]: SERVICE_VERSION, + [ATTR_SERVICE_VERSION]: serviceVersion(), + ...resourceAttributes(), }), spanProcessors: (() => { let countingProcessor: CountingSpanProcessor; @@ -93,7 +125,12 @@ const ensureGlobalTracerProvider = (): boolean => { }), OTEL_MAX_SPAN_QUEUE_SIZE, ); - return [countingProcessor]; + // Outermost wrapper: every span the isolate produces passes through here + // before it is queued for export, so credential-bearing query parameters + // (OAuth `code`/`state` on `/api/oauth/callback`, which Effect's + // HttpMiddleware.tracer stamps into `url.full`/`url.query` + // unconditionally) are stripped no matter which route emitted the span. + return [new UrlRedactingSpanProcessor(countingProcessor)]; })(), }); // Skip `provider.register()` — its StackContextManager / W3C propagator @@ -124,15 +161,25 @@ export const flushTracerProvider = async (): Promise => { }; const makeTelemetryLive = (): Layer.Layer => - Layer.unwrap( - Effect.sync(() => - ensureGlobalTracerProvider() - ? OtelTracer.layerGlobal.pipe( - Layer.provide( - Resource.layer({ serviceName: SERVICE_NAME, serviceVersion: SERVICE_VERSION }), - ), - ) - : Layer.empty, + Layer.mergeAll( + // Redaction applies even when the exporter is not installed: Effect still + // builds spans (and their header attributes) in-memory, and any future + // consumer of those spans must never observe an unredacted credential. + SpanHeaderRedactionLive, + Layer.unwrap( + Effect.sync(() => + ensureGlobalTracerProvider() + ? OtelTracer.layerGlobal.pipe( + Layer.provide( + Resource.layer({ + serviceName: SERVICE_NAME, + serviceVersion: serviceVersion(), + attributes: resourceAttributes(), + }), + ), + ) + : Layer.empty, + ), ), ); diff --git a/apps/cloud/src/org/auth-middleware.ts b/apps/cloud/src/org/auth-middleware.ts index 8479ff40cd..477de037bc 100644 --- a/apps/cloud/src/org/auth-middleware.ts +++ b/apps/cloud/src/org/auth-middleware.ts @@ -40,7 +40,11 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext }>()( .pipe(Effect.orElseSucceed(() => null)); if (!result) return unauthorized(); - const selector = request.headers[ORG_SELECTOR_HEADER] ?? result.organizationId; + // FAIL CLOSED: no header, no org — the sealed cookie's org is a + // browser-global pinned to whichever org WorkOS last touched, so + // falling back to it scopes a multi-org user's request to the WRONG + // org (see workos-auth-provider.resolveSessionPrincipal). + const selector = request.headers[ORG_SELECTOR_HEADER]; if (!selector) return noOrganization(); const org = yield* authorizeOrganizationSelector(result.userId, selector).pipe( diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index da60e91834..05445a3e28 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -70,6 +70,7 @@ const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) => // Mirrors `org/handlers.ts` `requireAdmin`. const requireAdmin = Effect.gen(function* () { const auth = yield* AuthContext; + if (auth.accountId === null) return yield* new Forbidden(); const workos = yield* WorkOSClient; const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); if (!current || current.role?.slug !== "admin") { diff --git a/apps/cloud/src/org/handlers.ts b/apps/cloud/src/org/handlers.ts index 37b26cf6ac..b338eee3f7 100644 --- a/apps/cloud/src/org/handlers.ts +++ b/apps/cloud/src/org/handlers.ts @@ -17,6 +17,11 @@ import { Forbidden, OrgHttpApi } from "./api"; const requireAdmin = Effect.gen(function* () { const auth = yield* AuthContext; + // This plane is mounted behind the session-only `orgAuthMiddleware`, so the + // caller is always a member — but `AuthContext.accountId` is nullable for the + // platform credential, and membership of "no member" is not a question worth + // asking WorkOS. Refuse rather than assert. + if (auth.accountId === null) return yield* new Forbidden(); const workos = yield* WorkOSClient; const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); if (!currentMembership || currentMembership.role?.slug !== "admin") { diff --git a/apps/cloud/src/quickjs-engine.wasm b/apps/cloud/src/quickjs-engine.wasm new file mode 100644 index 0000000000..ee1a98f5a7 Binary files /dev/null and b/apps/cloud/src/quickjs-engine.wasm differ diff --git a/apps/cloud/src/quickjs.ts b/apps/cloud/src/quickjs.ts new file mode 100644 index 0000000000..24ddee8b89 --- /dev/null +++ b/apps/cloud/src/quickjs.ts @@ -0,0 +1,66 @@ +import { newQuickJSWASMModuleFromVariant, newVariant } from "quickjs-emscripten-core"; +import baseVariant from "@jitl/quickjs-wasmfile-release-sync"; +// Static .wasm import: wrangler/workerd compiles this to a WebAssembly.Module at +// BUILD time. Workers forbid runtime WASM compilation (both fetching the .wasm +// and `WebAssembly.instantiate()` of bytes are blocked), so the engine bytes +// MUST be a pre-compiled module imported like this. The file is vendored into +// src/ (copied from @jitl/quickjs-wasmfile-release-sync) because wrangler's +// CompiledWasm module rule is rooted at the app dir and won't match the +// monorepo-root node_modules path — see scripts/vendor-quickjs-wasm.ts. +import wasmModule from "./quickjs-engine.wasm"; + +import { setQuickJSModule } from "@executor-js/runtime-quickjs"; +import { SpanStatusCode, trace } from "@opentelemetry/api"; + +// --------------------------------------------------------------------------- +// QuickJS-on-Workers WASM loading. +// +// The base variant's module loader resolves to the variant package's `workerd` +// build (its `./emscripten-module` export has a `workerd` condition wrangler +// selects) — that build expects the WASM module to be supplied rather than +// fetched/compiled at runtime. `newVariant(base, { wasmModule })` hands it the +// statically-imported, pre-compiled module, and `setQuickJSModule` makes every +// `makeQuickJsExecutor()` reuse it. Preloaded once per isolate. +// +// Callers trigger this lazily, from the artifact smoke-render path only — see +// `session-durable-object.ts`. Instantiation is ~1.4s of CPU, and most +// sessions never render an artifact, so it must not sit on every session +// init. The client span records exactly when/where that cost lands. +// --------------------------------------------------------------------------- + +let preloaded: Promise | null = null; + +const tracer = trace.getTracer("executor-cloud-quickjs"); + +export const preloadQuickJs = (): Promise => { + if (!preloaded) { + // oxlint-disable-next-line executor/no-promise-catch -- boundary: this module has no Effect context; reset the memoized promise on failure so a retried artifact render can trigger a fresh instantiation instead of caching a permanent rejection + preloaded = tracer + .startActiveSpan("quickjs.preload", async (span) => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: promise-native WASM instantiation; recording exception detail on the span before rethrowing + try { + const variant = newVariant(baseVariant, { wasmModule }); + const mod = await newQuickJSWASMModuleFromVariant(variant); + setQuickJSModule(mod); + } catch (err) { + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: normalizing an untyped instantiation failure only for the OTel span record; the original error is rethrown below unchanged + const cause = err instanceof Error ? err : String(err); + span.recordException(cause); + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: same normalization as the recordException line above + const message = typeof cause === "string" ? cause : cause.message; + span.setStatus({ code: SpanStatusCode.ERROR, message }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: rethrow after recording so the caller (and the memoization reset below) still observes the failure + throw err; + } finally { + span.end(); + } + }) + // oxlint-disable-next-line executor/no-promise-catch -- boundary: this module has no Effect context; reset the memoized promise on failure so a retried artifact render can trigger a fresh instantiation instead of caching a permanent rejection + .catch((err: unknown) => { + preloaded = null; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: rethrow so the caller's awaited promise still rejects with the original error + throw err; + }); + } + return preloaded; +}; diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index e689e72b78..d9641402b0 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -13,17 +13,22 @@ import { Route as SetupMcpRouteImport } from './routes/bare/setup-mcp' import { Route as LoginRouteImport } from './routes/bare/login' import { Route as CreateOrgRouteImport } from './routes/bare/create-org' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' import { Route as SecretsRouteImport } from './routes/app/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' import { Route as OrgRouteImport } from './routes/app/org' import { Route as BillingRouteImport } from './routes/app/billing' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' const SetupMcpRoute = SetupMcpRouteImport.update({ @@ -47,6 +52,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = path: '/{-$orgSlug}/', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({ + id: '/{-$orgSlug}/users', + path: '/{-$orgSlug}/users', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({ id: '/{-$orgSlug}/tools', @@ -80,6 +91,12 @@ const BillingRoute = BillingRouteImport.update({ path: '/{-$orgSlug}/billing', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const ApiKeysRoute = ApiKeysRouteImport.update({ id: '/{-$orgSlug}/api-keys', path: '/{-$orgSlug}/api-keys', @@ -99,6 +116,14 @@ const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({ path: '/{-$orgSlug}/resume/$executionId', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update( + { + id: '/{-$orgSlug}/integrations/browse', + path: '/{-$orgSlug}/integrations/browse', + getParentRoute: () => rootRouteImport, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( { @@ -107,11 +132,28 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update( + { + id: '/{-$orgSlug}/connect/$integrationSlug', + path: '/{-$orgSlug}/connect/$integrationSlug', + getParentRoute: () => rootRouteImport, + } as any, + ) const Billing_DotplansRoute = Billing_DotplansRouteImport.update({ id: '/{-$orgSlug}/billing_/plans', path: '/{-$orgSlug}/billing/plans', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport.update( + { + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport.update( { @@ -126,15 +168,20 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute + '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -144,15 +191,20 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute + '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -163,15 +215,20 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute '/{-$orgSlug}/org': typeof OrgRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof SecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute + '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -183,15 +240,20 @@ export interface FileRouteTypes { | '/login' | '/setup-mcp' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' + | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/billing/plans' + | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -201,15 +263,20 @@ export interface FileRouteTypes { | '/login' | '/setup-mcp' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' + | '/{-$orgSlug}/users' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/billing/plans' + | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -219,15 +286,20 @@ export interface FileRouteTypes { | '/login' | '/setup-mcp' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' | '/{-$orgSlug}/org' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' + | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' | '/{-$orgSlug}/billing_/plans' + | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -238,15 +310,19 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute SetupMcpRoute: typeof SetupMcpRoute ApiKeysRoute: typeof ApiKeysRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren BillingRoute: typeof BillingRoute OrgRoute: typeof OrgRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute SecretsRoute: typeof SecretsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute Billing_DotplansRoute: typeof Billing_DotplansRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute } @@ -281,6 +357,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/users': { + id: '/{-$orgSlug}/users' + path: '/{-$orgSlug}/users' + fullPath: '/{-$orgSlug}/users' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/tools': { id: '/{-$orgSlug}/tools' path: '/{-$orgSlug}/tools' @@ -323,6 +406,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BillingRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/api-keys': { id: '/{-$orgSlug}/api-keys' path: '/{-$orgSlug}/api-keys' @@ -344,6 +434,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResumeDotexecutionIdRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/integrations/browse': { + id: '/{-$orgSlug}/integrations/browse' + path: '/{-$orgSlug}/integrations/browse' + fullPath: '/{-$orgSlug}/integrations/browse' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/integrations/$namespace': { id: '/{-$orgSlug}/integrations/$namespace' path: '/{-$orgSlug}/integrations/$namespace' @@ -351,6 +448,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/connect/$integrationSlug': { + id: '/{-$orgSlug}/connect/$integrationSlug' + path: '/{-$orgSlug}/connect/$integrationSlug' + fullPath: '/{-$orgSlug}/connect/$integrationSlug' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/billing_/plans': { id: '/{-$orgSlug}/billing_/plans' path: '/{-$orgSlug}/billing/plans' @@ -358,6 +462,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof Billing_DotplansRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/integrations/add/$pluginKey': { id: '/{-$orgSlug}/integrations/add/$pluginKey' path: '/{-$orgSlug}/integrations/add/$pluginKey' @@ -368,6 +479,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -388,6 +514,8 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, SetupMcpRoute: SetupMcpRoute, ApiKeysRoute: ApiKeysRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, BillingRoute: BillingRoute, OrgRoute: OrgRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: @@ -397,11 +525,17 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, Billing_DotplansRoute: Billing_DotplansRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute, ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute, @@ -411,11 +545,15 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' + import type { startInstance } from './start.ts' + declare module '@tanstack/react-start' { interface Register { ssr: true + router: Awaited> + config: Awaited> } } diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index f508a316c4..7f141e6072 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -10,9 +10,10 @@ import { useParams, } from "@tanstack/react-router"; import { AutumnProvider } from "autumn-js/react"; +import { isValidOrgSlug } from "@executor-js/api"; import posthog from "posthog-js"; import { PostHogProvider } from "posthog-js/react"; -import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting"; +import { createSentryFrontendErrorReporter } from "@executor-js/react/api/error-reporting"; import { AnalyticsProvider, type AnalyticsClient } from "@executor-js/react/api/analytics"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; @@ -20,8 +21,8 @@ import { EXECUTOR_ORG_HEADER } from "@executor-js/react/api/server-connection"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; -import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; import { AuthProvider, useAuth } from "../web/auth"; import { loginPath } from "../auth/return-to"; import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths"; @@ -60,25 +61,27 @@ if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY) { }); } +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. A static import here would put it in this +// SSR app's server graph and 500 every document request, so it is registered as +// a dynamic import the artifact page resolves in the browser. Module scope keeps +// the loader identity stable, so the lazy component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + const analyticsClient: AnalyticsClient | undefined = typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_POSTHOG_KEY ? (name, properties) => posthog.capture(name, properties) : undefined; -const captureFrontendError: FrontendErrorReporter = (error, context) => { +// Shared with the desktop renderer: the factory normalizes the reported value +// to a real Error (handed an Effect Cause, Sentry has no message to title or +// group on) and owns the executor.ui tags. Only the transport differs. +const captureFrontendError = createSentryFrontendErrorReporter((error, applyScope) => { Sentry.captureException(error, (scope) => { - scope.setTag("executor.ui.surface", context.surface); - scope.setTag("executor.ui.action", context.action); - scope.setTag("executor.ui.severity", context.severity ?? "error"); - scope.setContext("executor.ui", { - surface: context.surface, - action: context.action, - message: context.message, - metadata: context.metadata, - }); + applyScope(scope); return scope; }); -}; +}); function NotFoundPage() { return ( @@ -102,27 +105,6 @@ function NotFoundPage() { export const Route = createRootRoute({ notFoundComponent: NotFoundPage, - // What the SSR gate attached to this document request (ssr-gate.ts → - // middleware context → serverContext). Loader data is dehydrated, so the - // client's first render sees the SAME values the server rendered with — the - // two can't disagree: - // - authHint: the verified identity, seeding AuthProvider's initial state. - // - origin: the request origin, seeding the server connection so the - // connect-card MCP URL SSRs as the real origin instead of the - // 127.0.0.1 client-side default (which would flash to the real - // value at hydration). - // Client-side re-runs have no serverContext and return null; both consumers - // fall back gracefully (the hint is already held, the origin to the - // window-derived global). - loader: (opts) => { - const serverContext = ( - opts as { serverContext?: { authHint?: AuthHint | null; origin?: string } } - ).serverContext; - return { - authHint: serverContext?.authHint ?? null, - origin: serverContext?.origin ?? null, - }; - }, head: () => ({ meta: [ { charSet: "utf-8" }, @@ -162,12 +144,15 @@ function RootDocument({ children }: { children: React.ReactNode }) { } function RootComponent() { - const { authHint, origin } = Route.useLoaderData(); + // SPA mode: no per-request server render, so nothing is dehydrated. Auth + // seeds from the client-readable hint cookie one frame after mount + // (AuthProvider's own fallback), and origin-derived UI reads the + // window-derived global. return ( - - + + @@ -204,7 +189,7 @@ function ShellErrorFallback() { ); } -function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { +function AuthGate() { const auth = useAuth(); const location = useLocation(); const navigate = useNavigate(); @@ -214,6 +199,14 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { // is scoped to it, so `auth.organization` IS this org when the caller is a // member — and `null` when the URL names an org they can't access. const urlOrgSlug = (useParams({ strict: false }) as { orgSlug?: string }).orgSlug; + // The same slug derived from the PATHNAME instead of the route params: the + // params resolve asynchronously (a fresh load renders once with no orgSlug, + // then again with it), and anything keyed on them remounts on that flap. + // The pathname is synchronously correct on the very first render, and it is + // exactly what the request header derives from (getActiveOrgSlug), so the + // registry scope below can never disagree with the header scope. + const firstSegment = location.pathname.split("/")[1] ?? ""; + const pathnameOrgSlug = isValidOrgSlug(firstSegment) ? firstSegment : null; // The SSR gate already bounced fresh org-less document requests to // /create-org; this catches the MID-SESSION transitions (org deleted, @@ -248,11 +241,11 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { } // Every state that isn't "authenticated with an org, on a page that wants - // the shell" is a moment between redirects or an edge the gates make - // near-impossible (a verified user whose hint hasn't seeded yet). Neutral + // the shell" is a moment between redirects, or the one frame between mount + // and the hint cookie seeding (SPA mode reads it in an effect). Neutral // blank — the one placeholder that's correct whatever happens next. The // app-shell skeleton this file used to render here is exactly the - // wrong-UI flash the SSR gate + hint exist to prevent. + // wrong-UI flash the document gate + hint exist to prevent. if (auth.status === "loading" || auth.status === "unauthenticated") { return ; } @@ -269,40 +262,66 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) { return urlOrgSlug ? : ; } - // Seed the server connection from the SSR origin so origin-derived UI (the - // connect card's MCP URL) renders the real host on the first paint instead - // of the 127.0.0.1 default the client-side global falls back to during SSR. - // Null on client loader re-runs → undefined → the window-derived global, - // which is the same origin, so the key never changes and nothing remounts. - const connection = ssrOrigin ? ({ kind: "http", origin: ssrOrigin } as const) : undefined; + // The authenticated answer must NAME the org the URL names before any shell + // is built from it. `auth.organization` is the auth-hint cookie until + // `/account/me` lands, and the hint always names the session's OWN org — so + // on a foreign slug it is an answer about a different organization, and + // rendering the shell from it puts the user in a workspace the URL never + // named. `/account/me` is scoped by the URL's slug (getActiveOrgSlug), so + // once it resolves this can only agree or be null; a disagreement is + // therefore always an unresolved answer, never a verdict. Blank, not + // not-found: the 404 above is the only thing entitled to declare a wrong + // address, and it waits for the server. + // + // The legitimate cold load is untouched: the hint names the slug in the URL, + // so this matches on the very first paint and the shell renders with no + // round trip. Only a slug the hint does not name pays the wait — a foreign + // slug (which then 404s) and the frame after an org switch (which then + // renders the org the URL asked for, instead of flashing the previous one). + if (pathnameOrgSlug != null && auth.organization.slug !== pathnameOrgSlug) { + return ; + } + const activeSlug = auth.organization.slug; // The org context's slug feeds the connect card's `//mcp` install URL. - // Prefer the URL's slug over the session's: on first paint `auth.organization` - // comes from the SSR auth-hint (the COOKIE's org), so a multi-org user viewing - // / while their cookie still points at orgA would briefly render orgA's - // slug in the copyable URL before /account/me (URL-scoped) corrects it. The - // URL slug is the actual request scope and is correct on the very first paint, - // so sourcing it from there removes that flash. Falls back to the session slug - // on a bare URL (which OrgSlugGate is about to canonicalize onto it anyway). - const scopeSlug = urlOrgSlug ?? activeSlug; + // Source it from the URL, which is the actual request scope and is correct on + // the very first paint. The gate above has already made the two agree + // whenever the URL names a slug at all, so this is the same value stated in + // the terms the rest of the tree is keyed on. VALIDATED (pathnameOrgSlug, not + // the raw route param): the `{-$orgSlug}` param also captures reserved + // console roots ("/integrations" → orgSlug "integrations"), which are not + // org scopes. Falls back to the auth org on a bare/reserved URL (which + // OrgSlugGate canonicalizes onto it below). + const scopeSlug = pathnameOrgSlug ?? activeSlug; const billingHeaders = scopeSlug ? { [EXECUTOR_ORG_HEADER]: scopeSlug } : undefined; return ( } showDialog={false}> - + {/* scopeKey ties the atom registry to the URL's org: cached query + results can never survive an org change, and the bare → slugged + canonicalization remounts the registry so anything fetched + header-less on first paint (rejected server-side) is refetched + with the org header. */} + }> - {/* The org header scopes every request to the URL's org, so - reaching here means the caller is a member of `activeSlug` - (a foreign slug already 404'd above). The gate only keeps - the URL canonical — bare → /. */} - - + {/* Canonicalize onto the URL's org, not the auth org: on first + paint `auth.organization` is the SSR hint (the COOKIE's + org), and canonicalizing onto that would rewrite a + multi-org user's / URL to / during the hint + window. `scopeSlug` prefers the URL slug, so a slugged URL + is already canonical (a foreign slug 404'd above) and only + a bare URL gets rewritten — onto the auth org, the one + thing it can mean. */} + + + + diff --git a/apps/cloud/src/routes/app/api-keys.tsx b/apps/cloud/src/routes/app/api-keys.tsx index ddc9807929..b686410175 100644 --- a/apps/cloud/src/routes/app/api-keys.tsx +++ b/apps/cloud/src/routes/app/api-keys.tsx @@ -1,8 +1,14 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ApiKeysPage } from "@executor-js/react/pages/api-keys"; +import { ApiKeysPage, OrgApiKeysSection } from "@executor-js/react/pages/api-keys"; // Cloud renders the SHARED API-keys page over the provider-neutral -// `/account/api-keys` surface — identical UI to self-host. +// `/account/api-keys` surface — identical UI to self-host, plus the +// cloud-only Organization keys section (self-host's provider refuses +// org keys; its admin plane is session-gated instead). export const Route = createFileRoute("/{-$orgSlug}/api-keys")({ - component: ApiKeysPage, + component: CloudApiKeysPage, }); + +function CloudApiKeysPage() { + return } />; +} diff --git a/apps/cloud/src/routes/app/billing.tsx b/apps/cloud/src/routes/app/billing.tsx index 06123bc16e..6d6f303874 100644 --- a/apps/cloud/src/routes/app/billing.tsx +++ b/apps/cloud/src/routes/app/billing.tsx @@ -13,7 +13,7 @@ export const Route = createFileRoute("/{-$orgSlug}/billing")({ const PLAN_TAGLINES: Record = { free: "Free for up to 3 members", - team: "$150 per organization", + team: "$15 per member per month", enterprise: "Custom enterprise agreement", }; diff --git a/apps/cloud/src/routes/app/billing_.plans.tsx b/apps/cloud/src/routes/app/billing_.plans.tsx index a94969e81f..76f9944d7d 100644 --- a/apps/cloud/src/routes/app/billing_.plans.tsx +++ b/apps/cloud/src/routes/app/billing_.plans.tsx @@ -47,7 +47,17 @@ function useRefreshAfterCheckout(plans: Plan[] | undefined, refetch: () => void) const [finalizingPlan, setFinalizingPlan] = useState(null); const plansRef = useRef(plans); plansRef.current = plans; - + const refetchRef = useRef(refetch); + refetchRef.current = refetch; + const armedAtRef = useRef(0); + + // One-shot: move the URL marker into state. Only this step is single-shot — + // the poll below keys off the STATE, because effects do not live as long as + // state: the shell re-runs effects shortly after mount (auth seeds a frame + // after first paint and the suspense boundary re-reveals), and when the poll + // lived inside this effect its re-run saw the already-stripped URL, so the + // cleanup killed the interval and nothing ever refetched — the page sat on + // "Activating" forever while the webhook had long landed. useEffect(() => { const params = new URLSearchParams(window.location.search); const attachedPlanId = params.get(CHECKOUT_RETURN_PARAM); @@ -56,25 +66,34 @@ function useRefreshAfterCheckout(plans: Plan[] | undefined, refetch: () => void) params.delete(CHECKOUT_RETURN_PARAM); const query = params.toString(); window.history.replaceState({}, "", `${window.location.pathname}${query ? `?${query}` : ""}`); + armedAtRef.current = Date.now(); setFinalizingPlan(attachedPlanId); + }, []); + + // Poll while a plan is finalizing. Re-armable: if the effect is recycled the + // state still says which plan is settling, so the interval comes right back. + // The timeout is measured from when the marker was consumed, not from the + // current effect run, so recycles cannot extend it. refetch goes through a + // ref so the interval always calls the CURRENT client's refetch, even after + // the billing provider rebuilds. + useEffect(() => { + if (!finalizingPlan) return; const reflected = () => - plansRef.current?.find((p) => p.id === attachedPlanId)?.customerEligibility?.status === + plansRef.current?.find((p) => p.id === finalizingPlan)?.customerEligibility?.status === "active"; - let elapsed = 0; - refetch(); + refetchRef.current(); const interval = setInterval(() => { - elapsed += 1500; - if (reflected() || elapsed >= 20_000) { + if (reflected() || Date.now() - armedAtRef.current >= 20_000) { clearInterval(interval); setFinalizingPlan(null); return; } - refetch(); + refetchRef.current(); }, 1500); return () => clearInterval(interval); - }, [refetch]); + }, [finalizingPlan]); // Drop the optimistic state the moment the refetched data reflects the plan, // so it does not linger until the next poll tick after the webhook lands. @@ -98,29 +117,32 @@ const ENTERPRISE_FEATURES = [ "Security reviews, DPA & SOC 2 on request", ]; -const PLAN_META: Record = { +// Price display is hardcoded alongside the feature copy: Team's price lives +// on its per-seat `members` item in Autumn, not the plan-level `price` the +// SDK surfaces, so there is no live field to render it from. +const PLAN_META: Record< + string, + { + tagline: string; + inherits?: string; + features: string[]; + price: { label: string; suffix?: string }; + } +> = { free: { tagline: "For small teams getting started", - features: [ - "Up to 3 members", - "10,000 included executions per month", - "$0.20 per 1,000 additional executions", - "Unlimited sources", - ], + price: { label: "$0", suffix: "USD / month" }, + features: ["Up to 3 members", "100,000 executions per month", "Unlimited sources"], }, team: { tagline: "For growing organizations", - features: [ - "Unlimited members", - "250,000 included executions per month", - "5 minute execution timeout", - "Join by team domain", - "$0.20 per 1,000 additional executions", - ], + price: { label: "$15", suffix: "USD / member / month" }, + features: ["Unlimited executions", "Verified domains & join by team domain"], }, enterprise: { tagline: "For orgs with custom needs", inherits: "Team", + price: { label: "Custom" }, features: ENTERPRISE_FEATURES, }, }; @@ -259,15 +281,10 @@ function PlansPage() {
- {plan.id === "enterprise" ? "Custom" : `$${plan.price?.amount ?? 0}`} + {meta.price.label} - {plan.id !== "enterprise" && plan.price?.interval && ( - - USD / org / {plan.price.interval} - - )} - {plan.id !== "enterprise" && !plan.price?.interval && ( - USD + {meta.price.suffix && ( + {meta.price.suffix} )}
@@ -384,7 +401,8 @@ function EnterpriseContactDialog() { Contact us - + {/* Static contact details only: clicking away dismisses it. */} + Talk to us about Enterprise @@ -432,7 +450,8 @@ function SlackContactCta() { → - + {/* Static contact details only: clicking away dismisses it. */} + Get in touch on Slack diff --git a/apps/cloud/src/routes/bare/login.tsx b/apps/cloud/src/routes/bare/login.tsx index 963d75a14b..c952f061d0 100644 --- a/apps/cloud/src/routes/bare/login.tsx +++ b/apps/cloud/src/routes/bare/login.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { safeReturnTo } from "../../auth/return-to"; import { LoginPage } from "../../web/pages/login"; -// The signed-out landing page. The SSR auth gate (auth/ssr-gate.ts) sends +// The signed-out landing page. The document auth gate (auth/doc-gate.ts) sends // signed-out document requests here with ?returnTo=, // and bounces already-signed-in visitors straight back to it. export const Route = createFileRoute("/login")({ diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 12aac6773d..4f5bf76289 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -1,6 +1,5 @@ import { DurableObject } from "cloudflare:workers"; -import { SpanKind, SpanStatusCode, context, trace } from "@opentelemetry/api"; -import type { ErrorEvent } from "@sentry/cloudflare"; +import { SpanKind, SpanStatusCode, context, trace, type SpanContext } from "@opentelemetry/api"; import { ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_STATUS_CODE, @@ -11,13 +10,15 @@ import { import * as Sentry from "@sentry/cloudflare"; import handler from "@tanstack/react-start/server-entry"; -import { isAppOwnedPath } from "./app-paths"; +import { isAppOwnedPath, servedByAppPlane } from "./app-paths"; +import { marketingProxyRequest } from "./edge/marketing"; +import { passthroughResponse } from "./edge/passthrough"; import { makeCloudMcpAgentHandler } from "./mcp/agent-handler"; import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount"; import { parseTraceparent } from "./mcp/traceparent"; import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object"; import { - beforeSendWithOtelCorrelation, + cloudSentryOptions, captureCause, otelCorrelationContextFromOpenTelemetrySpan, SENTRY_EVENT_ID_ATTRIBUTE, @@ -26,28 +27,6 @@ import { import { browserTracesResponse } from "./observability/browser-traces"; import { flushTracerProvider, installTracerProvider } from "./observability/telemetry"; -// --------------------------------------------------------------------------- -// Sentry config -// --------------------------------------------------------------------------- - -const sentryOptions = (env: Env) => ({ - dsn: env.SENTRY_DSN, - tracesSampleRate: 0, - enableLogs: true, - sendDefaultPii: true, - skipOpenTelemetrySetup: true, - beforeSend: (event: ErrorEvent) => - beforeSendWithOtelCorrelation(event, { - logPayload: !env.SENTRY_DSN || env.SENTRY_OTEL_LOG_PAYLOAD === "true", - }), - // NOTE: do NOT enable `instrumentPrototypeMethods`. It walks the DO prototype - // and reads every property — including accessors — to find methods to wrap, - // which invokes the `sessionId` getter with `this` bound to the prototype - // (where `ctx` is undefined) and throws during construction, 500ing every - // session create / cold restore. The DO captures its own errors via the - // `captureCause` seam (→ Sentry) instead. -}); - // --------------------------------------------------------------------------- // Durable Object — wrapped with Sentry so DO errors land in Sentry (inits the // client inside the DO isolate, which plain `Sentry.captureException` cannot @@ -56,7 +35,7 @@ const sentryOptions = (env: Env) => ({ // --------------------------------------------------------------------------- export const McpSessionDOSqlite = Sentry.instrumentDurableObjectWithSentry( - sentryOptions, + cloudSentryOptions, McpSessionDOBase, ); @@ -85,15 +64,12 @@ export { McpExecutionOwnerDirectoryDO } from "@executor-js/cloudflare/mcp/execut // migration — without the OTel-SDK version-conflict that package would now // drag in (it pins `@opentelemetry/otlp-* ^0.200.0`, we ship ^0.214.0). // -// ONLY for paths the Effect app does not own. App-owned paths (/api/*, /mcp, -// /.well-known/* — see app-paths.ts) get their `http.server` span from -// Effect's own HttpMiddleware.tracer, which parses `traceparent` itself and -// parents the workos/store/db child spans. Wrapping those here too produced -// two identical sibling `http.server` spans per request (scope -// `executor-cloud-worker` next to scope `executor-cloud`) — double ingest, -// and the waterfall showed a childless twin. The worker span remains for -// everything Effect never sees: Start SSR, the marketing proxy, /_astro -// assets. +// App-owned paths (/api/* and /.well-known/* — see app-paths.ts) get their +// `http.server` span from Effect's HttpMiddleware tracer. `/mcp` is dispatched +// directly and uses `traceCloudMcpRequest` below so the agent handler can skip +// the entire span envelope for negative-cache hits. Other paths keep this +// worker span. Wrapping Effect-owned paths here too produced duplicate sibling +// spans per request. // // SimpleSpanProcessor exports synchronously at span end but the underlying // `fetch()` to Axiom is fire-and-forget; the Worker may terminate before it @@ -101,17 +77,208 @@ export { McpExecutionOwnerDirectoryDO } from "@executor-js/cloudflare/mcp/execut // until the in-flight export resolves. // --------------------------------------------------------------------------- -const fetchHandler = handler.fetch as ( +const rawFetchHandler = handler.fetch as ( request: Request, env: Env, ctx: ExecutionContext, ) => Response | Promise; +/** + * Every entry into TanStack Start goes through here so `startGraphEntered` + * reflects whether this isolate has already paid the lazy `loadEntries` + * import — the cost that dominates a cold page request. + */ +const fetchHandler = ( + request: Request, + env: Env, + ctx: ExecutionContext, +): Response | Promise => { + markStartGraphEntered(); + return rawFetchHandler(request, env, ctx); +}; + const tracer = trace.getTracer("executor-cloud-worker"); + +const traceparentValueFor = (spanContext: SpanContext): string => + `00-${spanContext.traceId}-${spanContext.spanId}-${(spanContext.traceFlags & 0xff).toString(16).padStart(2, "0")}`; + +const withTraceparent = (request: Request, spanContext: SpanContext): Request => { + const headers = new Headers(request.headers); + headers.set("traceparent", traceparentValueFor(spanContext)); + return new Request(request, { headers }); +}; + +const traceCloudMcpRequest = async ( + request: Request, + _env: Env, + ctx: ExecutionContext, + handle: (tracedRequest: Request) => Promise, +): Promise => { + if (!installTracerProvider()) return handle(request); + + const url = new URL(request.url); + const inbound = parseTraceparent(request.headers.get("traceparent"), null); + const parentContext = inbound + ? trace.setSpanContext(context.active(), { + traceId: inbound.traceId, + spanId: inbound.spanId, + traceFlags: inbound.traceFlags, + isRemote: true, + }) + : context.active(); + + return tracer.startActiveSpan( + `http.server ${request.method}`, + { kind: SpanKind.SERVER }, + parentContext, + async (span) => { + span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method); + span.setAttribute(ATTR_URL_FULL, request.url); + span.setAttribute(ATTR_URL_PATH, url.pathname); + span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, "")); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep trace export alive after the Agents bridge resolves or rejects + try { + const response = await handle(withTraceparent(request, span.spanContext())); + span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); + if (response.status >= 500) { + span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); + } + return response; + } catch (err) { + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: Cloudflare's fetch callback throws untyped; normalized only for the OTel span record, the original error is rethrown below + const cause = err instanceof Error ? err : String(err); + span.recordException(cause); + // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above + const message = typeof cause === "string" ? cause : cause.message; + span.setStatus({ code: SpanStatusCode.ERROR, message }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime + throw err; + } finally { + span.end(); + ctx.waitUntil(flushTracerProvider()); + } + }, + ); +}; + const mcpAgentHandler = makeCloudMcpAgentHandler(); +// --------------------------------------------------------------------------- +// Isolate lifecycle signals +// --------------------------------------------------------------------------- +// +// The Aug 2026 page-latency hunt kept stalling on one blind spot: every span we +// emit starts INSIDE the fetch handler, so nothing could distinguish "this +// isolate is cold and paying Start's lazy `loadEntries` import" from "this +// isolate came up slowly before our code ran at all". Both look like one slow +// span. These three attributes make the distinction queryable: +// +// executor.isolate.request_seq - 1 means this request is the isolate's +// first; page requests measured 1.04 per +// isolate, which is why nearly every one +// pays the cold cost. +// executor.start_graph.entered - whether anything had already driven +// TanStack Start in this isolate. /mcp and +// the passthrough proxies return before +// `fetchHandler`, so an isolate can serve +// plenty of traffic and still be cold here. +// Measured with a temporary entry-lag probe correlated against `wrangler tail` +// event timestamps: a cold isolate (`request_seq` 1) spends **840-1844ms** +// coming up before our first line runs, while a reused one spends **0-2ms**. +// That startup is invisible to every span we emit, and it sits on top of the +// ~3.1s `loadEntries` import — together the 3-5s a signed-in page costs. +// +// executor.isolate.id - identifies the isolate itself, so reuse can +// be counted directly instead of inferred. +// executor.isolate.age_ms - ms since this isolate served its first +// request. +// +// The last two exist because the Aug 2026 hunt inferred "isolates stopped being +// reused" from a latency cutoff (requests slower than 1s were called cold) and +// then built a size-based theory on top of that proxy. The theory was wrong: +// reverting the offending packages restored production while moving the +// evaluated module closure by 0.02 MB (see scripts/start-closure.mjs). Grouping +// by isolate id answers "how many requests did this isolate serve, and were the +// slow ones its first?" directly, which no latency threshold can. +// +// All of it is cheap: two increments, one lazy uuid, and no I/O. +let isolateRequestSeq = 0; +let startGraphEntered = false; +// Minted on first request rather than at module scope: Workers reject random +// number generation during global-scope evaluation. +let isolateId: string | undefined; +let isolateFirstSeenAt = 0; + +const identifyIsolate = (): { readonly id: string; readonly ageMs: number } => { + if (isolateId === undefined) { + isolateId = crypto.randomUUID(); + isolateFirstSeenAt = Date.now(); + } + return { id: isolateId, ageMs: Date.now() - isolateFirstSeenAt }; +}; + +const markStartGraphEntered = (): void => { + startGraphEntered = true; +}; + +// --------------------------------------------------------------------------- +// Serving `/api/*` without entering TanStack Start. +// --------------------------------------------------------------------------- +// +// Everything under `/api` is the Effect app (`ExecutorApp.make`'s web handler) +// and uses no part of the router, React, or SSR. But it was dispatched from a +// Start *request middleware*, so reaching it meant paying Start's lazy +// `loadEntries` import of the whole server graph first. Measured on production +// 2026-08-19, splitting `/api/*` by whether the isolate had already loaded that +// graph: warm p50 **186ms**, cold p50 **2129ms**, with 28% of API requests cold. +// The dashboard fires many `/api/*` calls in parallel and waits for the slowest, +// so that cold tail is what the app actually feels like. +// +// So `/api` joins marketing, `/docs`, the PostHog proxy and `/mcp` at the Worker +// entry: classify and dispatch before anything touches Start. The evaluated +// closure for an API request drops from the full Start graph to the Worker's own +// (see `scripts/start-closure.mjs`). +// +// `servedByAppPlane` (./app-paths) decides which paths qualify — two under +// `/api` are claimed by Start's middleware first and must keep their old route. + +// Instantiated on the first request that needs it and memoized per isolate, +// mirroring `start.ts`'s `getApp`. The import stays dynamic so an isolate that +// only serves pages or proxies never evaluates the app graph at all. +let appPlane: ReturnType | undefined; +let appGraphEntered = false; + +const getAppPlane = async (): Promise> => { + if (appPlane === undefined) { + const { cloudApiHandler } = await import("./app"); + appPlane = cloudApiHandler(); + appGraphEntered = true; + } + return appPlane; +}; + const cloudflareHandler: ExportedHandler = { fetch: async (request, env, ctx) => { + isolateRequestSeq += 1; + + // Public pages must not enter TanStack Start: its first-request dynamic + // import loads the entire React + Effect server graph and can take seconds + // on a cold isolate. Classify and service-bind marketing at the Worker + // entry, before telemetry or fetchHandler touches that graph. + const marketingRequest = marketingProxyRequest(request); + const marketing: Fetcher | undefined = env.MARKETING; + if (marketingRequest && marketing) return marketing.fetch(marketingRequest); + + // Same reasoning, same seam: `/docs` and the PostHog proxy forward to an + // external origin and never touch the router, React, or the Effect app. + // Left in Start's middleware they still paid its lazy `loadEntries` import + // first — measured at p50 3.1s on a cold isolate, against p50 33ms for the + // request's own work, on a Worker where 1,666 dispatches spread across + // 1,608 isolates (so nearly every request is cold). Forward before Start. + const passthroughPath = new URL(request.url).pathname; + const passthrough = passthroughResponse(request, passthroughPath); + if (passthrough) return passthrough; + // Browser OTLP ingress — before the server span opens: exporter traffic // must never trace itself (the browser already excludes /v1/traces from // its own tracing for the same reason). @@ -120,10 +287,24 @@ const cloudflareHandler: ExportedHandler = { // The MCP dispatch is classified up front, independent of whether // telemetry installs — an unset `AXIOM_TOKEN` (tracer not installed) must // never take /mcp requests down with it. See `installTracerProvider`'s - // early return below: it only governs the tracing envelope for - // non-MCP paths. + // early return below: the handler invokes it for uncached MCP traffic, and + // this entry invokes it for non-MCP paths. const url = new URL(request.url); const mcpRoute = classifyMcpPath(url.pathname); + if (mcpRoute?.kind === "mcp") { + // The Cloudflare Agents MCP bridge needs the platform ExecutionContext + // to pass authenticated session props into the hibernatable DO. + // Discovery docs still flow through the app-level MCP envelope. + const forwarded = prepareMcpOrgScope(request); + // /mcp leaves the Effect app for the Agents bridge, so no downstream + // HttpMiddleware.tracer opens the request envelope — this worker span is + // THE `http.server` span for MCP traffic, and its context is stamped onto + // the forwarded traceparent so the agent handler and session DO parent + // under it instead of exporting orphaned roots. + return traceCloudMcpRequest(forwarded, env, ctx, (tracedRequest) => + Promise.resolve(mcpAgentHandler(tracedRequest, env, ctx)), + ); + } const tracingInstalled = installTracerProvider(); // Join the caller's W3C trace when the request carries one — the web UI // sends traceparent on every API fetch, so the browser's spans and this @@ -138,69 +319,63 @@ const cloudflareHandler: ExportedHandler = { isRemote: true, }) : context.active(); - if (mcpRoute?.kind === "mcp") { - // The Cloudflare Agents MCP bridge needs the platform ExecutionContext - // to pass authenticated session props into the hibernatable DO. - // Discovery docs still flow through the app-level MCP envelope. - const forwarded = prepareMcpOrgScope(request); - if (!tracingInstalled) { - return mcpAgentHandler(forwarded, env, ctx); - } - // /mcp left the Effect app in the Agents-bridge migration, so no - // downstream HttpMiddleware.tracer opens the request envelope anymore — - // this worker span is now THE `http.server` span for MCP traffic. Its - // context is stamped onto the forwarded request's traceparent so the - // agent handler's Effect programs (mcp.request and children) and the - // session DO parent under it instead of exporting orphaned roots. + if (!tracingInstalled) { + return fetchHandler(request, env, ctx); + } + // Effect-served paths bring their own http.server span (with traceparent + // join) — a second SERVER span here would duplicate it (the header note). + // What they do NOT cover is the time between this invocation starting and + // the Effect router opening its span (Start dispatch, middleware, lazy + // module graph): during the Aug 2026 regression that gap was seconds of + // invisible wall time. `worker.dispatch` is an INTERNAL parent that + // brackets the whole invocation; Effect's http.server span joins under it + // via the injected traceparent, so gap = dispatch minus server span. + if (isAppOwnedPath(url.pathname)) { return tracer.startActiveSpan( - `http.server ${request.method}`, - { kind: SpanKind.SERVER }, + "worker.dispatch", + { kind: SpanKind.INTERNAL }, parentContext, async (span) => { span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method); - span.setAttribute(ATTR_URL_FULL, request.url); span.setAttribute(ATTR_URL_PATH, url.pathname); - span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, "")); - const spanContext = span.spanContext(); - const headers = new Headers(forwarded.headers); - headers.set( - "traceparent", - `00-${spanContext.traceId}-${spanContext.spanId}-${(spanContext.traceFlags & 0xff).toString(16).padStart(2, "0")}`, - ); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep trace export alive after the Agents bridge resolves or rejects + const isolate = identifyIsolate(); + span.setAttribute("executor.isolate.request_seq", isolateRequestSeq); + span.setAttribute("executor.start_graph.entered", startGraphEntered); + span.setAttribute("executor.isolate.id", isolate.id); + span.setAttribute("executor.isolate.age_ms", isolate.ageMs); + // Which plane served this: "app" skipped the Start graph entirely, so + // `start_graph.entered` says nothing about it. `app_graph.entered` + // is the app-plane analogue - false means this request paid for the + // Effect graph's first evaluation in this isolate. + const appPlaneRequest = servedByAppPlane(url.pathname, request.method); + span.setAttribute("executor.dispatch.plane", appPlaneRequest ? "app" : "start"); + if (appPlaneRequest) span.setAttribute("executor.app_graph.entered", appGraphEntered); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep the flush alive past the response try { - const response = await mcpAgentHandler(new Request(forwarded, { headers }), env, ctx); + const traced = withTraceparent(request, span.spanContext()); + const response = appPlaneRequest + ? await (await getAppPlane()).handler(prepareMcpOrgScope(traced)) + : await fetchHandler(traced, env, ctx); span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); - if (response.status >= 500) { - span.setStatus({ code: SpanStatusCode.ERROR }); - } return response; } catch (err) { - span.setStatus({ code: SpanStatusCode.ERROR }); + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: Cloudflare's fetch callback throws untyped; normalized only for the OTel span record, the original error is rethrown below + const cause = err instanceof Error ? err : String(err); + span.recordException(cause); + // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above + const message = typeof cause === "string" ? cause : cause.message; + span.setStatus({ code: SpanStatusCode.ERROR, message }); // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime throw err; } finally { span.end(); + // The flush still must outlive the request — Effect's + // BatchSpanProcessor ships on a timer. ctx.waitUntil(flushTracerProvider()); } }, ); } - if (!tracingInstalled) { - return fetchHandler(request, env, ctx); - } - // Effect-served paths bring their own http.server span (with traceparent - // join) — opening one here too would duplicate it. See the header note. - if (isAppOwnedPath(url.pathname)) { - // The provider is installed (above) and the flush still must outlive - // the request — Effect's BatchSpanProcessor ships on a timer. - // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; mirror the traced path's finally - try { - return await fetchHandler(request, env, ctx); - } finally { - ctx.waitUntil(flushTracerProvider()); - } - } return tracer.startActiveSpan( `http.server ${request.method}`, { kind: SpanKind.SERVER }, @@ -237,11 +412,18 @@ const cloudflareHandler: ExportedHandler = { const response = await fetchHandler(request, env, ctx); span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); if (response.status >= 500) { - span.setStatus({ code: SpanStatusCode.ERROR }); + span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); } return response; } catch (err) { - span.setStatus({ code: SpanStatusCode.ERROR }); + // Record the exception itself, not just the status bit: without it + // these spans are ERROR with zero diagnostic content. + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: Cloudflare's fetch callback throws untyped; normalized only for the OTel span record, the original error is rethrown below + const cause = err instanceof Error ? err : String(err); + span.recordException(cause); + // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above + const message = typeof cause === "string" ? cause : cause.message; + span.setStatus({ code: SpanStatusCode.ERROR, message }); // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime throw err; } finally { @@ -253,4 +435,4 @@ const cloudflareHandler: ExportedHandler = { }, }; -export default Sentry.withSentry(sentryOptions, cloudflareHandler); +export default Sentry.withSentry(cloudSentryOptions, cloudflareHandler); diff --git a/apps/cloud/src/start.ts b/apps/cloud/src/start.ts index a35a327f15..aaadc1a521 100644 --- a/apps/cloud/src/start.ts +++ b/apps/cloud/src/start.ts @@ -1,16 +1,14 @@ import { createMiddleware, createStart } from "@tanstack/react-start"; import { decodeOAuthCallbackState } from "@executor-js/sdk/shared"; -import { cloudApiHandler } from "./app"; import { isAppOwnedPath } from "./app-paths"; -import { authGateMiddleware } from "./auth/ssr-gate"; +import { authGateMiddleware } from "./auth/doc-gate"; import { parseCookie } from "./auth/cookies"; import { ORG_SELECTOR_HEADER } from "./auth/organization"; import { loginPath } from "./auth/return-to"; import { prepareMcpOrgScope } from "./mcp/mount"; import { docsProxyMiddleware, - marketingMiddleware, openAiAppsChallengeMiddleware, posthogProxyMiddleware, sentryTunnelMiddleware, @@ -36,8 +34,18 @@ import { // (a workerd-only virtual module) into the browser build, breaking it. Keeping // the call inside the server callback mirrors how every other server concern // here stays server-only. -let app: ReturnType | undefined; -const getApp = () => (app ??= cloudApiHandler()); +// +// The IMPORT is dynamic for a second, server-side reason: `server.ts` now +// dispatches `/api/*` at the Worker entry, so the only paths that still reach +// this middleware are the two Start's own chain claims first (the Sentry tunnel +// and the OAuth callback's signed-out redirect) plus `/mcp`. A static import +// would still put the entire Effect app in the graph every SSR page load +// evaluates — 3.06 MB of the 12.94 MB page closure, for code a page never runs. +// Deferring it takes the page closure to 9.88 MB; `scripts/start-closure.mjs` +// reports both planes and will show it coming back if this becomes static. +let app: ReturnType | undefined; +const getApp = async (): Promise> => + (app ??= (await import("./app")).cloudApiHandler()); const SESSION_COOKIE = "wos-session"; const OAUTH_CALLBACK_PATH = "/api/oauth/callback"; @@ -79,30 +87,29 @@ const oauthCallbackSignInMiddleware = createMiddleware({ type: "request" }).serv // envelope routes, pinning the org in an internal header (a no-op for everything // else, including `/api/*`). const appRequestMiddleware = createMiddleware({ type: "request" }).server( - ({ pathname, request, next }) => { + async ({ pathname, request, next }) => { if (isAppOwnedPath(pathname)) { const scopedRequest = pathname === OAUTH_CALLBACK_PATH ? oauthCallbackOrgScopedRequest(request) : request; - return getApp().handler(prepareMcpOrgScope(scopedRequest)); + return (await getApp()).handler(prepareMcpOrgScope(scopedRequest)); } return next(); }, ); -// The edge concerns (marketing proxy, docs proxy, sentry tunnel, posthog proxy) -// live in `./edge`; they run before the app's own dispatch. Ordering is -// load-bearing: marketing first (production landing/page proxy), then the docs -// proxy and analytics tunnels, then the unified app plane (api + mcp), and last -// the SSR auth gate — it only sees document requests nothing above claimed, so -// signed-out visitors are redirected to /login before the SPA (and its -// app-shell skeleton) is served. The docs proxy sits among the edges (not after -// the auth gate) because `/docs` is public and must skip the sign-in redirect; -// its path is disjoint from every other matcher, so its slot is not otherwise -// load-bearing. +// The remaining edge concerns (docs proxy, sentry tunnel, posthog proxy) live +// in `./edge`; they run before the app's own dispatch. Marketing is handled in +// server.ts before this module is loaded. Ordering here is load-bearing: public +// challenges and docs, then analytics tunnels, then the unified app plane (api +// + mcp), and last the SSR auth gate — it only sees document requests nothing +// above claimed, so signed-out visitors are redirected to /login before the SPA +// (and its app-shell skeleton) is served. The docs proxy sits among the edges +// (not after the auth gate) because `/docs` is public and must skip the sign-in +// redirect; its path is disjoint from every other matcher, so its slot is not +// otherwise load-bearing. export const startInstance = createStart(() => ({ requestMiddleware: [ openAiAppsChallengeMiddleware, - marketingMiddleware, docsProxyMiddleware, sentryTunnelMiddleware, posthogProxyMiddleware, diff --git a/apps/cloud/src/test-globalsetup-exit.node.test.ts b/apps/cloud/src/test-globalsetup-exit.node.test.ts new file mode 100644 index 0000000000..c36a4ff7d2 --- /dev/null +++ b/apps/cloud/src/test-globalsetup-exit.node.test.ts @@ -0,0 +1,46 @@ +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "@effect/vitest"; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const vitestBin = resolve(appRoot, "../../node_modules/vitest/vitest.mjs"); +const fixtureConfig = resolve(appRoot, "test-fixtures/test-globalsetup-exit/vitest.config.ts"); + +// The nested globalsetup binds an OS-assigned port (0): the fixture test never +// connects to the database, and the fixed ports this file used to pass +// (45435/45436) sat inside the Linux ephemeral range, where a concurrent +// suite's outbound socket could hold them — the nested vitest then hung on the +// swallowed bind failure until spawnSync's timeout killed it (signal !== null). +const runFixture = (shouldPass: boolean) => + spawnSync(process.execPath, [vitestBin, "run", "--config", fixtureConfig], { + cwd: appRoot, + encoding: "utf8", + timeout: 60_000, + env: { + ...process.env, + CLOUD_TEST_DB_PORT: "0", + TEST_GLOBALSETUP_SHOULD_PASS: String(shouldPass), + }, + }); + +const diagnostic = (result: ReturnType): string => + [result.stdout, result.stderr].filter(Boolean).join("\n"); + +describe("cloud test global setup", () => { + it("does not let PGlite teardown turn a passed test red", { timeout: 60_000 }, () => { + const result = runFixture(true); + + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status, diagnostic(result)).toBe(0); + }); + + it("does not let PGlite teardown turn a failed test green", { timeout: 60_000 }, () => { + const result = runFixture(false); + + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status, diagnostic(result)).toBe(1); + }); +}); diff --git a/apps/cloud/src/wasm.d.ts b/apps/cloud/src/wasm.d.ts new file mode 100644 index 0000000000..1bfd61f61e --- /dev/null +++ b/apps/cloud/src/wasm.d.ts @@ -0,0 +1,6 @@ +// On Cloudflare Workers, a `.wasm` import resolves to a pre-compiled +// `WebAssembly.Module` (wrangler's built-in CompiledWasm module rule). +declare module "*.wasm" { + const wasmModule: WebAssembly.Module; + export default wasmModule; +} diff --git a/apps/cloud/src/web/auth.tsx b/apps/cloud/src/web/auth.tsx index 98512e16b7..8e46a6cad8 100644 --- a/apps/cloud/src/web/auth.tsx +++ b/apps/cloud/src/web/auth.tsx @@ -9,6 +9,7 @@ import { } from "@executor-js/react/multiplayer/auth-context"; import type { AuthHint } from "@executor-js/react/multiplayer/auth-hint"; +import { writeLastOrgCookie } from "../auth/last-org-cookie"; import { CloudApiClient } from "./client"; // --------------------------------------------------------------------------- @@ -57,6 +58,16 @@ export const AuthProvider = ({ const onIdentify = React.useCallback( (state) => { + // Record the org this browser is verifiably viewing: onIdentify fires + // only on resolved `/account/me` answers (never hint optimism), and that + // call is URL-scoped, so `state.organization` IS the org of the current + // tab. The cookie makes the next bare entry (`/`) and the next login + // land here instead of the session's login-time org — see + // ../auth/last-org-cookie.ts. Deliberately not cleared on signout: the + // preference is the point of surviving the session. + if (state.status === "authenticated" && state.organization) { + writeLastOrgCookie(state.organization.slug); + } if (!posthog) return; if (state.status === "authenticated") { posthog.identify(state.user.id, { diff --git a/apps/cloud/src/web/components/support-slot.tsx b/apps/cloud/src/web/components/support-slot.tsx index 834fc4b6ed..1d46f5d66b 100644 --- a/apps/cloud/src/web/components/support-slot.tsx +++ b/apps/cloud/src/web/components/support-slot.tsx @@ -44,7 +44,8 @@ export function SupportSlot() { Get support - + {/* Static links only: clicking away dismisses it. */} + Get support diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index d136131661..99a60178df 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -1,6 +1,7 @@ import type React from "react"; import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; +import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav"; import { trackEvent } from "@executor-js/react/api/analytics"; import { AUTH_PATHS } from "../auth/api"; import { OrgMenuSlot } from "./components/org-menu-slot"; @@ -25,6 +26,12 @@ const navItems = [ { to: "/billing", label: "Billing" }, ]; +// Sections only an admin of the org may open. The Users page reads the +// tenant-wide admin plane, which cloud gates on an admin-role membership — so +// a plain member is not shown a link that would only refuse them. The page +// still renders its own denied state if one arrives by URL. +const adminNavItems = [{ to: "/users", label: "Users" }]; + // A top-level form POST, not fetch: the logout response 302s through the // WorkOS logout endpoint (ending the hosted AuthKit session) before landing // back home. A fetch would follow that chain invisibly inside the XHR and @@ -39,10 +46,11 @@ const signOut = () => { }; export function Shell(props: { readonly content?: React.ReactNode }) { + const items = useAdminNavItems(navItems, adminNavItems); return ( } supportSlot={} content={props.content} diff --git a/apps/cloud/src/worker-bundler-artifact.d.ts b/apps/cloud/src/worker-bundler-artifact.d.ts deleted file mode 100644 index ad70339ab6..0000000000 --- a/apps/cloud/src/worker-bundler-artifact.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module "virtual:executor/worker-bundler-artifact" { - export const sourcePath: string; - export const wasmPath: string; - export const source: string | undefined; - export const wasmBase64: string | undefined; -} diff --git a/apps/cloud/test-fixtures/test-globalsetup-exit/fixture.test.ts b/apps/cloud/test-fixtures/test-globalsetup-exit/fixture.test.ts new file mode 100644 index 0000000000..453b8f8b8b --- /dev/null +++ b/apps/cloud/test-fixtures/test-globalsetup-exit/fixture.test.ts @@ -0,0 +1,7 @@ +import { expect, it } from "@effect/vitest"; + +it("exercises the global-setup exit path", () => { + if (process.env.TEST_GLOBALSETUP_SHOULD_PASS === "true") return; + + expect("deliberate failure").toBe("reported as success"); +}); diff --git a/apps/cloud/test-fixtures/test-globalsetup-exit/vitest.config.ts b/apps/cloud/test-fixtures/test-globalsetup-exit/vitest.config.ts new file mode 100644 index 0000000000..e073da19f6 --- /dev/null +++ b/apps/cloud/test-fixtures/test-globalsetup-exit/vitest.config.ts @@ -0,0 +1,12 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +const appRoot = resolve(__dirname, "../.."); + +export default defineConfig({ + root: appRoot, + test: { + include: ["test-fixtures/test-globalsetup-exit/fixture.test.ts"], + globalSetup: [resolve(appRoot, "scripts/test-globalsetup.ts")], + }, +}); diff --git a/apps/cloud/vite.config.ts b/apps/cloud/vite.config.ts index 83be48137c..308d5d60d8 100644 --- a/apps/cloud/vite.config.ts +++ b/apps/cloud/vite.config.ts @@ -5,7 +5,7 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; -import { workerBundlerArtifact } from "@executor-js/plugin-apps/vite"; +import { innerRendererPlugin, mcpAppsShellAsset } from "@executor-js/mcp-apps-shell/vite"; import { unstable_readConfig } from "wrangler"; import { routes } from "./tsr.routes"; @@ -126,8 +126,14 @@ export default defineConfig(({ command, mode }) => { }, plugins: [ devCrashGuard(), - workerBundlerArtifact(), tailwindcss(), + // The artifact page hosts the MCP-Apps shell as a sandboxed iframe over + // the MCP-Apps protocol: `mcpAppsShellAsset` emits the built shell + // document and hands the page its URL, and `innerRendererPlugin` inlines + // the shell's own sandboxed inner frame from + // `virtual:executor-inner-renderer`. + mcpAppsShellAsset() as Plugin, + innerRendererPlugin(), executorVitePlugin(), cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }), tanstackStart({ @@ -136,6 +142,16 @@ export default defineConfig(({ command, mode }) => { router: { virtualRouteConfig: routes, }, + // SPA mode: the console is 100% authenticated UI (marketing is its own + // Astro app, docs are a proxy), so nothing needs per-request React + // SSR. The shell is prerendered once at build; document requests still + // run the request-middleware chain (doc-gate auth redirects + session + // cookie rotation) but serve that static shell, which drops the whole + // React app from the worker bundle and takes per-request render cost + // to zero. + spa: { + enabled: true, + }, }), react(), ], diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 267f428ef1..3578de6d25 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -5,8 +5,7 @@ "compatibility_flags": ["nodejs_compat"], "main": "src/server.ts", // The vite plugin fills in assets.directory (dist/client); the binding must - // be declared here or env.ASSETS does not exist at runtime. The apps plugin - // fetches the worker-bundler artifact through it on first sync. + // be declared here or env.ASSETS does not exist at runtime. "assets": { "binding": "ASSETS", }, @@ -22,6 +21,12 @@ "observability": { "enabled": true, }, + // Script-level logpush feeds the account's workers_trace_events Logpush job + // (invocation logs, outcomes like exceededMemory, console output) into + // Axiom. Pinned here because the setting lives on the script: a deploy that + // omits it can reset it to false, which is how the export silently died in + // the July deploy-tooling migration. + "logpush": true, "durable_objects": { "bindings": [ { @@ -89,6 +94,11 @@ "bucket_name": "executor-cloud-blobs", }, ], + // Pins the Worker to the Hyperdrive/Postgres region. Added in #420 to fix + // MCP Hyperdrive connection pressure; dropped in #1693 as a placement + // experiment, which took `user_store.*` from ~12ms to ~300ms because every + // query started crossing regions. Restored to measure that against the + // JWKS verify time the experiment also moved. "placement": { "region": "aws:us-east-1", }, @@ -97,8 +107,22 @@ "binding": "LOADER", }, ], + // Stamps spans (`service.version`) and mem-metrics snapshots with the + // running Worker version id, so an Axiom step-change maps to the exact + // deploy without Cloudflare API archaeology. memory-metrics.ts already + // reads this binding; it was never declared before, so it always fell back. + "version_metadata": { + "binding": "CF_VERSION_METADATA", + }, "vars": { "VITE_PUBLIC_SITE_URL": "https://executor.sh", + // Keeps the /__sentry-otel-verify probe live in production: Sentry + // delivery failed silently for weeks (zero events after ~Jul 30 with an + // active DSN), and without this there is no way to test the pipeline + // end-to-end short of waiting for a real error. The endpoint only fires + // when its exact path is requested and the events are explicitly tagged + // synthetic. + "SENTRY_OTEL_VERIFY": "true", "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL", // Browser OTLP spans → same-origin, forwarded to Axiom by the worker // (src/observability/browser-traces.ts). Relative on purpose: the diff --git a/apps/desktop/CHANGELOG.md b/apps/desktop/CHANGELOG.md index 0d51bf41ec..49ac57e36b 100644 --- a/apps/desktop/CHANGELOG.md +++ b/apps/desktop/CHANGELOG.md @@ -1,5 +1,51 @@ # @executor-js/desktop +## 1.6.7 + +### Patch Changes + +- [#1876](https://github.com/UsefulSoftwareCo/executor/pull/1876) [`75b3674`](https://github.com/UsefulSoftwareCo/executor/commit/75b3674136b44a2e43fb23eb7a058e7e51528527) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Let macOS ask before denying Codex plugins Automation access. The desktop app + and its bundled daemon are hardened-runtime signed without the Apple Events + entitlement, so tccd refused to even show the consent prompt: every Messages + call was denied silently, no Automation row was ever created in System + Settings, and the access check sat on "Checking…" for a full minute before + misreporting the hang as a failed start. The app and daemon are now signed + with `com.apple.security.automation.apple-events` and carry a usage + description, so the first call raises the real consent prompt and the grant + becomes visible in Privacy & Security → Automation. + + The access check also stops waiting after 25 seconds and says what a hang + means — answer the permission prompt on screen, then check again — instead of + blaming the Codex install. + +## 1.6.6 + +## 1.6.5 + +## 1.6.4 + +## 1.6.3 + +## 1.6.2 + +## 1.6.1 + +## 1.6.0 + +## 1.5.42 + +## 1.5.41 + +## 1.5.40 + +## 1.5.39 + +## 1.5.38 + +## 1.5.37 + +## 1.5.36 + ## 1.5.35 ## 1.5.34 diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist index 043ecf496e..c5a652b654 100644 --- a/apps/desktop/build/entitlements.mac.plist +++ b/apps/desktop/build/entitlements.mac.plist @@ -17,5 +17,14 @@ of the Electron bundle's signing chain. --> com.apple.security.cs.disable-library-validation + + com.apple.security.automation.apple-events + diff --git a/apps/desktop/electron-builder.config.ts b/apps/desktop/electron-builder.config.ts index 2766833954..39a15aa397 100644 --- a/apps/desktop/electron-builder.config.ts +++ b/apps/desktop/electron-builder.config.ts @@ -37,6 +37,13 @@ const config: Configuration = { entitlements: "build/entitlements.mac.plist", entitlementsInherit: "build/entitlements.mac.plist", notarize: true, + extendInfo: { + // Shown in the macOS Automation consent prompt. Required alongside the + // apple-events entitlement in entitlements.mac.plist: without the + // usage string, tccd declines to prompt and denies silently. + NSAppleEventsUsageDescription: + "Executor runs local plugins that control apps like Messages on your behalf.", + }, }, // Same arch rule as mac (see comment above): never pin `arch:` in the // target objects. The win/linux pins used to force both archs out of a @@ -46,6 +53,27 @@ const config: Configuration = { // artifact only exists once a leg stages an arm64 executor for it. win: { target: ["nsis"], + // There is no Windows code-signing certificate yet, so these installers + // ship unsigned, and this flag records that fact for the updater. + // + // Left at its `true` default, electron-builder writes a `publisherName` + // into app-update.yml derived from the signing certificate's subject CN + // (PublishManager.getAppUpdatePublishConfiguration, gated on + // WinPackager.isForceCodeSigningVerification). electron-updater then + // requires every downloaded installer to carry an Authenticode signature + // matching that name and refuses the update otherwise + // (NsisUpdater.verifySignature). An unsigned build that still claims a + // publisher can therefore never update itself. + // + // With this false no `publisherName` is emitted, `verifySignature` + // returns null early, and unsigned installers update as intended. It does + // not disable signing: `isForceCodeSigningVerification` is only ever read + // when computing the updater manifest. + // + // Delete this line when a real Windows certificate lands (signtoolOptions + // or azureSignOptions); leaving it would keep verification off for a + // signed build. + verifyUpdateCodeSignature: false, }, nsis: { oneClick: true, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8a2158f9c5..af7491b2b9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/desktop", - "version": "1.5.35", + "version": "1.6.7", "private": true, "homepage": "https://github.com/UsefulSoftwareCo/executor", "license": "MIT", diff --git a/apps/desktop/scripts/check-build-resources.ts b/apps/desktop/scripts/check-build-resources.ts new file mode 100644 index 0000000000..4f2d58aed2 --- /dev/null +++ b/apps/desktop/scripts/check-build-resources.ts @@ -0,0 +1,97 @@ +/** + * Preflight for the packaged app's static build inputs (`apps/desktop/build/`). + * + * electron-builder resolves `directories.buildResources` and `mac.entitlements` + * lazily, deep inside packaging, so a missing file there is silent until it is + * far too late. When `build/entitlements.mac.plist` is absent the mac legs run + * for ~8 minutes and then die inside codesign with + * `build/entitlements.mac.plist: cannot read entitlement data`, naming an + * arbitrary `*.lproj/locale.pak` — the first leaf codesign happened to reach, + * not the actual problem. A missing `build/icon.png` never fails at all: + * electron-builder logs `default Electron icon is used` and ships a generic + * Electron app. + * + * These files are load-bearing but unreferenced: nothing in the repo imports + * them, so deleting them breaks no build, type, or test. That is how the v1.6.1 + * tag was cut without them. This check gives them a reference. It runs in the + * desktop unit tests on every PR, and again in publish-desktop.yml before + * electron-builder starts, so the failure names the real cause in seconds. + */ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import config from "../electron-builder.config"; + +// `import.meta.url` rather than Bun's `import.meta.dir`: this module is also +// imported by Vitest, which serves it through Vite's transform where the Bun +// property is undefined. +const ROOT = fileURLToPath(new URL("..", import.meta.url)); + +const buildResources = config.directories?.buildResources ?? "build"; + +const requiredPaths = (): readonly string[] => { + const paths = new Set(); + + // electron-builder finds the app icon by convention + // (`/icon.png`) rather than from a config field, so there is + // no value to read here — the convention has to be spelled out. + paths.add(`${buildResources}/icon.png`); + + const mac = config.mac; + for (const entitlements of [mac?.entitlements, mac?.entitlementsInherit]) { + if (typeof entitlements === "string") paths.add(entitlements); + } + + return [...paths].sort(); +}; + +const isPlistShaped = (text: string): boolean => + text.includes("") && text.includes(""); + +/** + * Returns one human-readable problem per broken build input, or an empty array + * when every input is present and usable. Never throws: callers decide whether + * a problem is a failed assertion or a non-zero exit. + */ +export const checkBuildResources = async (): Promise => { + const problems: string[] = []; + + for (const relativePath of requiredPaths()) { + const absolutePath = resolve(ROOT, relativePath); + const file = Bun.file(absolutePath); + + if (!(await file.exists())) { + problems.push(`missing: ${relativePath} (electron-builder.config.ts references it)`); + continue; + } + + if (file.size === 0) { + problems.push(`empty: ${relativePath}`); + continue; + } + + // codesign reads the entitlements file itself, so a truncated or + // non-plist file fails at signing time with the same opaque + // "cannot read entitlement data" as a missing one. + if (relativePath.endsWith(".plist") && !isPlistShaped(await file.text())) { + problems.push(`not a plist: ${relativePath}`); + } + } + + return problems; +}; + +if (import.meta.main) { + const problems = await checkBuildResources(); + + if (problems.length > 0) { + console.error("[check-build-resources] FAIL — apps/desktop/build/ is incomplete:"); + for (const problem of problems) console.error(` - ${problem}`); + console.error( + "\nThese files are committed static build inputs. Restore them from the last" + + "\ngood tag, e.g. `git checkout -- apps/desktop/build/`.", + ); + process.exit(1); + } + + console.log(`[check-build-resources] OK — ${requiredPaths().join(", ")}`); +} diff --git a/apps/desktop/src/build-resources.test.ts b/apps/desktop/src/build-resources.test.ts new file mode 100644 index 0000000000..f94527c9f2 --- /dev/null +++ b/apps/desktop/src/build-resources.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "@effect/vitest"; +import { checkBuildResources } from "../scripts/check-build-resources"; + +// The files under apps/desktop/build/ are committed inputs that no module +// imports, so nothing else in the repo notices when they disappear. This test +// is their only reference: it runs in `bun run test` on every PR, which is the +// gate that would have stopped them being deleted before the v1.6.1 tag. +describe("desktop build resources", () => { + it("keeps every static build input electron-builder packages and signs with", async () => { + expect(await checkBuildResources()).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/app-quit.ts b/apps/desktop/src/main/app-quit.ts new file mode 100644 index 0000000000..616e1bdb19 --- /dev/null +++ b/apps/desktop/src/main/app-quit.ts @@ -0,0 +1,23 @@ +/** + * Shared "is the app on its way out?" flag for the main process. + * + * Teardown makes several failures expected rather than exceptional: a sidecar + * child dying, a navigation aborting mid-load. Both the sidecar and window code + * need the same answer, so the flag lives here instead of being threaded + * through either. Listeners are registered at import time (Electron accepts + * `app.on` before ready) so no caller can forget to arm it. + */ + +import { app } from "electron"; + +let quitting = false; + +const markQuitting = () => { + quitting = true; +}; + +app.on("before-quit", markQuitting); +app.on("will-quit", markQuitting); +app.on("quit", markQuitting); + +export const isAppQuitting = (): boolean => quitting; diff --git a/apps/desktop/src/main/crash-fingerprint.test.ts b/apps/desktop/src/main/crash-fingerprint.test.ts new file mode 100644 index 0000000000..057498f9c7 --- /dev/null +++ b/apps/desktop/src/main/crash-fingerprint.test.ts @@ -0,0 +1,143 @@ +import { expect, test } from "@effect/vitest"; + +import { + crashReportFingerprint, + mainCrashReportingOptions, + withCrashReportFingerprint, + type CrashEvent, +} from "./crash-fingerprint"; + +/** A crash event as Sentry hands it to `beforeSend` — with the grouping key + * slot the hook is allowed to fill. */ +type SentCrashEvent = CrashEvent & { readonly fingerprint?: readonly string[] | undefined }; + +// Chromium's soft-assert path (`NOTREACHED()`/`DCHECK`) dumps and keeps +// running. Sentry titles each one with the faulting load address, so one +// condition arrives as a new issue every time the address moves. +const softAssertEvent = (address: string): SentCrashEvent => ({ + exception: { + values: [ + { + type: "Fatal Error", + value: `Simulated Exception / ${address}`, + mechanism: { type: "minidump" }, + stacktrace: { + frames: [ + { function: "logging::NotReachedLogMessage::~NotReachedLogMessage" }, + { function: "logging::HandleCheckErrorLogMessage" }, + { function: "base::debug::DumpWithoutCrashing" }, + { function: "crash_reporter::DumpWithoutCrashing" }, + ], + }, + }, + ], + }, +}); + +test("address-keyed Chromium soft asserts collapse to one fingerprint", () => { + const first = crashReportFingerprint(softAssertEvent("0x00000001a2b3c4d5")); + const second = crashReportFingerprint(softAssertEvent("0x00000007e8f9a0b1")); + + expect(first).toEqual(["chromium-dump-without-crashing"]); + expect(first).toEqual(second); +}); + +test("a real native abort keeps Sentry's own grouping", () => { + const abortEvent: CrashEvent = { + exception: { + values: [ + { + type: "EXC_CRASH", + value: "SIGABRT", + mechanism: { type: "minidump" }, + stacktrace: { + frames: [ + { function: "abort" }, + { function: "pthread_kill" }, + { function: "__pthread_kill" }, + ], + }, + }, + ], + }, + }; + + expect(crashReportFingerprint(abortEvent)).toBeUndefined(); +}); + +test("renderer chunk hashes are normalized out of the fingerprint", () => { + const rendererEvent = (chunkHash: string): CrashEvent => ({ + culprit: `loadConnections(assets/atoms-${chunkHash})`, + exception: { + values: [ + { + type: "TypeError", + value: "cannot read properties of undefined", + stacktrace: { + frames: [ + { + filename: `http://127.0.0.1:4789/assets/atoms-${chunkHash}.js`, + module: `atoms-${chunkHash}`, + function: "loadConnections", + in_app: true, + }, + ], + }, + }, + ], + }, + }); + + const release1 = crashReportFingerprint(rendererEvent("Yemn7yhP")); + const release2 = crashReportFingerprint(rendererEvent("CeCENfWa")); + + expect(release1).toEqual(["TypeError", "loadConnections@atoms"]); + expect(release1).toEqual(release2); +}); + +// `withCrashReportFingerprint` is the function object diagnostics.ts installs +// as its `beforeSend`, so these assertions cover the main-process wiring and +// not just the classifier behind it. +test("the main-process beforeSend pins the key and forwards the event", () => { + const collapsed = withCrashReportFingerprint(softAssertEvent("0x00000001a2b3c4d5")); + expect(collapsed.fingerprint).toEqual(["chromium-dump-without-crashing"]); + // Nothing else about the event is touched — this is grouping only. + expect(collapsed.exception?.values?.[0]?.value).toBe("Simulated Exception / 0x00000001a2b3c4d5"); + + const untouched: SentCrashEvent = { + exception: { values: [{ type: "EXC_CRASH", stacktrace: { frames: [{ function: "abort" }] } }] }, + }; + expect(withCrashReportFingerprint(untouched)).toBe(untouched); +}); + +// The wiring check: this is the exact object handed to `Sentry.init` in +// diagnostics.ts. If the hook is ever dropped from the main process, this +// fails. +test("the options the main process installs carry the fingerprinting hook", () => { + const options = mainCrashReportingOptions({ + dsn: "https://public@example.invalid/1", + release: "executor-desktop@0.0.0", + environment: "production", + runId: "abcdef123456", + }); + const sent = options.beforeSend(softAssertEvent("0x00000001a2b3c4d5")); + expect(sent.fingerprint).toEqual(["chromium-dump-without-crashing"]); +}); + +test("events with no volatile grouping input are left alone", () => { + expect(crashReportFingerprint({})).toBeUndefined(); + expect( + crashReportFingerprint({ + exception: { + values: [ + { + type: "Error", + stacktrace: { + frames: [{ filename: "/src/main/sidecar.ts", function: "startSidecar" }], + }, + }, + ], + }, + }), + ).toBeUndefined(); +}); diff --git a/apps/desktop/src/main/crash-fingerprint.ts b/apps/desktop/src/main/crash-fingerprint.ts new file mode 100644 index 0000000000..105f60a101 --- /dev/null +++ b/apps/desktop/src/main/crash-fingerprint.ts @@ -0,0 +1,80 @@ +/** + * Grouping keys for desktop crash reports. + * + * Two things split one desktop problem across many Sentry issues: + * + * - Chromium's soft-assert path (`NOTREACHED()`/`DCHECK`) calls + * `DumpWithoutCrashing`, which files a minidump and lets the process carry + * on. Sentry titles each one with the faulting load address, so the same + * condition arrives as a new issue every time the address moves. + * - Renderer and main bundles ship as content-hashed chunks, so unresolved + * frames name `atoms-` and re-group on every release. + * + * Both are grouping problems only: nothing here drops, downgrades or edits an + * event, and the frames keep their hashes so sourcemap resolution still works. + */ + +import { + stableGroupingFingerprint, + withStableGroupingFingerprint, + type GroupingEvent, + type GroupingFrame, +} from "@executor-js/sdk/sentry-grouping"; + +export type CrashEvent = GroupingEvent; + +export const CHROMIUM_SOFT_ASSERT_FINGERPRINT = "chromium-dump-without-crashing"; + +const isSoftAssertFrame = (frame: GroupingFrame): boolean => + (frame.function ?? "").includes("DumpWithoutCrashing"); + +/** + * The fingerprint to attach to a crash event, or `undefined` to keep Sentry's + * default grouping (which is right for a real native abort — one issue per + * distinct stack is what we want there). + */ +export const crashReportFingerprint = (event: CrashEvent): readonly string[] | undefined => { + const frames = (event.exception?.values ?? []).flatMap((value) => value.stacktrace?.frames ?? []); + if (frames.some(isSoftAssertFrame)) return [CHROMIUM_SOFT_ASSERT_FINGERPRINT]; + return stableGroupingFingerprint(event); +}; + +/** + * The `beforeSend` the Electron main process installs. Grouping only — an + * event with no volatile grouping input is forwarded unchanged. + */ +export const withCrashReportFingerprint = (event: T): T => { + const frames = (event.exception?.values ?? []).flatMap((value) => value.stacktrace?.frames ?? []); + if (frames.some(isSoftAssertFrame)) { + return { ...event, fingerprint: [CHROMIUM_SOFT_ASSERT_FINGERPRINT] }; + } + return withStableGroupingFingerprint(event); +}; + +/** + * The Sentry options the Electron main process installs, assembled here rather + * than inline at the `Sentry.init` call so the `beforeSend` wiring is covered + * by crash-fingerprint.test.ts. `diagnostics.ts` only passes this through. + * + * Typed structurally on purpose: this module stays importable without pulling + * in electron, which is what keeps it unit-testable at all. + */ +export const mainCrashReportingOptions = (config: { + readonly dsn: string; + readonly release: string; + readonly environment: string; + readonly runId: string; +}) => ({ + dsn: config.dsn, + release: config.release, + environment: config.environment, + initialScope: { + tags: { + platform: process.platform, + arch: process.arch, + runId: config.runId, + }, + }, + // Grouping only — the event is forwarded untouched otherwise. + beforeSend: withCrashReportFingerprint, +}); diff --git a/apps/desktop/src/main/crash-screen.test.ts b/apps/desktop/src/main/crash-screen.test.ts new file mode 100644 index 0000000000..3c0e6bd8b1 --- /dev/null +++ b/apps/desktop/src/main/crash-screen.test.ts @@ -0,0 +1,26 @@ +/** + * The screen is shown for an outside interrupt as well as a real crash — the + * server is gone either way — so the one sentence that must not appear when + * nothing was sent upstream is pinned here. Telling a user a report was filed + * when none was is the visible half of the reported problem. + */ + +import { describe, expect, it } from "@effect/vitest"; +import { sidecarCrashHtml } from "./crash-screen"; + +const REPORT_CLAIM = "A crash report was sent automatically"; + +describe("sidecarCrashHtml", () => { + it("claims nothing was sent when nothing was", () => { + const html = sidecarCrashHtml({ reported: false }); + + expect(html).not.toContain(REPORT_CLAIM); + // The screen still has to explain the server is gone and offer a way back. + expect(html).toContain("The local Executor server stopped unexpectedly"); + expect(html).toContain("Restart server"); + }); + + it("says a report was sent when one was", () => { + expect(sidecarCrashHtml({ reported: true })).toContain(REPORT_CLAIM); + }); +}); diff --git a/apps/desktop/src/main/diagnostics.ts b/apps/desktop/src/main/diagnostics.ts index 3d04b1cc29..c10127a478 100644 --- a/apps/desktop/src/main/diagnostics.ts +++ b/apps/desktop/src/main/diagnostics.ts @@ -23,6 +23,7 @@ import { dirname, join } from "node:path"; import { app, crashReporter, dialog, shell } from "electron"; import log from "electron-log/main.js"; import * as Sentry from "@sentry/electron/main"; +import { mainCrashReportingOptions } from "./crash-fingerprint"; import { getServerSettings } from "./settings"; const sentryDsn = __EXECUTOR_SENTRY_DSN__; @@ -78,18 +79,14 @@ export const sidecarCrashReportingEnv = (): Record => */ export const initErrorReporting = () => { if (errorReportingEnabled) { - Sentry.init({ - dsn: sentryDsn, - release: releaseTag(), - environment: environmentTag(), - initialScope: { - tags: { - platform: process.platform, - arch: process.arch, - runId, - }, - }, - }); + Sentry.init( + mainCrashReportingOptions({ + dsn: sentryDsn, + release: releaseTag(), + environment: environmentTag(), + runId, + }), + ); } else { // No DSN baked in — keep native crash dumps local so a user-reported // crash still leaves minidumps for the diagnostics zip to collect. diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index ba64a02e72..98f9ec7b88 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -35,6 +35,8 @@ import { reportAProblem, } from "./diagnostics"; import { sidecarCrashHtml, startupWindowHtml } from "./crash-screen"; +import { isExpectedNavigationAbort } from "./navigation-errors"; +import { isAppQuitting } from "./app-quit"; import { replaceSupervisedDaemonForDesktop } from "./supervised-connection"; import { bundledExecutorPath, @@ -76,7 +78,13 @@ import { // executor.jsonc plugin manifest) is pinned separately to ~/.executor in // main/sidecar.ts — that path matches the CLI's default. app.setName("Executor"); -app.setPath("userData", join(app.getPath("appData"), "Executor")); +// A dev run must not collide with an installed Executor: userData also holds +// Electron's single-instance lock, so sharing it makes the second process quit +// silently at startup. `EXECUTOR_DESKTOP_USER_DATA` gives a dev run its own. +app.setPath( + "userData", + process.env.EXECUTOR_DESKTOP_USER_DATA ?? join(app.getPath("appData"), "Executor"), +); log.initialize({ preload: true }); log.transports.file.level = "info"; @@ -148,6 +156,37 @@ const webUrlForConnection = (conn: SidecarConnection): string => { const htmlDataUrl = (html: string): string => `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +/** + * True when a `loadURL` rejection is just Chromium cancelling a navigation + * whose window went away — the window closing mid-load is a race we cause, not + * a failure to reach the server. + */ +const isTeardownNavigationError = (error: unknown, window: BrowserWindow): boolean => + isExpectedNavigationAbort({ + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: Electron rejects loadURL with a plain Node Error carrying the net error name + message: error instanceof Error ? error.message : String(error), + windowDestroyed: window.isDestroyed(), + appQuitting: isAppQuitting(), + }); + +/** + * Fire-and-forget navigation. The caller has no way to react to a failure, so + * an uncaught rejection here would only reach the main-process unhandled + * rejection handler; log it instead, quietly when it is teardown noise. + */ +const loadWindowUrl = async (window: BrowserWindow, url: string): Promise => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Electron navigation rejects; there is no caller left to surface it to + try { + await window.loadURL(url); + } catch (error) { + if (isTeardownNavigationError(error, window)) { + log.info("Navigation aborted while the window was closing"); + return; + } + log.error("Failed to load window URL", error); + } +}; + // The supervised daemon (and the desktop sidecar) own this data dir — the same // path the CLI's `executor web`/daemon uses, so desktop and CLI share state. const DESKTOP_DATA_DIR = join(homedir(), ".executor"); @@ -355,7 +394,7 @@ const armSupervisedMonitor = () => { supervisedDaemonDown = false; connection = live; installBearerAuthHeader(live.baseUrl, live.authToken); - if (window) void window.loadURL(webUrlForConnection(live)); + if (window) void loadWindowUrl(window, webUrlForConnection(live)); } })(); }, 10_000); @@ -540,10 +579,13 @@ const showStartupWindow = async (): Promise => { } }; -const showCrashScreen = (window: BrowserWindow | null = liveMainWindow()): void => { +const showCrashScreen = ( + window: BrowserWindow | null = liveMainWindow(), + options: { readonly reported: boolean } = { reported: errorReportingEnabled }, +): void => { if (!window) return; - const html = sidecarCrashHtml({ reported: errorReportingEnabled }); - void window.loadURL(htmlDataUrl(html)); + const html = sidecarCrashHtml({ reported: options.reported && errorReportingEnabled }); + void loadWindowUrl(window, htmlDataUrl(html)); }; const createWindow = async (conn: SidecarConnection) => { @@ -560,6 +602,12 @@ const createWindow = async (conn: SidecarConnection) => { await window.loadURL(webUrlForConnection(conn)); if (!window.isDestroyed() && !window.isVisible()) window.show(); } catch (error) { + // The window was closed (or the app is quitting) while this navigation was + // in flight. Nothing failed — there is just nowhere left to navigate. + if (isTeardownNavigationError(error, window)) { + log.info("Executor web UI load aborted while the window was closing"); + return; + } log.error("Failed to load Executor web UI", error); if (!existingWindow) destroyWindow(window); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: caller decides whether to fall back or surface startup failure @@ -1158,8 +1206,11 @@ const boot = async () => { // A sidecar that dies under a live window would leave the web UI failing // every request with no explanation. Swap in the crash screen — its // buttons drive the regular preload bridge (restart / export diagnostics). - onUnexpectedSidecarExit(() => { - showCrashScreen(); + onUnexpectedSidecarExit((notice) => { + // An expected shutdown (an interrupt aimed at the app) still leaves the web + // UI dead, so the screen is the same — it just must not claim a crash + // report was sent when none was. + showCrashScreen(liveMainWindow(), { reported: notice.reported }); // A crashing sidecar may be a broken release — quietly stage any // available update so the install prompt appears on its own (same // self-heal as the fatal startup path). diff --git a/apps/desktop/src/main/navigation-errors.test.ts b/apps/desktop/src/main/navigation-errors.test.ts new file mode 100644 index 0000000000..eb6d166956 --- /dev/null +++ b/apps/desktop/src/main/navigation-errors.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "@effect/vitest"; +import { isExpectedNavigationAbort } from "./navigation-errors"; + +const abort = (overrides: Partial[0]> = {}) => + isExpectedNavigationAbort({ + message: "ERR_FAILED (-2) loading 'http://127.0.0.1:4789/?_token=redacted'", + windowDestroyed: false, + appQuitting: false, + ...overrides, + }); + +describe("isExpectedNavigationAbort", () => { + it("treats ERR_FAILED as expected when the window was destroyed mid-load", () => { + expect(abort({ windowDestroyed: true })).toBe(true); + }); + + it("treats ERR_CONNECTION_RESET as expected while the app is quitting", () => { + expect( + abort({ + message: "ERR_CONNECTION_RESET (-101) loading 'http://127.0.0.1:4789/'", + appQuitting: true, + }), + ).toBe(true); + }); + + it("keeps ERR_FAILED unexpected under a live window", () => { + // A load that fails while the window is alive and the app is running is a + // genuine "cannot reach the local server" failure and must still surface. + expect(abort()).toBe(false); + }); + + it("keeps an unrelated navigation failure unexpected even during teardown", () => { + expect( + abort({ + message: "ERR_CERT_AUTHORITY_INVALID (-202) loading 'https://example.invalid/'", + windowDestroyed: true, + }), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/navigation-errors.ts b/apps/desktop/src/main/navigation-errors.ts new file mode 100644 index 0000000000..cc77f5068a --- /dev/null +++ b/apps/desktop/src/main/navigation-errors.ts @@ -0,0 +1,21 @@ +/** + * Classification of `BrowserWindow.loadURL` rejections. + * + * Chromium cancels an in-flight navigation when the window it belongs to is + * destroyed, and reports the cancellation as a generic net error (ERR_FAILED + * / ERR_ABORTED / ERR_CONNECTION_RESET). That is a shutdown race with no + * diagnostic value. The same net errors under a live window mean the local + * server genuinely could not be loaded, which must still surface — so this is + * a branch, never a blanket ignore. + */ + +const TEARDOWN_NET_ERRORS = /ERR_FAILED|ERR_ABORTED|ERR_CONNECTION_RESET|ERR_CONNECTION_CLOSED/; + +export interface NavigationAbortInput { + readonly message: string; + readonly windowDestroyed: boolean; + readonly appQuitting: boolean; +} + +export const isExpectedNavigationAbort = (input: NavigationAbortInput): boolean => + (input.windowDestroyed || input.appQuitting) && TEARDOWN_NET_ERRORS.test(input.message); diff --git a/apps/desktop/src/main/settings-permissions.test.ts b/apps/desktop/src/main/settings-permissions.test.ts new file mode 100644 index 0000000000..42264fc950 --- /dev/null +++ b/apps/desktop/src/main/settings-permissions.test.ts @@ -0,0 +1,92 @@ +// --------------------------------------------------------------------------- +// The desktop settings store must be owner-only. +// +// `serverProfiles` carries a remote server's credential — a bearer token, or a +// basic-auth password — and `conf` (under electron-store) defaults to +// `configFileMode: 0o666`, so without an explicit mode the file lands 0644 under +// a normal umask. On Linux, where `~/.config` is not reliably 0700, that is +// readable by every other account on the machine. +// +// WHAT THIS TEST DOES AND DOES NOT BIND, stated plainly rather than left to be +// discovered. It exercises the real `electron-store` with the SAME options +// `settings.ts` passes, so it verifies the load-bearing uncertainty in that fix: +// that ONE option is enough, where the rest of this app uses a mode-plus-chmod +// pair. That shortcut rests on a claim about a transitive dependency — +// `atomically` chmods the temp inode only when the requested mode differs from +// its own default, and the atomic rename then carries the tight mode onto an +// already-loose file — and a claim about a dependency is worth checking rather +// than repeating. +// +// It does NOT bind the wiring in `settings.ts`. That module builds its store at +// module scope and pulls in `electron`, which cannot be imported outside an +// Electron runtime (`electron/index.js` throws from `getElectronPath`). That is +// why the repo has no `settings.test.ts` at all. Mocking `electron-store` away +// would leave the test asserting the mock rather than the behaviour that is +// actually in question, so the option itself is verified here and the wiring is +// left uncovered on purpose. +// --------------------------------------------------------------------------- + +import { describe, expect, it, onTestFinished } from "@effect/vitest"; +import Store from "electron-store"; +import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** The exact options `settings.ts` constructs its store with. */ +const makeStore = (dir: string) => + new Store<{ readonly serverProfiles?: string }>({ + name: "settings", + cwd: dir, + configFileMode: 0o600, + defaults: {}, + }); + +const settingsPathIn = (dir: string) => join(dir, "settings.json"); + +/** A temp dir removed when the test ends. `try/finally` would do the same, but the repo's + * no-try-catch-or-throw rule rejects the construct and a registered hook reads better. */ +const tempDir = (prefix: string) => { + const dir = mkdtempSync(join(tmpdir(), prefix)); + onTestFinished(() => rmSync(dir, { recursive: true, force: true })); + return dir; +}; + +describe("electron-store configFileMode", () => { + it("creates the settings file owner-only", () => { + const dir = tempDir("executor-desktop-settings-"); + + makeStore(dir).set("serverProfiles", JSON.stringify({ token: "sk-secret" })); + + expect(statSync(settingsPathIn(dir)).mode & 0o777).toBe(0o600); + }); + + it("tightens a settings file that already exists world-readable", () => { + // This is the half that would silently not happen if the single option did + // not trigger `atomically`'s chmod: an existing 0644 file from a build + // without the mode set must not stay 0644 after the next write. + const dir = tempDir("executor-desktop-settings-loose-"); + + writeFileSync(settingsPathIn(dir), JSON.stringify({ serverProfiles: "old" })); + chmodSync(settingsPathIn(dir), 0o644); + + makeStore(dir).set("serverProfiles", JSON.stringify({ token: "sk-secret" })); + + expect(statSync(settingsPathIn(dir)).mode & 0o777).toBe(0o600); + }); + + it("without the option, the file really is world-readable — the defect this pins", () => { + // A control. Without it, both assertions above would pass just as happily on + // a machine with a restrictive umask, and would prove nothing about the fix. + const dir = tempDir("executor-desktop-settings-default-"); + + new Store<{ readonly serverProfiles?: string }>({ + name: "settings", + cwd: dir, + defaults: {}, + }).set("serverProfiles", JSON.stringify({ token: "sk-secret" })); + + const mode = statSync(settingsPathIn(dir)).mode & 0o777; + expect(mode).not.toBe(0o600); + expect(mode & 0o044).not.toBe(0); + }); +}); diff --git a/apps/desktop/src/main/settings.ts b/apps/desktop/src/main/settings.ts index ff1c45856c..0de03d26fa 100644 --- a/apps/desktop/src/main/settings.ts +++ b/apps/desktop/src/main/settings.ts @@ -12,6 +12,16 @@ const store = new Store({ ...(process.env.EXECUTOR_DESKTOP_SETTINGS_DIR ? { cwd: process.env.EXECUTOR_DESKTOP_SETTINGS_DIR } : {}), + // `serverProfiles` carries a remote server's credential — a bearer token, or a + // basic-auth password — so this file is owner-only, the same standard + // `local-auth.ts` already applies to `auth.json` in this app. + // + // Without this, `conf` defaults to `0o666` and the file lands 0644 under a + // normal umask. One option is enough: `atomically` only chmods the temp inode + // when the requested mode DIFFERS from its own default, so setting it here is + // what turns that chmod on, and the atomic rename then carries the tight mode + // onto an existing loose file too. + configFileMode: 0o600, defaults: { server: DEFAULT_SERVER_SETTINGS }, }); diff --git a/apps/desktop/src/main/sidecar-exit-wiring.test.ts b/apps/desktop/src/main/sidecar-exit-wiring.test.ts new file mode 100644 index 0000000000..c51e9a7dd1 --- /dev/null +++ b/apps/desktop/src/main/sidecar-exit-wiring.test.ts @@ -0,0 +1,163 @@ +/** + * The classifier is pure and tested next door; this covers what `startSidecar` + * DOES with each verdict — which is where the reported symptom lived. A child + * interrupted from outside must leave the crash channel untouched and tell the + * window no report was sent, while a genuine post-boot death must still be + * reported. Both halves are driven through the real `startSidecar`, with only + * the process spawn and Electron's environment replaced. + */ + +import { describe, expect, it } from "@effect/vitest"; +// oxlint-disable-next-line executor/no-vitest-import -- boundary: vi.mock must come from vitest itself for mock hoisting to resolve +import { vi } from "vitest"; +import { EventEmitter } from "node:events"; + +let appQuitting = false; +const crashReports: string[] = []; + +vi.mock("electron", () => ({ + app: { isPackaged: true, getVersion: () => "1.6.0", on: () => {} }, +})); + +vi.mock("electron-log/main.js", () => { + const scope = () => ({ info: () => {}, error: () => {}, warn: () => {}, debug: () => {} }); + return { default: { scope }, scope }; +}); + +vi.mock("./local-auth", () => ({ loadOrMintLocalAuthToken: () => "test-token" })); + +vi.mock("./settings", () => ({ getServerSettings: () => ({ port: 4789 }) })); + +vi.mock("./diagnostics", () => ({ + reportSidecarCrash: (message: string) => { + crashReports.push(message); + }, + sidecarCrashReportingEnv: () => ({}), +})); + +vi.mock("./supervised-daemon", () => ({ resolveSupervisedDaemonAttach: () => null })); + +vi.mock("./app-quit", () => ({ isAppQuitting: () => appQuitting })); + +class FakeChild extends EventEmitter { + readonly stdout = new EventEmitter(); + readonly stderr = new EventEmitter(); + readonly pid = 4242; + exitCode: number | null = null; + killed = false; + kill() { + this.killed = true; + return true; + } +} + +// The packaged branch of `resolveSidecarCommand` reads Electron's resources +// path; nothing is executed, since the spawn itself is replaced below. +(process as { resourcesPath?: string }).resourcesPath ??= "/tmp/executor-test-resources"; + +let spawned: FakeChild | null = null; + +vi.mock("node:child_process", () => ({ + spawn: () => { + spawned = new FakeChild(); + return spawned; + }, +})); + +const { startSidecar, onUnexpectedSidecarExit, stopSidecar } = await import("./sidecar"); + +/** Boot a sidecar to the point `startSidecar` resolves, then hand back the child. */ +const bootedSidecar = async (): Promise => { + const started = startSidecar(); + // The child announces readiness with the structured stdout sentinel. + await Promise.resolve(); + spawned?.stdout.emit("data", Buffer.from("EXECUTOR_READY:4789\n")); + await started; + // oxlint-disable-next-line executor/no-non-null-assertion -- the spawn mock always records a child + return spawned!; +}; + +interface Observed { + readonly notices: { readonly reported: boolean }[]; + readonly reports: string[]; +} + +/** Boot, drive one post-boot exit, and report what the app did about it. */ +const exitAfterBoot = async ( + exit: { readonly code: number | null; readonly signal: NodeJS.Signals | null }, + options: { readonly quitting?: boolean; readonly stopFirst?: boolean } = {}, +): Promise => { + appQuitting = false; + crashReports.length = 0; + const notices: { readonly reported: boolean }[] = []; + onUnexpectedSidecarExit((notice) => notices.push(notice)); + + const child = await bootedSidecar(); + child.stderr.emit("data", Buffer.from("some trailing output\n")); + if (options.stopFirst) { + const stopping = stopSidecar(child as never); + child.emit("exit", exit.code, exit.signal); + await stopping; + } else { + appQuitting = options.quitting ?? false; + child.emit("exit", exit.code, exit.signal); + } + await Promise.resolve(); + return { notices, reports: [...crashReports] }; +}; + +describe("startSidecar post-boot exit handling", () => { + it("does not report an outside interrupt, and tells the window nothing was sent", async () => { + // A group-wide Ctrl-C reaches the child because it shares Electron's + // process group; Node surfaces it as 128+SIGINT. The server really is gone, + // so the window must still be told — but no crash report exists to claim. + const { notices, reports } = await exitAfterBoot({ code: 130, signal: null }); + + expect(reports).toEqual([]); + expect(notices).toEqual([{ reported: false }]); + }); + + it("does not report a SIGTERM the app did not send", async () => { + const { notices, reports } = await exitAfterBoot({ code: null, signal: "SIGTERM" }); + + expect(reports).toEqual([]); + expect(notices).toEqual([{ reported: false }]); + }); + + it("still reports a genuine post-boot death and says a report was sent", async () => { + const { notices, reports } = await exitAfterBoot({ code: 1, signal: null }); + + expect(reports).toHaveLength(1); + expect(reports[0]).toContain("code=1"); + expect(notices).toEqual([{ reported: true }]); + }); + + it("still reports an abnormal death signal", async () => { + const { notices, reports } = await exitAfterBoot({ code: null, signal: "SIGSEGV" }); + + expect(reports).toHaveLength(1); + expect(notices).toEqual([{ reported: true }]); + }); + + it("says nothing at all when the app itself stopped the sidecar", async () => { + const { notices, reports } = await exitAfterBoot( + { code: null, signal: "SIGTERM" }, + { stopFirst: true }, + ); + + expect(reports).toEqual([]); + expect(notices).toEqual([]); + }); + + it("says nothing at all when the app is quitting", async () => { + // Quit tears the tree down; the window is going away, so surfacing a + // disconnected screen at that moment would be its own bug. + const { notices, reports } = await exitAfterBoot( + { code: 130, signal: null }, + { quitting: true }, + ); + + expect(reports).toEqual([]); + expect(notices).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/sidecar-exit.test.ts b/apps/desktop/src/main/sidecar-exit.test.ts new file mode 100644 index 0000000000..0fdb7417ac --- /dev/null +++ b/apps/desktop/src/main/sidecar-exit.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "@effect/vitest"; +import { classifySidecarExit, type SidecarExitInput } from "./sidecar-exit"; + +const classify = (overrides: Partial = {}) => + classifySidecarExit({ + code: null, + signal: null, + stoppedByUs: false, + appQuitting: false, + ...overrides, + }); + +describe("classifySidecarExit", () => { + it("classifies the SIGTERM stopSidecar sends as a managed stop", () => { + expect(classify({ stoppedByUs: true, signal: "SIGTERM" })).toEqual({ + kind: "managed-stop", + reason: "stopped-by-app", + }); + }); + + it("classifies any exit while the app is quitting as a managed stop", () => { + // Quit tears the whole process tree down; whichever signal wins the race, + // the sidecar going away is what we asked for. + expect(classify({ appQuitting: true, code: 130 })).toEqual({ + kind: "managed-stop", + reason: "app-quitting", + }); + expect(classify({ appQuitting: true, code: 1 })).toEqual({ + kind: "managed-stop", + reason: "app-quitting", + }); + }); + + it("classifies exit code 130 as an external shutdown rather than a crash", () => { + // 128+SIGINT. Nothing in the app sends SIGINT, so it came from outside + // (a group interrupt aimed at Electron) — an expected shutdown, not a crash. + expect(classify({ code: 130 })).toEqual({ + kind: "external-shutdown", + reason: "code 130", + }); + }); + + it("classifies a SIGINT signal exit as an external shutdown", () => { + expect(classify({ code: null, signal: "SIGINT" })).toEqual({ + kind: "external-shutdown", + reason: "SIGINT", + }); + }); + + it("classifies an unsolicited SIGTERM (code 143) as an external shutdown", () => { + expect(classify({ code: 143 })).toEqual({ + kind: "external-shutdown", + reason: "code 143", + }); + expect(classify({ signal: "SIGTERM" })).toEqual({ + kind: "external-shutdown", + reason: "SIGTERM", + }); + }); + + it("still classifies an ordinary non-zero exit as a crash", () => { + expect(classify({ code: 1 })).toEqual({ kind: "crash" }); + expect(classify({ code: 7 })).toEqual({ kind: "crash" }); + }); + + it("still classifies an abnormal death signal as a crash", () => { + expect(classify({ signal: "SIGSEGV" })).toEqual({ kind: "crash" }); + expect(classify({ code: 134 })).toEqual({ kind: "crash" }); + }); +}); diff --git a/apps/desktop/src/main/sidecar-exit.ts b/apps/desktop/src/main/sidecar-exit.ts new file mode 100644 index 0000000000..758e251c05 --- /dev/null +++ b/apps/desktop/src/main/sidecar-exit.ts @@ -0,0 +1,50 @@ +/** + * Classification of a sidecar child exit that happened AFTER a successful boot. + * + * Pure so it can be unit-tested without Electron. Three outcomes, because the + * three deserve different treatment: + * + * managed-stop — we asked for it (stopSidecar, or the app is quitting). + * Log it; the UI is going away anyway. + * external-shutdown — something outside the app interrupted the child + * (a group SIGINT aimed at Electron is the common one, + * reported by Node as code 130 = 128+SIGINT). The server + * really is gone, so the window must say so, but this is + * a shutdown and not a crash — nothing to report upstream. + * crash — anything else. Reported with the stderr tail. + * + * The distinction matters both ways: 130 exits were drowning the crash channel, + * and a genuine post-boot death has to stay visible in it. + */ + +/** Signals that mean "stop", as opposed to "you are broken". */ +const SHUTDOWN_SIGNALS: ReadonlySet = new Set(["SIGINT", "SIGTERM", "SIGHUP"]); + +/** POSIX 128+N exit codes for those same signals. */ +const SHUTDOWN_EXIT_CODES: ReadonlySet = new Set([129, 130, 143]); + +export interface SidecarExitInput { + readonly code: number | null; + readonly signal: string | null; + /** True when `stopSidecar` signalled this child (quit, restart, update). */ + readonly stoppedByUs: boolean; + /** True once Electron has begun quitting. */ + readonly appQuitting: boolean; +} + +export type SidecarExitClassification = + | { readonly kind: "managed-stop"; readonly reason: "stopped-by-app" | "app-quitting" } + | { readonly kind: "external-shutdown"; readonly reason: string } + | { readonly kind: "crash" }; + +export const classifySidecarExit = (input: SidecarExitInput): SidecarExitClassification => { + if (input.stoppedByUs) return { kind: "managed-stop", reason: "stopped-by-app" }; + if (input.appQuitting) return { kind: "managed-stop", reason: "app-quitting" }; + if (input.signal !== null && SHUTDOWN_SIGNALS.has(input.signal)) { + return { kind: "external-shutdown", reason: input.signal }; + } + if (input.code !== null && SHUTDOWN_EXIT_CODES.has(input.code)) { + return { kind: "external-shutdown", reason: `code ${input.code}` }; + } + return { kind: "crash" }; +}; diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts index b0f5bb7e72..3438a6fa6d 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/apps/desktop/src/main/sidecar.ts @@ -25,6 +25,8 @@ import { loadOrMintLocalAuthToken } from "./local-auth"; import { getServerSettings } from "./settings"; import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics"; import { resolveSupervisedDaemonAttach } from "./supervised-daemon"; +import { classifySidecarExit } from "./sidecar-exit"; +import { isAppQuitting } from "./app-quit"; import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings"; // Sidecar output is echoed to the terminal (visible when Electron is run @@ -44,8 +46,12 @@ const expectedExits = new WeakSet(); // Main/index.ts subscribes to swap the dead web UI for the in-window crash // screen. A callback (not an import) keeps this module free of window // concerns. -let unexpectedExitListener: (() => void) | null = null; -export const onUnexpectedSidecarExit = (listener: () => void) => { +export interface SidecarExitNotice { + /** False for an expected shutdown, where nothing was sent upstream. */ + readonly reported: boolean; +} +let unexpectedExitListener: ((notice: SidecarExitNotice) => void) | null = null; +export const onUnexpectedSidecarExit = (listener: (notice: SidecarExitNotice) => void) => { unexpectedExitListener = listener; }; @@ -209,6 +215,18 @@ const resolveClientDir = (): string => { const delay = (ms: number): Promise => new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); +/** + * Where this app keeps `data.db`, `auth.json`, and the server manifest. + * + * A dev run overrides it: that SQLite has ONE owner, so a dev build started + * beside an installed Executor otherwise dies on the installed app's lock. + * Redirecting HOME is not an alternative — the machine-local tools a plugin + * drives resolve their own paths from it, and Codex Computer Use fails at + * "native pipe startup" under a synthetic home. + */ +const executorScopeDir = (): string => + process.env.EXECUTOR_DESKTOP_SCOPE_DIR ?? join(homedir(), ".executor"); + export async function startSidecar(options: StartOptions = {}): Promise { const hostname = options.hostname ?? "127.0.0.1"; const settings = getServerSettings(); @@ -220,7 +238,7 @@ export async function startSidecar(options: StartOptions = {}): Promise { if (resolved) { - // Post-boot exit: expected when we stopped it ourselves (quit, - // restart, update); anything else is a sidecar crash under a live - // window — log it and report upstream with the stderr tail. - if (expectedExits.has(child)) { - sidecarLog.info(`exited (code=${code} signal=${signal})`); + // Post-boot exit. Expected when we stopped it ourselves (quit, restart, + // update) and when something outside the app interrupted it — the child + // shares Electron's process group, so a group-wide SIGINT reaches it + // directly and surfaces as code 130. Only the rest is a crash under a + // live window, reported upstream with the stderr tail. + const classification = classifySidecarExit({ + code, + signal, + stoppedByUs: expectedExits.has(child), + appQuitting: isAppQuitting(), + }); + if (classification.kind === "managed-stop") { + sidecarLog.info(`exited (code=${code} signal=${signal}) — ${classification.reason}`); + return; + } + if (classification.kind === "external-shutdown") { + // The server really is gone, so the window still has to say so; it + // just wasn't a crash, so nothing is reported. + sidecarLog.info( + `Sidecar shut down by ${classification.reason} (code=${code} signal=${signal})`, + ); + unexpectedExitListener?.({ reported: false }); return; } const message = `Sidecar exited unexpectedly (code=${code} signal=${signal})`; sidecarLog.error(message); reportSidecarCrash(message, stderrBuffer); - unexpectedExitListener?.(); + unexpectedExitListener?.({ reported: true }); return; } if (rejected) return; @@ -406,7 +441,7 @@ const isDaemonReachable = async (origin: string): Promise => { * is handled by the existing single-instance / ownership logic. */ export async function attachToSupervisedDaemon(): Promise { - const dataDir = join(homedir(), ".executor"); + const dataDir = executorScopeDir(); const manifest = readManifest(dataDir); const decision = await resolveSupervisedDaemonAttach(manifest, { isReachable: isDaemonReachable, diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 563d264029..2b47bbae22 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -33,7 +33,7 @@ }, { "group": "Hosted", - "pages": ["hosted/cloud", "hosted/docker", "hosted/cloudflare"] + "pages": ["hosted/cloud", "hosted/docker", "hosted/cloudflare", "hosted/tracing"] }, { "group": "Concepts", diff --git a/apps/docs/hosted/docker.mdx b/apps/docs/hosted/docker.mdx index 9de4d394ba..8fc30f0e85 100644 --- a/apps/docs/hosted/docker.mdx +++ b/apps/docs/hosted/docker.mdx @@ -60,6 +60,7 @@ the container defaults. | `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. | | `EXECUTOR_DB_PATH` | `/data.db` | SQLite database file. | | `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). | +| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. | | `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. | | `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. | | `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. | @@ -69,6 +70,9 @@ the container defaults. | `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. | | `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. | +Tracing is configured separately, and off unless you turn it on — see +[Tracing](/hosted/tracing). + ## Behind a domain or TLS @@ -88,6 +92,22 @@ For a headless deploy (CI or infra-as-code), set `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser setup screen. +### Additional browser aliases + +Keep `EXECUTOR_WEB_BASE_URL` pinned to the canonical public HTTPS origin used for +OAuth callbacks and generated links. If the same instance is intentionally +available through additional browser addresses, allow those exact origins +separately: + +```bash +-e EXECUTOR_WEB_BASE_URL=https://executor.example.com \ +-e EXECUTOR_TRUSTED_ORIGINS=http://executor.home.arpa:4788,http://192.0.2.10:4788 +``` + +Only cookie-authenticated browser requests use this allowlist. OAuth callbacks, +MCP metadata, approval links, and other absolute URLs remain pinned to +`EXECUTOR_WEB_BASE_URL`. Origins are never inferred from request headers. + ## Connect an agent The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at diff --git a/apps/docs/hosted/tracing.mdx b/apps/docs/hosted/tracing.mdx new file mode 100644 index 0000000000..09a49db274 --- /dev/null +++ b/apps/docs/hosted/tracing.mdx @@ -0,0 +1,115 @@ +--- +title: Tracing +description: "Export traces from a self-hosted server to an OpenTelemetry collector, so a slow request becomes a waterfall instead of a stopwatch." +--- + +When a request through your server is slow, the useful question is which part was +slow: building the executor stack, initializing plugins, a storage query, or the +upstream integration answering. The server already produces spans for all of +that — pointing it at a collector is what makes them readable. + +Export is off by default and stays off until you set an endpoint. With none set +there is no exporter and no buffer, and nothing leaves the process. + +## Turn it on + +Set the collector's base URL. The server appends `/v1/traces`. + +```bash +docker run -d \ + -p 4788:4788 \ + -v executor-data:/data \ + -e OTEL_EXPORTER_OTLP_ENDPOINT=http://collector.local:4318 \ + ghcr.io/usefulsoftwareco/executor-selfhost:latest +``` + +Traces are sent as OTLP over HTTP with a JSON payload, which every collector +accepts — the OpenTelemetry Collector, Grafana Alloy, Jaeger, or a hosted backend. +Port `4318` is the OTLP/HTTP convention, but the URL is whatever you point it at. + +| Variable | Default | Purpose | +| ------------------------------------ | ------- | --------------------------------------------------------------------------- | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Collector base URL. Turns on trace export; `/v1/traces` is appended. | +| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | unset | Full traces URL, used as-is. Overrides the base for this signal. | +| `OTEL_EXPORTER_OTLP_HEADERS` | unset | `key=value,key2=value2` headers, for a collector that needs authentication. | +| `EXECUTOR_OTEL_EXPORT_LOGS` | `false` | Also export logs. A separate opt-in — logs carry more than timings. | +| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | unset | Full logs URL. Required if only the traces endpoint is set. | + +Traces report under the service name `executor-selfhost`. On boot the server logs +the endpoint it will export to, so a missing line means export is off. + +For a hosted backend, put the credential in the headers: + +```bash +-e OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com \ +-e OTEL_EXPORTER_OTLP_HEADERS='authorization=Bearer ' +``` + +## Using motel locally + +[motel](https://github.com/kitlangton/motel) is a local OTLP store and viewer: one +command, a SQLite file, no Docker and no account. It is the quickest way to look at +a trace on your own machine. + +It needs [Bun](https://bun.sh/), and listens on port `27686`: + +```bash +bunx @kitlangton/motel +``` + +That starts the ingest server and opens the terminal UI. For ingest without the +UI, run `bunx @kitlangton/motel server`. + +If Executor runs directly on the same machine, point it at motel: + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:27686 +``` + +From a container, motel is on the host, not in the container's network — and it +binds loopback by default, so it also has to listen on an address the container +can reach: + +```bash +# start motel so it accepts connections from outside the host +MOTEL_OTEL_HOST=0.0.0.0 bunx @kitlangton/motel server + +docker run -d \ + -p 4788:4788 \ + -v executor-data:/data \ + --add-host host.docker.internal:host-gateway \ + -e OTEL_EXPORTER_OTLP_ENDPOINT=http://host.docker.internal:27686 \ + ghcr.io/usefulsoftwareco/executor-selfhost:latest +``` + + + `MOTEL_OTEL_HOST=0.0.0.0` exposes the trace store to your whole network. Traces carry request URLs + and timings, and logs carry more. Use it on a trusted network, or publish the port only to the + container. + + +## Reading a trace + +A single API request looks like this — each line a span, indented under its parent, +with the time it took: + +``` +http.server GET 31ms + executor.stack.http.resolve 28ms + executor.stack.build 28ms + executor.stack.scoped_executor 27ms + executor.plugins.init 16ms + executor.stack.create_executor 2ms + executor.subject.touch 7ms + fumadb.subject.findFirst 2ms + fumadb.subject.create 5ms + fumadb.integration.findMany 1ms +``` + +The indentation is where the answer lives. Time held by `http.server` but not by +any child is time spent outside the instrumented code — the network in front of +the server, or a tunnel. Time inside a single child is the phase to look at next. + +Requests arriving with a `traceparent` header continue that trace rather than +starting a new one, so a request through a proxy or tunnel keeps one trace id from +end to end. diff --git a/apps/host-cloudflare/README.md b/apps/host-cloudflare/README.md index 1770dbbe85..9700570289 100644 --- a/apps/host-cloudflare/README.md +++ b/apps/host-cloudflare/README.md @@ -36,14 +36,14 @@ bunx wrangler login bun run deploy:setup # apps/host-cloudflare — provisions D1 + secret + deploys ``` -`deploy:setup` (scripts/deploy.sh) is idempotent: it creates/reuses the -`executor` D1 database, writes its id into `wrangler.jsonc`, generates + -uploads `EXECUTOR_SECRET_KEY`, and deploys. It then prints the one manual step. +`deploy:setup` (scripts/deploy.sh) is idempotent. It creates or reuses the +`executor` D1 database, writes its id into `wrangler.jsonc`, generates and +uploads `EXECUTOR_SECRET_KEY`, then deploys. It then prints the one manual step. ### The one manual step — Cloudflare Access -The Worker returns 401 until it's behind a Cloudflare Access application. In the -Zero Trust dashboard: +After the first deploy, API and MCP requests return 503 and name the missing +Access variables until configuration is complete. In the Zero Trust dashboard: 1. **Access → Applications → Add an application → Self-hosted** 2. Application domain: `executor-cloudflare..workers.dev` @@ -52,13 +52,17 @@ Zero Trust dashboard: ```bash bunx wrangler deploy \ --var ACCESS_AUD: \ - --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com + --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com \ + --var ADMIN_EMAILS: ``` - (or set them in `wrangler.jsonc` and redeploy) Now visiting the Worker prompts an Access login; the Worker validates the issued -JWT on every request. MCP clients present an Access JWT or -`Cf-Access-Client-Id`/`-Secret` service-token headers. +JWT on every request. Unauthenticated requests return 401. MCP clients present +an Access JWT or `Cf-Access-Client-Id`/`-Secret` service-token headers. + +The Access values are live Worker variables, not values in `wrangler.jsonc`. +Wrangler's `keep_vars` option preserves them during later code deploys. Run the +command above again whenever you need to change them. ## Local development diff --git a/apps/host-cloudflare/package.json b/apps/host-cloudflare/package.json index d7159620d7..b693834278 100644 --- a/apps/host-cloudflare/package.json +++ b/apps/host-cloudflare/package.json @@ -3,8 +3,8 @@ "private": true, "type": "module", "scripts": { - "build": "vite build", - "deploy": "vite build && wrangler deploy", + "build": "vite build && node scripts/assert-shell-asset.mjs", + "deploy": "vite build && node scripts/assert-shell-asset.mjs && wrangler deploy", "dev": "wrangler dev", "dev:web": "vite dev", "typecheck": "tsgo --noEmit", @@ -23,6 +23,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -30,6 +31,7 @@ "@executor-js/plugin-provider-service-split": "workspace:*", "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/react": "workspace:*", + "@executor-js/runtime-dynamic-worker": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", "@jitl/quickjs-wasmfile-release-sync": "catalog:", @@ -53,6 +55,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", + "jsonc-parser": "^3.3.1", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", diff --git a/apps/host-cloudflare/scripts/assert-shell-asset.mjs b/apps/host-cloudflare/scripts/assert-shell-asset.mjs new file mode 100644 index 0000000000..d11fff1460 --- /dev/null +++ b/apps/host-cloudflare/scripts/assert-shell-asset.mjs @@ -0,0 +1,17 @@ +// Build-blocking check that the SPA build emitted the stable-named MCP-Apps +// shell document. The MCP session DO serves `ui://executor/shell.html` by +// fetching this asset through the ASSETS binding — a deployed Worker has no +// filesystem — so a build without it ships a Worker whose artifact resource +// reads all fail. Fail the build here instead. Same shape as apps/cloud's +// assertion in scripts/build.mjs. +import { existsSync, readFileSync } from "node:fs"; + +const shellAsset = new URL("../dist/assets/executor-mcp-apps-shell-stable.html", import.meta.url); +if (!existsSync(shellAsset)) { + console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`); + process.exit(1); +} +if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) { + console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`); + process.exit(1); +} diff --git a/apps/host-cloudflare/scripts/deploy.sh b/apps/host-cloudflare/scripts/deploy.sh index 4d8a2857e8..130bd5507d 100755 --- a/apps/host-cloudflare/scripts/deploy.sh +++ b/apps/host-cloudflare/scripts/deploy.sh @@ -7,7 +7,7 @@ # wrangler.jsonc # 3. generates + uploads EXECUTOR_SECRET_KEY (the at-rest secret key) if unset # 4. deploys the Worker -# 5. prints the single manual step: the Cloudflare Access application +# 5. prints the single manual step: configure the Cloudflare Access application # # Idempotent — safe to re-run. Run from anywhere: # bash apps/host-cloudflare/scripts/deploy.sh @@ -71,18 +71,22 @@ cat <<'NEXT' ==> One manual step left: turn on Cloudflare Access (the auth layer) - The Worker is deployed but every request returns 401 until you put it behind - a Cloudflare Access application. In the Zero Trust dashboard: + The Worker is deployed but is not ready to serve requests until you configure + a Cloudflare Access application. API and MCP requests return 503 and name the + missing variables until configuration is complete. In the Zero Trust + dashboard: 1. Access -> Applications -> Add an application -> Self-hosted 2. Application domain: executor-cloudflare..workers.dev 3. Add an Access policy (e.g. "Emails ending in @yourcompany.com") 4. After saving, copy the Application Audience (AUD) tag, then set: - bunx wrangler deploy --var ACCESS_AUD: \ - --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com - (or edit the vars in wrangler.jsonc and redeploy) + bunx wrangler deploy --var ACCESS_AUD: \ + --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com \ + --var ADMIN_EMAILS: - That's it — visiting the Worker URL now prompts a Cloudflare Access login, + Wrangler preserves these live variables during later code deploys. + + That's it. Visiting the Worker URL now prompts a Cloudflare Access login, and the Worker validates the issued JWT on every request. NEXT diff --git a/apps/host-cloudflare/src/account/account-provider.ts b/apps/host-cloudflare/src/account/account-provider.ts index c4965e1e88..bbbbd3d522 100644 --- a/apps/host-cloudflare/src/account/account-provider.ts +++ b/apps/host-cloudflare/src/account/account-provider.ts @@ -61,6 +61,11 @@ export const cloudflareAccountProvider = ( listApiKeys: () => Effect.succeed({ apiKeys: [] }), createApiKey: () => forbiddenWrite, revokeApiKey: () => forbiddenWrite, + // Org-owned keys are a WorkOS concept; Access-managed instances have no + // credential store of their own to mint one from. + listOrgApiKeys: () => Effect.succeed({ apiKeys: [] }), + createOrgApiKey: () => forbiddenWrite, + revokeOrgApiKey: () => forbiddenWrite, listMembers: () => Effect.succeed({ members: [] }), listRoles: () => Effect.succeed({ roles: [] }), inviteMember: () => forbiddenWrite, diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts index 8d4a75151c..1e1a7e00a7 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; +import { orgWriteAccessForPrincipal } from "@executor-js/host-mcp"; + import type { CloudflareConfig } from "../config"; import { principalFromAccessClaims } from "./cloudflare-access"; @@ -34,6 +36,9 @@ describe("principalFromAccessClaims", () => { it("grants admin when the email is in the allowlist", () => { const p = principalFromAccessClaims({ sub: "u", email: "ADMIN@example.com" }, config); expect(p.roles).toContain("admin"); + expect(p.orgRoleModel).toBe("organization"); + expect(p.orgRole).toBe("admin"); + expect(orgWriteAccessForPrincipal(p)).toBe("allowed"); }); it("gives a SERVICE TOKEN (common_name, no email/sub) a stable identity", () => { @@ -49,5 +54,8 @@ describe("principalFromAccessClaims", () => { it("defaults to member when there are no groups and no admin match", () => { const p = principalFromAccessClaims({ sub: "u", email: "nobody@other.com" }, config); expect(p.roles).toEqual(["member"]); + expect(p.orgRoleModel).toBe("organization"); + expect(p.orgRole).toBe("member"); + expect(orgWriteAccessForPrincipal(p)).toBe("denied"); }); }); diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.ts b/apps/host-cloudflare/src/auth/cloudflare-access.ts index 178f8818f5..b311256c2e 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.ts @@ -38,6 +38,7 @@ export const principalFromAccessClaims = ( const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase()); return { + kind: "member", accountId: sub || email || commonName, organizationId: config.organizationId, organizationName: config.organizationName, @@ -46,6 +47,8 @@ export const principalFromAccessClaims = ( name: typeof nameClaim === "string" ? nameClaim : commonName || null, avatarUrl: null, roles: isAdmin ? ["admin", ...groups] : groups.length > 0 ? groups : ["member"], + orgRoleModel: "organization", + orgRole: isAdmin ? "admin" : "member", }; }; @@ -60,12 +63,15 @@ export const principalFromAccessClaims = ( export const makeAccessVerifier = (config: CloudflareConfig) => { const issuer = `https://${config.accessTeamDomain}`; // Cached, lazily-fetched team signing keys; jose handles rotation + caching. - const jwks = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`)); + const jwks = config.enableDevAuth + ? null + : createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`)); // Dev/single-user escape hatch: bypass Access entirely, every request is a // fixed admin. Only when explicitly enabled (and the instance is otherwise // unprotected). Mirrors the local app's single-user model. const devPrincipal: Principal = { + kind: "member", accountId: "dev", organizationId: config.organizationId, organizationName: config.organizationName, @@ -74,11 +80,14 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { name: "Dev", avatarUrl: null, roles: ["admin"], + orgRoleModel: "organization", + orgRole: "admin", }; const verify = (request: Request): Effect.Effect => Effect.gen(function* () { if (config.enableDevAuth) return devPrincipal; + if (!jwks) return null; const token = request.headers.get("Cf-Access-Jwt-Assertion"); if (!token) return null; diff --git a/apps/host-cloudflare/src/config.test.ts b/apps/host-cloudflare/src/config.test.ts new file mode 100644 index 0000000000..39aab1e7d3 --- /dev/null +++ b/apps/host-cloudflare/src/config.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "@effect/vitest"; +import { parse } from "jsonc-parser"; + +import { loadConfig } from "./config"; + +type ConfigEnv = Parameters[0]; + +const makeEnv = (overrides: Partial = {}): ConfigEnv => ({ + EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", + VITE_PUBLIC_SITE_URL: "https://executor.example.com", + ...overrides, +}); + +describe("loadConfig", () => { + it("rejects missing Cloudflare Access configuration outside local development", () => { + expect(() => loadConfig(makeEnv())).toThrowError( + "Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN and ACCESS_AUD before serving requests.", + ); + }); + + it("rejects the repository's former team-domain placeholder", () => { + expect(() => + loadConfig( + makeEnv({ + ACCESS_TEAM_DOMAIN: "your-team.cloudflareaccess.com", + ACCESS_AUD: "aud-tag", + }), + ), + ).toThrowError( + "Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN before serving requests.", + ); + }); + + it("allows local development to bypass Cloudflare Access", () => { + expect(loadConfig(makeEnv({ ENABLE_DEV_AUTH: "true" }))).toMatchObject({ + accessTeamDomain: "", + accessAud: "", + enableDevAuth: true, + }); + }); + + it("normalises configured Access values without requiring an administrator", () => { + expect( + loadConfig( + makeEnv({ + ACCESS_TEAM_DOMAIN: "https://Team.cloudflareaccess.com/", + ACCESS_AUD: " aud-tag ", + }), + ), + ).toMatchObject({ + accessTeamDomain: "Team.cloudflareaccess.com", + accessAud: "aud-tag", + adminEmails: [], + enableDevAuth: false, + }); + }); +}); + +describe("Cloudflare deployment configuration", () => { + it("preserves operator-managed Access variables across deploys", () => { + const config = parse(readFileSync(new URL("../wrangler.jsonc", import.meta.url), "utf8")) as { + readonly keep_vars?: boolean; + readonly vars?: Readonly>; + }; + + expect(config.keep_vars).toBe(true); + expect(config.vars).not.toHaveProperty("ACCESS_TEAM_DOMAIN"); + expect(config.vars).not.toHaveProperty("ACCESS_AUD"); + expect(config.vars).not.toHaveProperty("ADMIN_EMAILS"); + expect(config.vars).toHaveProperty("ENABLE_DEV_AUTH", "false"); + }); +}); diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index bded06965a..c397c4ef87 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -21,15 +21,19 @@ export interface CloudflareEnv { readonly DB: D1Database; /** R2 bucket binding — holds values too large for a D1 row (~1-2MB cap). */ readonly BLOBS?: R2Bucket; + /** Static assets binding (wrangler.jsonc `assets.binding`). The MCP session + * DO fetches the built MCP-Apps shell document through it — a deployed + * Worker has no filesystem to read the shell from. */ + readonly ASSETS: { readonly fetch: (request: Request) => Promise }; /** MCP session Durable Object namespace — one addressable isolate per MCP * session (the DO id IS the session id), so a session survives across the * Worker's stateless isolates. */ readonly MCP_SESSION: DurableObjectNamespace; readonly MCP_EXECUTION_OWNER?: DurableObjectNamespace; /** Zero Trust team domain, e.g. `your-team.cloudflareaccess.com`. */ - readonly ACCESS_TEAM_DOMAIN: string; + readonly ACCESS_TEAM_DOMAIN?: string; /** The Access application's AUD tag (the JWT audience to verify). */ - readonly ACCESS_AUD: string; + readonly ACCESS_AUD?: string; /** Claim holding the display name (default `name`). */ readonly ACCESS_NAME_CLAIM?: string; /** Claim holding the user's groups (default `groups`). */ @@ -72,12 +76,44 @@ export interface CloudflareConfig { readonly enableDevAuth: boolean; } +type CloudflareConfigEnv = Omit< + CloudflareEnv, + "DB" | "BLOBS" | "ASSETS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER" +>; + +type CloudflareAccessEnv = Pick< + CloudflareConfigEnv, + "ACCESS_TEAM_DOMAIN" | "ACCESS_AUD" | "ENABLE_DEV_AUTH" +>; + const splitLower = (value: string | undefined): readonly string[] => (value ?? "") .split(",") .map((part) => part.trim().toLowerCase()) .filter((part) => part.length > 0); +const normalizeAccessTeamDomain = (value: string | undefined): string => + (value ?? "") + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/+$/, ""); + +export const missingCloudflareAccessVars = (env: CloudflareAccessEnv): readonly string[] => { + if (env.ENABLE_DEV_AUTH === "true") return []; + const accessTeamDomain = normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN); + const accessAud = (env.ACCESS_AUD ?? "").trim(); + return [ + ...(accessTeamDomain.length === 0 || + accessTeamDomain.toLowerCase() === "your-team.cloudflareaccess.com" + ? ["ACCESS_TEAM_DOMAIN"] + : []), + ...(accessAud.length === 0 ? ["ACCESS_AUD"] : []), + ]; +}; + +export const cloudflareAccessConfigErrorMessage = (missingVars: readonly string[]): string => + `Cloudflare Access is not configured. Set ${missingVars.join(" and ")} before serving requests.`; + // The org slug doubles as a URL segment (`//policies`), so an // operator-set value must fit the shared grammar and avoid reserved root // segments — a colliding slug would shadow real routes (notably /api, /mcp, @@ -93,7 +129,7 @@ const resolveOrgSlug = (value: string | undefined): string => { return value; }; -export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { +export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => { const secretKey = env.EXECUTOR_SECRET_KEY?.trim(); if (!secretKey || secretKey.length < 16) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the Worker must not boot without the at-rest secret key @@ -102,6 +138,13 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { ); } const enableDevAuth = env.ENABLE_DEV_AUTH === "true"; + const accessTeamDomain = normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN); + const accessAud = (env.ACCESS_AUD ?? "").trim(); + const missingAccessVars = missingCloudflareAccessVars(env); + if (missingAccessVars.length > 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: production must fail closed without a valid Access verifier + throw new Error(cloudflareAccessConfigErrorMessage(missingAccessVars)); + } const webBaseUrl = resolvePublicOrigin({ explicit: env.VITE_PUBLIC_SITE_URL, env: {} }); if (!webBaseUrl && !enableDevAuth && !warnedNoCloudflareOrigin) { warnedNoCloudflareOrigin = true; @@ -113,8 +156,8 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { ); } return { - accessTeamDomain: env.ACCESS_TEAM_DOMAIN.replace(/^https?:\/\//, "").replace(/\/+$/, ""), - accessAud: env.ACCESS_AUD, + accessTeamDomain, + accessAud, accessNameClaim: env.ACCESS_NAME_CLAIM ?? "name", accessGroupsClaim: env.ACCESS_GROUPS_CLAIM ?? "groups", adminEmails: splitLower(env.ADMIN_EMAILS), diff --git a/apps/host-cloudflare/src/db/d1.test.ts b/apps/host-cloudflare/src/db/d1.test.ts new file mode 100644 index 0000000000..5ccf270e56 --- /dev/null +++ b/apps/host-cloudflare/src/db/d1.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { D1Database } from "@cloudflare/workers-types"; + +import { collectTables, type SqliteDataMigrationClient } from "@executor-js/sdk"; +import { createSqliteTestFumaDb } from "@executor-js/sdk/testing"; + +import { createD1ExecutorDb } from "./d1"; + +const makeRecordingD1 = ( + client: SqliteDataMigrationClient, +): { + readonly db: D1Database; + readonly statements: string[]; + readonly failWhen: (predicate: ((sql: string) => boolean) | null) => void; +} => { + const statements: string[] = []; + let failurePredicate: ((sql: string) => boolean) | null = null; + const record = (sql: string): void => { + statements.push(sql); + if (failurePredicate?.(sql)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: the fake D1 adapter must reject exactly where the real D1 query would reject + throw new Error("forced D1 failure"); + } + }; + const prepare = (sql: string) => { + const statement = (args: readonly unknown[]): Record => ({ + bind: (...values: readonly unknown[]) => statement([...args, ...values]), + all: async () => { + record(sql); + const result = await client.execute({ sql, args }); + return { success: true, meta: {}, results: result.rows }; + }, + run: async () => { + record(sql); + await client.execute({ sql, args }); + return { success: true, meta: {}, results: [] }; + }, + }); + return statement([]); + }; + + // oxlint-disable-next-line executor/no-double-cast -- test double: only the D1 methods used by schema preparation and migrations are implemented + const db = { + prepare, + withSession: () => ({ prepare }), + } as unknown as D1Database; + return { + db, + statements, + failWhen: (predicate) => { + failurePredicate = predicate; + }, + }; +}; + +const isRuntimeSchemaStatement = (sql: string): boolean => { + const normalized = sql.trim().toUpperCase(); + if (normalized.startsWith("CREATE UNIQUE INDEX IF NOT EXISTS")) return true; + if (normalized.startsWith("ALTER TABLE") && normalized.includes(" ADD COLUMN ")) return true; + if (!normalized.startsWith("CREATE TABLE IF NOT EXISTS")) return false; + return !normalized.includes("DATA_MIGRATION"); +}; + +describe("createD1ExecutorDb", () => { + it("does not repeat runtime schema DDL after the current schema was prepared", async () => { + const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); + const { db, statements } = makeRecordingD1(sqlite.client); + + const first = await createD1ExecutorDb(db, undefined); + await first.close(); + const firstSchemaStatements = statements.filter(isRuntimeSchemaStatement); + expect(firstSchemaStatements.length).toBeGreaterThan(0); + + const secondStart = statements.length; + const second = await createD1ExecutorDb(db, undefined); + await second.close(); + const secondStatements = statements.slice(secondStart); + + expect(secondStatements.filter(isRuntimeSchemaStatement)).toEqual([]); + expect(secondStatements.some((sql) => sql.includes("SELECT name FROM data_migration"))).toBe( + true, + ); + + await sqlite.close(); + }); + + it("runs the schema ensure again when the generated fingerprint is stale", async () => { + const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); + const { db, statements } = makeRecordingD1(sqlite.client); + + const first = await createD1ExecutorDb(db, undefined); + await first.close(); + await sqlite.client.execute( + `UPDATE private_executor_cloudflare_schema_fingerprint + SET fingerprint = 'stale' + WHERE id = 'runtime-schema'`, + ); + + const secondStart = statements.length; + const second = await createD1ExecutorDb(db, undefined); + await second.close(); + const secondStatements = statements.slice(secondStart); + + expect( + secondStatements.some((sql) => sql.startsWith('CREATE TABLE IF NOT EXISTS "integration"')), + ).toBe(true); + + await sqlite.close(); + }); + + it("converges safely when concurrent opens see the same stale fingerprint", async () => { + const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); + const { db } = makeRecordingD1(sqlite.client); + const prepared = await createD1ExecutorDb(db, undefined); + await prepared.close(); + await sqlite.client.execute( + `UPDATE private_executor_cloudflare_schema_fingerprint + SET fingerprint = 'stale' + WHERE id = 'runtime-schema'`, + ); + + const handles = await Promise.all([ + createD1ExecutorDb(db, undefined), + createD1ExecutorDb(db, undefined), + ]); + await Promise.all(handles.map((handle) => handle.close())); + + const fingerprints = await sqlite.client.execute( + `SELECT fingerprint + FROM private_executor_cloudflare_schema_fingerprint + WHERE id = 'runtime-schema'`, + ); + expect(fingerprints.rows).toHaveLength(1); + expect(fingerprints.rows[0]?.fingerprint).not.toBe("stale"); + + await sqlite.close(); + }); + + it("stamps only after compatibility migrations leave the final schema current", async () => { + const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); + await sqlite.client.execute("DROP TABLE connection"); + await sqlite.client.execute(` + CREATE TABLE connection ( + integration text NOT NULL, + name text NOT NULL, + template text NOT NULL, + provider text NOT NULL, + item_id text NOT NULL, + identity_label text, + description text, + tools_synced_at integer, + oauth_client text, + oauth_client_owner text, + refresh_item_id text, + expires_at integer, + oauth_scope text, + oauth_token_url text, + provider_state text, + created_at integer NOT NULL, + updated_at integer NOT NULL, + row_id text PRIMARY KEY NOT NULL, + tenant text NOT NULL, + owner text NOT NULL, + subject text NOT NULL + ) + `); + const { db } = makeRecordingD1(sqlite.client); + + const handle = await createD1ExecutorDb(db, undefined); + await handle.close(); + + const columns = await sqlite.client.execute("PRAGMA table_info('connection')"); + const columnNames = columns.rows.map((row) => row.name); + expect(columnNames).toContain("item_ids"); + expect(columnNames).toContain("last_health"); + expect(columnNames).not.toContain("item_id"); + const indexes = await sqlite.client.execute( + `SELECT name FROM sqlite_master + WHERE type = 'index' + AND name = 'connection_uidx'`, + ); + expect(indexes.rows).toEqual([{ name: "connection_uidx" }]); + + await sqlite.close(); + }); + + it("does not stamp a fingerprint when schema preparation fails", async () => { + const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); + const { db, failWhen } = makeRecordingD1(sqlite.client); + failWhen((sql) => sql.startsWith('CREATE TABLE IF NOT EXISTS "integration"')); + + await expect(createD1ExecutorDb(db, undefined)).rejects.toBeDefined(); + + const marker = await sqlite.client.execute( + `SELECT name FROM sqlite_master + WHERE type = 'table' + AND name = 'private_executor_cloudflare_schema_fingerprint'`, + ); + expect(marker.rows).toEqual([]); + + failWhen(null); + const recovered = await createD1ExecutorDb(db, undefined); + await recovered.close(); + + await sqlite.close(); + }); +}); diff --git a/apps/host-cloudflare/src/db/d1.ts b/apps/host-cloudflare/src/db/d1.ts index 0a20226263..43bc375878 100644 --- a/apps/host-cloudflare/src/db/d1.ts +++ b/apps/host-cloudflare/src/db/d1.ts @@ -1,6 +1,7 @@ import { drizzle } from "drizzle-orm/d1"; import { createDrizzleRuntimeSchemaFromTables, + createDrizzleRuntimeSchemaSqlFromTables, ensureDrizzleRuntimeSchemaFromTables, } from "@executor-js/fumadb/adapters/drizzle"; import type { D1Database, R2Bucket } from "@cloudflare/workers-types"; @@ -13,7 +14,64 @@ import { import { makeR2BlobStore } from "@executor-js/cloudflare/blob-store"; import { CLOUDFLARE_NAMESPACE, CLOUDFLARE_SCHEMA_VERSION } from "../config"; -import { runCloudflareDataMigrations } from "./data-migrations"; +import { prepareCloudflareD1Data } from "./data-migrations"; + +const SCHEMA_FINGERPRINT_TABLE = `private_${CLOUDFLARE_NAMESPACE}_schema_fingerprint`; +const SCHEMA_FINGERPRINT_ID = "runtime-schema"; + +const schemaFingerprint = async (statements: readonly string[]): Promise => { + const bytes = new TextEncoder().encode(statements.join("\0")); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +}; + +const isMissingFingerprintTable = (error: unknown): boolean => { + for (let current = error, depth = 0; current != null && depth < 8; depth += 1) { + const record = + typeof current === "object" ? (current as { message?: unknown; cause?: unknown }) : null; + const message = typeof record?.message === "string" ? record.message : String(current); + if (/no such table/i.test(message) && message.includes(SCHEMA_FINGERPRINT_TABLE)) return true; + current = record?.cause ?? null; + } + return false; +}; + +const readPreparedSchemaFingerprint = async (db: D1Database): Promise => { + const session = db.withSession("first-primary"); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a missing marker table is the expected first-boot signal; every other D1 failure must still reject startup + try { + const result = await session + .prepare(`SELECT fingerprint FROM "${SCHEMA_FINGERPRINT_TABLE}" WHERE id = ?`) + .bind(SCHEMA_FINGERPRINT_ID) + .all<{ readonly fingerprint?: unknown }>(); + const value = result.results[0]?.fingerprint; + return typeof value === "string" ? value : null; + } catch (error) { + if (isMissingFingerprintTable(error)) return null; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve the original D1 adapter rejection for every failure except the expected absent first-boot marker + throw error; + } +}; + +const storePreparedSchemaFingerprint = async ( + db: D1Database, + fingerprint: string, +): Promise => { + const session = db.withSession("first-primary"); + await session + .prepare( + `CREATE TABLE IF NOT EXISTS "${SCHEMA_FINGERPRINT_TABLE}" (id text PRIMARY KEY NOT NULL, fingerprint text NOT NULL)`, + ) + .run(); + await session + .prepare( + `INSERT INTO "${SCHEMA_FINGERPRINT_TABLE}" (id, fingerprint) + VALUES (?, ?) + ON CONFLICT(id) DO UPDATE SET fingerprint = excluded.fingerprint`, + ) + .bind(SCHEMA_FINGERPRINT_ID, fingerprint) + .run(); +}; // --------------------------------------------------------------------------- // D1 DbProvider handle — the CF-native swap for self-host's libSQL handle. @@ -42,10 +100,31 @@ export const createD1ExecutorDb = async ( // D1 rejects SQL `BEGIN TRANSACTION` / `SAVEPOINT` (it requires the JS batch // API), and the shared ensure wraps its DDL in a transaction when the handle - // exposes one. The bring-up is idempotent `CREATE TABLE IF NOT EXISTS`, so run - // it WITHOUT a transaction by handing the ensure a run-only view of the handle. - await ensureDrizzleRuntimeSchemaFromTables({ run: (query) => drizzleDb.run(query) }, options); - await runCloudflareDataMigrations(db, blobs); + // exposes one. A generated fingerprint lets every Worker/DO isolate prove the + // expected generated schema was already prepared with one primary read instead of + // replaying dozens of idempotent CREATE/ALTER statements on every database + // open. The marker is stored only after the ensure, data migrations, and any + // required post-compatibility ensure succeed. Data migrations retain their + // own ledger and still run below on every open. + const expectedFingerprint = await schemaFingerprint( + createDrizzleRuntimeSchemaSqlFromTables(options), + ); + const preparedFingerprint = await readPreparedSchemaFingerprint(db); + const requiresSchemaPreparation = preparedFingerprint !== expectedFingerprint; + if (requiresSchemaPreparation) { + await ensureDrizzleRuntimeSchemaFromTables({ run: (query) => drizzleDb.run(query) }, options); + } + const migrationResult = await prepareCloudflareD1Data(db, blobs); + if (requiresSchemaPreparation) { + // Compatibility migrations can rebuild a legacy table. Re-run the + // idempotent ensure before stamping so the database matches the generated + // schema after migration, including newer nullable columns the legacy + // rebuild did not know about. + if (migrationResult.schemaChanged) { + await ensureDrizzleRuntimeSchemaFromTables({ run: (query) => drizzleDb.run(query) }, options); + } + await storePreparedSchemaFingerprint(db, expectedFingerprint); + } // `interactiveTransactions: false` — D1 rejects interactive transactions, so // the fuma adapter runs transaction callbacks directly (auto-commit per diff --git a/apps/host-cloudflare/src/db/data-migrations.test.ts b/apps/host-cloudflare/src/db/data-migrations.test.ts index 02dc9be278..254c023776 100644 --- a/apps/host-cloudflare/src/db/data-migrations.test.ts +++ b/apps/host-cloudflare/src/db/data-migrations.test.ts @@ -241,6 +241,7 @@ describe("runCloudflareDataMigrations", () => { "2026-06-20-google-openapi-ownership", "2026-07-08-provider-service-split", "2026-07-09-openapi-ndjson-output-arrays", + "2026-07-27-encrypted-secrets-owner-repartition", ]); expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); @@ -285,6 +286,7 @@ describe("runCloudflareDataMigrations", () => { "2026-06-20-google-openapi-ownership", "2026-07-08-provider-service-split", "2026-07-09-openapi-ndjson-output-arrays", + "2026-07-27-encrypted-secrets-owner-repartition", ]); expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); @@ -336,6 +338,7 @@ describe("runCloudflareDataMigrations", () => { "2026-06-20-google-openapi-ownership", "2026-07-08-provider-service-split", "2026-07-09-openapi-ndjson-output-arrays", + "2026-07-27-encrypted-secrets-owner-repartition", ]); expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]); diff --git a/apps/host-cloudflare/src/db/data-migrations.ts b/apps/host-cloudflare/src/db/data-migrations.ts index 85f48b3713..1efb4340dd 100644 --- a/apps/host-cloudflare/src/db/data-migrations.ts +++ b/apps/host-cloudflare/src/db/data-migrations.ts @@ -9,6 +9,7 @@ import { } from "@executor-js/sdk"; import { openApiNdjsonOutputDataMigration } from "@executor-js/plugin-openapi"; import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google"; +import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-encrypted-secrets"; import { providerServiceSplitDataMigration, @@ -108,7 +109,8 @@ const rebuildLegacyConnectionTable = async (db: D1Database): Promise => { } }; -export const ensureCloudflareD1SchemaCompatibility = async (db: D1Database): Promise => { +export const ensureCloudflareD1SchemaCompatibility = async (db: D1Database): Promise => { + let schemaChanged = false; const integrationColumns = await tableColumns(db, "integration"); if (integrationColumns.has("config")) { await db @@ -122,14 +124,17 @@ export const ensureCloudflareD1SchemaCompatibility = async (db: D1Database): Pro } const connectionColumns = await tableColumns(db, "connection"); - if (connectionColumns.size === 0) return; + if (connectionColumns.size === 0) return schemaChanged; if (!connectionColumns.has("item_ids")) { await db.prepare(`ALTER TABLE connection ADD COLUMN item_ids json NOT NULL DEFAULT '{}'`).run(); + schemaChanged = true; } const updatedConnectionColumns = await tableColumns(db, "connection"); if (updatedConnectionColumns.has("item_id")) { await rebuildLegacyConnectionTable(db); + schemaChanged = true; } + return schemaChanged; }; const r2ObjectName = (tenant: string, pluginId: string, key: string): string => @@ -246,16 +251,29 @@ const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly Sqlite // Stale-mark connections whose operations return NDJSON so their tool rows // rebuild with array-wrapped output schemas (mirrors cloud's drizzle 0010). openApiNdjsonOutputDataMigration, + // Re-file credential rows the pre-fix provider stored under the acting + // caller's partition instead of the owner embedded in the item id (#1453). + encryptedSecretsRepartitionDataMigration, ]; -export const runCloudflareDataMigrations = ( +export const prepareCloudflareD1Data = ( db: D1Database, bucket: R2Bucket | undefined, -): Promise => +): Promise<{ + readonly appliedMigrations: readonly string[]; + readonly schemaChanged: boolean; +}> => Effect.runPromise( Effect.promise(() => ensureCloudflareD1SchemaCompatibility(db)).pipe( - Effect.flatMap(() => - runSqliteDataMigrations(d1DataMigrationClient(db), cloudflareDataMigrations(bucket)), + Effect.flatMap((schemaChanged) => + runSqliteDataMigrations(d1DataMigrationClient(db), cloudflareDataMigrations(bucket)).pipe( + Effect.map((appliedMigrations) => ({ appliedMigrations, schemaChanged })), + ), ), ), ); + +export const runCloudflareDataMigrations = async ( + db: D1Database, + bucket: R2Bucket | undefined, +): Promise => (await prepareCloudflareD1Data(db, bucket)).appliedMigrations; diff --git a/apps/host-cloudflare/src/execution.ts b/apps/host-cloudflare/src/execution.ts index a3b91e6510..1ee0e4b1e9 100644 --- a/apps/host-cloudflare/src/execution.ts +++ b/apps/host-cloudflare/src/execution.ts @@ -10,7 +10,9 @@ import { PluginsProvider, type ExecutorDbHandle, } from "@executor-js/api/server"; +import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; +import { env } from "cloudflare:workers"; import type { CloudflareConfig } from "./config"; import { makeCloudflarePlugins } from "./plugins"; @@ -20,10 +22,10 @@ import { makeCloudflarePlugins } from "./plugins"; // substrate, no-op engine decorator), with the plugins + host config built from // the per-request `env`-derived config rather than process.env. // -// QuickJS-wasm is the default code substrate because it runs in a single Worker -// with no extra binding. When Cloudflare's dynamic Worker Loader leaves closed -// beta, swap CodeExecutorProvider for the dynamic-worker executor (cloud's) — -// it's a one-Layer change behind this same seam. +// Code substrate: when the Worker declares a `worker_loaders` LOADER binding, +// use the dynamic-worker executor (cloud's substrate) — real isolates with +// `globalOutbound: null`. Without the binding, fall back to QuickJS-wasm, +// which runs in a single Worker with no extra binding. // --------------------------------------------------------------------------- export { makeExecutionStack } from "@executor-js/api/server"; @@ -31,7 +33,10 @@ export { EngineDecoratorNoop }; export const CloudflareCodeExecutorProvider: Layer.Layer = Layer.sync( CodeExecutorProvider, - () => makeQuickJsExecutor(), + () => { + const loader = (env as { LOADER?: WorkerLoader }).LOADER; + return loader ? makeDynamicWorkerExecutor({ loader }) : makeQuickJsExecutor(); + }, ); export const makeCloudflarePluginsProvider = ( diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index c2f8bb579c..64ea2d4176 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -4,15 +4,20 @@ import { McpAuthProvider, jsonRpcErrorBody, defaultMcpResource, + orgWriteAccessForPrincipal, + withOrgWriteAccess, type AuthOutcome, type Principal, } from "@executor-js/host-mcp"; import { currentPropagationHeaders, + readArtifactsEnabled, readElicitationMode, + readSearchToolsEnabled, withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { sessionOrgRoleMetadata } from "@executor-js/cloudflare/mcp/role-metadata"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { CloudflareConfig, CloudflareEnv } from "../config"; @@ -76,8 +81,11 @@ const propsForPrincipal = ( return { session: { organizationId: principal.organizationId, + ...sessionOrgRoleMetadata(principal), userId: principal.accountId, elicitationMode: readElicitationMode(request), + artifactsEnabled: readArtifactsEnabled(request), + searchToolsEnabled: readSearchToolsEnabled(request), // host-cloudflare only routes the bare `/mcp` endpoint to the Agent // bridge (see worker.ts), so the session always serves the default // resource. @@ -136,13 +144,16 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal)); (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; - const forwarded = withVerifiedIdentityHeaders( - request, - { - accountId: outcome.principal.accountId, - organizationId: outcome.principal.organizationId, - }, - defaultMcpResource, + const forwarded = withOrgWriteAccess( + withVerifiedIdentityHeaders( + request, + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + defaultMcpResource, + ), + orgWriteAccessForPrincipal(outcome.principal), ); return serve.fetch(forwarded, env, ctx); }; diff --git a/apps/host-cloudflare/src/mcp/index.ts b/apps/host-cloudflare/src/mcp/index.ts index 1de283c205..bcb9d74b22 100644 --- a/apps/host-cloudflare/src/mcp/index.ts +++ b/apps/host-cloudflare/src/mcp/index.ts @@ -1,7 +1,10 @@ import { Effect } from "effect"; import { decodeResumeResponse } from "@executor-js/host-mcp/browser-approval"; -import type { McpApprovalOwner } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import type { + McpApprovalOwner, + McpApprovalPrincipal, +} from "@executor-js/cloudflare/mcp/agent-durable-object"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { CloudflareConfig, CloudflareEnv } from "../config"; @@ -45,6 +48,10 @@ export const makeCloudflareApprovalHandler = ( const resume = RESUME_PATH.exec(pathname); if (resume && request.method === "POST") { + const approver: McpApprovalPrincipal = { + ...owner, + orgRole: principal.orgRole === "admin" ? "admin" : "member", + }; const raw = await Effect.runPromise( Effect.tryPromise({ try: () => request.json(), catch: () => null }).pipe( Effect.orElseSucceed(() => null), @@ -55,7 +62,7 @@ export const makeCloudflareApprovalHandler = ( const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval( decodeURIComponent(resume[2]!), - owner, + approver, response, ); if (result.status !== "ok") return jsonResponse({ error: "Paused execution not found" }, 404); diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 95b366b7e4..ba4f2ff861 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -5,6 +5,9 @@ import { createExecutorMcpServer, } from "@executor-js/host-mcp/tool-server"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; +import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; +import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; +import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import type { ExecutorDbHandle } from "@executor-js/api/server"; import { McpAgentSessionDOBase, @@ -14,6 +17,7 @@ import { type McpSessionInit, type SessionMeta, } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { sessionOrgRoleMetadata } from "@executor-js/cloudflare/mcp/role-metadata"; import { mcpExecutionOwnerDirectoryFromNamespace, type McpExecutionOwnerDirectory, @@ -54,6 +58,14 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward export class McpSessionDO extends McpAgentSessionDOBase { private readonly cfEnv: CloudflareEnv; private readonly cfConfig: CloudflareConfig; + /** + * The `ui://executor/shell.html` document, over the ASSETS binding: a + * deployed Worker has no filesystem, so the shell is the stable-named asset + * the SPA build emitted into `./dist` (`mcpAppsShellAsset`). No dev-mode + * escape hatch here, unlike cloud: this Worker is bundled by wrangler, not + * Vite, and `wrangler dev` serves the same built `./dist` the binding reads. + */ + private readonly loadAppShellHtml: () => Promise; constructor( ctx: ConstructorParameters>[0], @@ -62,12 +74,25 @@ export class McpSessionDO extends McpAgentSessionDOBase { + // Routed through this session's OWN stub (never a direct in-process call) + // so `requestCapEviction`'s teardown runs under an IoContext scoped to + // this request, not whatever request happened to trigger the eviction + // check — see the base class's `requestSelfEviction` doc comment. + return mcpSessionStub(this.cfEnv.MCP_SESSION, this.sessionId).requestCapEviction(); + } + protected override forwardModelResumeToOwner( owner: McpExecutionOwnerRoute, identity: McpApprovalOwner, @@ -90,16 +115,24 @@ export class McpSessionDO extends McpAgentSessionDOBase handle.close() }; } - protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect { + protected override resolveSessionMeta( + token: McpSessionInit, + _storedMeta: SessionMeta | null, + ): Effect.Effect { // Single-tenant: every Access principal belongs to the one configured org, - // so there is nothing to resolve — stamp the configured org name. + // so there is nothing to resolve — stamp the configured org name. Nothing + // to reuse from the stored meta either; config is already the cheapest and + // freshest source there is. return Effect.succeed({ organizationId: token.organizationId, organizationName: this.cfConfig.organizationName, organizationSlug: this.cfConfig.organizationSlug, + ...sessionOrgRoleMetadata(token), userId: token.userId, resource: token.resource, elicitationMode: token.elicitationMode, + artifactsEnabled: token.artifactsEnabled, + searchToolsEnabled: token.searchToolsEnabled, } satisfies SessionMeta); } @@ -113,19 +146,41 @@ export class McpSessionDO extends McpAgentSessionDOBase preloadQuickJs()); - const { engine } = yield* makeExecutionStack( + const { engine, executor } = yield* makeExecutionStack( sessionMeta.userId, sessionMeta.organizationId, sessionMeta.organizationName, - { mcpResource: sessionMeta.resource }, + { mcpResource: sessionMeta.resource, orgWrites: "request" }, ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); // Browser elicitation mode (the base owns the approval store + the HTTP // approval RPCs): a gated execution pauses and returns an approvalUrl into // the console resume page. The URL origin is the create request's origin // (captured by the base), falling back to the configured site URL. const elicitationMode = sessionMeta.elicitationMode ?? "model"; + // Same origin story as the approval URL below: an artifact deep link is + // only offered when this deployment knows its own public URL. Without one + // the artifact is still saved, and the tool says so. + const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; const mcpServer = yield* createExecutorMcpServer({ engine, + artifacts: executor.artifacts, + connections: executor.connections, + // Artifacts are on by default, opt-out per connection. A session + // persisted without a value restores to the default, same as a fresh + // connection whose URL says nothing about `?artifacts=`. + artifactsEnabled: sessionMeta.artifactsEnabled ?? true, + // Per-integration search tools are off by default, opt-in per + // connection (`?search_tools=true`). Same restore rule as artifacts. + searchToolsEnabled: sessionMeta.searchToolsEnabled ?? false, + // Cold restores rebuild this server with no `initialize` to replay, so + // the negotiated apps support comes back from storage instead. + restoredAppsEnabled: sessionMeta.appsEnabled ?? false, + onAppsEnabledChange: (appsEnabled) => self.persistAppsEnabled(appsEnabled), + loadAppShellHtml: self.loadAppShellHtml, + smokeRenderArtifact, + ...(artifactOrigin + ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) } + : {}), browserApprovalStore: self.browserApprovalStore, pausedExecutionHooks: self.pausedExecutionHooks, pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS, diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 84c6fb0111..738a358827 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -6,6 +6,9 @@ import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; import { Schema } from "effect"; import { unstable_dev, type Unstable_DevWorker } from "wrangler"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { microsoftCatalog } from "@executor-js/plugin-openapi/providers/microsoft"; // --------------------------------------------------------------------------- @@ -19,6 +22,18 @@ import { microsoftCatalog } from "@executor-js/plugin-openapi/providers/microsof const dir = fileURLToPath(new URL(".", import.meta.url)); const runId = randomUUID().slice(0, 8); +const ensureStaticAssets = () => { + // CI runs from a fresh checkout with no `vite build`, so `./dist` (the SPA + // assets dir wrangler.jsonc points `assets.directory` at) is absent and + // `unstable_dev`'s assets validation aborts boot. These tests drive the + // API/MCP surface (all `run_worker_first` paths), not the SPA. + const distIndex = resolve(dir, "../dist/index.html"); + if (!existsSync(distIndex)) { + mkdirSync(resolve(dir, "../dist"), { recursive: true }); + writeFileSync(distIndex, "executor"); + } +}; + // Inline spec (no network); registers one tool, exercising the D1 write path. const SPEC = JSON.stringify({ openapi: "3.0.0", @@ -77,21 +92,13 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { let worker: Unstable_DevWorker; beforeAll(async () => { - // CI runs from a fresh checkout with no `vite build`, so `./dist` (the SPA - // assets dir wrangler.jsonc points `assets.directory` at) is absent and - // `unstable_dev`'s assets validation aborts boot. This e2e drives the - // API/MCP surface (all `run_worker_first` paths), not the SPA, so a minimal - // placeholder index.html satisfies the validation without a real build. - const distIndex = resolve(dir, "../dist/index.html"); - if (!existsSync(distIndex)) { - mkdirSync(resolve(dir, "../dist"), { recursive: true }); - writeFileSync(distIndex, "executor"); - } + ensureStaticAssets(); worker = await unstable_dev(resolve(dir, "worker.ts"), { config: resolve(dir, "../wrangler.jsonc"), ip: "127.0.0.1", local: true, + persist: false, experimental: { disableExperimentalWarning: true }, vars: { EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", @@ -382,6 +389,43 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { expect(result.result?.structuredContent?.result).toBe(42); }, 60_000); + it("delivers native elicitation on the approval-gated tool call stream", async () => { + const client = new Client( + { name: "native-elicitation-test", version: "1.0.0" }, + { capabilities: { elicitation: { form: {}, url: {} } } }, + ); + let receivedElicitation = false; + client.setRequestHandler(ElicitRequestSchema, async () => { + receivedElicitation = true; + return { action: "accept" as const, content: {} }; + }); + const transport = new StreamableHTTPClientTransport( + new URL("/mcp?elicitation_mode=native", `http://${worker.address}:${worker.port}`), + ); + await client.connect(transport); + + const result = await client.callTool( + { + name: "execute", + arguments: { + code: [ + "return await tools.executor.coreTools.policies.create({", + ' owner: "org",', + ` pattern: "native-elicitation-${runId}.*",`, + ' action: "require_approval"', + "});", + ].join("\n"), + }, + }, + undefined, + { timeout: 5_000 }, + ); + + expect(receivedElicitation).toBe(true); + expect(result.isError).toBeFalsy(); + await client.close(); + }, 60_000); + it("resumes a model-paused execution from a fresh MCP session", async () => { const accept = "application/json, text/event-stream"; const rpc = (sessionId: string | null, body: unknown) => @@ -474,3 +518,37 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { expect(resumed.result?.structuredContent?.status).toBe("completed"); }, 60_000); }); + +describe("cloudflare host configuration errors", () => { + let worker: Unstable_DevWorker; + + beforeAll(async () => { + ensureStaticAssets(); + worker = await unstable_dev(resolve(dir, "worker.ts"), { + config: resolve(dir, "../wrangler.jsonc"), + ip: "127.0.0.1", + local: true, + persist: false, + experimental: { disableExperimentalWarning: true }, + vars: { + EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", + }, + }); + }, 120_000); + + afterAll(async () => { + await worker?.stop(); + }); + + it("returns an actionable response when Cloudflare Access is not configured", async () => { + for (const path of ["/api/account/me", "/mcp"]) { + const response = await worker.fetch(path); + + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + await expect(response.text()).resolves.toBe( + "Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN and ACCESS_AUD before serving requests.\n", + ); + } + }); +}); diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index 74a1be074b..ac9c1b30b7 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -1,5 +1,9 @@ import { makeCloudflareApp } from "./app"; -import type { CloudflareEnv } from "./config"; +import { + cloudflareAccessConfigErrorMessage, + missingCloudflareAccessVars, + type CloudflareEnv, +} from "./config"; // The MCP Durable Object classes, bound in wrangler.jsonc. They must be exported // at the Worker entry module scope for the runtime to find them. @@ -27,8 +31,22 @@ const resolveHandler = (env: CloudflareEnv) => { return handlerPromise; }; +const accessConfigErrorResponse = (missingVars: readonly string[]): Response => + new Response(`${cloudflareAccessConfigErrorMessage(missingVars)}\n`, { + status: 503, + headers: { + "cache-control": "no-store", + "content-type": "text/plain; charset=utf-8", + }, + }); + export default { fetch: async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => { + const missingAccessVars = missingCloudflareAccessVars(env); + if (missingAccessVars.length > 0) { + return accessConfigErrorResponse(missingAccessVars); + } + const serve = await resolveHandler(env); if (new URL(request.url).pathname === "/mcp") { return serve.mcp(request, env, ctx); diff --git a/apps/host-cloudflare/test-stubs/cloudflare-workers.ts b/apps/host-cloudflare/test-stubs/cloudflare-workers.ts new file mode 100644 index 0000000000..7c4b769fc8 --- /dev/null +++ b/apps/host-cloudflare/test-stubs/cloudflare-workers.ts @@ -0,0 +1,7 @@ +/** Node-test stand-ins for Cloudflare module exports reached during handler imports. */ +export const env: Record = {}; +export class WorkerEntrypoint {} +export class DurableObject {} +export class WorkflowEntrypoint {} +export class RpcTarget {} +export const exports: Record = {}; diff --git a/apps/host-cloudflare/tsconfig.json b/apps/host-cloudflare/tsconfig.json index f659f662dd..82688a468d 100644 --- a/apps/host-cloudflare/tsconfig.json +++ b/apps/host-cloudflare/tsconfig.json @@ -3,6 +3,10 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", + // This host opts into the create-time artifact smoke render, whose entry + // dynamically imports a `.tsx` component graph. Nothing here renders + // anything; the compiler still resolves that graph. + "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/apps/host-cloudflare/tsr.routes.ts b/apps/host-cloudflare/tsr.routes.ts index 2295e7a082..10b3525c91 100644 --- a/apps/host-cloudflare/tsr.routes.ts +++ b/apps/host-cloudflare/tsr.routes.ts @@ -10,6 +10,10 @@ import { consoleRoutes } from "@executor-js/react/console-routes"; // this app only owns its root (the Cloudflare-Access shell). To add // app-specific routes, create web/routes/app and mount it inside the org // scope via `orgScoped: [physical("", "app")]` — the directory must exist. +// +// /users is excluded: this host mounts no admin-users routes (identity is +// Cloudflare Access, which has no in-app role to gate on), so the page would +// only ever render its denied state. export const routes = rootRoute("__root.tsx", [ - ...consoleRoutes({ dir: "../../../../packages/react/src/routes" }), + ...consoleRoutes({ dir: "../../../../packages/react/src/routes", exclude: ["/users"] }), ]); diff --git a/apps/host-cloudflare/vite.config.ts b/apps/host-cloudflare/vite.config.ts index eff4d12c40..aea34e7210 100644 --- a/apps/host-cloudflare/vite.config.ts +++ b/apps/host-cloudflare/vite.config.ts @@ -1,11 +1,12 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { innerRendererPlugin, mcpAppsShellAsset } from "@executor-js/mcp-apps-shell/vite"; import { routes } from "./tsr.routes"; @@ -60,6 +61,12 @@ export default defineConfig({ }, plugins: [ tailwindcss(), + // The artifact page hosts the MCP-Apps shell as a sandboxed iframe over the + // MCP-Apps protocol: `mcpAppsShellAsset` emits the built shell document and + // hands the page its URL, and `innerRendererPlugin` inlines the shell's own + // sandboxed inner frame from `virtual:executor-inner-renderer`. + mcpAppsShellAsset() as Plugin, + innerRendererPlugin(), executorVitePlugin({ configPath: fileURLToPath(new URL("./executor.config.ts", import.meta.url)), }), diff --git a/apps/host-cloudflare/vitest.config.ts b/apps/host-cloudflare/vitest.config.ts index 5bfa2d586e..44cc8cc150 100644 --- a/apps/host-cloudflare/vitest.config.ts +++ b/apps/host-cloudflare/vitest.config.ts @@ -1,6 +1,13 @@ +import { resolve } from "node:path"; + import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "cloudflare:workers": resolve(__dirname, "./test-stubs/cloudflare-workers.ts"), + }, + }, test: { include: ["src/**/*.test.ts"], passWithNoTests: true, diff --git a/apps/host-cloudflare/web/routeTree.gen.ts b/apps/host-cloudflare/web/routeTree.gen.ts index 4938fe8540..8de64af12d 100644 --- a/apps/host-cloudflare/web/routeTree.gen.ts +++ b/apps/host-cloudflare/web/routeTree.gen.ts @@ -14,9 +14,13 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' @@ -50,6 +54,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport.update( { @@ -67,6 +77,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update( + { + id: '/{-$orgSlug}/integrations/browse', + path: '/{-$orgSlug}/integrations/browse', + getParentRoute: () => rootRouteImport, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( { @@ -75,6 +93,23 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update( + { + id: '/{-$orgSlug}/connect/$integrationSlug', + path: '/{-$orgSlug}/connect/$integrationSlug', + getParentRoute: () => rootRouteImport, + } as any, + ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport.update( + { + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( { @@ -93,24 +128,32 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK ) export interface FileRoutesByFullPath { + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute '/{-$orgSlug}/plugins/$pluginId/$': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute } export interface FileRoutesByTo { + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -118,12 +161,16 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -132,36 +179,48 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' + | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' fileRoutesByTo: FileRoutesByTo to: + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' + | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' | '/{-$orgSlug}/plugins/$pluginId/$' id: | '__root__' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' + | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -169,12 +228,15 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute @@ -217,6 +279,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/toolkits/$toolkitSlug': { id: '/{-$orgSlug}/toolkits/$toolkitSlug' path: '/$toolkitSlug' @@ -231,6 +300,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/integrations/browse': { + id: '/{-$orgSlug}/integrations/browse' + path: '/{-$orgSlug}/integrations/browse' + fullPath: '/{-$orgSlug}/integrations/browse' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/integrations/$namespace': { id: '/{-$orgSlug}/integrations/$namespace' path: '/{-$orgSlug}/integrations/$namespace' @@ -238,6 +314,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/connect/$integrationSlug': { + id: '/{-$orgSlug}/connect/$integrationSlug' + path: '/{-$orgSlug}/connect/$integrationSlug' + fullPath: '/{-$orgSlug}/connect/$integrationSlug' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport + parentRoute: typeof rootRouteImport + } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -255,6 +345,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -271,6 +376,8 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = ) const rootRouteChildren: RootRouteChildren = { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: @@ -281,8 +388,12 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: diff --git a/apps/host-cloudflare/web/routes/__root.tsx b/apps/host-cloudflare/web/routes/__root.tsx index eaf4e356a9..24f8485c1d 100644 --- a/apps/host-cloudflare/web/routes/__root.tsx +++ b/apps/host-cloudflare/web/routes/__root.tsx @@ -3,6 +3,7 @@ import { useEffect, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; @@ -24,6 +25,13 @@ import { plugins as clientPlugins } from "virtual:executor/plugins-client"; // omits the API-keys nav item and just uses the default set. // --------------------------------------------------------------------------- +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. It is registered as a dynamic import the +// artifact page resolves in the browser, never a static one, so it stays out of +// any server graph. Module scope keeps the loader identity stable, so the lazy +// component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + export const Route = createRootRoute({ component: RootComponent, }); @@ -78,7 +86,13 @@ function AuthenticatedApp() { wrangler.jsonc run_worker_first), so a slug-pinned URL would fall through to the SPA. */} - {organization ? {gated} : gated} + + {organization ? ( + {gated} + ) : ( + gated + )} +
diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index 6b3a12b7ff..f97e19507e 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -4,6 +4,9 @@ "compatibility_date": "2025-04-01", "compatibility_flags": ["nodejs_compat"], "main": "src/worker.ts", + // Access configuration is set per installation after the first deploy. + // Preserve those live bindings when this source config does not declare them. + "keep_vars": true, "observability": { "enabled": true }, // The web UI (Workers Static Assets) — the shared multiplayer SPA built by // `vite build` into ./dist. `single-page-application` serves index.html for @@ -12,6 +15,10 @@ // Worker instead of the SPA fallback. "assets": { "directory": "./dist", + // The MCP session DO reads the built MCP-Apps shell document through this + // binding (`ui://executor/shell.html` has no filesystem to come from on a + // deployed Worker). + "binding": "ASSETS", "not_found_handling": "single-page-application", "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*", "/v1", "/v1/*"], }, @@ -34,6 +41,15 @@ "bucket_name": "executor-blobs", }, ], + // Dynamic Worker Loader binding: when present, sandboxed code runs in real + // workerd isolates with `globalOutbound: null` (platform-enforced zero egress) + // via @executor-js/runtime-dynamic-worker; without it the host falls back to + // the QuickJS-wasm substrate. Remove this block to stay on QuickJS. + "worker_loaders": [ + { + "binding": "LOADER", + }, + ], // The MCP session Durable Object: one addressable isolate per MCP session (the // DO id IS the session id) so a session survives across the Worker's stateless // isolates — without it, `tools/list` after `initialize` can land on a fresh @@ -49,17 +65,16 @@ { "tag": "v1", "new_sqlite_classes": ["McpSessionDO"] }, { "tag": "v2", "new_sqlite_classes": ["McpExecutionOwnerDirectoryDO"] }, ], - // Cloudflare Access is the entire auth layer: the Worker validates the - // Cf-Access-Jwt-Assertion JWT against the team JWKS. Set these to your Zero - // Trust team domain + the Access application's AUD tag. EXECUTOR_SECRET_KEY - // (the at-rest secret-encryption key) is a SECRET — set it with + // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD, + // and ADMIN_EMAILS are installation-specific live vars, set after the first + // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (the at-rest + // secret-encryption key) is a SECRET, set it with // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars. "vars": { - "ACCESS_TEAM_DOMAIN": "your-team.cloudflareaccess.com", - "ACCESS_AUD": "", "ACCESS_NAME_CLAIM": "name", "ACCESS_GROUPS_CLAIM": "groups", - "ADMIN_EMAILS": "", + // Never preserve a production dev-auth override through keep_vars. + "ENABLE_DEV_AUTH": "false", "SELF_HOSTED_ORG_ID": "default", "SELF_HOSTED_ORG_NAME": "Default", // VITE_PUBLIC_SITE_URL is intentionally unset: with no static URL the worker diff --git a/apps/host-selfhost/.env.example b/apps/host-selfhost/.env.example index 8256845c5f..f15d73b7fd 100644 --- a/apps/host-selfhost/.env.example +++ b/apps/host-selfhost/.env.example @@ -8,6 +8,11 @@ # rejected. Behind a reverse proxy / TLS, set this to your public https URL. # EXECUTOR_WEB_BASE_URL=https://executor.example.com +# Additional browser origins allowed to authenticate with this instance, as a +# comma-separated list. These aliases do not change OAuth callbacks or generated +# links, which remain pinned to EXECUTOR_WEB_BASE_URL. Use exact origins only. +# EXECUTOR_TRUSTED_ORIGINS=http://executor.home.arpa:4788,http://192.0.2.10:4788 + # --- Session secret ----------------------------------------------------------- # Generated and persisted under the data volume on first boot if unset. Set this # to manage it yourself (must be at least 32 characters). Rotating it signs every @@ -51,3 +56,9 @@ # Allow sandboxed code to reach loopback/private network addresses. Off by # default — adversarial generated code should not reach your internal network. # EXECUTOR_ALLOW_LOCAL_NETWORK=false + +# --- Local stdio MCP (trusted deployments only) ------------------------------- +# Stdio MCP is disabled unless this is explicitly set to the exact string +# "true". Enabling it lets users configure MCP servers whose commands execute +# on this host. +# EXECUTOR_ALLOW_STDIO_MCP=true diff --git a/apps/host-selfhost/CHANGELOG.md b/apps/host-selfhost/CHANGELOG.md index e67d264769..ad348e72c0 100644 --- a/apps/host-selfhost/CHANGELOG.md +++ b/apps/host-selfhost/CHANGELOG.md @@ -1,5 +1,323 @@ # @executor-js/host-selfhost +## 0.0.49 + +### Patch Changes + +- Updated dependencies [[`75b3674`](https://github.com/UsefulSoftwareCo/executor/commit/75b3674136b44a2e43fb23eb7a058e7e51528527), [`98d6c6a`](https://github.com/UsefulSoftwareCo/executor/commit/98d6c6ad3272fca371fc2d8b14b2e332100d8322)]: + - @executor-js/plugin-mcp@1.6.7 + - @executor-js/sdk@1.6.7 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.14 + - @executor-js/api@1.4.70 + - @executor-js/execution@1.6.7 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.18 + - @executor-js/plugin-encrypted-secrets@0.0.49 + - @executor-js/plugin-graphql@1.6.7 + - @executor-js/plugin-openapi@1.6.7 + - @executor-js/plugin-provider-service-split@0.0.21 + - @executor-js/plugin-toolkits@1.5.42 + - @executor-js/react@1.4.70 + - @executor-js/runtime-quickjs@1.6.7 + +## 0.0.48 + +### Patch Changes + +- Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed), [`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d), [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: + - @executor-js/plugin-mcp@1.6.6 + - @executor-js/execution@1.6.6 + - @executor-js/react@1.4.69 + - @executor-js/analytics@0.1.13 + - @executor-js/api@1.4.69 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.17 + - @executor-js/app@1.4.4 + - @executor-js/plugin-graphql@1.6.6 + - @executor-js/plugin-openapi@1.6.6 + - @executor-js/plugin-toolkits@1.5.41 + - @executor-js/plugin-provider-service-split@0.0.20 + - @executor-js/sdk@1.6.6 + - @executor-js/runtime-quickjs@1.6.6 + - @executor-js/plugin-encrypted-secrets@0.0.48 + +## 0.0.47 + +### Patch Changes + +- Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]: + - @executor-js/plugin-mcp@1.6.5 + - @executor-js/sdk@1.6.5 + - @executor-js/runtime-quickjs@1.6.5 + - @executor-js/execution@1.6.5 + - @executor-js/plugin-graphql@1.6.5 + - @executor-js/plugin-openapi@1.6.5 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.12 + - @executor-js/api@1.4.68 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.16 + - @executor-js/plugin-encrypted-secrets@0.0.47 + - @executor-js/plugin-provider-service-split@0.0.19 + - @executor-js/plugin-toolkits@1.5.40 + - @executor-js/react@1.4.68 + +## 0.0.46 + +### Patch Changes + +- Updated dependencies [[`ffcfbc0`](https://github.com/UsefulSoftwareCo/executor/commit/ffcfbc0de27d0ae55215839fb70395b0b7d9a65c), [`10e16a5`](https://github.com/UsefulSoftwareCo/executor/commit/10e16a5baa2648657b70038e7d11429c58e4d242), [`9dcfaa5`](https://github.com/UsefulSoftwareCo/executor/commit/9dcfaa5ee8ad2ebc17407caf94d8d4dcf55e3562), [`515d6aa`](https://github.com/UsefulSoftwareCo/executor/commit/515d6aa391a04a3579a7b10f974ec316a563cf7a), [`06bf742`](https://github.com/UsefulSoftwareCo/executor/commit/06bf74254f3432e8d75fd8b493ef7a435ea4bc84)]: + - @executor-js/plugin-mcp@1.6.4 + - @executor-js/sdk@1.6.4 + - @executor-js/react@1.4.67 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.11 + - @executor-js/api@1.4.67 + - @executor-js/execution@1.6.4 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.15 + - @executor-js/plugin-encrypted-secrets@0.0.46 + - @executor-js/plugin-graphql@1.6.4 + - @executor-js/plugin-openapi@1.6.4 + - @executor-js/plugin-provider-service-split@0.0.18 + - @executor-js/plugin-toolkits@1.5.39 + - @executor-js/runtime-quickjs@1.6.4 + +## 0.0.45 + +### Patch Changes + +- Updated dependencies [[`66fb1a4`](https://github.com/UsefulSoftwareCo/executor/commit/66fb1a4154226d28691ca83bdf6f3daa417ef0ce), [`4b0fbf6`](https://github.com/UsefulSoftwareCo/executor/commit/4b0fbf68550516af9235c9267f91a962da993946), [`ba62f1a`](https://github.com/UsefulSoftwareCo/executor/commit/ba62f1a5d14b7002ba0a4686a9e1ae43bd77f54f), [`8324e1e`](https://github.com/UsefulSoftwareCo/executor/commit/8324e1eb8b03965050147309f049bdb52be6fcad), [`6305b6d`](https://github.com/UsefulSoftwareCo/executor/commit/6305b6d11505358fa73ec2b3e768ec4256c36435), [`c1f51b7`](https://github.com/UsefulSoftwareCo/executor/commit/c1f51b7f96328b795669bb3d241667660dc2b060), [`d7e4b73`](https://github.com/UsefulSoftwareCo/executor/commit/d7e4b73a86b8e413af70e0fcb26f38a35a3f4546), [`85b1955`](https://github.com/UsefulSoftwareCo/executor/commit/85b1955b4d24c332e637e15a025d64455e28a626), [`02b52cd`](https://github.com/UsefulSoftwareCo/executor/commit/02b52cd01b09d3601ffe88d1f9c0b777f26e76ae)]: + - @executor-js/react@1.4.66 + - @executor-js/plugin-mcp@1.6.3 + - @executor-js/sdk@1.6.3 + - @executor-js/plugin-openapi@1.6.3 + - @executor-js/fumadb@1.5.8 + - @executor-js/app@1.4.4 + - @executor-js/mcp-apps-shell@1.4.14 + - @executor-js/plugin-graphql@1.6.3 + - @executor-js/plugin-toolkits@1.5.38 + - @executor-js/analytics@0.1.10 + - @executor-js/api@1.4.66 + - @executor-js/execution@1.6.3 + - @executor-js/host-mcp@1.4.4 + - @executor-js/plugin-encrypted-secrets@0.0.45 + - @executor-js/plugin-provider-service-split@0.0.17 + - @executor-js/runtime-quickjs@1.6.3 + +## 0.0.44 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.2 + - @executor-js/runtime-quickjs@1.6.2 + - @executor-js/execution@1.6.2 + - @executor-js/plugin-graphql@1.6.2 + - @executor-js/plugin-mcp@1.6.2 + - @executor-js/plugin-openapi@1.6.2 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.9 + - @executor-js/api@1.4.65 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.13 + - @executor-js/plugin-encrypted-secrets@0.0.44 + - @executor-js/plugin-provider-service-split@0.0.16 + - @executor-js/plugin-toolkits@1.5.37 + - @executor-js/react@1.4.65 + +## 0.0.43 + +### Patch Changes + +- Updated dependencies [[`9dff4e8`](https://github.com/UsefulSoftwareCo/executor/commit/9dff4e8e6598e7d3108634a71269245ba9b480bb), [`7c12aee`](https://github.com/UsefulSoftwareCo/executor/commit/7c12aeea390225291ce4c97865b392237ee7934d), [`ddbf0fe`](https://github.com/UsefulSoftwareCo/executor/commit/ddbf0feba38c8502d78fa20c3081391b8ba3d112), [`91062c2`](https://github.com/UsefulSoftwareCo/executor/commit/91062c2b1d7b8edbc8470ca5eaa544045652afaa), [`9c35f26`](https://github.com/UsefulSoftwareCo/executor/commit/9c35f269dd5de3548111fe5c83cf1e877f23c80d), [`62748e8`](https://github.com/UsefulSoftwareCo/executor/commit/62748e86122b747226c76c2e112c5c4d2b4f7095), [`0007474`](https://github.com/UsefulSoftwareCo/executor/commit/0007474602d8da3642648f216bfdb0f09eb0914f), [`d4afe0c`](https://github.com/UsefulSoftwareCo/executor/commit/d4afe0c79f146dd169a00988a2d5d0469297be19), [`55180cb`](https://github.com/UsefulSoftwareCo/executor/commit/55180cb1487f9a3a28ddc0ee0bedfab8464c1f72)]: + - @executor-js/react@1.4.64 + - @executor-js/plugin-openapi@1.6.1 + - @executor-js/plugin-mcp@1.6.1 + - @executor-js/execution@1.6.1 + - @executor-js/sdk@1.6.1 + - @executor-js/api@1.4.64 + - @executor-js/app@1.4.4 + - @executor-js/mcp-apps-shell@1.4.12 + - @executor-js/plugin-graphql@1.6.1 + - @executor-js/plugin-toolkits@1.5.36 + - @executor-js/plugin-provider-service-split@0.0.15 + - @executor-js/analytics@0.1.8 + - @executor-js/host-mcp@1.4.4 + - @executor-js/plugin-encrypted-secrets@0.0.43 + - @executor-js/runtime-quickjs@1.6.1 + +## 0.0.42 + +### Patch Changes + +- Updated dependencies [[`c11bef2`](https://github.com/UsefulSoftwareCo/executor/commit/c11bef2cd049db7bbf51b15e18761b14acccb534), [`46cea2c`](https://github.com/UsefulSoftwareCo/executor/commit/46cea2cbb1f414ae58ac876819a51b11967909a6), [`a2d1417`](https://github.com/UsefulSoftwareCo/executor/commit/a2d141758e478274813c8c24d354e1fd0f66af49), [`2bdbedf`](https://github.com/UsefulSoftwareCo/executor/commit/2bdbedf257f54d7c209e8c856c618174c10d6bb3), [`0b0b74f`](https://github.com/UsefulSoftwareCo/executor/commit/0b0b74f673b8098c5248159be36c648097f3c87b), [`256e25e`](https://github.com/UsefulSoftwareCo/executor/commit/256e25e7b291b0c023bc7547d092004b66781bba)]: + - @executor-js/plugin-mcp@1.6.0 + - @executor-js/plugin-openapi@1.6.0 + - @executor-js/sdk@1.6.0 + - @executor-js/react@1.4.63 + - @executor-js/plugin-provider-service-split@0.0.14 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.7 + - @executor-js/api@1.4.63 + - @executor-js/execution@1.6.0 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.11 + - @executor-js/plugin-encrypted-secrets@0.0.42 + - @executor-js/plugin-graphql@1.6.0 + - @executor-js/plugin-toolkits@1.5.35 + - @executor-js/runtime-quickjs@1.6.0 + +## 0.0.41 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be), [`9ecc7cb`](https://github.com/UsefulSoftwareCo/executor/commit/9ecc7cb8b30375ffa960e3fefe4d211e0254e691)]: + - @executor-js/sdk@1.5.42 + - @executor-js/plugin-openapi@1.5.42 + - @executor-js/plugin-mcp@1.5.42 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.6 + - @executor-js/api@1.4.62 + - @executor-js/execution@1.5.42 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.10 + - @executor-js/plugin-encrypted-secrets@0.0.41 + - @executor-js/plugin-graphql@1.5.42 + - @executor-js/plugin-provider-service-split@0.0.13 + - @executor-js/plugin-toolkits@1.5.34 + - @executor-js/react@1.4.62 + - @executor-js/runtime-quickjs@1.5.42 + +## 0.0.40 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]: + - @executor-js/sdk@1.5.41 + - @executor-js/plugin-mcp@1.5.41 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.5 + - @executor-js/api@1.4.61 + - @executor-js/execution@1.5.41 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.9 + - @executor-js/plugin-encrypted-secrets@0.0.40 + - @executor-js/plugin-graphql@1.5.41 + - @executor-js/plugin-openapi@1.5.41 + - @executor-js/plugin-provider-service-split@0.0.12 + - @executor-js/plugin-toolkits@1.5.33 + - @executor-js/react@1.4.61 + - @executor-js/runtime-quickjs@1.5.41 + +## 0.0.39 + +### Patch Changes + +- Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a)]: + - @executor-js/sdk@1.5.40 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.4 + - @executor-js/api@1.4.60 + - @executor-js/execution@1.5.40 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.8 + - @executor-js/plugin-encrypted-secrets@0.0.39 + - @executor-js/plugin-graphql@1.5.40 + - @executor-js/plugin-mcp@1.5.40 + - @executor-js/plugin-openapi@1.5.40 + - @executor-js/plugin-provider-service-split@0.0.11 + - @executor-js/plugin-toolkits@1.5.32 + - @executor-js/react@1.4.60 + - @executor-js/runtime-quickjs@1.5.40 + +## 0.0.38 + +### Patch Changes + +- Updated dependencies [[`6c316c7`](https://github.com/UsefulSoftwareCo/executor/commit/6c316c77a9efc98784976236852b58c6156e016e)]: + - @executor-js/sdk@1.5.39 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.3 + - @executor-js/api@1.4.59 + - @executor-js/execution@1.5.39 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.7 + - @executor-js/plugin-encrypted-secrets@0.0.38 + - @executor-js/plugin-graphql@1.5.39 + - @executor-js/plugin-mcp@1.5.39 + - @executor-js/plugin-openapi@1.5.39 + - @executor-js/plugin-provider-service-split@0.0.10 + - @executor-js/plugin-toolkits@1.5.31 + - @executor-js/react@1.4.59 + - @executor-js/runtime-quickjs@1.5.39 + +## 0.0.37 + +### Patch Changes + +- [#1525](https://github.com/UsefulSoftwareCo/executor/pull/1525) [`c757e13`](https://github.com/UsefulSoftwareCo/executor/commit/c757e13fa5de3a0e49865f2198ee84788452c175) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Export self-host traces to an OpenTelemetry collector when `OTEL_EXPORTER_OTLP_ENDPOINT` is set, so a slow request can be read as a waterfall rather than a wall-clock number. Off by default; logs are a separate opt-in via `EXECUTOR_OTEL_EXPORT_LOGS`. + +- Updated dependencies [[`df01d91`](https://github.com/UsefulSoftwareCo/executor/commit/df01d9197e7b4fca9bd0adaca0705a80435e188c), [`6a924dd`](https://github.com/UsefulSoftwareCo/executor/commit/6a924dd98de916d6ff8cea2329bf672f149b64f4), [`1de85fc`](https://github.com/UsefulSoftwareCo/executor/commit/1de85fc0201c0c23c0e71e003c49228d406af6c8)]: + - @executor-js/plugin-openapi@1.5.38 + - @executor-js/sdk@1.5.38 + - @executor-js/react@1.4.58 + - @executor-js/plugin-provider-service-split@0.0.9 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.2 + - @executor-js/api@1.4.58 + - @executor-js/execution@1.5.38 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.6 + - @executor-js/plugin-encrypted-secrets@0.0.37 + - @executor-js/plugin-graphql@1.5.38 + - @executor-js/plugin-mcp@1.5.38 + - @executor-js/plugin-toolkits@1.5.30 + - @executor-js/runtime-quickjs@1.5.38 + +## 0.0.36 + +### Patch Changes + +- [#1498](https://github.com/UsefulSoftwareCo/executor/pull/1498) [`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Add anonymous product analytics to the local daemon (CLI + desktop) and self-host: execution counts split by MCP/API plane, toolkit usage, integration add/remove, and artifact usage (created/viewed/updated/deleted, attributed to agent tools vs the console UI), filed under a persisted per-install anonymous id. Opt out with DO_NOT_TRACK or EXECUTOR_DISABLE_ANALYTICS. + +- Updated dependencies [[`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb)]: + - @executor-js/analytics@0.1.1 + - @executor-js/sdk@1.5.37 + - @executor-js/api@1.4.57 + - @executor-js/app@1.4.4 + - @executor-js/execution@1.5.37 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.5 + - @executor-js/plugin-encrypted-secrets@0.0.36 + - @executor-js/plugin-graphql@1.5.37 + - @executor-js/plugin-mcp@1.5.37 + - @executor-js/plugin-openapi@1.5.37 + - @executor-js/plugin-provider-service-split@0.0.8 + - @executor-js/plugin-toolkits@1.5.29 + - @executor-js/react@1.4.57 + - @executor-js/runtime-quickjs@1.5.37 + +## 0.0.35 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.36 + - @executor-js/runtime-quickjs@1.5.36 + - @executor-js/execution@1.5.36 + - @executor-js/plugin-graphql@1.5.36 + - @executor-js/plugin-mcp@1.5.36 + - @executor-js/plugin-openapi@1.5.36 + - @executor-js/app@1.4.4 + - @executor-js/api@1.4.56 + - @executor-js/host-mcp@1.4.4 + - @executor-js/plugin-encrypted-secrets@0.0.35 + - @executor-js/plugin-provider-service-split@0.0.7 + - @executor-js/plugin-toolkits@1.5.28 + - @executor-js/react@1.4.56 + ## 0.0.34 ### Patch Changes diff --git a/apps/host-selfhost/Dockerfile b/apps/host-selfhost/Dockerfile index 890bdba9c1..9028188cdf 100644 --- a/apps/host-selfhost/Dockerfile +++ b/apps/host-selfhost/Dockerfile @@ -37,5 +37,5 @@ WORKDIR /app/apps/host-selfhost VOLUME ["/data"] EXPOSE 4788 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=5 \ - CMD bun -e "fetch('http://127.0.0.1:4788/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))" + CMD ["bun", "-e", "fetch('http://127.0.0.1:4788/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"] CMD ["bun", "run", "dist-server/serve.js"] diff --git a/apps/host-selfhost/README.md b/apps/host-selfhost/README.md index db2c42ff1b..71606494a9 100644 --- a/apps/host-selfhost/README.md +++ b/apps/host-selfhost/README.md @@ -54,7 +54,7 @@ Then set `EXECUTOR_WEB_BASE_URL`, `EXECUTOR_OIDC_ENABLED=true`, and the exact `EXECUTOR_OIDC_CLIENT_ID` values supplied by the provider. Every URL must be a credential-free HTTPS URL without a query or fragment. Supply exactly one of `EXECUTOR_OIDC_CLIENT_SECRET` or `EXECUTOR_OIDC_CLIENT_SECRET_FILE`. A secret -must be exactly 64 base64url characters. A secret file must be a non-symlink +is the nonempty opaque value issued by the provider. A secret file must be a non-symlink regular file owned by the Executor process UID with mode `0600`. OIDC cannot create an Executor user or silently attach to an email match. Each @@ -64,6 +64,14 @@ with PKCE S256 and `client_secret_basic`, and accepts identity only from the configured UserInfo endpoint with a stable subject and verified email. UserInfo must be HTTPS JSON, arrive within five seconds, and fit within 16 KiB; redirects, malformed or oversized claims, and unverified email fail closed. +The separate `EXECUTOR_SSO_*` provider can coexist with external OIDC. Its +verified-domain signup and verified-email linking behavior remains independent; +`external-oidc` is reserved and cannot be used as its provider ID. Existing +setup or invite accounts can choose **Link to an existing account** +on the login page, prove their local password, and approve the matching, +verified provider identity. No local email-verification delivery or database +edit is needed. SSO linking still requires an allowed provider email domain; +failed sign-in or linking returns to the login form with the requested page retained. After linking, either login method remains available. Raw ID tokens are not persisted; access and refresh tokens are encrypted at rest. @@ -93,3 +101,12 @@ src/ db/ · mcp/ · execution.ts · plugins.ts · observability.ts web/ the TanStack Router SPA (setup, login, join, admin, …) ``` + +External OIDC links now bind the subject to the configured issuer. Existing +subject-only links are retained but cannot authenticate: their original issuer +was not recorded. Users must sign in with their local password and explicitly +link again with a matching verified provider email. Changing issuer requires +that same proof; it never reuses another issuer's link. No automatic database +rewrite or link deletion occurs. Qualify local account access before upgrading. +A source downgrade does not understand new links and may reactivate old links; +do not downgrade with a different issuer or assume database rollback safety. diff --git a/apps/host-selfhost/executor.config.ts b/apps/host-selfhost/executor.config.ts index fbd6884398..c0826b9bcb 100644 --- a/apps/host-selfhost/executor.config.ts +++ b/apps/host-selfhost/executor.config.ts @@ -10,11 +10,6 @@ import { } from "@executor-js/plugin-openapi/providers/microsoft"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; -import { - makeWorkerdAppToolExecutor, - makeWorkerBundlerBackend, -} from "@executor-js/plugin-apps/selfhost"; -import { appsHttpPlugin } from "@executor-js/plugin-apps/api"; import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; @@ -24,35 +19,30 @@ import { resolveSecretKey } from "./src/config"; // Single source of truth for the self-hosted app's plugin list. // // Self-host runs the same protocol/provider plugins as cloud, minus the -// multi-tenant-only secret backends (WorkOS Vault). `dangerouslyAllowStdioMCP` -// is false: a server reachable by multiple users must not let one user spawn -// arbitrary stdio MCP processes on the host. The encrypted DB secret provider -// (slice 4) is added here as the first writable secret provider. +// multi-tenant-only secret backends (WorkOS Vault). Stdio MCP remains opt-in: +// a server reachable by multiple users must not let one user spawn arbitrary +// stdio MCP processes on the host. The encrypted DB secret provider (slice 4) +// is added here as the first writable secret provider. // --------------------------------------------------------------------------- interface SelfHostPluginDeps { readonly activeToolkitSlug?: string; - readonly sourceKinds?: readonly ("git" | "local-directory")[]; /** Accepted for test-harness parity; the Microsoft Graph URL override moved * into the OpenAPI provider presets, so the factory no longer reads it. */ readonly allowLocalNetwork?: boolean; } export default defineExecutorConfig({ - plugins: ({ activeToolkitSlug, sourceKinds }: SelfHostPluginDeps = {}) => + plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) => [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], }), - mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), - graphqlHttpPlugin(), - appsHttpPlugin({ - executor: makeWorkerdAppToolExecutor(), - bundler: makeWorkerBundlerBackend(), - sourceKinds: sourceKinds ?? ["git"], - allowPrivateGitHosts: true, + mcpHttpPlugin({ + dangerouslyAllowStdioMCP: process.env.EXECUTOR_ALLOW_STDIO_MCP === "true", }), + graphqlHttpPlugin(), toolkitsPlugin({ activeToolkitSlug }), // First writable secret provider -> the default for `secrets.set`. encryptedSecretsPlugin({ key: resolveSecretKey() }), diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index 172972da8b..1ae303fd9b 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/host-selfhost", - "version": "0.0.34", + "version": "0.0.49", "private": true, "type": "module", "exports": { @@ -10,7 +10,7 @@ }, "scripts": { "dev": "bunx --bun vite dev", - "build": "turbo run build --filter @executor-js/vite-plugin && vite build", + "build": "turbo run build --filter @executor-js/vite-plugin && vite build && node scripts/assert-shell-asset.mjs", "start": "bun run src/serve.ts", "typecheck": "tsgo --noEmit", "test": "vitest run", @@ -19,14 +19,16 @@ }, "dependencies": { "@better-auth/api-key": "^1.6.11", + "@cloudflare/worker-bundler": "0.2.1", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", + "@executor-js/analytics": "workspace:*", "@executor-js/api": "workspace:*", "@executor-js/app": "workspace:*", "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", - "@executor-js/plugin-apps": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -43,12 +45,14 @@ "better-auth": "^1.6.11", "drizzle-orm": "catalog:", "effect": "catalog:", + "esbuild": "^0.27.3", "react": "catalog:", - "react-dom": "catalog:" + "react-dom": "catalog:", + "workerd": "1.20260708.1" }, "devDependencies": { "@effect/vitest": "catalog:", - "@executor-js/emulate": "^0.13.6", + "@executor-js/emulate": "^0.14.0", "@executor-js/vite-plugin": "workspace:*", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", diff --git a/apps/host-selfhost/scripts/assert-shell-asset.mjs b/apps/host-selfhost/scripts/assert-shell-asset.mjs new file mode 100644 index 0000000000..238d1d7cd6 --- /dev/null +++ b/apps/host-selfhost/scripts/assert-shell-asset.mjs @@ -0,0 +1,18 @@ +// Build-blocking check that the SPA build emitted the stable-named MCP-Apps +// shell document. The self-host server serves `ui://executor/shell.html` by +// reading this file from the SPA dist (`loadMcpAppsShellHtml`'s image-layout +// candidate) — the runtime image carries no `packages/` tree for the package +// dist candidates to hit — so a build without it ships an image whose +// artifact resource reads all fail. Fail the build here instead. Same shape +// as apps/cloud's assertion in scripts/build.mjs. +import { existsSync, readFileSync } from "node:fs"; + +const shellAsset = new URL("../dist/assets/executor-mcp-apps-shell-stable.html", import.meta.url); +if (!existsSync(shellAsset)) { + console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`); + process.exit(1); +} +if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) { + console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`); + process.exit(1); +} diff --git a/apps/host-selfhost/scripts/package-runtime.ts b/apps/host-selfhost/scripts/package-runtime.ts index 9c76b14249..5329aad607 100644 --- a/apps/host-selfhost/scripts/package-runtime.ts +++ b/apps/host-selfhost/scripts/package-runtime.ts @@ -91,8 +91,7 @@ const copyPackage = (name: string): void => { cpSync(packageDir(name), destination, { recursive: true, dereference: true }); }; -// The worker-bundler backend (packages/plugins/apps/src/pipeline/ -// worker-bundler-artifact.ts) loads `dist/index.bundled.js` from the resolved +// Worker-bundler consumers load `dist/index.bundled.js` from the resolved // package, falling back to bundling `dist/index.js` with esbuild at runtime. // The npm package doesn't ship the bundled entry and the runtime image has no // esbuild, so produce it here, the same way apps/cli/src/build.ts does for the diff --git a/apps/host-selfhost/src/account/better-auth-account-provider.ts b/apps/host-selfhost/src/account/better-auth-account-provider.ts index 472bbf6585..c7ed75a30e 100644 --- a/apps/host-selfhost/src/account/better-auth-account-provider.ts +++ b/apps/host-selfhost/src/account/better-auth-account-provider.ts @@ -106,6 +106,33 @@ export const betterAuthAccountProvider: Layer.Layer Effect.succeed({ apiKeys: [] }), + + createOrgApiKey: () => + Effect.fail( + new AccountError({ + message: "Organization API keys are not available on self-hosted instances", + }), + ), + + // Nothing to revoke: `listOrgApiKeys` is empty and `createOrgApiKey` + // refuses, so any id reaching here names a key this instance never + // issued. Refusing (rather than succeeding vacuously) keeps the console + // from reporting a revoke that did not happen. + revokeOrgApiKey: () => + Effect.fail( + new AccountError({ + message: "Organization API keys are not available on self-hosted instances", + }), + ), + listMembers: (headers) => Effect.gen(function* () { const resolved = yield* getSession(headers); diff --git a/apps/host-selfhost/src/admin/admin-escalation.node.test.ts b/apps/host-selfhost/src/admin/admin-escalation.node.test.ts new file mode 100644 index 0000000000..3e44d36ddf --- /dev/null +++ b/apps/host-selfhost/src/admin/admin-escalation.node.test.ts @@ -0,0 +1,184 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, expect, test } from "@effect/vitest"; + +import { mintInviteCode } from "../testing/mint-invite"; + +// --------------------------------------------------------------------------- +// THE ESCALATION, performed rather than asserted about. +// +// `admin-users.node.test.ts` already asserts a plain member gets a 403, and it +// PASSED while the instance was fully bypassable — because it only ever asked +// the question one way. A member who simply calls the admin plane is refused; +// a member who first arranges to be an owner OF SOMETHING is a different +// caller, and the old gate could not tell the difference: it read the role from +// `session.activeOrganizationId`, which the caller controls, while the reads +// underneath stayed scoped to the instance's boot-seeded org. +// +// So this file does not test the label ("members are refused"). It runs the +// actual attack an invited member can run with nothing but their own session +// and the auth routes Better Auth mounts: +// +// 1. POST /api/auth/organization/create → become `owner` of a new org +// 2. POST /api/auth/organization/set-active → point the session at it +// 3. call the admin planes → must STILL be refused +// +// and asserts the refusal survives every step, on BOTH planes. The users plane +// leaks the instance's whole user directory and connection inventory; the +// invite plane is worse, because a `role: "admin"` invite code is durable +// escalation that outlives the session. Step 1 is now refused outright +// (`allowUserToCreateOrganization: false`), so the test asserts what it can at +// each step and never lets a blocked earlier step silently excuse a later one — +// steps 2 and 3 run regardless of whether step 1 succeeded. +// +// MUTATION-CHECKED, both directions: +// - gate reverted to a session-scoped role lookup + org creation re-opened: +// FAILS with "/api/admin/users refuses the escalated member: expected 200 +// to be 403". The escalation really is reachable, and this test really does +// catch it. +// - gate fixed, org creation re-opened: every admin-plane assertion above +// PASSES and only the final defense-in-depth line fails. That is the +// evidence that the gate — not the flag — is what closes the hole. +// --------------------------------------------------------------------------- + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-admin-escalation-")); +process.env.BETTER_AUTH_SECRET = "admin-escalation-secret-0123456789-abcdefg"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@escalation.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; + +const { makeSelfHostApiHandler } = await import("../app"); +const { handler, dispose } = await makeSelfHostApiHandler(); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; + +const asMember = (token: string) => ({ authorization: `Bearer ${token}` }); + +const post = (path: string, token: string, body: unknown) => + handler( + new Request(`${BASE}${path}`, { + method: "POST", + headers: { "content-type": "application/json", ...asMember(token) }, + body: JSON.stringify(body), + }), + ); + +const get = (path: string, token: string) => + handler(new Request(`${BASE}${path}`, { headers: asMember(token) })); + +/** Join the instance for real: mint an invite as the bootstrap admin, redeem it. + * This is a legitimate, fully authenticated member of the instance org — the + * weakest principal that has any standing at all, which is precisely the + * attacker this gate has to hold against. */ +const joinAsMember = async (email: string): Promise => { + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password: "password-12345678", name: "Member", inviteCode }), + }), + ); + expect(signUp.status, "the member joined through the real invite flow").toBe(200); + const token = signUp.headers.get("set-auth-token") ?? ""; + expect(token).not.toBe(""); + return token; +}; + +test("a member cannot escalate by owning an organization of their own", async () => { + const memberToken = await joinAsMember("climber@escalation.test"); + + // Baseline: the ordinary refusal the old test already covered. If this were + // ever a 200 the rest of the file would be meaningless, so it is asserted + // first rather than assumed. + expect((await get("/api/admin/users", memberToken)).status, "a plain member is refused").toBe( + 403, + ); + + // -- Step 1: become an owner. --------------------------------------------- + // With `allowUserToCreateOrganization: false` this is refused outright. Its + // status is RECORDED, not asserted here: the security property this test + // exists for is "the admin plane stays shut", and asserting the block at step + // 1 would abort the run before step 3 ever asserts it. So the attack proceeds + // as far as it can either way, the planes are checked, and only then is the + // defense-in-depth flag asserted at the bottom. + const created = await post("/api/auth/organization/create", memberToken, { + name: "Climber Org", + slug: "climber-org", + }); + const createdOrgId = created.ok ? ((await created.json()) as { id?: string }).id : undefined; + + // -- Step 2: point the session at an org the caller owns. ------------------ + // The cheaper repeat path. Attempted against the created org when step 1 + // somehow succeeded, and unconditionally against the slug either way. + if (createdOrgId) { + await post("/api/auth/organization/set-active", memberToken, { + organizationId: createdOrgId, + }); + } + await post("/api/auth/organization/set-active", memberToken, { + organizationSlug: "climber-org", + }); + + // -- Step 3: the planes must still refuse. --------------------------------- + // Every admin users route, not just the list: a gate applied at four call + // sites can be fixed at three. + for (const path of [ + "/api/admin/users", + "/api/admin/users/with-connections", + "/api/admin/users/user_anyone/connections", + "/api/admin/users/user_anyone", + `/api/admin/users/${encodeURIComponent("admin@escalation.test")}`, + ]) { + const response = await get(path, memberToken); + expect(response.status, `${path} refuses the escalated member`).toBe(403); + // A refusal that leaked the answer in its body would be no refusal at all. + expect(await response.text(), `${path} discloses nothing`).not.toContain( + "admin@escalation.test", + ); + } + + // The invite plane shares the gate, and a bypass there is DURABLE: a + // `role: "admin"` code redeems into a real admin membership that outlives + // this session. Read and write are both checked — listing invite codes is + // itself a disclosure (a live code is a credential). + expect((await get("/api/admin/invites", memberToken)).status, "invite list refused").toBe(403); + + const minted = await post("/api/admin/invites", memberToken, { role: "admin" }); + expect(minted.status, "an admin invite cannot be minted by a member").toBe(403); + expect(await minted.text(), "and no code came back").not.toContain("code"); + + const revoked = await handler( + new Request(`${BASE}/api/admin/invites/some-invite-id`, { + method: "DELETE", + headers: asMember(memberToken), + }), + ); + expect(revoked.status, "revoking someone else's invite is refused too").toBe(403); + + // The instance's real admin is unaffected — the gate closed the hole, it did + // not close the door. Without this, every assertion above would also pass if + // the admin plane were simply broken for everyone. + const adminList = await get("/api/admin/users", await signInAdmin()); + expect(adminList.status, "the real instance admin still gets in").toBe(200); + + // Defense in depth, asserted LAST so it can never mask the assertions above: + // step 1 of the attack is itself closed on a single-org instance. + expect(created.status, "org creation is closed on a single-org instance").not.toBe(200); +}); + +const signInAdmin = async (): Promise => { + const response = await handler( + new Request(`${BASE}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, + password: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, + }), + }), + ); + return response.headers.get("set-auth-token") ?? ""; +}; diff --git a/apps/host-selfhost/src/admin/admin-users-api.ts b/apps/host-selfhost/src/admin/admin-users-api.ts new file mode 100644 index 0000000000..38f767a168 --- /dev/null +++ b/apps/host-selfhost/src/admin/admin-users-api.ts @@ -0,0 +1,225 @@ +// --------------------------------------------------------------------------- +// Self-host admin users API — the shared, provider-neutral `AdminUsersHandlers` +// backed by a Better-Auth-authorized platform view, mounted at +// `/api/admin/users*` beside the existing invite-code admin routes. +// +// AUTH: an owner/admin member of the single org, resolved by the shared +// `requireInstanceAdmin` — the SAME gate the invite-code admin API uses +// (`admin/handlers.ts`), not a new permission concept. Membership is resolved +// against the INSTANCE's organization id, never the caller's +// `session.activeOrganizationId`: see require-admin.ts for the privilege +// escalation that distinction refuses. Self-host has no +// organization-OWNED api key (Better Auth keys always belong to the user who +// created one), so there is no machine-credential path here; the operator's own +// admin session is the credential. That asymmetry with cloud is deliberate and +// is why `AdminUsersProvider` is a seam rather than one shared implementation. +// +// The READ half is identical to cloud's: a subject-less, tenant-reach executor +// from `makePlatformExecutor`, projected by the shared `admin/reads`. Self-host +// is single-tenant, so the tenant is always the boot-seeded org. +// --------------------------------------------------------------------------- + +import { HttpRouter } from "effect/unstable/http"; +import { Effect, Layer } from "effect"; + +import { + AdminUsersProvider, + DbProvider, + HostConfig, + PluginsProvider, + getAdminUser, + listAdminUserConnections, + listAdminUsers, + listAdminUsersWithConnections, + makeAdminUsersApiLayer, + makePlatformExecutor, + normalizeAdminUserEmail, + platformViewOf, + requestScopedMiddleware, + type AdminUserDirectory, + type AdminUserIdentity, + type AdminUsersHeaders, +} from "@executor-js/api/server"; +import { + AdminUserNotFound, + AdminUsersError, + AdminUsersForbidden, + AdminUsersUnauthorized, +} from "@executor-js/api"; +import type { Executor } from "@executor-js/sdk"; + +import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; +import { requireInstanceAdmin } from "./require-admin"; +import { SelfHostDb, SelfHostDbProvider, type SelfHostDbHandle } from "../db/self-host-db"; +import { SelfHostHostConfig, SelfHostPluginsProvider } from "../execution"; + +/** + * The SAME gate the invite-code admin API applies — literally the same + * function, not a second copy of the same idea, which is how the two planes + * came to share a bypass. A plain member session is refused, and so is an owner + * of some OTHER organization: this plane reports every user in the instance, so + * the org it authorizes against is the instance's own (see require-admin.ts). + */ +const requireAdmin = (headers: AdminUsersHeaders) => + requireInstanceAdmin(new Headers(headers)).pipe( + Effect.mapError((denial) => + denial === "unauthorized" ? new AdminUsersUnauthorized() : new AdminUsersForbidden(), + ), + ); + +/** + * Self-host's member directory: `externalId` → email/name. + * + * THE JOIN KEY is `member.userId`, the Better Auth `user.id` — precisely what + * `auth/identity.ts` binds as `accountId` and therefore what the subject table + * records in `external_id`. `member.id` is the organization `member` ROW id and + * joins to nothing; the two look alike, so the choice is pinned here and in the + * node test rather than left to a reader. + * + * One `listMembers` call per request: Better Auth's organization plugin already + * attaches the `user` row to each member, so email and name arrive with the + * membership and no per-user lookup is needed. The requested ids are not passed + * to the call — the plugin offers no id filter, and a single-instance member + * list is small — but the caller only reads the ids it asked for. + * + * Runs as the CALLER, using their own admin headers, so this reads exactly the + * directory that session is already entitled to on `/account/members`. + */ +const listMembers = (auth: BetterAuthHandle["auth"], headers: AdminUsersHeaders) => + Effect.tryPromise(() => auth.api.listMembers({ headers: new Headers(headers) })); + +/** + * Both directions of self-host's directory, over the SAME single `listMembers` + * read. + * + * The reverse (email → `user.id`) needs no extra call and no new permission: + * the organization plugin already attaches the `user` row to each member, so + * the email is sitting beside the id the forward join uses. Better Auth + * lower-cases every email it writes, but the directory value is normalized + * anyway so this host cannot answer differently from cloud if that ever + * changes. + * + * A member with no `user.email` cannot match — `null` is not an address, and + * coercing it to "" would let an empty `?email=` select an arbitrary row. + */ +const userDirectory = ( + auth: BetterAuthHandle["auth"], + headers: AdminUsersHeaders, +): AdminUserDirectory => ({ + identities: () => + listMembers(auth, headers).pipe( + Effect.map((result) => { + const identities = new Map(); + for (const member of result.members) { + identities.set(member.userId, { + email: member.user?.email ?? null, + displayName: member.user?.name ?? null, + }); + } + return identities; + }), + ), + resolveEmail: (email) => + listMembers(auth, headers).pipe( + Effect.map( + (result) => + result.members.find((member) => { + const stored = member.user?.email; + return stored != null && normalizeAdminUserEmail(stored) === email; + })?.userId ?? null, + ), + ), +}); + +const withPlatformView = ( + headers: AdminUsersHeaders, + organizationId: string, + body: (executor: Executor) => Effect.Effect, +): Effect.Effect< + A, + // `AdminUsersError` unconditionally: opening the platform view can fail that + // way regardless of what `body` itself raises. + E | AdminUsersError | AdminUsersUnauthorized | AdminUsersForbidden, + BetterAuth | DbProvider | PluginsProvider | HostConfig +> => + Effect.gen(function* () { + yield* requireAdmin(headers); + const executor = yield* makePlatformExecutor(organizationId).pipe( + Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })), + ); + return yield* Effect.ensuring(body(executor), executor.close().pipe(Effect.ignore)); + }); + +export const betterAuthAdminUsersProvider: Layer.Layer< + AdminUsersProvider, + never, + BetterAuth | DbProvider | PluginsProvider | HostConfig +> = Layer.effect(AdminUsersProvider)( + Effect.gen(function* () { + const context = yield* Effect.context(); + const { auth, organizationId } = yield* BetterAuth; + return AdminUsersProvider.of({ + listUsers: (headers, options) => + withPlatformView(headers, organizationId, (executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => listAdminUsers(admin, options, userDirectory(auth, headers))), + ), + ).pipe(Effect.provideContext(context)), + listUsersWithConnections: (headers, options) => + withPlatformView(headers, organizationId, (executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => + listAdminUsersWithConnections(admin, options, userDirectory(auth, headers)), + ), + ), + ).pipe(Effect.provideContext(context)), + listUserConnections: (headers, externalId) => + withPlatformView(headers, organizationId, (executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => listAdminUserConnections(admin, externalId)), + ), + ).pipe(Effect.provideContext(context)), + getUser: (headers, identifier) => + withPlatformView(headers, organizationId, (executor) => + platformViewOf(executor).pipe( + Effect.flatMap((admin) => + getAdminUser(admin, identifier, userDirectory(auth, headers)), + ), + ), + ).pipe(Effect.provideContext(context)), + }); + }), +); + +export interface SelfHostAdminUsersApiDeps { + readonly betterAuth: BetterAuthHandle; + readonly db: SelfHostDbHandle; + readonly mountPrefix: `/${string}`; +} + +/** + * The mountable extension route layer for `/api/admin/users*`. Self-host's DB + * handle and Better Auth are app singletons, so the provider is self-contained + * (no per-request socket to close over, unlike cloud) — but it still goes + * through `requestScopedMiddleware`, because an HttpApi handler's service + * requirement is not erased by a plain `Layer.provide` on the builder layer. + */ +export const makeSelfHostAdminUsersApiLayer = ({ + betterAuth, + db, + mountPrefix, +}: SelfHostAdminUsersApiDeps) => { + const prefixedRouter = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(mountPrefix)), + ); + const provider = betterAuthAdminUsersProvider.pipe( + Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), + Layer.provide(SelfHostDbProvider), + Layer.provide(SelfHostPluginsProvider), + Layer.provide(SelfHostHostConfig), + Layer.provide(Layer.succeed(SelfHostDb)(db)), + ); + return makeAdminUsersApiLayer(requestScopedMiddleware(provider).layer, { + router: prefixedRouter, + }); +}; diff --git a/apps/host-selfhost/src/admin/admin-users.node.test.ts b/apps/host-selfhost/src/admin/admin-users.node.test.ts new file mode 100644 index 0000000000..149c0655e6 --- /dev/null +++ b/apps/host-selfhost/src/admin/admin-users.node.test.ts @@ -0,0 +1,234 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, expect, test } from "@effect/vitest"; + +import { mintInviteCode } from "../testing/mint-invite"; + +// The self-host admin users plane (`/api/admin/users*`), over the REAL booted +// app: Better Auth, the libSQL store, and the mounted route layer. +// +// Self-host has no organization-owned api key (Better Auth keys always belong +// to the user who made one), so the credential here is the operator's own +// owner/admin session — the same gate the invite-code admin API uses. What this +// pins is that the mount is live, that the owner/admin gate actually refuses a +// plain member, and that a member who has used the instance shows up in the +// owner's view without their credential coming with them. + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-admin-users-")); +process.env.BETTER_AUTH_SECRET = "admin-users-test-secret-0123456789-abcdefghij"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@admin-users.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; + +const { makeSelfHostApiHandler } = await import("../app"); +const { handler, dispose } = await makeSelfHostApiHandler(); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; + +const signIn = async (email: string, password: string): Promise => { + const response = await handler( + new Request(`${BASE}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, password }), + }), + ); + return response.headers.get("set-auth-token") ?? ""; +}; + +const adminUsers = (token?: string, path = "/api/admin/users") => + handler( + new Request(`${BASE}${path}`, { + headers: token ? { authorization: `Bearer ${token}` } : {}, + }), + ); + +test("the owner sees who uses the instance; a plain member cannot look", async () => { + const adminToken = await signIn( + process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL!, + process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD!, + ); + expect(adminToken).not.toBe(""); + + // A second person joins through the real invite flow and uses the instance. + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "member@admin-users.test", + password: "password-12345678", + name: "Member", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(200); + const memberToken = signUp.headers.get("set-auth-token") ?? ""; + expect(memberToken).not.toBe(""); + + // A user earns their row the first time they touch the EXECUTOR plane, so + // have both make one product read before looking at the admin view. + for (const token of [adminToken, memberToken]) { + const integrations = await handler( + new Request(`${BASE}/api/integrations`, { headers: { authorization: `Bearer ${token}` } }), + ); + expect(integrations.status).toBe(200); + } + + // The owner's view reports both people. + const response = await adminUsers(adminToken); + expect(response.status).toBe(200); + const body = (await response.json()) as { + users: ReadonlyArray<{ + externalId: string; + lastSeenAt: number | null; + email: string | null; + displayName: string | null; + }>; + }; + expect(body.users.length, "both the owner and the invited member are reported").toBe(2); + expect(typeof body.users[0]?.lastSeenAt, "last-seen crosses the wire as a number").toBe("number"); + + // THE JOIN KEY, proven against real Better Auth rather than assumed. + // + // `auth/identity.ts` binds `session.user.id` as the accountId, so that is what + // the subject table records in `external_id` — and it is the member list's + // `userId`, NOT its `id` (the organization `member` ROW id). The two look + // alike and only one joins, so this asserts the actual emails land on the + // actual rows: a mismatch would leave every user unnamed while the counts + // above still passed. + const byEmail = new Map(body.users.map((user) => [user.email, user.externalId])); + expect( + [...byEmail.keys()].sort(), + "each user carries the email Better Auth holds for them", + ).toEqual(["admin@admin-users.test", "member@admin-users.test"]); + expect( + body.users.every((user) => user.externalId.length > 0), + "and the id space still lines up — nobody was named by matching nothing", + ).toBe(true); + expect( + body.users.find((user) => user.email === "member@admin-users.test")?.displayName, + "the sign-up name comes through as the display name", + ).toBe("Member"); + + // The joined view is mounted too, and carries the same identity. + const joined = await adminUsers(adminToken, "/api/admin/users/with-connections"); + expect(joined.status).toBe(200); + const joinedBody = (await joined.json()) as { + users: ReadonlyArray<{ + externalId: string; + email: string | null; + connections: readonly unknown[]; + }>; + }; + expect(joinedBody.users.length).toBe(2); + expect( + joinedBody.users.every((user) => Array.isArray(user.connections)), + "every user carries a connections array", + ).toBe(true); + expect( + joinedBody.users.map((user) => [user.externalId, user.email]).sort(), + "the joined view reports the same identities, keyed the same way", + ).toEqual(body.users.map((user) => [user.externalId, user.email]).sort()); + + // ------------------------------------------------------------------------- + // The single-user read, by opaque id and by email. + // ------------------------------------------------------------------------- + + const memberId = byEmail.get("member@admin-users.test")!; + + type UserBody = { + user: { + externalId: string; + email: string | null; + displayName: string | null; + connections: readonly unknown[]; + }; + }; + + const single = await adminUsers(adminToken, `/api/admin/users/${memberId}`); + expect(single.status).toBe(200); + const singleBody = (await single.json()) as UserBody; + expect(singleBody.user.externalId).toBe(memberId); + expect(singleBody.user.email).toBe("member@admin-users.test"); + expect(singleBody.user.displayName).toBe("Member"); + expect( + Array.isArray(singleBody.user.connections), + "identity and connections arrive in ONE call", + ).toBe(true); + + // The same person by email, in three casings. Better Auth stores emails + // lower-cased, so this proves the QUERY side is normalized against a real + // directory rather than against a fixture that happens to agree. + for (const spelling of [ + "member@admin-users.test", + "Member@Admin-Users.Test", + "MEMBER@ADMIN-USERS.TEST", + ]) { + const byEmailResponse = await adminUsers( + adminToken, + `/api/admin/users/${encodeURIComponent(spelling)}`, + ); + expect(byEmailResponse.status, `${spelling} resolves`).toBe(200); + expect(((await byEmailResponse.json()) as UserBody).user.externalId).toBe(memberId); + } + + // An email nobody in the directory holds. + const unknownEmail = await adminUsers( + adminToken, + `/api/admin/users/${encodeURIComponent("nobody@admin-users.test")}`, + ); + expect(unknownEmail.status, "unknown email → 404").toBe(404); + // Asserted on the encoded body — the distinct error is what lets a caller + // tell "no footprint" from "bad credential" without parsing a message. + expect(await unknownEmail.text()).toContain("AdminUserNotFound"); + + // An id that names no subject row. + const unknownId = await adminUsers(adminToken, "/api/admin/users/user_does_not_exist"); + expect(unknownId.status, "unknown id → 404").toBe(404); + + // The bulk `?email=` filter, over the same real directory. + const filtered = await adminUsers( + adminToken, + `/api/admin/users?email=${encodeURIComponent("MEMBER@admin-users.test")}`, + ); + expect(filtered.status).toBe(200); + const filteredBody = (await filtered.json()) as { users: ReadonlyArray<{ externalId: string }> }; + expect( + filteredBody.users.map((user) => user.externalId), + "the filter selects exactly the one member, case-insensitively", + ).toEqual([memberId]); + + const filteredUnknown = await adminUsers( + adminToken, + `/api/admin/users?email=${encodeURIComponent("nobody@admin-users.test")}`, + ); + expect( + ((await filteredUnknown.json()) as { users: readonly unknown[] }).users, + "an unknown email is an EMPTY page, never the unfiltered tenant", + ).toEqual([]); + + // The router-ordering invariant against the real mount: the static segment + // must still beat `/:identifier`. + const stillTheList = await adminUsers(adminToken, "/api/admin/users/with-connections"); + expect(stillTheList.status).toBe(200); + expect(((await stillTheList.json()) as { users: readonly unknown[] }).users.length).toBe(2); + + // The gate: a plain member may not read the instance-wide view. + const asMember = await adminUsers(memberToken); + expect(asMember.status, "a member is refused").toBe(403); + + // And an anonymous caller has no session at all. + const anonymous = await adminUsers(); + expect(anonymous.status, "no session → unauthorized").toBe(401); + + // The single-user read refuses on the same terms — and refuses BEFORE + // looking, so a refused caller cannot probe which users exist. + expect((await adminUsers(memberToken, `/api/admin/users/${memberId}`)).status).toBe(403); + expect((await adminUsers(memberToken, "/api/admin/users/user_nobody")).status).toBe(403); + expect((await adminUsers(undefined, `/api/admin/users/${memberId}`)).status).toBe(401); +}); diff --git a/apps/host-selfhost/src/admin/handlers.ts b/apps/host-selfhost/src/admin/handlers.ts index 592eba2dc3..b41f7a7fd0 100644 --- a/apps/host-selfhost/src/admin/handlers.ts +++ b/apps/host-selfhost/src/admin/handlers.ts @@ -10,6 +10,7 @@ import { type InviteCode as InviteCodeSchema, } from "./api"; import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; +import { requireInstanceAdmin } from "./require-admin"; import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; import { createInviteCode, @@ -22,9 +23,11 @@ import { // --------------------------------------------------------------------------- // Handlers for the self-host admin (invite-code) API. Every Promise-returning // boundary (Better Auth, the libSQL store) is wrapped in Effect.tryPromise with -// a typed failure — no raw try/catch, no Promise.catch. Each route is gated: -// the caller must be an owner/admin member of the one org (resolved through the -// org primitive's getActiveMember). +// a typed failure — no raw try/catch, no Promise.catch. Each route is gated by +// the SHARED `requireInstanceAdmin`: the caller must be an owner/admin member +// of THIS INSTANCE'S organization, named explicitly rather than taken from the +// caller's session (see require-admin.ts for the escalation that rule refuses — +// this plane mints invite codes, so a bypass here is durable admin). // --------------------------------------------------------------------------- const requestHeaders = Effect.map( @@ -32,18 +35,15 @@ const requestHeaders = Effect.map( (request): Headers => new Headers({ ...request.headers }), ); -// Resolve + authorize the caller, returning their member record (for userId). +// Resolve + authorize the caller, rendering the shared gate's denial in THIS +// plane's error vocabulary (the HttpApi group declares these, not the users +// plane's). const requireAdmin = (headers: Headers) => - Effect.gen(function* () { - const { auth } = yield* BetterAuth; - const member = yield* Effect.tryPromise({ - try: () => auth.api.getActiveMember({ headers }), - catch: () => new AdminError({ message: "Failed to resolve session" }), - }).pipe(Effect.orElseSucceed(() => null)); - if (!member) return yield* new AdminUnauthorized(); - if (member.role !== "owner" && member.role !== "admin") return yield* new AdminForbidden(); - return member; - }); + requireInstanceAdmin(headers).pipe( + Effect.mapError((denial) => + denial === "unauthorized" ? new AdminUnauthorized() : new AdminForbidden(), + ), + ); const narrowRole = (role: string | undefined): InviteRole => role === "admin" ? "admin" : "member"; diff --git a/apps/host-selfhost/src/admin/require-admin.ts b/apps/host-selfhost/src/admin/require-admin.ts new file mode 100644 index 0000000000..794f2170ed --- /dev/null +++ b/apps/host-selfhost/src/admin/require-admin.ts @@ -0,0 +1,130 @@ +// --------------------------------------------------------------------------- +// THE self-host admin gate. One implementation, shared by every admin plane: +// the invite-code API (`handlers.ts`) and the admin users API +// (`admin-users-api.ts`). Both planes report on, or grant power over, the whole +// instance, so they must agree on exactly who counts as an admin — and there is +// only one way to be wrong here, so there is only one place to be right. +// +// WHY THIS IS NOT `getActiveMember`. +// +// Both planes previously authorized with `auth.api.getActiveMember({ headers +// })`. That endpoint resolves the caller's membership in +// `session.activeOrganizationId` — a field the CALLER controls. Better Auth's +// organization plugin exposes `POST /organization/create` (mounted bare, +// `allowUserToCreateOrganization` defaults to true) and `POST +// /organization/set-active`, so any authenticated member could create an +// organization of their own, become its `owner` (the creator role), have that +// org set active, and then present a session whose active org is one they own. +// `getActiveMember` would answer `role: "owner"` — for THEIR org — and the gate +// would open. Meanwhile the reads underneath are scoped to the instance's +// boot-seeded org, so the escalated caller read the real instance: every user's +// externalId/createdAt/lastSeenAt and their whole connection inventory on one +// plane, and a live `role: "admin"` invite code (durable escalation) on the +// other. +// +// The bug is that the gate and the data disagreed about WHICH organization the +// request was about. This gate removes the disagreement by naming the +// instance's organization explicitly, so the authorization decision is made +// against the same org the reads are scoped to and nothing the caller sends can +// redirect it. +// +// HOW. `getActiveMemberRole` accepts an explicit `organizationId` query, and +// when given one it ignores `session.activeOrganizationId` entirely, looks up +// the caller's membership in THAT org, and refuses with FORBIDDEN when there +// isn't one (better-auth 1.6.12, `plugins/organization/routes/crud-members.mjs` +// — read against the installed build, not the docs). So a caller who owns ten +// organizations of their own still resolves to "not a member of the instance +// org" unless they were actually invited to it. There is no +// `member.organizationId !== organizationId` check to write, because the query +// makes the mismatch unrepresentable rather than detectable. +// +// Defense in depth lives in `auth/better-auth.ts`, which now mounts +// `organization({ allowUserToCreateOrganization: false })` so the first step of +// the escalation is refused too. Either fix alone closes the hole; the gate is +// the load-bearing one, because it is what makes the authorization decision +// correct rather than merely making one route to it harder. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { BetterAuth } from "../auth/better-auth"; + +/** + * Why the caller was refused, in the vocabulary both planes share. + * + * The two planes render refusals through their OWN error classes + * (`AdminUnauthorized` / `AdminUsersUnauthorized`, and so on), because those + * are what their respective HttpApi contracts declare. So the gate decides, and + * each caller translates — rather than the gate importing one plane's errors + * and the other plane mapping between two error vocabularies. + */ +export type AdminGateDenial = "unauthorized" | "forbidden"; + +/** What a caller who passed the gate is. `userId` is the Better Auth `user.id` + * — the same id `auth/identity.ts` binds as the accountId, which is what the + * invite plane records as `createdBy`. */ +export interface InstanceAdmin { + readonly userId: string; + readonly role: string; +} + +/** + * Better Auth writes a member's roles as a comma-separated list (its own + * `leaveOrganization` reads them with `role.split(",")`), so a single-role + * string is the common case rather than the contract. Membership in the + * privileged set is therefore tested per role, not by equality on the whole + * field — an `"owner,admin"` value must not read as neither. + * + * Exported for the identity seam: the same "who counts as an admin" answer + * decides the executor's workspace-write binding (`Principal.orgRole`), and + * there is only one place to be right about it. + */ +export const isPrivileged = (role: string): boolean => + role + .split(",") + .map((part) => part.trim()) + .some((part) => part === "owner" || part === "admin"); + +/** + * Authorize the caller as an owner/admin OF THE INSTANCE'S ORGANIZATION. + * + * Fails with `"unauthorized"` when there is no session at all, and + * `"forbidden"` when there is a session that is not an owner/admin member of + * the instance org — including a session belonging to an owner of some OTHER + * organization, which is the escalation this gate exists to refuse. + * + * Both Better Auth calls FAIL CLOSED, and closed means the less-informative + * answer of the two: a `getSession` that throws is treated as "no session" + * (401) and a `getActiveMemberRole` that throws is treated as "not entitled" + * (403), because the endpoint's own way of saying "you are not a member of this + * organization" IS a thrown FORBIDDEN. An infrastructure fault therefore + * refuses the request rather than surfacing as a 500, which is the correct + * trade on a plane where the wrong answer is a disclosure. + * + * TWO CALLS, NOT ONE. The session read is what distinguishes 401 from 403 — the + * role endpoint cannot, since it refuses the anonymous and the unentitled with + * the same status — and it is also where `userId` comes from, since the role + * endpoint answers with a role and nothing else. + */ +export const requireInstanceAdmin = ( + headers: Headers, +): Effect.Effect => + Effect.gen(function* () { + const { auth, organizationId } = yield* BetterAuth; + + const session = yield* Effect.tryPromise(() => auth.api.getSession({ headers })).pipe( + Effect.orElseSucceed(() => null), + ); + if (!session) return yield* Effect.fail("unauthorized"); + + // The whole fix in one argument: the org is named by the INSTANCE, never + // read from the caller's session. + const resolved = yield* Effect.tryPromise(() => + auth.api.getActiveMemberRole({ headers, query: { organizationId } }), + ).pipe(Effect.orElseSucceed(() => null)); + if (!resolved || !isPrivileged(resolved.role)) { + return yield* Effect.fail("forbidden"); + } + + return { userId: session.user.id, role: resolved.role }; + }); diff --git a/apps/host-selfhost/src/analytics.ts b/apps/host-selfhost/src/analytics.ts new file mode 100644 index 0000000000..7991b97b0e --- /dev/null +++ b/apps/host-selfhost/src/analytics.ts @@ -0,0 +1,83 @@ +import { Effect, Layer, ManagedRuntime } from "effect"; + +import { + Analytics, + defaultLayer as analyticsDefaultLayer, + withExecutionAnalytics, + type AnalyticsService, +} from "@executor-js/analytics"; +import { EngineDecorator } from "@executor-js/api/server"; + +import packageJson from "../package.json" with { type: "json" }; +import { resolveDataDir } from "./config"; + +// --------------------------------------------------------------------------- +// Self-host product analytics: one anonymous per-install service for the +// server lifetime. The anonymous id persists next to the instance's other +// first-boot state (secret.key) under the data dir, so an instance counts +// once across restarts. Module-singleton runtime; dispose flushes at shutdown. +// --------------------------------------------------------------------------- + +// Deployed self-host builds are published releases; the prerelease heuristic +// mirrors apps/local/src/installation.ts. +const VERSION: string = packageJson.version; +const CHANNEL = VERSION.includes("-") ? ("beta" as const) : ("stable" as const); + +let analyticsRuntime: ManagedRuntime.ManagedRuntime | null = null; + +const getAnalyticsRuntime = (): ManagedRuntime.ManagedRuntime => { + if (analyticsRuntime) return analyticsRuntime; + analyticsRuntime = ManagedRuntime.make( + analyticsDefaultLayer({ + surface: "selfhost", + version: VERSION, + channel: CHANNEL, + dataDir: resolveDataDir(), + }), + ); + return analyticsRuntime; +}; + +/** + * Lazy facade over the instance's shared service. `record` forks into the + * module runtime (fire-and-forget) so no request path ever waits on the + * analytics layer; ordering within the runtime preserves event order. + */ +export const selfHostAnalytics: AnalyticsService = { + record: (name, properties) => + Effect.sync(() => { + getAnalyticsRuntime().runFork( + Effect.flatMap(Analytics.asEffect(), (analytics) => analytics.record(name, properties)), + ); + }), + flush: Effect.promise(() => + getAnalyticsRuntime().runPromise( + Effect.flatMap(Analytics.asEffect(), (analytics) => analytics.flush), + ), + ), +}; + +/** Flush and dispose the analytics runtime (server shutdown). */ +export const disposeAnalytics = async (): Promise => { + const runtime = analyticsRuntime; + if (!runtime) return; + analyticsRuntime = null; + await Effect.runPromise(Effect.promise(() => runtime.dispose()).pipe(Effect.ignoreCause())); +}; + +/** + * The execution-analytics `EngineDecorator`: every engine the shared stack + * builds gets wrapped once, with the plane derived from what the stack was + * built to serve — an MCP session carries its `mcpResource`, the HTTP + * executions plane has none. The wrap point IS the plane, so misattribution + * is structurally impossible. + */ +export const SelfHostAnalyticsEngineDecorator: Layer.Layer = Layer.succeed( + EngineDecorator, +)({ + decorate: (engine, _identity, context) => + withExecutionAnalytics(engine, selfHostAnalytics, { + plane: context.mcpResource === undefined ? "api" : "mcp", + toolkit: context.mcpResource?.kind === "toolkit", + }), +}); diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index a33678b4bd..a23287db48 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -2,17 +2,24 @@ import { HttpApiSwagger } from "effect/unstable/httpapi"; import { HttpEffect, HttpRouter } from "effect/unstable/http"; import { Effect, Layer } from "effect"; -import { composePluginApi, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; +import { + ArtifactUsageObserver, + composePluginApi, + ExecutorApp, + textFailureStrategy, +} from "@executor-js/api/server"; import { runSqliteDataMigrations } from "@executor-js/sdk"; import { resolveAuthProviders } from "./auth"; import { selfHostDataMigrations } from "./db/data-migrations"; import { makeSelfHostAdminApiLayer } from "./admin/handlers"; +import { makeSelfHostAdminUsersApiLayer } from "./admin/admin-users-api"; import { makeSelfHostSystemApiLayer } from "./system/handlers"; import { selfHostAccountMiddleware } from "./account"; import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config"; import { createSelfHostDb, SelfHostDb, SelfHostDbProvider } from "./db/self-host-db"; +import { selfHostAnalytics, SelfHostAnalyticsEngineDecorator } from "./analytics"; import { SelfHostCodeExecutorProvider, SelfHostHostConfig, @@ -69,9 +76,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle); // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- - // Pass the pinned public origin so browser-approval URLs are reachable behind - // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). - const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl); + const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config); // CLI device-login discovery (`executor login`). Points the CLI at Better // Auth's device endpoints; `requestFormat: "json"` because those endpoints @@ -97,7 +102,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { identity: identityLayer, account: selfHostAccountMiddleware(betterAuth), db: SelfHostDbProvider, - engine: { codeExecutor: SelfHostCodeExecutorProvider }, // decorator defaults to no-op (no metering) + engine: { + codeExecutor: SelfHostCodeExecutorProvider, + // Anonymous execution analytics (this seam is the HTTP plane; the MCP + // plane's decorator is wired in mcp/session-store.ts's stack layer). + decorator: SelfHostAnalyticsEngineDecorator, + }, mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig }, errorCapture: ErrorCaptureLive, @@ -130,6 +140,10 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)), // App-local admin (invite-code) API, served under /api/admin/*. makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), + // Tenant-wide admin users API (/api/admin/users*): the owner's view of + // who uses this instance and what they've connected. Owner/admin-gated, + // same as the invite routes above. + makeSelfHostAdminUsersApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), // Public system API: /api/health + /api/setup-status (unauthenticated). makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). @@ -139,8 +153,16 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { config: { mountPrefix: "/api", failure: textFailureStrategy }, // The boot-scoped context provideMerge'd under everything: the long-lived DB // handle (read by the DbProvider seam, Better Auth, and the MCP store) + the - // resolved identity (captured once by the execution middleware + MCP auth). - boot: Layer.merge(Layer.succeed(SelfHostDb)(dbHandle), identityLayer), + // resolved identity (captured once by the execution middleware + MCP auth) + // + the artifact-usage observer (this HTTP plane is the console UI's data + // layer, so operations it serves file as `via: "ui"`). + boot: Layer.mergeAll( + Layer.succeed(SelfHostDb)(dbHandle), + identityLayer, + Layer.succeed(ArtifactUsageObserver)((action) => + selfHostAnalytics.record(`artifact_${action}`, { via: "ui" }), + ), + ), }); return { diff --git a/apps/host-selfhost/src/auth/better-auth.test.ts b/apps/host-selfhost/src/auth/better-auth.test.ts index a0a7751297..83b9a716ee 100644 --- a/apps/host-selfhost/src/auth/better-auth.test.ts +++ b/apps/host-selfhost/src/auth/better-auth.test.ts @@ -7,6 +7,10 @@ import { afterAll, expect, test } from "@effect/vitest"; import { mintInviteCode } from "../testing/mint-invite"; // Real Better Auth path: set a secret + bootstrap admin before importing. +// Better Auth skips origin checks in test mode by default; this suite exercises +// the production check so the trusted-origin cases below cover the real path. +process.env.NODE_ENV = "production"; +process.env.TEST = "false"; process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-")); process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv"; process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; @@ -19,6 +23,7 @@ process.env.EXECUTOR_OIDC_TOKEN_URL = "https://identity.example.test/oauth2/toke process.env.EXECUTOR_OIDC_USERINFO_URL = "https://identity.example.test/oauth2/userinfo"; process.env.EXECUTOR_OIDC_CLIENT_ID = "executor-test"; process.env.EXECUTOR_OIDC_CLIENT_SECRET = "A".repeat(64); +process.env.EXECUTOR_TRUSTED_ORIGINS = "http://executor.home.arpa:4788"; const { makeSelfHostApiHandler } = await import("../app"); @@ -106,6 +111,79 @@ test("OIDC account linking requires an existing local session", async () => { ); }); +test("an HTTP trusted alias receives a usable session cookie with an HTTPS canonical URL", async () => { + const alias = "http://executor.home.arpa:4788"; + const signIn = await handler( + new Request(`${alias}/api/auth/sign-in/email`, { + method: "POST", + headers: { + "content-type": "application/json", + origin: alias, + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + }, + body: JSON.stringify({ + email: "admin@test.local", + password: "admin-password-123", + }), + }), + ); + expect(signIn.status).toBe(200); + const sessionCookie = signIn.headers.get("set-cookie"); + expect(sessionCookie).toContain("better-auth.session_token="); + expect(sessionCookie).not.toMatch(/(?:^|;\s*)Secure(?:;|$)/i); + expect(sessionCookie).not.toContain("__Secure-"); +}); + +test("an explicitly trusted browser alias can sign up without changing the canonical base URL", async () => { + const alias = "http://executor.home.arpa:4788"; + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${alias}/api/auth/sign-up/email`, { + method: "POST", + headers: { + "content-type": "application/json", + origin: alias, + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + }, + body: JSON.stringify({ + email: "trusted-alias@test.local", + password: "member-password-123", + name: "Trusted Alias", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(200); +}); + +test("an unlisted browser alias remains blocked", async () => { + const alias = "http://untrusted.home.arpa:4788"; + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${alias}/api/auth/sign-up/email`, { + method: "POST", + headers: { + "content-type": "application/json", + origin: alias, + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + }, + body: JSON.stringify({ + email: "untrusted-alias@test.local", + password: "member-password-123", + name: "Untrusted Alias", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(403); +}); + test("migrations create both the Better Auth and FumaDB executor schema regions", async () => { // Open a SEPARATE libSQL connection to the same file Better Auth (via its own // LibsqlDialect connection) and the FumaDB drizzle client wrote to. That this diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index f28caa4476..d78ea3b47d 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -1,5 +1,5 @@ import { betterAuth, type BetterAuthOptions } from "better-auth"; -import { APIError } from "better-auth/api"; +import { APIError, createAuthMiddleware } from "better-auth/api"; import { admin, bearer, @@ -9,14 +9,17 @@ import { organization, type GenericOAuthConfig, } from "better-auth/plugins"; +import { parseState } from "better-auth/oauth2"; import { apiKey } from "@better-auth/api-key"; import { type Client } from "@libsql/client"; import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql"; import { Context, Option, Schema } from "effect"; import { loadConfig, type SelfHostOidcConfig } from "../config"; +import { safeReturnTo } from "./return-to"; import { seedOrgAndAdmin } from "./seed"; import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites"; +import { isAdmitted, isOAuthCallback, ssoProviderConfig } from "./sso"; // The self-service signup gate: present only on the live (phase-2) auth // instance, so the bootstrap seed's `createUser` — which @@ -166,10 +169,16 @@ export const makeExternalOidcConfig = (oidc: SelfHostOidcConfig): GenericOAuthCo (value) => value, () => null, ); - return decodeExternalOidcUserInfo(body); + const user = decodeExternalOidcUserInfo(body); + // Subjects are issuer-local. The separator cannot occur in a validated + // issuer or subject, nor in any legacy subject-only account ID. Legacy + // links require authenticated relinking; their original issuer is unknown. + return user ? { ...user, id: `${oidc.issuer}\n${user.id}` } : null; }, }); +let warnedInsecureTrustedOrigin = false; + // --------------------------------------------------------------------------- // Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor // tables ("one connection, two schema regions"). @@ -208,6 +217,27 @@ export const makeExternalOidcConfig = (oidc: SelfHostOidcConfig): GenericOAuthCo const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => { const config = loadConfig(); + // A `Secure` session cookie is never sent back over plain HTTP, so an HTTP + // alias can sign in and then look signed out on every later request. Drop the + // attribute when ANY trusted origin is HTTP. This is not a new relaxation for + // the common cases: Better Auth already infers `useSecureCookies` from the + // baseURL scheme, so an all-HTTPS instance still gets `true` and the plain + // `http://localhost` default still gets `false`. It only changes the mixed + // case an operator opts into with EXECUTOR_TRUSTED_ORIGINS. + const hasInsecureTrustedOrigin = config.trustedOrigins.some((origin) => + origin.startsWith("http://"), + ); + // Warn only for that mixed case. An HTTP-only instance (local dev, a LAN + // deploy) never had Secure cookies to lose, and warning there would fire on + // every default boot. + const downgradesCanonicalCookies = + hasInsecureTrustedOrigin && config.webBaseUrl.startsWith("https://"); + if (downgradesCanonicalCookies && !warnedInsecureTrustedOrigin) { + warnedInsecureTrustedOrigin = true; + console.warn( + "[executor] EXECUTOR_TRUSTED_ORIGINS contains an http:// origin, so session cookies drop the Secure attribute for every origin — including the https:// canonical URL. Use https:// aliases to keep session cookies transport-secure.", + ); + } // Always resolved (generated + persisted when no env is set); this guards only // an explicitly-set env secret that is too weak. const secret = config.authSecret; @@ -233,16 +263,17 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: type: "sqlite" as const, }, secret, - // The browser Origin must match this exactly; CLI/MCP bearer requests carry - // no Origin and are unaffected. `config.webBaseUrl` resolves from an explicit - // EXECUTOR_WEB_BASE_URL, else a platform-injected origin (Railway/Render/Fly/ - // …), else localhost — so a PaaS deploy is zero-config and any other host - // sets the one variable (a loud warning fires on the localhost fallback). - // See config.ts. We deliberately do NOT derive this from the request `Host`: - // matching the ecosystem (Windmill `BASE_URL`, n8n `WEBHOOK_URL`), a pinned - // origin keeps host-header injection out of OAuth redirects and links. + // The canonical browser Origin is config.webBaseUrl; explicitly configured + // aliases may also send cookie-authenticated requests. CLI/MCP bearer + // requests carry no Origin and are unaffected. We deliberately do NOT derive + // either value from the request `Host`: matching the ecosystem (Windmill + // `BASE_URL`, n8n `WEBHOOK_URL`), a pinned origin keeps host-header injection + // out of OAuth redirects and links. Additional trusted origins affect only + // Better Auth's request validation; generated links and OAuth callbacks stay + // pinned to config.webBaseUrl. baseURL: config.webBaseUrl, - trustedOrigins: [config.webBaseUrl], + trustedOrigins: [...config.trustedOrigins], + advanced: { useSecureCookies: !hasInsecureTrustedOrigin }, emailAndPassword: { enabled: true }, account: { encryptOAuthTokens: true, @@ -252,6 +283,33 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: allowDifferentEmails: false, }, }, + hooks: { + before: createAuthMiddleware(async (context) => { + if ( + context.path === "/oauth2/callback/:providerId" && + config.sso !== undefined && + context.params?.providerId === config.sso.providerId + ) { + if (context.query?.error) { + // Generic OAuth handles provider denial before reading its state. + // Validate the state here so the saved login return path is retained. + const state = await parseState(context); + const destination = safeReturnTo(state.errorURL) ?? "/login?error=sso"; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Better Auth represents redirect responses with APIError + throw context.redirect(destination); + } + // Better Auth merges this into a fresh request context. Never mutate + // shared options: concurrent external callbacks must remain explicit-link only. + return { + context: { + context: { + options: { account: { accountLinking: { disableImplicitLinking: false } } }, + }, + }, + }; + } + }), + }, // `apiKey` issues long-lived personal keys (the API-keys page). With // `enableSessionForAPIKeys`, presenting a key resolves to its owner's // session — so a key works as a Bearer token for the API + MCP endpoint. @@ -263,16 +321,29 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: // are re-emitted by the shared envelope (MCP clients probe the origin root, // not the /api/auth basePath). plugins: [ - organization(), + // SINGLE-ORG INSTANCE, ENFORCED. `allowUserToCreateOrganization: false` + // closes `POST /api/auth/organization/create` to every session. Left at + // its default (true) it was the first step of a privilege escalation: any + // invited member could create an organization of their own, become its + // `owner` (the creator role), get it set as their active org, and then + // pass an admin gate that resolved the caller's role from + // `session.activeOrganizationId` — reading the whole instance's user + // directory and minting themselves a durable `role: "admin"` invite code. + // The load-bearing fix is `admin/require-admin.ts`, which authorizes + // against THIS instance's org id instead of the session's; this flag is + // defense in depth, and is also just true of the product — self-host + // serves exactly one organization, so a second one is never legitimate. + // + // The bootstrap seed is unaffected: `seed.ts` calls `createOrganization` + // with `userId` and NO session, which better-auth 1.6.12 treats as + // `isSystemAction` and short-circuits past the `canCreateOrg` check + // (`plugins/organization/routes/crud-org.mjs` — verified against the + // installed build, and covered by every node test here, all of which boot + // through that path). + organization({ allowUserToCreateOrganization: false }), admin(), apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), bearer(), - // Additive normal-plane browser login. With no OIDC configuration this - // plugin has no provider and local email/password remains the exact - // rollback path. New OIDC users cannot be created, and a matching email - // is never linked implicitly: the user must first authenticate locally - // and deliberately invoke the plugin's session-gated /oauth2/link flow. - genericOAuth({ config: config.oidc ? [makeExternalOidcConfig(config.oidc)] : [] }), // RFC 8628 device authorization, the CLI `executor login` flow. Registers // /device/code + /device/token + the approval endpoints; the issued token // is an opaque session that `bearer()` (above) accepts as `Authorization: @@ -281,6 +352,20 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: // is the page the user opens to confirm the code — the self-host app serves // it at /device (this is also the Better Auth default; pinned for clarity). deviceAuthorization({ verificationUri: "/device" }), + // The operator-configured SSO provider (see config.ts), spoken over plain + // OIDC discovery so Google, Okta, Entra, or any compliant IdP slots in — + // and so tests can point it at an emulated IdP. The domain gate below is + // what admits or refuses the users this creates; enabling the provider + // alone never opens registration. Always in the plugin tuple (an empty + // provider list serves no routes that match) so the inferred `auth.api` + // shape doesn't depend on the environment. + genericOAuth({ + config: [ + ...(config.sso ? [ssoProviderConfig(config.sso)] : []), + // External OIDC only signs in explicitly linked local accounts. + ...(config.oidc ? [makeExternalOidcConfig(config.oidc)] : []), + ], + }), // `consentPage` makes the MCP authorize flow redirect to a human approval // screen instead of auto-issuing a code — but ONLY when the request // carries `prompt=consent`. MCP clients don't send that, so the self-host @@ -331,8 +416,26 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: ? { user: { create: { - before: async (_user, context) => { - if (context?.path !== SIGNUP_PATH) return; + before: async (user, context) => { + if (context?.path !== SIGNUP_PATH) { + // SSO sign-ups arrive on an OAuth callback path; the + // verified-domain allowlist gates them in place of an + // invite code. Server-side creation (the seed, admin + // add-user) passes. + const sso = config.sso; + if ( + isOAuthCallback(context?.path) && + !(sso !== undefined && isAdmitted(sso, user)) + ) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a Better Auth create hook rejects a request by throwing APIError + throw new APIError("FORBIDDEN", { + message: sso + ? `Sign-ups are restricted to verified ${sso.allowedDomains.map((d) => `@${d}`).join(", ")} accounts.` + : "SSO sign-up is not enabled on this instance.", + }); + } + return; + } if (await orgHasNoMembers(gate)) return; // first user claims the org const code = inviteCodeFrom(context); if (!code) { @@ -349,9 +452,30 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: } }, after: async (user, context) => { - if (context?.path !== SIGNUP_PATH) return; const auth = gate.getAuth(); if (!auth) return; + if (context?.path !== SIGNUP_PATH) { + // An SSO user that reached `after` was admitted by + // `before`; joining the instance org as a member is what an + // invite redemption would have done. Server-side creation + // (no callback path) is left alone — the seed manages its + // own membership. + const sso = config.sso; + if ( + isOAuthCallback(context?.path) && + sso !== undefined && + isAdmitted(sso, user) + ) { + await auth.api.addMember({ + body: { + userId: user.id, + role: "member", + organizationId: gate.organizationId, + }, + }); + } + return; + } // First user into an empty org becomes its owner (no code). if (await orgHasNoMembers(gate)) { await auth.api.addMember({ diff --git a/apps/host-selfhost/src/auth/external-oidc-callback.emulator.test.ts b/apps/host-selfhost/src/auth/external-oidc-callback.emulator.test.ts index c143ce2f58..0e0a54a1b7 100644 --- a/apps/host-selfhost/src/auth/external-oidc-callback.emulator.test.ts +++ b/apps/host-selfhost/src/auth/external-oidc-callback.emulator.test.ts @@ -16,7 +16,7 @@ const AUTHORIZATION_URL = `${ISSUER}/oauth2/authorize`; const TOKEN_URL = `${ISSUER}/oauth2/token`; const USERINFO_URL = `${ISSUER}/oauth2/userinfo`; const CALLBACK = `${BASE}/api/auth/oauth2/callback/${EXTERNAL_OIDC_PROVIDER_ID}`; -const CLIENT_SECRET = "A".repeat(64); +const CLIENT_SECRET = "opaque+provider/credential=fixture!"; const DATA_DIR = mkdtempSync(join(tmpdir(), "executor-external-oidc-emulator-")); process.env.EXECUTOR_DATA_DIR = DATA_DIR; @@ -24,6 +24,13 @@ process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "linked@example.test"; process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "linked-password-123"; process.env.EXECUTOR_WEB_BASE_URL = BASE; +// A matching SSO allowlist must never admit an unlinked external OIDC user. +process.env.EXECUTOR_SSO_PROVIDER_ID = "okta"; +process.env.EXECUTOR_SSO_DISCOVERY_URL = + "https://sso.example.test/.well-known/openid-configuration"; +process.env.EXECUTOR_SSO_CLIENT_ID = "sso-client"; +process.env.EXECUTOR_SSO_CLIENT_SECRET = CLIENT_SECRET; +process.env.EXECUTOR_SSO_ALLOWED_DOMAINS = "example.test"; process.env.EXECUTOR_OIDC_ENABLED = "true"; process.env.EXECUTOR_OIDC_ISSUER = ISSUER; process.env.EXECUTOR_OIDC_AUTHORIZATION_URL = AUTHORIZATION_URL; @@ -47,6 +54,13 @@ const emulator: Emulator = await createEmulator({ seed: { okta: { users: [ + { + okta_id: "oidc-sso-subject", + login: "sso@example.test", + email: "sso@example.test", + first_name: "SSO", + last_name: "Member", + }, { okta_id: "oidc-linked-subject", login: "linked@example.test", @@ -70,6 +84,16 @@ const emulator: Emulator = await createEmulator({ }, ], oauth_clients: [ + { + client_id: "sso-client", + client_secret: CLIENT_SECRET, + name: "SSO callback contract", + redirect_uris: [`${BASE}/api/auth/oauth2/callback/okta`], + response_types: ["code"], + grant_types: ["authorization_code"], + token_endpoint_auth_method: "client_secret_post", + auth_server_id: "default", + }, { client_id: "executor", client_secret: CLIENT_SECRET, @@ -85,6 +109,8 @@ const emulator: Emulator = await createEmulator({ }, }); +process.env.EXECUTOR_SSO_DISCOVERY_URL = `${emulator.url}/oauth2/default/.well-known/openid-configuration`; + const nativeFetch = globalThis.fetch; const providerFetch = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { const rawUrl = input instanceof Request ? input.url : String(input); @@ -101,7 +127,7 @@ const providerFetch = vi.spyOn(globalThis, "fetch").mockImplementation((input, i }); const { makeSelfHostApiHandler } = await import("../app"); -const { handler, dispose } = await makeSelfHostApiHandler(); +let { handler, dispose } = await makeSelfHostApiHandler(); afterAll(async () => { providerFetch.mockRestore(); @@ -184,15 +210,47 @@ const finishOidc = async ( ); expect(selected.status).toBe(302); const callback = new URL(selected.headers.get("location") ?? ""); - callback.searchParams.set("iss", ISSUER); + if (callback.pathname.endsWith(EXTERNAL_OIDC_PROVIDER_ID)) + callback.searchParams.set("iss", process.env.EXECUTOR_OIDC_ISSUER!); return handler(new Request(callback, { headers: { cookie: started.cookie } })); }; test("real OIDC callbacks deny signup and takeover while explicit matching links remain usable", async () => { + const ssoStart = await handler( + new Request(`${BASE}/api/auth/sign-in/oauth2`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE }, + body: JSON.stringify({ providerId: "okta", callbackURL: "/" }), + }), + ); + expect(ssoStart.status).toBe(200); + const ssoBody = (await ssoStart.json()) as { url: string }; + const ssoCallback = await finishOidc( + { authorization: new URL(ssoBody.url), cookie: cookieHeader(ssoStart) }, + "oidc-sso-subject", + ); + expect(ssoCallback.status).toBe(302); + expect(callbackError(ssoCallback)).toBeNull(); + // Even a verified local email must remain unlinked after an SSO callback. + const verificationDb = createClient({ url: `file:${join(DATA_DIR, "data.db")}` }); + await verificationDb.execute({ + sql: "UPDATE user SET emailVerified = 1 WHERE email = ?", + args: ["linked@example.test"], + }); + verificationDb.close(); + const unknown = await finishOidc(await startOidc("signin"), "oidc-new-subject"); expect(unknown.status).toBe(302); expect(callbackError(unknown)).toBe("signup_disabled"); + // Model a pre-upgrade database: subject-only links recorded no issuer. + const legacyDb = createClient({ url: `file:${join(DATA_DIR, "data.db")}` }); + await legacyDb.execute({ + sql: "INSERT INTO account (id, accountId, providerId, userId, createdAt, updatedAt) SELECT 'legacy-oidc-fixture', ?, ?, id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM user WHERE email = ?", + args: ["oidc-linked-subject", EXTERNAL_OIDC_PROVIDER_ID, "linked@example.test"], + }); + legacyDb.close(); + const unlinked = await finishOidc(await startOidc("signin"), "oidc-linked-subject"); expect(unlinked.status).toBe(302); expect(callbackError(unlinked)).toBe("account_not_linked"); @@ -252,16 +310,21 @@ test("real OIDC callbacks deny signup and takeover while explicit matching links const users = await db.execute("SELECT email FROM user ORDER BY email"); expect(users.rows.map((row) => row.email)).not.toContain("new@example.test"); const accounts = await db.execute({ - sql: "SELECT a.userId, u.email, a.accessToken, a.refreshToken, a.idToken FROM account a JOIN user u ON u.id = a.userId WHERE a.providerId = ?", + sql: "SELECT a.userId, u.email, a.accessToken, a.refreshToken, a.idToken, a.accountId FROM account a JOIN user u ON u.id = a.userId WHERE a.providerId = ?", args: [EXTERNAL_OIDC_PROVIDER_ID], }); - expect(accounts.rows).toHaveLength(1); - expect(accounts.rows[0]?.email).toBe("linked@example.test"); - expect(accounts.rows[0]?.idToken).toBeNull(); - expect(String(accounts.rows[0]?.accessToken)).toMatch(/^[0-9a-f]{64,}$/i); - expect(String(accounts.rows[0]?.accessToken)).not.toContain("okta_"); - expect(String(accounts.rows[0]?.refreshToken)).toMatch(/^[0-9a-f]{64,}$/i); - expect(String(accounts.rows[0]?.refreshToken)).not.toContain("r_okta_"); + expect(accounts.rows).toHaveLength(2); + expect(accounts.rows.map((row) => row.accountId)).toContain("oidc-linked-subject"); + const scopedAccounts = accounts.rows.filter( + (row) => row.accountId === `${ISSUER}\noidc-linked-subject`, + ); + expect(scopedAccounts).toHaveLength(1); + expect(scopedAccounts[0]?.email).toBe("linked@example.test"); + expect(scopedAccounts[0]?.idToken).toBeNull(); + expect(String(scopedAccounts[0]?.accessToken)).toMatch(/^[0-9a-f]{64,}$/i); + expect(String(scopedAccounts[0]?.accessToken)).not.toContain("okta_"); + expect(String(scopedAccounts[0]?.refreshToken)).toMatch(/^[0-9a-f]{64,}$/i); + expect(String(scopedAccounts[0]?.refreshToken)).not.toContain("r_okta_"); db.close(); const ledger = await emulator.ledger.list(); @@ -273,3 +336,18 @@ test("real OIDC callbacks deny signup and takeover while explicit matching links ); expect(deniedDeepLink.status).not.toBe(404); }); + +test("a changed issuer cannot reuse legacy or issuer-bound links with the same subject", async () => { + await dispose(); + process.env.EXECUTOR_OIDC_ISSUER = "https://second-identity.example.test"; + ({ handler, dispose } = await makeSelfHostApiHandler()); + const denied = await finishOidc(await startOidc("signin"), "oidc-linked-subject"); + expect(callbackError(denied)).not.toBeNull(); + expect(cookieHeader(denied)).not.toContain("session_token"); + await dispose(); + process.env.EXECUTOR_OIDC_ISSUER = ISSUER; + ({ handler, dispose } = await makeSelfHostApiHandler()); + const originalIssuer = await finishOidc(await startOidc("signin"), "oidc-linked-subject"); + expect(callbackError(originalIssuer)).toBeNull(); + expect(cookieHeader(originalIssuer)).toContain("session_token"); +}); diff --git a/apps/host-selfhost/src/auth/external-oidc.test.ts b/apps/host-selfhost/src/auth/external-oidc.test.ts index cd93c8e7f1..9739d08d0e 100644 --- a/apps/host-selfhost/src/auth/external-oidc.test.ts +++ b/apps/host-selfhost/src/auth/external-oidc.test.ts @@ -106,7 +106,7 @@ describe("external OIDC contract", () => { idToken: "forged-id-token-with-claims", }), ).resolves.toEqual({ - id: "verified-subject", + id: "https://identity.example.test\nverified-subject", email: "operator@example.test", emailVerified: true, name: "Operator", diff --git a/apps/host-selfhost/src/auth/identity.ts b/apps/host-selfhost/src/auth/identity.ts index 6c86fd9de0..a637b12a6a 100644 --- a/apps/host-selfhost/src/auth/identity.ts +++ b/apps/host-selfhost/src/auth/identity.ts @@ -2,7 +2,8 @@ import { Effect, Layer } from "effect"; import { IdentityProvider, Unauthorized } from "@executor-js/api/server"; -import { BetterAuth } from "./better-auth"; +import { isPrivileged } from "../admin/require-admin"; +import { BetterAuth, type BetterAuthHandle } from "./better-auth"; // --------------------------------------------------------------------------- // The self-host identity seam — the production implementation of the shared @@ -27,6 +28,28 @@ const bearerToken = (headers: Headers): string | undefined => { : undefined; }; +/** + * Resolve workspace-write authority from the caller's current membership in + * the self-host instance organization. Both ordinary API/MCP requests and the + * browser-decision adapter use this exact lookup so a role change takes effect + * at the mutation decision, without trusting the global Better Auth user role. + * Lookup failures fail closed to member authority. + */ +export const resolveSelfHostOrgRole = ( + betterAuth: BetterAuthHandle, + headers: Headers | Record, + organizationId: string, +): Effect.Effect<"admin" | "member"> => + Effect.tryPromise(() => + betterAuth.auth.api.getActiveMemberRole({ + headers, + query: { organizationId }, + }), + ).pipe( + Effect.orElseSucceed(() => null), + Effect.map((membership) => (membership && isPrivileged(membership.role) ? "admin" : "member")), + ); + // --------------------------------------------------------------------------- // The production IdentityProvider: resolve a request to a Better Auth session // and map it to a neutral Principal. Three credential shapes resolve here: @@ -42,20 +65,26 @@ const bearerToken = (headers: Headers): string | undefined => { export const betterAuthIdentityLayer: Layer.Layer = Layer.effect(IdentityProvider)( Effect.gen(function* () { - const { auth, organizationId, organizationName, organizationSlug } = yield* BetterAuth; + const betterAuth = yield* BetterAuth; + const { auth, organizationId, organizationName, organizationSlug } = betterAuth; return IdentityProvider.of({ authenticate: (request) => Effect.gen(function* () { let resolved = yield* Effect.promise(() => auth.api.getSession({ headers: request.headers }), ); + // The credential shape that resolved the session — the SAME headers + // are what the membership-role lookup below must present. + let sessionHeaders: Headers | Record = request.headers; if (!resolved) { const token = bearerToken(request.headers); if (token) { + const apiKeyHeaders = { "x-api-key": token }; resolved = yield* Effect.tryPromise({ - try: () => auth.api.getSession({ headers: { "x-api-key": token } }), + try: () => auth.api.getSession({ headers: apiKeyHeaders }), catch: () => "api-key session lookup failed", }).pipe(Effect.orElseSucceed(() => null)); + sessionHeaders = apiKeyHeaders; } } // No session resolved from any credential shape -> unauthenticated. @@ -66,7 +95,18 @@ export const betterAuthIdentityLayer: Layer.Layer role.trim()) .filter((role) => role.length > 0), + orgRoleModel: "organization", + orgRole, }; }), }); diff --git a/apps/host-selfhost/src/auth/invalid-origin-help.test.ts b/apps/host-selfhost/src/auth/invalid-origin-help.test.ts index e9cbae7319..cdbefa7f34 100644 --- a/apps/host-selfhost/src/auth/invalid-origin-help.test.ts +++ b/apps/host-selfhost/src/auth/invalid-origin-help.test.ts @@ -16,6 +16,7 @@ test("originOf prefers Origin, then x-forwarded-host, then host", () => { test("the help message names the URL to set", () => { const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788"); expect(msg).toContain("EXECUTOR_WEB_BASE_URL"); + expect(msg).toContain("EXECUTOR_TRUSTED_ORIGINS"); expect(msg).toContain("https://app.example.com"); expect(msg).toContain("http://localhost:4788"); }); @@ -38,6 +39,7 @@ test("rewriteInvalidOrigin replaces a 403 'Invalid origin' with the setup messag const body = (await rewritten!.json()) as { code: string; message: string }; expect(body.code).toBe("INVALID_ORIGIN"); expect(body.message).toContain("EXECUTOR_WEB_BASE_URL"); + expect(body.message).toContain("EXECUTOR_TRUSTED_ORIGINS"); expect(body.message).toContain("https://app.example.com"); }); diff --git a/apps/host-selfhost/src/auth/invalid-origin-help.ts b/apps/host-selfhost/src/auth/invalid-origin-help.ts index f548b7902d..de0df2f065 100644 --- a/apps/host-selfhost/src/auth/invalid-origin-help.ts +++ b/apps/host-selfhost/src/auth/invalid-origin-help.ts @@ -21,10 +21,11 @@ export const originOf = (request: Request): string | null => { export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string => requestOrigin ? `This Executor instance is configured for ${webBaseUrl}, but you're connecting from ${requestOrigin}. ` + - `Set the EXECUTOR_WEB_BASE_URL environment variable to ${requestOrigin} and restart the server, then try again. ` + - `(Railway, Render, Fly, Vercel, and similar hosts are detected automatically; set it explicitly for a custom domain or other host.)` + `Set EXECUTOR_WEB_BASE_URL to ${requestOrigin} if it should be the canonical public URL, or add it to ` + + `EXECUTOR_TRUSTED_ORIGINS if it is an intentional browser alias, then restart the server. ` + + `(Railway, Render, Fly, Vercel, and similar hosts detect the canonical public URL automatically.)` : `This Executor instance is configured for ${webBaseUrl}. If you're reaching it at a different address, ` + - `set EXECUTOR_WEB_BASE_URL to that address and restart the server.`; + `set EXECUTOR_WEB_BASE_URL to that canonical address or add the alias to EXECUTOR_TRUSTED_ORIGINS, then restart the server.`; /** * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy diff --git a/apps/host-selfhost/src/auth/origin-resolution.test.ts b/apps/host-selfhost/src/auth/origin-resolution.test.ts index 95615464ba..52cfd3ee4b 100644 --- a/apps/host-selfhost/src/auth/origin-resolution.test.ts +++ b/apps/host-selfhost/src/auth/origin-resolution.test.ts @@ -20,6 +20,7 @@ process.env.EXECUTOR_DATA_DIR ??= mkdtempSync(join(tmpdir(), "origin-cfg-")); // try/finally — these are the only env vars these cases read). const PLATFORM_VARS = [ "EXECUTOR_WEB_BASE_URL", + "EXECUTOR_TRUSTED_ORIGINS", "RAILWAY_PUBLIC_DOMAIN", "RENDER_EXTERNAL_URL", "RENDER_EXTERNAL_HOSTNAME", @@ -33,7 +34,9 @@ const resetOriginEnv = (): void => { test("webBaseUrl falls back to localhost with no public origin", () => { resetOriginEnv(); - expect(loadConfig().webBaseUrl).toBe("http://localhost:4788"); + const config = loadConfig(); + expect(config.webBaseUrl).toBe("http://localhost:4788"); + expect(config.trustedOrigins).toEqual(["http://localhost:4788"]); }); test("webBaseUrl auto-resolves from a platform host var (Railway, host only → https)", () => { @@ -60,3 +63,52 @@ test("an explicit EXECUTOR_WEB_BASE_URL always wins over a platform var", () => process.env.EXECUTOR_WEB_BASE_URL = "https://pinned.example.com"; expect(loadConfig().webBaseUrl).toBe("https://pinned.example.com"); }); + +test("additional trusted origins are trimmed, normalized, and deduplicated", () => { + resetOriginEnv(); + process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com"; + process.env.EXECUTOR_TRUSTED_ORIGINS = + " http://executor.home.arpa:4788/, https://executor.example.com, http://192.0.2.10:4788 "; + expect(loadConfig().trustedOrigins).toEqual([ + "https://executor.example.com", + "http://executor.home.arpa:4788", + "http://192.0.2.10:4788", + ]); +}); + +test("an empty or blank trusted-origins list leaves the canonical origin alone", () => { + resetOriginEnv(); + process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com"; + process.env.EXECUTOR_TRUSTED_ORIGINS = " , , "; + expect(loadConfig().trustedOrigins).toEqual(["https://executor.example.com"]); +}); + +// Every rejected shape is one an operator could plausibly type and then believe +// was in force. A wildcard host is the dangerous one: accepting it as a literal +// hostname would silently allow nothing while reading like it allows a whole +// domain. A path/query/fragment reads like a scoped grant that origins cannot +// express, and credentials in the URL are almost always a copy-paste mistake. +test.each([ + "https://executor.example.com/login", + "https://executor.example.com/?next=/", + "https://executor.example.com/#top", + "https://user:pass@executor.example.com", + "https://*.example.com", + "file:///tmp/executor", + "ftp://executor.example.com", +])("a trusted origin that is not an exact http(s) origin (%s) refuses to boot", (raw) => { + resetOriginEnv(); + process.env.EXECUTOR_TRUSTED_ORIGINS = raw; + expect(() => loadConfig()).toThrow(/exact http\(s\) origin/); +}); + +// A bare hostname is the most common typo, and it never parses as a URL at all, +// so it gets the other message. Both name the variable. +test.each(["executor.example.com", "//executor.example.com", "not a url"])( + "a trusted origin that is not a URL (%s) refuses to boot", + (raw) => { + resetOriginEnv(); + process.env.EXECUTOR_TRUSTED_ORIGINS = raw; + expect(() => loadConfig()).toThrow(/EXECUTOR_TRUSTED_ORIGINS/); + }, +); diff --git a/apps/host-selfhost/src/auth/return-to.test.ts b/apps/host-selfhost/src/auth/return-to.test.ts index 201e520c30..10f494d0d9 100644 --- a/apps/host-selfhost/src/auth/return-to.test.ts +++ b/apps/host-selfhost/src/auth/return-to.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; -import { isSafeReturnTo, loginPath, mcpAuthorizeResumeTarget, safeReturnTo } from "./return-to"; +import { + isSafeReturnTo, + loginPath, + loginErrorCallback, + mcpAuthorizeResumeTarget, + postLoginTarget, + safeReturnTo, +} from "./return-to"; describe("isSafeReturnTo", () => { const safe = [ @@ -73,6 +80,44 @@ describe("mcpAuthorizeResumeTarget", () => { }); }); +describe("postLoginTarget", () => { + // Self-host renders the login page IN PLACE of the requested route without + // navigating, so the live location is the only record of where the person was + // headed. A connect deep link must survive sign-in on that basis alone. + it("returns to the deep link the person actually opened", () => { + expect(postLoginTarget({ pathname: "/connect/linear", search: "" })).toBe("/connect/linear"); + expect(postLoginTarget({ pathname: "/integrations/sentry", search: "?addAccount=1" })).toBe( + "/integrations/sentry?addAccount=1", + ); + }); + + it("lands on the dashboard when signing in from the bare login page", () => { + // No deep link to resume, and echoing /login back would re-render the form. + expect(postLoginTarget({ pathname: "/login", search: "" })).toBe("/"); + }); + + it("prefers an explicit returnTo over the current location", () => { + expect(postLoginTarget({ pathname: "/login", search: "?returnTo=%2Fconnect%2Flinear" })).toBe( + "/connect/linear", + ); + }); + + it("prefers an MCP authorize resume over everything else", () => { + const target = postLoginTarget({ + pathname: "/login", + search: + "?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback", + }); + expect(target.startsWith("/api/auth/mcp/authorize?")).toBe(true); + }); + + it("never honors an unsafe location", () => { + // `safeReturnTo` vets the echoed path too — an app-plane path is not a + // place to land a freshly signed-in browser. + expect(postLoginTarget({ pathname: "/api/auth/logout", search: "" })).toBe("/"); + }); +}); + describe("loginPath", () => { it("omits returnTo for the root", () => { expect(loginPath("/")).toBe("/login"); @@ -84,3 +129,20 @@ describe("loginPath", () => { ); }); }); + +describe("loginErrorCallback", () => { + it("retains MCP authorization parameters across repeated provider failures", () => { + const request = + "?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&state=client-state&code_challenge=pkce&code_challenge_method=S256&scope=openid"; + const target = postLoginTarget({ pathname: "/login", search: request }); + const first = new URL(loginErrorCallback(target), "https://executor.example.test"); + const resumed = postLoginTarget(first); + const second = new URL(loginErrorCallback(resumed), first.origin); + const actual = new URL(postLoginTarget(second), first.origin); + expect(actual.pathname).toBe("/api/auth/mcp/authorize"); + for (const [key, value] of new URLSearchParams(request)) { + expect(actual.searchParams.get(key)).toBe(value); + } + expect(safeReturnTo("/api/auth/mcp/authorize?response_type=code")).toBeNull(); + }); +}); diff --git a/apps/host-selfhost/src/auth/return-to.ts b/apps/host-selfhost/src/auth/return-to.ts index 798815ec31..fc1a8375bf 100644 --- a/apps/host-selfhost/src/auth/return-to.ts +++ b/apps/host-selfhost/src/auth/return-to.ts @@ -32,3 +32,44 @@ export const mcpAuthorizeResumeTarget = (search: string): string | null => { if (!params.get("client_id") || !params.get("redirect_uri")) return null; return `${MCP_AUTHORIZE_PATH}?${params.toString()}`; }; + +const LOGIN_PATH = "/login"; + +/** + * Where the self-host login page sends someone after a successful sign-in, in + * priority order: + * + * 1. an interrupted MCP OAuth authorize (the params ARE the request), + * 2. an explicit safe `returnTo` (e.g. the integration OAuth callback), + * 3. the URL they actually opened, and + * 4. the dashboard. + * + * Step 3 is what makes a deep link like `/connect/linear` survive sign-in here. + * Unlike cloud — which redirects to `/login?returnTo=…` — self-host's gate + * swaps the login page in WITHOUT navigating, so the address bar still holds + * the requested URL and no `returnTo` was ever written. `/login` itself is + * excluded so signing in from the bare login page lands on the dashboard rather + * than looping back to the form. + */ +export const postLoginTarget = (location: { + readonly pathname: string; + readonly search: string; +}): string => + mcpAuthorizeResumeTarget(location.search) ?? + safeReturnTo(new URLSearchParams(location.search).get("returnTo")) ?? + (location.pathname === LOGIN_PATH + ? null + : safeReturnTo(`${location.pathname}${location.search}`)) ?? + "/"; + +/** Preserve the original MCP request as top-level parameters through errors. */ +export const loginErrorCallback = (postLogin: string): string => { + const query = postLogin.startsWith(`${MCP_AUTHORIZE_PATH}?`) + ? postLogin.slice(MCP_AUTHORIZE_PATH.length + 1) + : ""; + const params = new URLSearchParams( + mcpAuthorizeResumeTarget(query) ? query : { returnTo: postLogin }, + ); + params.set("error", "oidc"); + return `${LOGIN_PATH}?${params.toString()}`; +}; diff --git a/apps/host-selfhost/src/auth/sso.test.ts b/apps/host-selfhost/src/auth/sso.test.ts new file mode 100644 index 0000000000..6ef7af394a --- /dev/null +++ b/apps/host-selfhost/src/auth/sso.test.ts @@ -0,0 +1,444 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, describe, expect, it, test } from "@effect/vitest"; +import { createEmulator } from "@executor-js/emulate"; + +import { mintInviteCode } from "../testing/mint-invite"; + +// Real Better Auth path with an SSO provider configured — and a REAL IdP on the +// wire: an @executor-js/emulate Okta instance serving OIDC discovery, +// authorize, and token, so the round-trip tests below exercise the same +// discovery -> redirect -> consent -> callback flow a production Google/Okta +// deployment does. Emulator + registered client + env must all exist before +// the app import, so `loadConfig` sees a fully-configured instance at boot. +const BASE = "http://localhost:4788"; +const CALLBACK = `${BASE}/api/auth/oauth2/callback/okta`; + +const idp = await createEmulator({ service: "okta", port: 4790 }); +afterAll(() => idp.close()); + +const registration = await fetch(`${idp.url}/oauth2/v1/clients`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "executor-selfhost-test", + redirect_uris: [CALLBACK], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_post", + }), +}).then((r) => r.json() as Promise<{ client_id: string; client_secret: string }>); + +const createIdpUser = (email: string, firstName: string) => + fetch(`${idp.url}/api/v1/users`, { + method: "POST", + headers: { "content-type": "application/json", authorization: "SSWS test" }, + body: JSON.stringify({ profile: { login: email, email, firstName, lastName: "User" } }), + }); +await createIdpUser("alice@example.com", "Alice"); +await createIdpUser("mallory@evil.test", "Mallory"); + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-sso-")); +process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123"; +// Exercise SSO with the explicit-link provider enabled in the same plugin. +process.env.EXECUTOR_OIDC_ENABLED = "true"; +process.env.EXECUTOR_OIDC_ISSUER = "https://identity.example.test"; +process.env.EXECUTOR_OIDC_AUTHORIZATION_URL = "https://identity.example.test/authorize"; +process.env.EXECUTOR_OIDC_TOKEN_URL = "https://identity.example.test/token"; +process.env.EXECUTOR_OIDC_USERINFO_URL = "https://identity.example.test/userinfo"; +process.env.EXECUTOR_OIDC_CLIENT_ID = "executor-external"; +process.env.EXECUTOR_OIDC_CLIENT_SECRET = "A".repeat(64); +process.env.EXECUTOR_SSO_PROVIDER_ID = "okta"; +process.env.EXECUTOR_SSO_DISCOVERY_URL = `${idp.url}/.well-known/openid-configuration`; +process.env.EXECUTOR_SSO_CLIENT_ID = registration.client_id; +process.env.EXECUTOR_SSO_CLIENT_SECRET = registration.client_secret; +process.env.EXECUTOR_SSO_ALLOWED_DOMAINS = "Example.com, @second.example ,"; + +const { loadConfig } = await import("../config"); +const { emailDomain } = await import("./sso"); +const { makeSelfHostApiHandler } = await import("../app"); + +const { handler, dispose } = await makeSelfHostApiHandler(); +afterAll(() => dispose()); + +const SSO_ENV_KEYS = [ + "EXECUTOR_SSO_PROVIDER_ID", + "EXECUTOR_SSO_PROVIDER_NAME", + "EXECUTOR_SSO_DISCOVERY_URL", + "EXECUTOR_SSO_CLIENT_ID", + "EXECUTOR_SSO_CLIENT_SECRET", + "EXECUTOR_SSO_ALLOWED_DOMAINS", +] as const; + +// Run a block with the SSO env vars swapped out, restoring them afterwards so +// the booted instance's request-time config reads stay consistent. +const withSsoEnv = (overrides: Record, run: () => T): T => { + const saved = Object.fromEntries(SSO_ENV_KEYS.map((k) => [k, process.env[k]])); + for (const key of SSO_ENV_KEYS) { + const value = overrides[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: env save/restore around config reads must restore on assertion failure + try { + return run(); + } finally { + for (const key of SSO_ENV_KEYS) { + const value = saved[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}; + +describe("sso config resolution", () => { + it("normalizes the domain allowlist (trim, lowercase, strip @, drop empties)", () => { + const sso = loadConfig().sso; + expect(sso).toBeDefined(); + expect(sso!.allowedDomains).toEqual(["example.com", "second.example"]); + }); + + it("derives the display name from the provider id when not set", () => { + expect(loadConfig().sso!.providerName).toBe("Okta"); + }); + + it("is undefined when no SSO env is set", () => { + withSsoEnv({}, () => { + expect(loadConfig().sso).toBeUndefined(); + }); + }); + + it("reserves the external OIDC provider identity", () => { + withSsoEnv( + { + EXECUTOR_SSO_PROVIDER_ID: "external-oidc", + EXECUTOR_SSO_CLIENT_ID: "id", + EXECUTOR_SSO_CLIENT_SECRET: "secret", + EXECUTOR_SSO_DISCOVERY_URL: + "https://identity.example.test/.well-known/openid-configuration", + EXECUTOR_SSO_ALLOWED_DOMAINS: "example.test", + }, + () => { + expect(() => loadConfig()).toThrow(/reserved for external OIDC/); + }, + ); + }); + + it("refuses half-configured credentials", () => { + withSsoEnv({ EXECUTOR_SSO_CLIENT_ID: "id-only" }, () => { + expect(() => loadConfig()).toThrow(/must be set together/); + }); + }); + + for (const key of SSO_ENV_KEYS) { + it(`refuses isolated SSO configuration ${key}`, () => { + withSsoEnv({ [key]: "configured" }, () => { + expect(() => loadConfig()).toThrow(/must be set together/); + }); + withSsoEnv({ [key]: "" }, () => { + expect(() => loadConfig()).toThrow(/must be set together/); + }); + }); + } + + it("refuses credentials without a provider id", () => { + withSsoEnv({ EXECUTOR_SSO_CLIENT_ID: "id", EXECUTOR_SSO_CLIENT_SECRET: "secret" }, () => { + expect(() => loadConfig()).toThrow(/EXECUTOR_SSO_PROVIDER_ID/); + }); + }); + + it("presets Google's discovery document so only credentials + domains are needed", () => { + withSsoEnv( + { + EXECUTOR_SSO_PROVIDER_ID: "google", + EXECUTOR_SSO_CLIENT_ID: "id.apps.googleusercontent.com", + EXECUTOR_SSO_CLIENT_SECRET: "secret", + EXECUTOR_SSO_ALLOWED_DOMAINS: "example.com", + }, + () => { + const sso = loadConfig().sso; + expect(sso!.discoveryUrl).toBe( + "https://accounts.google.com/.well-known/openid-configuration", + ); + expect(sso!.providerName).toBe("Google"); + }, + ); + }); + + it("refuses a provider without a known or explicit discovery URL", () => { + withSsoEnv( + { + EXECUTOR_SSO_PROVIDER_ID: "okta", + EXECUTOR_SSO_CLIENT_ID: "id", + EXECUTOR_SSO_CLIENT_SECRET: "secret", + EXECUTOR_SSO_ALLOWED_DOMAINS: "example.com", + }, + () => { + expect(() => loadConfig()).toThrow(/EXECUTOR_SSO_DISCOVERY_URL/); + }, + ); + }); + + it("refuses a configured provider without a domain allowlist", () => { + withSsoEnv( + { + EXECUTOR_SSO_PROVIDER_ID: "okta", + EXECUTOR_SSO_DISCOVERY_URL: "https://idp.example/.well-known/openid-configuration", + EXECUTOR_SSO_CLIENT_ID: "id", + EXECUTOR_SSO_CLIENT_SECRET: "secret", + }, + () => { + expect(() => loadConfig()).toThrow(/EXECUTOR_SSO_ALLOWED_DOMAINS/); + }, + ); + }); +}); + +describe("emailDomain", () => { + it("extracts the lowercased domain of a well-formed address", () => { + expect(emailDomain("User@Example.COM")).toBe("example.com"); + }); + + it("returns null for malformed addresses instead of a matchable domain", () => { + expect(emailDomain("@example.com")).toBeNull(); + expect(emailDomain("user@")).toBeNull(); + expect(emailDomain("no-at-sign")).toBeNull(); + }); +}); + +test("auth-config advertises the configured provider (id + name only)", async () => { + const res = await handler(new Request(`${BASE}/api/auth-config`)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ssoProviders: [{ id: "okta", name: "Okta" }] }); +}); + +// --- The full OIDC round-trip against the emulated IdP ----------------------- + +const decodeHtml = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +const formFields = (form: string): Record => { + const fields: Record = {}; + for (const input of form.matchAll(/]*>/gi)) { + const tag = input[0]; + const name = tag.match(/\bname=["']([^"']+)["']/i)?.[1]; + const value = tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? ""; + if (name) fields[decodeHtml(name)] = decodeHtml(value); + } + return fields; +}; + +// Drive the whole flow as the given IdP account: sign-in redirect -> IdP +// consent page (pick the account's form) -> callback back into the app. +// Returns the callback response plus the cookies it set. +const signInThroughIdp = async (email: string, sessionCookie?: string) => { + const start = await handler( + new Request(`${BASE}/api/auth/${sessionCookie ? "oauth2/link" : "sign-in/oauth2"}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(sessionCookie ? { cookie: sessionCookie } : {}), + }, + body: JSON.stringify({ + providerId: "okta", + callbackURL: "/", + errorCallbackURL: "/login?error=sso&returnTo=%2Fapi-keys", + }), + }), + ); + expect(start.status).toBe(200); + const { url } = (await start.json()) as { url: string }; + expect(url.startsWith(idp.url)).toBe(true); + const stateCookies = start.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]!) + .join("; "); + + const consentHtml = await fetch(url).then((r) => r.text()); + const form = [...consentHtml.matchAll(//gi)] + .map((m) => m[0]) + .find((f) => f.includes(email)); + expect(form).toBeDefined(); + const action = decodeHtml(form!.match(/\baction=["']([^"']+)["']/i)![1]!); + const consent = await fetch(action, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(formFields(form!)), + redirect: "manual", + }); + expect(consent.status).toBe(302); + const callbackUrl = consent.headers.get("location")!; + expect(callbackUrl.startsWith(CALLBACK)).toBe(true); + + const callback = await handler( + new Request(callbackUrl, { + headers: { cookie: [sessionCookie, stateCookies].filter(Boolean).join("; ") }, + }), + ); + const cookies = callback.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]!) + .join("; "); + return { callback, cookies }; +}; + +test("an allowlisted-domain IdP account signs in and joins the org as a member", async () => { + const { callback, cookies } = await signInThroughIdp("alice@example.com"); + expect(callback.status).toBe(302); + expect(callback.headers.get("location")).toBe("/"); + + const session = await handler( + new Request(`${BASE}/api/auth/get-session`, { headers: { cookie: cookies } }), + ); + expect(session.status).toBe(200); + const sessionBody = (await session.json()) as { user?: { email?: string } }; + expect(sessionBody.user?.email).toBe("alice@example.com"); + + const orgs = await handler( + new Request(`${BASE}/api/auth/organization/list`, { headers: { cookie: cookies } }), + ); + expect(orgs.status).toBe(200); + expect(((await orgs.json()) as unknown[]).length).toBe(1); +}); + +test("an IdP account outside the allowlist is refused and gets no session", async () => { + const { callback, cookies } = await signInThroughIdp("mallory@evil.test"); + // The rejection surfaces as better-auth's error redirect, not a success. + expect(callback.status).toBe(302); + expect(callback.headers.get("location")).not.toBe("/"); + const errorLocation = new URL(callback.headers.get("location")!, BASE); + expect(errorLocation.pathname).toBe("/login"); + expect(errorLocation.searchParams.get("returnTo")).toBe("/api-keys"); + + const session = await handler( + new Request(`${BASE}/api/auth/get-session`, { headers: { cookie: cookies } }), + ); + const sessionBody = (await session.json()) as { user?: unknown } | null; + expect(sessionBody?.user ?? null).toBeNull(); +}); + +// --- Invite-code regressions with SSO configured ------------------------------ + +test("invite-gated email signup still works with an SSO provider configured", async () => { + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "member@test.local", + password: "member-password-123", + name: "Member", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(200); +}); + +test("email signup without an invite is still refused with an SSO provider configured", async () => { + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "stranger@example.com", + password: "stranger-password-123", + name: "Stranger", + }), + }), + ); + expect(signUp.status).toBe(403); +}); + +test("an invited local account adopts SSO through authenticated explicit linking", async () => { + await createIdpUser("existing@example.com", "Existing"); + const unauthenticated = await handler( + new Request(`${BASE}/api/auth/oauth2/link`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ providerId: "okta", callbackURL: "/" }), + }), + ); + expect(unauthenticated.status).toBe(401); + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "existing@example.com", + password: "existing-password-123", + name: "Existing", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(200); + const local = (await signUp.json()) as { user: { id: string; emailVerified: boolean } }; + expect(local.user.emailVerified).toBe(false); + expect( + (await signInThroughIdp("existing@example.com")).callback.headers.get("location"), + ).toContain("account_not_linked"); + const localSignIn = await handler( + new Request(`${BASE}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "existing@example.com", password: "existing-password-123" }), + }), + ); + expect(localSignIn.status).toBe(200); + const localCookie = localSignIn.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]!) + .join("; "); + const mismatched = await signInThroughIdp("alice@example.com", localCookie); + expect(mismatched.callback.headers.get("location")).toContain("email_doesn"); + const linked = await signInThroughIdp("existing@example.com", localCookie); + expect(linked.callback.headers.get("location")).toBe("/"); + const { callback, cookies } = await signInThroughIdp("existing@example.com"); + expect(callback.status).toBe(302); + expect(callback.headers.get("location")).toBe("/"); + const session = await handler( + new Request(`${BASE}/api/auth/get-session`, { headers: { cookie: cookies } }), + ); + const signedIn = (await session.json()) as { user: { id: string } }; + expect(signedIn.user.id).toBe(local.user.id); +}); + +test("provider denial returns to login with the saved deep link", async () => { + const start = await handler( + new Request(`${BASE}/api/auth/sign-in/oauth2`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + providerId: "okta", + callbackURL: "/api-keys", + errorCallbackURL: "/login?error=sso&returnTo=%2Fapi-keys", + }), + }), + ); + expect(start.status).toBe(200); + const { url } = (await start.json()) as { url: string }; + const denied = new URL(CALLBACK); + denied.searchParams.set("state", new URL(url).searchParams.get("state")!); + denied.searchParams.set("error", "access_denied"); + const cookie = start.headers + .getSetCookie() + .map((value) => value.split(";")[0]!) + .join("; "); + const response = await handler(new Request(denied, { headers: { cookie } })); + expect(response.status).toBe(302); + const destination = new URL(response.headers.get("location")!, BASE); + expect(destination.pathname).toBe("/login"); + expect(destination.searchParams.get("returnTo")).toBe("/api-keys"); +}); diff --git a/apps/host-selfhost/src/auth/sso.ts b/apps/host-selfhost/src/auth/sso.ts new file mode 100644 index 0000000000..7be843fd5a --- /dev/null +++ b/apps/host-selfhost/src/auth/sso.ts @@ -0,0 +1,66 @@ +import { APIError, getOAuthState } from "better-auth/api"; +import { safeReturnTo } from "./return-to"; + +import { type SsoConfig } from "../config"; + +// Better Auth serves OAuth sign-in callbacks at `/oauth2/callback/:providerId` +// (genericOAuth) and `/callback/:providerId` (built-in social providers) — the +// only paths an IdP-initiated user creation arrives on, so this splits "a +// stranger signed in at the IdP" from server-side creation (the seed, admin +// add-user), which never carries either. +export const isOAuthCallback = (path: string | undefined): boolean => + path?.startsWith("/oauth2/callback/") === true || path?.startsWith("/callback/") === true; + +// The domain of a well-formed address, or null — so a malformed email can never +// match an allowlist entry (`emailDomain("@example.com")` is null, not "example.com"). +export const emailDomain = (email: string): string | null => { + const at = email.lastIndexOf("@"); + if (at <= 0 || at === email.length - 1) return null; + return email.slice(at + 1).toLowerCase(); +}; + +// Admission = the IdP vouches for the address (`email_verified`, mapped to +// `emailVerified` by the genericOAuth callback) AND its domain is allowlisted. +// Without the verified check, anyone could register an IdP account with a +// made-up allowlisted address and walk in. +export const isAdmitted = ( + sso: SsoConfig, + user: { email: string; emailVerified: boolean }, +): boolean => { + if (!user.emailVerified) return false; + const domain = emailDomain(user.email); + return domain !== null && sso.allowedDomains.includes(domain); +}; + +// The genericOAuth registration for the configured provider, derived from its +// OIDC discovery document. For Google with a single allowed domain, `hd` +// pre-filters the account chooser — a UX hint only (Google treats it as +// advisory); the create-hook gate is the enforcement. +export const ssoProviderConfig = (sso: SsoConfig) => ({ + providerId: sso.providerId, + clientId: sso.clientId, + clientSecret: sso.clientSecret, + discoveryUrl: sso.discoveryUrl, + scopes: ["openid", "email", "profile"], + pkce: true, + mapProfileToUser: async (profile: Record) => { + const state = await getOAuthState(); + if ( + state?.link && + !isAdmitted(sso, { + email: typeof profile.email === "string" ? profile.email : "", + emailVerified: profile.emailVerified === true, + }) + ) { + // Explicit linking bypasses the new-user hook, so enforce admission here too. + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Better Auth represents redirects with APIError + throw new APIError("FOUND", undefined, { + location: safeReturnTo(state.errorURL) ?? "/login?error=sso", + }); + } + return {}; + }, + ...(sso.providerId === "google" && sso.allowedDomains.length === 1 + ? { authorizationUrlParams: { hd: sso.allowedDomains[0]! } } + : {}), +}); diff --git a/apps/host-selfhost/src/boot.test.ts b/apps/host-selfhost/src/boot.test.ts index 369308f166..c14707a043 100644 --- a/apps/host-selfhost/src/boot.test.ts +++ b/apps/host-selfhost/src/boot.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -97,109 +97,3 @@ test("POST /executions runs code in the QuickJS sandbox", async () => { expect(body.text).toBe("42"); expect(body.isError).toBe(false); }); - -test("selfhost rejects local-directory app sources over HTTP", async () => { - const created = await handler( - new Request("http://localhost/api/apps/sources", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - kind: "local-directory", - slug: "local-greeter-disabled", - app: "local-greeter-disabled", - path: "/tmp/local-greeter-disabled", - }), - }), - ); - expect(created.status).toBe(500); - - const dirs = await handler(new Request("http://localhost/api/apps/fs/dirs")); - expect(dirs.status).toBe(404); -}); - -test("apps source sync publishes and invokes a local-directory tool over HTTP", async () => { - const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app"); - const localDirectoryApp = await makeSelfHostTestApp({ - identity: singleAdminIdentityLayer({ - userId: "admin-local-directory", - organizationId: "default-org-local-directory", - organizationName: "Default Local Directory", - }), - // This test exercises the plugin loop, not the selfhost product posture. - pluginDeps: { sourceKinds: ["git", "local-directory"] }, - }); - const root = mkdtempSync(join(tmpdir(), "eh-app-src-")); - mkdirSync(join(root, "tools"), { recursive: true }); - writeFileSync( - join(root, "tools", "greeter.ts"), - ` - import { defineTool } from "executor:app"; - - export default defineTool({ - description: "Greets through the app source path", - input: { - type: "object", - properties: { name: { type: "string" } }, - required: ["name"], - }, - async handler(input) { - return { greeting: "hello " + input.name }; - }, - }); - `, - ); - writeFileSync(join(root, "package.json"), JSON.stringify({ name: "local-greeter" })); - - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test fixture must dispose the booted app after assertions - try { - const created = await localDirectoryApp.handler( - new Request("http://localhost/api/apps/sources", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - kind: "local-directory", - slug: "local-greeter", - app: "local-greeter", - path: root, - }), - }), - ); - expect(created.status).toBe(200); - - const synced = await localDirectoryApp.handler( - new Request("http://localhost/api/apps/sources/local-greeter/sync", { method: "POST" }), - ); - expect(synced.status).toBe(200); - const syncBody = (await synced.json()) as { - readonly status: string; - readonly tools: readonly string[]; - readonly errors?: readonly unknown[]; - }; - expect(syncBody.status, JSON.stringify(syncBody.errors)).toBe("published"); - expect(syncBody.tools).toEqual(["greeter"]); - - const listed = await localDirectoryApp.handler(new Request("http://localhost/api/tools")); - expect(listed.status).toBe(200); - const tools = (await listed.json()) as ReadonlyArray<{ readonly address: string }>; - expect( - tools.some((tool) => tool.address === "tools.local-greeter.org.published.greeter"), - JSON.stringify(tools.map((tool) => tool.address)), - ).toBe(true); - - const invoked = await localDirectoryApp.handler( - new Request("http://localhost/api/executions", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - code: 'export default await tools["local-greeter.org.published.greeter"]({ name: "Ada" })', - }), - }), - ); - expect(invoked.status).toBe(200); - const output = (await invoked.json()) as { readonly text: string; readonly isError: boolean }; - expect(output.isError).toBe(false); - expect(output.text).toContain("hello Ada"); - } finally { - await localDirectoryApp.dispose(); - } -}); diff --git a/apps/host-selfhost/src/config-oidc.test.ts b/apps/host-selfhost/src/config-oidc.test.ts index dc30d60582..9ec18fc399 100644 --- a/apps/host-selfhost/src/config-oidc.test.ts +++ b/apps/host-selfhost/src/config-oidc.test.ts @@ -24,7 +24,7 @@ const original = { secret: process.env.EXECUTOR_OIDC_CLIENT_SECRET, secretFile: process.env.EXECUTOR_OIDC_CLIENT_SECRET_FILE, }; -const VALID_CLIENT_SECRET = "A".repeat(64); +const VALID_CLIENT_SECRET = "provider-issued+opaque/secret=with punctuation!"; const openDescriptorCount = (): number => existsSync("/proc/self/fd") ? readdirSync("/proc/self/fd").length : 0; @@ -89,21 +89,18 @@ describe("resolveOidcConfig", () => { expect(resolveOidcConfig()?.clientSecret).toBe(VALID_CLIENT_SECRET); }); - it("requires exactly 64 base64url characters and closes a rejected secret file", () => { + it("rejects empty credentials and closes a rejected secret file", () => { setValidOidcEnvironment(); delete process.env.EXECUTOR_OIDC_CLIENT_SECRET_FILE; - for (const invalid of ["A".repeat(63), "A".repeat(65), `${"A".repeat(63)}+`]) { - process.env.EXECUTOR_OIDC_CLIENT_SECRET = invalid; - expect(() => resolveOidcConfig()).toThrow("exactly 64 base64url"); - } - + process.env.EXECUTOR_OIDC_CLIENT_SECRET = ""; + expect(() => resolveOidcConfig()).toThrow("nonempty"); const directory = mkdtempSync(join(tmpdir(), "executor-oidc-secret-shape-")); const invalidFile = join(directory, "client-secret"); - writeFileSync(invalidFile, `${"A".repeat(63)}+`, { mode: 0o600 }); + writeFileSync(invalidFile, "\n", { mode: 0o600 }); delete process.env.EXECUTOR_OIDC_CLIENT_SECRET; process.env.EXECUTOR_OIDC_CLIENT_SECRET_FILE = invalidFile; const descriptorsBefore = openDescriptorCount(); - expect(() => resolveOidcConfig()).toThrow("exactly 64 base64url"); + expect(() => resolveOidcConfig()).toThrow("nonempty"); expect(openDescriptorCount()).toBe(descriptorsBefore); renameSync(invalidFile, join(directory, "closed-client-secret")); }); diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index 6c2c648160..8e917a170f 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -28,7 +28,6 @@ import { export const SELF_HOST_NAMESPACE = "executor_selfhost"; export const SELF_HOST_SCHEMA_VERSION = "1.0.0"; export const EXTERNAL_OIDC_PROVIDER_ID = "external-oidc"; -const EXTERNAL_OIDC_CLIENT_SECRET_PATTERN = /^[A-Za-z0-9_-]{64}$/; const EXTERNAL_OIDC_CLIENT_ID_PATTERN = /^[\x21-\x7e]{1,128}$/; export interface SelfHostOidcConfig { @@ -41,6 +40,26 @@ export interface SelfHostOidcConfig { readonly clientSecret: string; } +/** + * SSO sign-in for the self-host login page and the MCP OAuth connect flow: one + * OIDC provider (Google, Okta, Entra, any discovery-compliant IdP) resolved + * from the environment. Present only when the operator configured it; the + * allowlist is what replaces the invite code for SSO sign-ups (the domain IS + * the invite), so it is required whenever the provider is enabled. + */ +export interface SsoConfig { + /** URL-safe id — also the OAuth callback path segment and the button key. */ + readonly providerId: string; + /** Display name for the login button (“Continue with ”). */ + readonly providerName: string; + /** The IdP's OIDC discovery document (…/.well-known/openid-configuration). */ + readonly discoveryUrl: string; + readonly clientId: string; + readonly clientSecret: string; + /** Lowercased email domains admitted without an invite code. */ + readonly allowedDomains: readonly string[]; +} + export interface SelfHostConfig { /** Bind address. Defaults to loopback. */ readonly host: string; @@ -49,6 +68,8 @@ export interface SelfHostConfig { readonly dbPath: string; /** Public base URL used by core tools that build absolute links. */ readonly webBaseUrl: string; + /** Browser origins allowed to send cookie-authenticated requests. */ + readonly trustedOrigins: readonly string[]; /** * Whether sandboxed code may reach loopback/private network addresses. * Defaults to false — adversarial LLM code should not hit the host's @@ -68,6 +89,27 @@ export interface SelfHostConfig { readonly organizationName: string; /** URL slug for org-prefixed console paths (`//policies`). */ readonly orgSlug: string; + /** + * Sandbox execution budget passed to the QuickJS runtime, or undefined for + * the runtime's own default (5 minutes). An operator knob in principle, but + * its real consumer is the e2e harness, which shrinks it to seconds so the + * sandbox-deadline scenario proves its race without waiting out real + * minutes (the same pattern as MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS on cloud). + */ + readonly sandboxTimeoutMs: number | undefined; + /** SSO sign-in, or undefined when the operator hasn't configured it. */ + readonly sso: SsoConfig | undefined; + /** + * How long an MCP session may sit idle before the in-process store evicts it, + * or undefined for the store's own default (30 minutes). 0 disables eviction. + */ + readonly mcpSessionIdleTtlMs: number | undefined; + /** + * How long a connection's persisted remote tool catalog stays fresh, in ms. + * `undefined` takes the SDK default (15 minutes); `null` disables time-based + * re-sync, leaving stale-marking and config revision as the only triggers. + */ + readonly toolsSyncTtlMs: number | null | undefined; } export const resolveDataDir = (): string => @@ -145,9 +187,9 @@ const resolveOidcClientSecret = (): string => { ); } if (direct !== undefined) { - if (!EXTERNAL_OIDC_CLIENT_SECRET_PATTERN.test(direct)) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: reject weak or ambiguously encoded confidential-client credentials before auth starts - throw new Error("EXECUTOR_OIDC_CLIENT_SECRET must be exactly 64 base64url characters"); + if (direct.length === 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: reject empty confidential-client credentials before auth starts + throw new Error("EXECUTOR_OIDC_CLIENT_SECRET must be a nonempty provider-issued secret"); } return direct; } @@ -162,7 +204,7 @@ const resolveOidcClientSecret = (): string => { if (pathMetadata.isSymbolicLink()) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: secret links make custody ambiguous throw new Error( - "EXECUTOR_OIDC_CLIENT_SECRET_FILE must be an owner-owned, owner-only regular file containing exactly 64 base64url characters", + "EXECUTOR_OIDC_CLIENT_SECRET_FILE must be an owner-owned, owner-only regular file containing a nonempty provider-issued secret", ); } @@ -177,20 +219,19 @@ const resolveOidcClientSecret = (): string => { !metadata.isFile() || (metadata.mode & 0o077) !== 0 || (currentUid !== undefined && metadata.uid !== currentUid) || - metadata.size < 64 || - metadata.size > 65 + metadata.size === 0 ) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse secrets with ambiguous ownership, links, broad permissions, or invalid size throw new Error( - "EXECUTOR_OIDC_CLIENT_SECRET_FILE must be an owner-owned, owner-only regular file containing exactly 64 base64url characters", + "EXECUTOR_OIDC_CLIENT_SECRET_FILE must be an owner-owned, owner-only regular file containing a nonempty provider-issued secret", ); } const contents = readFileSync(descriptor, "utf8"); const secret = contents.endsWith("\n") ? contents.slice(0, -1) : contents; - if (!EXTERNAL_OIDC_CLIENT_SECRET_PATTERN.test(secret)) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: reject weak or ambiguously encoded confidential-client credentials before auth starts + if (secret.length === 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: reject empty confidential-client credentials before auth starts throw new Error( - "EXECUTOR_OIDC_CLIENT_SECRET_FILE must contain exactly 64 base64url characters", + "EXECUTOR_OIDC_CLIENT_SECRET_FILE must contain a nonempty provider-issued secret", ); } return secret; @@ -279,11 +320,13 @@ const resolveWebBaseUrl = (port: number): string => { export const loadConfig = (): SelfHostConfig => { const port = Number.parseInt(process.env.PORT ?? "4788", 10); const dataDir = resolveDataDir(); + const webBaseUrl = resolveWebBaseUrl(port); return { host: process.env.EXECUTOR_HOST ?? "127.0.0.1", port, dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"), - webBaseUrl: resolveWebBaseUrl(port), + webBaseUrl, + trustedOrigins: resolveTrustedOrigins(webBaseUrl), allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", authSecret: resolveAuthSecret(), oidc: resolveOidcConfig(), @@ -292,9 +335,165 @@ export const loadConfig = (): SelfHostConfig => { bootstrapAdminName: process.env.EXECUTOR_BOOTSTRAP_ADMIN_NAME ?? "Admin", organizationName: process.env.EXECUTOR_ORG_NAME ?? "Default", orgSlug: resolveOrgSlug(), + sandboxTimeoutMs: resolveSandboxTimeoutMs(), + sso: resolveSso(), + mcpSessionIdleTtlMs: resolveMcpSessionIdleTtlMs(), + toolsSyncTtlMs: resolveToolsSyncTtlMs(), }; }; +// Well-known discovery documents for providers an operator can name without +// hunting down the URL. Anything else (Okta, Entra, Auth0, …) has a +// tenant-specific issuer, so EXECUTOR_SSO_DISCOVERY_URL is required for it. +const DISCOVERY_PRESETS: Record = { + google: "https://accounts.google.com/.well-known/openid-configuration", +}; + +// The provider id doubles as the OAuth callback path segment +// (`/api/auth/oauth2/callback/`), so it must be URL-safe. +const PROVIDER_ID_PATTERN = /^[a-z0-9-]{1,48}$/; + +// A half-configured provider is refused rather than silently ignored (same +// posture as resolveSandboxTimeoutMs): an operator who set some of the +// variables should find out at boot, not by staring at a login page with no +// button. An empty domain allowlist is refused too — without it, SSO sign-in +// would be open registration for anyone with an account at the IdP, bypassing +// the invite gate entirely. +const resolveSso = (): SsoConfig | undefined => { + const clientId = process.env.EXECUTOR_SSO_CLIENT_ID?.trim(); + const clientSecret = process.env.EXECUTOR_SSO_CLIENT_SECRET?.trim(); + const configured = [ + "EXECUTOR_SSO_CLIENT_ID", + "EXECUTOR_SSO_CLIENT_SECRET", + "EXECUTOR_SSO_PROVIDER_ID", + "EXECUTOR_SSO_PROVIDER_NAME", + "EXECUTOR_SSO_DISCOVERY_URL", + "EXECUTOR_SSO_ALLOWED_DOMAINS", + ].some((name) => process.env[name] !== undefined); + if (!configured) return undefined; + if (!clientId || !clientSecret) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on half-configured SSO credentials + throw new Error("EXECUTOR_SSO_CLIENT_ID and EXECUTOR_SSO_CLIENT_SECRET must be set together"); + } + const providerId = process.env.EXECUTOR_SSO_PROVIDER_ID?.trim().toLowerCase() ?? ""; + if (!PROVIDER_ID_PATTERN.test(providerId)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a missing/malformed provider id + throw new Error( + 'EXECUTOR_SSO_PROVIDER_ID is required when SSO is configured (1-48 chars of [a-z0-9-], e.g. "google" or "okta") — it names the provider and its OAuth callback path', + ); + } + if (providerId === EXTERNAL_OIDC_PROVIDER_ID) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: reserve the explicit-link provider identity across configuration changes + throw new Error("EXECUTOR_SSO_PROVIDER_ID external-oidc is reserved for external OIDC"); + } + const discoveryUrl = + process.env.EXECUTOR_SSO_DISCOVERY_URL?.trim() || DISCOVERY_PRESETS[providerId]; + if (!discoveryUrl) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot without a way to reach the IdP + throw new Error( + `EXECUTOR_SSO_DISCOVERY_URL is required for provider ${JSON.stringify(providerId)} (the IdP's …/.well-known/openid-configuration URL)`, + ); + } + const allowedDomains = (process.env.EXECUTOR_SSO_ALLOWED_DOMAINS ?? "") + .split(",") + .map((domain) => domain.trim().replace(/^@/, "").toLowerCase()) + .filter((domain) => domain.length > 0); + if (allowedDomains.length === 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: SSO sign-in without a domain allowlist is open registration; refuse to boot + throw new Error( + 'EXECUTOR_SSO_ALLOWED_DOMAINS is required when SSO is configured (comma-separated email domains, e.g. "example.com") — it is what gates sign-ups in place of an invite code', + ); + } + const providerName = + process.env.EXECUTOR_SSO_PROVIDER_NAME?.trim() || + providerId.charAt(0).toUpperCase() + providerId.slice(1); + return { providerId, providerName, discoveryUrl, clientId, clientSecret, allowedDomains }; +}; + +// A malformed value is refused rather than silently ignored: an operator who +// sets the knob and typos it should find out at boot, not by watching a +// runaway execution use the 5-minute default. +const resolveSandboxTimeoutMs = (): number | undefined => { + const raw = process.env.EXECUTOR_SANDBOX_TIMEOUT_MS; + if (!raw) return undefined; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_SANDBOX_TIMEOUT_MS ${JSON.stringify(raw)} is not a positive number of milliseconds`, + ); + } + return Math.floor(parsed); +}; + +// How long an MCP session may sit idle before the store evicts it. 0 disables +// eviction, which restores the old behaviour of holding every session for the +// lifetime of the process — only useful for diagnosing a client that cannot +// tolerate re-initializing. +const resolveMcpSessionIdleTtlMs = (): number | undefined => { + const raw = process.env.EXECUTOR_MCP_SESSION_IDLE_TTL_MS; + if (!raw) return undefined; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_MCP_SESSION_IDLE_TTL_MS ${JSON.stringify(raw)} is not a non-negative number of milliseconds`, + ); + } + return Math.floor(parsed); +}; + +// EXECUTOR_TRUSTED_ORIGINS — extra browser origins allowed to send +// cookie-authenticated requests when one instance is deliberately reachable +// under more than one address (a LAN IP as well as a domain, say). +// +// This list widens ONLY Better Auth's origin/CSRF check. `webBaseUrl` stays the +// single canonical origin for OAuth callbacks, MCP metadata, and every other +// generated link, so an alias can never redirect a callback somewhere else. +// +// Entries must be exact origins. A path, query, fragment, credential, wildcard +// host, or non-http(s) scheme is refused rather than trimmed off: an operator +// who writes `https://*.example.com` means a pattern, and silently accepting it +// as the literal host would leave them believing a wildcard is in force. Like +// the other knobs here, a malformed value refuses to boot instead of quietly +// leaving the browser locked out with an "Invalid origin" page. +const normalizeTrustedOrigin = (value: string): string => { + if (!URL.canParse(value)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_TRUSTED_ORIGINS contains ${JSON.stringify(value)}, which is not a valid URL origin`, + ); + } + const url = new URL(value); + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username.length > 0 || + url.password.length > 0 || + url.hostname.includes("*") || + (url.pathname !== "" && url.pathname !== "/") || + url.search.length > 0 || + url.hash.length > 0 + ) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_TRUSTED_ORIGINS entry ${JSON.stringify(value)} must be an exact http(s) origin (scheme, host, and optional port only)`, + ); + } + return url.origin; +}; + +// The canonical origin always leads the list, so the unset case reproduces the +// previous `[webBaseUrl]` exactly and an operator who repeats it in the env var +// does not get a duplicate. +const resolveTrustedOrigins = (webBaseUrl: string): readonly string[] => { + const additional = (process.env.EXECUTOR_TRUSTED_ORIGINS ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map(normalizeTrustedOrigin); + return [...new Set([webBaseUrl, ...additional])]; +}; + // The org slug doubles as a URL segment (`//policies`), so an // operator-set value must fit the shared grammar and avoid reserved root // segments (api, mcp, login, …) — a colliding slug would shadow real routes. @@ -309,3 +508,42 @@ const resolveOrgSlug = (): string => { } return slug; }; + +// EXECUTOR_TOOLS_SYNC_TTL_MS — how long a remote tool catalog (an MCP server's +// tool set, which changes server-side with no executor-visible signal) stays +// fresh before the next tools read re-lists it. Unset takes the SDK default of +// 15 minutes. +// +// The value forwards to the SDK's `toolsSyncTtlMs` verbatim, so `0` keeps the +// SDK's meaning — every catalog is expired on every read. "off", "null" and +// "false" disable time-based re-sync (the SDK's `null` sentinel), since +// operators reach for all three spellings. The comparison is case-insensitive: +// "OFF" and "False" are the same intent typed by a different operator. +// +// Like the other knobs here a malformed or negative value is refused rather +// than silently ignored: an operator who sets the TTL and typos it should find +// out at boot, not by wondering months later why catalogs never refresh. +const TOOLS_SYNC_TTL_DISABLE_TOKENS = new Set(["off", "null", "false"]); + +const resolveToolsSyncTtlMs = (): number | null | undefined => { + const raw = process.env.EXECUTOR_TOOLS_SYNC_TTL_MS?.trim(); + if (!raw) return undefined; + if (TOOLS_SYNC_TTL_DISABLE_TOKENS.has(raw.toLowerCase())) return null; + const parsed = Number(raw); + // `isSafeInteger`, not `isInteger`: past 2^53 a decimal literal silently + // rounds to a nearby representable value, so an operator's typo'd digit + // would boot as a TTL they never wrote. Refuse it instead. + if (!Number.isSafeInteger(parsed)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_TOOLS_SYNC_TTL_MS ${JSON.stringify(raw)} is not an exactly representable whole number of milliseconds ("off", "null" or "false" disable time-based re-sync)`, + ); + } + if (parsed < 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_TOOLS_SYNC_TTL_MS ${JSON.stringify(raw)} must not be negative (use "off" to disable time-based re-sync)`, + ); + } + return parsed; +}; diff --git a/apps/host-selfhost/src/db/data-migrations.ts b/apps/host-selfhost/src/db/data-migrations.ts index 295f77d5b4..aaf3b2295d 100644 --- a/apps/host-selfhost/src/db/data-migrations.ts +++ b/apps/host-selfhost/src/db/data-migrations.ts @@ -5,7 +5,11 @@ // renamed. // --------------------------------------------------------------------------- -import { sqliteDataMigration, type SqliteDataMigration } from "@executor-js/sdk"; +import { + bigintStorageClassSqliteMigration, + sqliteDataMigration, + type SqliteDataMigration, +} from "@executor-js/sdk"; import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth"; import { openApiNdjsonOutputDataMigration, @@ -16,9 +20,15 @@ import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graph import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google"; import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split"; +import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-encrypted-secrets"; import { authConfigTransforms } from "./auth-config-migration"; export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ + // FIRST, because it un-bricks reads every later migration and the whole app + // depend on: `bigint` columns an older build left in SQLite's INTEGER storage + // class cannot be read by the bigint row mapper, so a single legacy + // `connection.expires_at` failed every catalog read (issue #1771). + bigintStorageClassSqliteMigration, // Rewrite pre-canonical integration auth configs into the shared // placements model. sqliteDataMigration("2026-06-05-auth-config-placements", (client) => @@ -37,4 +47,7 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ // Stale-mark connections whose operations return NDJSON so their tool rows // rebuild with array-wrapped output schemas (mirrors cloud's drizzle 0010). openApiNdjsonOutputDataMigration, + // Re-file credential rows the pre-fix provider stored under the acting + // caller's partition instead of the owner embedded in the item id (#1453). + encryptedSecretsRepartitionDataMigration, ]; diff --git a/apps/host-selfhost/src/execution.ts b/apps/host-selfhost/src/execution.ts index c7ffbbf231..8dab577b93 100644 --- a/apps/host-selfhost/src/execution.ts +++ b/apps/host-selfhost/src/execution.ts @@ -4,13 +4,13 @@ import { CodeExecutorProvider, DbProvider, EngineDecorator, - EngineDecoratorNoop, HostConfig, PluginsProvider, } from "@executor-js/api/server"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import executorConfig from "../executor.config"; +import { selfHostAnalytics, SelfHostAnalyticsEngineDecorator } from "./analytics"; import { SelfHostDb, SelfHostDbProvider } from "./db/self-host-db"; import { loadConfig } from "./config"; @@ -21,7 +21,7 @@ import { loadConfig } from "./config"; // makeScopedExecutor -> createExecutionEngine -> EngineDecorator.decorate. // Self-host just supplies the five seam Layers it reads from. Differences from // cloud: the QuickJS in-process code substrate (vs the Cloudflare dynamic -// worker) and a NO-OP engine decorator (no usage metering). +// worker) and an analytics engine decorator instead of cloud's usage metering. // // - DbProvider -> SelfHostDbProvider: projects the long-lived // libSQL handle (built once at boot, see db/). The @@ -33,7 +33,8 @@ import { loadConfig } from "./config"; // - HostConfig -> `{ allowLocalNetwork, webBaseUrl }` from // `loadConfig()`. // - CodeExecutorProvider -> `makeQuickJsExecutor()`. -// - EngineDecorator -> no-op (self-host does not meter executions). +// - EngineDecorator -> execution analytics (anonymous per-install +// counters; see ./analytics.ts). // --------------------------------------------------------------------------- export { makeExecutionStack } from "@executor-js/api/server"; @@ -54,12 +55,23 @@ export const SelfHostHostConfig: Layer.Layer = Layer.sync(HostConfig allowLocalNetwork: config.allowLocalNetwork, webBaseUrl: config.webBaseUrl, oauthCallbackPath: "/api/oauth/callback", + toolsSyncTtlMs: config.toolsSyncTtlMs, + onIntegrationChange: (event) => + selfHostAnalytics.record( + event.kind === "added" ? "integration_added" : "integration_removed", + { plugin_key: event.pluginKey }, + ), }; }); export const SelfHostCodeExecutorProvider: Layer.Layer = Layer.sync( CodeExecutorProvider, - () => makeQuickJsExecutor(), + () => { + const { sandboxTimeoutMs } = loadConfig(); + return makeQuickJsExecutor( + sandboxTimeoutMs === undefined ? {} : { timeoutMs: sandboxTimeoutMs }, + ); + }, ); /** @@ -82,4 +94,8 @@ export const SelfHostExecutionStackLayer: Layer.Layer< DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator, never, SelfHostDb -> = Layer.mergeAll(SelfHostScopedExecutorSeams, SelfHostCodeExecutorProvider, EngineDecoratorNoop); +> = Layer.mergeAll( + SelfHostScopedExecutorSeams, + SelfHostCodeExecutorProvider, + SelfHostAnalyticsEngineDecorator, +); diff --git a/apps/host-selfhost/src/executor-config.test.ts b/apps/host-selfhost/src/executor-config.test.ts new file mode 100644 index 0000000000..313d097b85 --- /dev/null +++ b/apps/host-selfhost/src/executor-config.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, expect, test } from "@effect/vitest"; + +import { loadConfig } from "./config"; +import executorConfig from "../executor.config"; + +const ENV_NAME = "EXECUTOR_ALLOW_STDIO_MCP"; +const SECRET_ENV_NAME = "EXECUTOR_SECRET_KEY"; +const TTL_ENV_NAME = "EXECUTOR_TOOLS_SYNC_TTL_MS"; +const originalValue = process.env[ENV_NAME]; +const originalSecret = process.env[SECRET_ENV_NAME]; +const originalTtl = process.env[TTL_ENV_NAME]; + +beforeEach(() => { + process.env[SECRET_ENV_NAME] = originalSecret ?? "executor-config-test-secret"; +}); + +afterEach(() => { + if (originalValue === undefined) { + delete process.env[ENV_NAME]; + } else { + process.env[ENV_NAME] = originalValue; + } + if (originalSecret === undefined) { + delete process.env[SECRET_ENV_NAME]; + } else { + process.env[SECRET_ENV_NAME] = originalSecret; + } + if (originalTtl === undefined) { + delete process.env[TTL_ENV_NAME]; + } else { + process.env[TTL_ENV_NAME] = originalTtl; + } +}); + +const allowStdio = (): boolean => { + const mcp = executorConfig.plugins().find((plugin) => plugin.id === "mcp"); + expect(mcp).toBeDefined(); + const clientConfig = mcp?.clientConfig; + if ( + clientConfig && + typeof clientConfig === "object" && + "allowStdio" in clientConfig && + typeof clientConfig.allowStdio === "boolean" + ) { + return clientConfig.allowStdio; + } + expect.fail("MCP plugin did not expose its stdio setting"); + return false; +}; + +test("stdio MCP stays disabled when the opt-in is absent", () => { + delete process.env[ENV_NAME]; + expect(allowStdio()).toBe(false); +}); + +test("stdio MCP stays disabled unless the opt-in is exactly true", () => { + process.env[ENV_NAME] = "false"; + expect(allowStdio()).toBe(false); + + process.env[ENV_NAME] = "TRUE"; + expect(allowStdio()).toBe(false); +}); + +test("stdio MCP is enabled when the opt-in is exactly true", () => { + process.env[ENV_NAME] = "true"; + expect(allowStdio()).toBe(true); +}); + +test("an unset tools-sync TTL leaves the SDK default in place", () => { + delete process.env[TTL_ENV_NAME]; + expect(loadConfig().toolsSyncTtlMs).toBeUndefined(); + + process.env[TTL_ENV_NAME] = " "; + expect(loadConfig().toolsSyncTtlMs).toBeUndefined(); +}); + +test("a positive tools-sync TTL is forwarded verbatim", () => { + process.env[TTL_ENV_NAME] = "60000"; + expect(loadConfig().toolsSyncTtlMs).toBe(60000); +}); + +// 0 keeps the SDK's own meaning — every catalog is expired on every read — +// so the env var never means the opposite of the config field it feeds. +test("a zero tools-sync TTL forwards as the SDK's always-stale 0", () => { + process.env[TTL_ENV_NAME] = "0"; + expect(loadConfig().toolsSyncTtlMs).toBe(0); +}); + +// Case-insensitive: the disable tokens are operator intent, not a keyword, and +// "OFF" typed in a systemd unit means what "off" means in a .env file. +test.each(["off", "null", "false", "OFF", "Null", "FALSE", " Off "])( + "the tools-sync TTL is disabled by %s", + (raw) => { + process.env[TTL_ENV_NAME] = raw; + expect(loadConfig().toolsSyncTtlMs).toBeNull(); + }, +); + +// A typo'd knob must not silently degrade into the 15-minute default; the +// operator finds out at boot instead of wondering why catalogs never refresh. +// "9007199254740993" and "1e30" are whole numbers that no longer round-trip +// through a double — accepting them would boot a TTL the operator never wrote. +test.each(["abc", "60_000", "1.5", "1e3ms", "NaN", "Infinity", "9007199254740993", "1e30"])( + "a malformed tools-sync TTL (%s) refuses to boot", + (raw) => { + process.env[TTL_ENV_NAME] = raw; + expect(() => loadConfig()).toThrow(/EXECUTOR_TOOLS_SYNC_TTL_MS/); + }, +); + +test("a negative tools-sync TTL refuses to boot", () => { + process.env[TTL_ENV_NAME] = "-1"; + expect(() => loadConfig()).toThrow(/must not be negative/); +}); diff --git a/apps/host-selfhost/src/mcp/auth.ts b/apps/host-selfhost/src/mcp/auth.ts index 2a1313579e..967d4255d3 100644 --- a/apps/host-selfhost/src/mcp/auth.ts +++ b/apps/host-selfhost/src/mcp/auth.ts @@ -1,7 +1,7 @@ import { Effect, Layer } from "effect"; import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins"; -import { IdentityProvider } from "@executor-js/api/server"; +import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server"; import { authenticated, McpAuthProvider, @@ -11,6 +11,7 @@ import { type Principal, } from "@executor-js/host-mcp"; +import { isPrivileged } from "../admin/require-admin"; import { BetterAuth } from "../auth/better-auth"; import { MCP_ORIGINAL_PATH_HEADER, mcpResourcePathFromOriginalPath } from "./org-path"; @@ -205,6 +206,24 @@ export const selfHostMcpAuth: Layer.Layer context.internalAdapter.findUserById(userId)); if (!user) return null; + // The workspace role, read from the INSTANCE org's membership row + // (an OAuth token carries no session, so the header-based + // `getActiveMemberRole` gate is out of reach — the adapter query + // answers the same question against the same table). FAIL CLOSED to + // "member": an infra fault demotes rather than escalates. + const membership = yield* Effect.promise(() => + context.adapter.findOne<{ readonly role?: string | null }>({ + model: "member", + where: [ + { field: "userId", value: userId }, + { field: "organizationId", value: organizationId }, + ], + }), + ).pipe(Effect.orElseSucceed(() => null)); + const orgRole = + membership?.role != null && isPrivileged(membership.role) + ? ("admin" as const) + : ("member" as const); return { accountId: user.id, // Single-org self-host: OAuth tokens carry no active org, so pin to @@ -216,6 +235,8 @@ export const selfHostMcpAuth: Layer.Layer null)); /** (b) The existing cookie / bearer-session / x-api-key path. The fallback's - * api `Principal` shape is byte-identical to host-mcp's `Principal`. */ + * api `Principal` shape is byte-identical to host-mcp's `Principal`. The + * neutral seam can also resolve a platform credential, which self-host's + * identity never produces — narrowed away rather than asserted, so an MCP + * session can never bind to a subject-less credential if that changes. */ const authenticateSession = (request: Request): Effect.Effect => fallback.authenticate(request).pipe( + Effect.map((principal) => (isPlatformPrincipal(principal) ? null : principal)), Effect.catchTags({ Unauthorized: () => Effect.succeed(null), NoOrganization: () => Effect.succeed(null), + ReadOnlyCredential: () => Effect.succeed(null), }), ); diff --git a/apps/host-selfhost/src/mcp/index.ts b/apps/host-selfhost/src/mcp/index.ts index 52287518cd..c8a8683227 100644 --- a/apps/host-selfhost/src/mcp/index.ts +++ b/apps/host-selfhost/src/mcp/index.ts @@ -9,7 +9,9 @@ import type { } from "@executor-js/host-mcp"; import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; +import { resolveSelfHostOrgRole } from "../auth/identity"; import type { SelfHostDbHandle } from "../db/self-host-db"; +import type { SelfHostConfig } from "../config"; import { selfHostMcpAuth } from "./auth"; import { makeSelfHostMcpSessionStore, @@ -67,7 +69,10 @@ export interface SelfHostMcpSeams { } const jsonResponse = (value: unknown, status: number): Response => - new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); + new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); const parseRoles = (role: string | null | undefined): ReadonlyArray => (role ?? "user") @@ -82,15 +87,23 @@ type BetterAuthSession = NonNullable< const principalFromSession = ( resolved: BetterAuthSession, betterAuth: BetterAuthHandle, -): Principal => ({ - accountId: resolved.user.id, - organizationId: resolved.session.activeOrganizationId ?? betterAuth.organizationId, - organizationName: betterAuth.organizationName, - email: resolved.user.email, - name: resolved.user.name ?? null, - avatarUrl: resolved.user.image ?? null, - roles: parseRoles(resolved.user.role ?? null), -}); + headers: Headers, +): Effect.Effect => { + const organizationId = resolved.session.activeOrganizationId ?? betterAuth.organizationId; + return resolveSelfHostOrgRole(betterAuth, headers, organizationId).pipe( + Effect.map((orgRole) => ({ + accountId: resolved.user.id, + organizationId, + organizationName: betterAuth.organizationName, + email: resolved.user.email, + name: resolved.user.name ?? null, + avatarUrl: resolved.user.image ?? null, + roles: parseRoles(resolved.user.role ?? null), + orgRoleModel: "organization" as const, + orgRole, + })), + ); +}; /** * Gate the browser-approval endpoints behind a valid Better Auth session (the @@ -113,7 +126,9 @@ const makeApprovalHandler = }).pipe(Effect.orElseSucceed(() => null)), ); if (!session) return jsonResponse({ error: "Unauthorized" }, 401); - const principal = principalFromSession(session, betterAuth); + const principal = await Effect.runPromise( + principalFromSession(session, betterAuth, request.headers), + ); return ( (await store.handlePausedRequest(request, principal)) ?? @@ -128,13 +143,24 @@ const makeApprovalHandler = * instance provided; it still requires `IdentityProvider` from the resolved * identity seam. Returns the three seam Layers plus the `close()` lifetime hook * the app wires into shutdown. + * + * Takes the already-resolved `SelfHostConfig` rather than reading it here: the + * app loads it once at boot, and `loadConfig()` refuses to boot on a malformed + * operator knob, so calling it from a seam factory would both hide an env read + * behind construction and move that failure off the boot path. */ export const makeSelfHostMcpSeams = ( dbHandle: SelfHostDbHandle, betterAuth: BetterAuthHandle, - webBaseUrl?: string, + config: SelfHostConfig, ): SelfHostMcpSeams => { - const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl); + // The pinned public origin keeps browser-approval URLs reachable behind a + // reverse proxy (not the internal 127.0.0.1 bind from the request URL). + const sessionStore = makeSelfHostMcpSessionStore( + dbHandle, + config.webBaseUrl, + config.mcpSessionIdleTtlMs, + ); const auth: Layer.Layer = selfHostMcpAuth.pipe( Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), ); diff --git a/apps/host-selfhost/src/mcp/mcp-oauth.test.ts b/apps/host-selfhost/src/mcp/mcp-oauth.test.ts index a66952a0e1..e8f6f22b38 100644 --- a/apps/host-selfhost/src/mcp/mcp-oauth.test.ts +++ b/apps/host-selfhost/src/mcp/mcp-oauth.test.ts @@ -105,108 +105,130 @@ const b64url = (buf: Uint8Array): string => .replaceAll("/", "_") .replaceAll("=", ""); -test("MCP OAuth opaque-bearer flow authenticates /mcp end-to-end", async () => { - const cookie = await signUp("oauth@env.test"); - - // 1. Dynamic client registration (public/PKCE client). - const reg = await handler( - new Request(`${BASE}/api/auth/mcp/register`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - client_name: "test-client", - redirect_uris: ["http://localhost:9999/callback"], - token_endpoint_auth_method: "none", - grant_types: ["authorization_code"], - response_types: ["code"], +test.each([false, true])( + "MCP OAuth opaque-bearer flow authenticates /mcp end-to-end (interrupted login: %s)", + async (interrupted) => { + const email = `oauth-${interrupted}@env.test`; + let cookie = await signUp(email); + + // 1. Dynamic client registration (public/PKCE client). + const reg = await handler( + new Request(`${BASE}/api/auth/mcp/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "test-client", + redirect_uris: ["http://localhost:9999/callback"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + response_types: ["code"], + }), }), - }), - ); - expect([200, 201]).toContain(reg.status); - const clientId = String((await json(reg)).client_id); - - // 2. PKCE authorize with the signed-in session cookie. Self-host forces an - // approval screen (prompt=consent injected by the serving layer), so authorize - // does NOT redirect straight to the client callback — it redirects to the - // relative /mcp-consent page carrying a consent_code. A real client's browser - // lands there and the user clicks Allow; we drive that POST directly. - const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); - const challengeBytes = new Uint8Array( - await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)), - ); - const codeChallenge = b64url(challengeBytes); - const authorizeUrl = new URL(`${BASE}/api/auth/mcp/authorize`); - authorizeUrl.search = new URLSearchParams({ - response_type: "code", - client_id: clientId, - redirect_uri: "http://localhost:9999/callback", - code_challenge: codeChallenge, - code_challenge_method: "S256", - scope: "openid", - }).toString(); - const authorize = await handler( - new Request(authorizeUrl, { headers: { cookie }, redirect: "manual" }), - ); - expect(authorize.status).toBe(302); - // Resolve against BASE — the consent redirect is a relative app path. - const consentRedirect = new URL(authorize.headers.get("location") ?? "", BASE); - expect(consentRedirect.pathname).toBe("/mcp-consent"); - const consentCode = consentRedirect.searchParams.get("consent_code") ?? ""; - expect(consentCode).not.toBe(""); - - // 2b. Approve on the consent screen — the same POST the /mcp-consent page makes. - // It returns { redirectURI } back to the client's callback carrying the code. - const consent = await handler( - new Request(`${BASE}/api/auth/oauth2/consent`, { - method: "POST", - headers: { "content-type": "application/json", cookie }, - body: JSON.stringify({ accept: true, consent_code: consentCode }), - }), - ); - expect(consent.status).toBe(200); - const redirectURI = String((await json(consent)).redirectURI ?? ""); - const code = new URL(redirectURI).searchParams.get("code") ?? ""; - expect(code).not.toBe(""); - - // 3. Token exchange. - const token = await handler( - new Request(`${BASE}/api/auth/mcp/token`, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: "http://localhost:9999/callback", - client_id: clientId, - code_verifier: verifier, - }).toString(), - }), - ); - expect(token.status).toBe(200); - const accessToken = String((await json(token)).access_token); - expect(accessToken).not.toBe(""); - - // 4. The opaque access token authenticates /mcp (initialize succeeds). - const init = await handler( - new Request(`${BASE}/mcp`, { - method: "POST", - headers: { - authorization: `Bearer ${accessToken}`, - "content-type": "application/json", - accept: "application/json, text/event-stream", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "t", version: "1" }, + ); + expect([200, 201]).toContain(reg.status); + const clientId = String((await json(reg)).client_id); + + // 2. PKCE authorize with the signed-in session cookie. Self-host forces an + // approval screen (prompt=consent injected by the serving layer), so authorize + // does NOT redirect straight to the client callback — it redirects to the + // relative /mcp-consent page carrying a consent_code. A real client's browser + // lands there and the user clicks Allow; we drive that POST directly. + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); + const challengeBytes = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)), + ); + const codeChallenge = b64url(challengeBytes); + const authorizeUrl = new URL(`${BASE}/api/auth/mcp/authorize`); + authorizeUrl.search = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: "http://localhost:9999/callback", + code_challenge: codeChallenge, + code_challenge_method: "S256", + scope: "openid", + }).toString(); + let authorize = await handler( + new Request(authorizeUrl, { headers: interrupted ? {} : { cookie }, redirect: "manual" }), + ); + if (interrupted) { + const promptCookie = authorize.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + authorize = await handler( + new Request(`${BASE}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json", cookie: promptCookie }, + body: JSON.stringify({ email, password: "password-12345678" }), + }), + ); + cookie = authorize.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + } + expect(authorize.status).toBe(302); + expect(cookie).toContain("session_token="); + // Resolve against BASE — the consent redirect is a relative app path. + const consentRedirect = new URL(authorize.headers.get("location") ?? "", BASE); + expect(consentRedirect.pathname).toBe("/mcp-consent"); + const consentCode = consentRedirect.searchParams.get("consent_code") ?? ""; + expect(consentCode).not.toBe(""); + + // 2b. Approve on the consent screen — the same POST the /mcp-consent page makes. + // It returns { redirectURI } back to the client's callback carrying the code. + const consent = await handler( + new Request(`${BASE}/api/auth/oauth2/consent`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ accept: true, consent_code: consentCode }), + }), + ); + expect(consent.status).toBe(200); + const redirectURI = String((await json(consent)).redirectURI ?? ""); + const code = new URL(redirectURI).searchParams.get("code") ?? ""; + expect(code).not.toBe(""); + + // 3. Token exchange. + const token = await handler( + new Request(`${BASE}/api/auth/mcp/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: "http://localhost:9999/callback", + client_id: clientId, + code_verifier: verifier, + }).toString(), + }), + ); + expect(token.status).toBe(200); + const accessToken = String((await json(token)).access_token); + expect(accessToken).not.toBe(""); + + // 4. The opaque access token authenticates /mcp (initialize succeeds). + const init = await handler( + new Request(`${BASE}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "t", version: "1" }, + }, + }), }), - }), - ); - expect(init.status).toBe(200); - expect(init.headers.get("mcp-session-id")).not.toBe(null); -}); + ); + expect(init.status).toBe(200); + expect(init.headers.get("mcp-session-id")).not.toBe(null); + }, +); diff --git a/apps/host-selfhost/src/mcp/mcp.test.ts b/apps/host-selfhost/src/mcp/mcp.test.ts index 0f061adecc..60f07d2988 100644 --- a/apps/host-selfhost/src/mcp/mcp.test.ts +++ b/apps/host-selfhost/src/mcp/mcp.test.ts @@ -18,7 +18,17 @@ afterAll(() => dispose()); const BASE = "http://localhost:4788"; -const signUp = async (email: string): Promise => { +interface AuthenticatedIdentity { + readonly token: string; + readonly cookie: string; +} + +const identityFromResponse = (response: Response): AuthenticatedIdentity => ({ + token: response.headers.get("set-auth-token") ?? "", + cookie: response.headers.get("set-cookie")?.split(";", 1)[0] ?? "", +}); + +const signUpIdentity = async (email: string): Promise => { const inviteCode = await mintInviteCode(handler); const res = await handler( new Request(`${BASE}/api/auth/sign-up/email`, { @@ -28,12 +38,29 @@ const signUp = async (email: string): Promise => { }), ); expect(res.status).toBe(200); - return res.headers.get("set-auth-token") ?? ""; + return identityFromResponse(res); +}; + +const signUp = async (email: string): Promise => (await signUpIdentity(email)).token; + +const signInBootstrap = async (): Promise => { + const response = await handler( + new Request(`${BASE}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, + password: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, + }), + }), + ); + expect(response.status).toBe(200); + return identityFromResponse(response); }; -const mcp = (token: string, body: unknown, sessionId?: string) => +const mcp = (token: string, body: unknown, sessionId?: string, browser = false) => handler( - new Request(`${BASE}/mcp`, { + new Request(`${BASE}/mcp${browser ? "?elicitation_mode=browser" : ""}`, { method: "POST", headers: { authorization: `Bearer ${token}`, @@ -45,25 +72,42 @@ const mcp = (token: string, body: unknown, sessionId?: string) => }), ); -const initSession = async (token: string): Promise => { - const res = await mcp(token, { - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "t", version: "1" }, +const initSession = async (token: string, browser = false): Promise => { + const res = await mcp( + token, + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "t", version: "1" }, + }, }, - }); + undefined, + browser, + ); expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); const sessionId = res.headers.get("mcp-session-id") ?? ""; expect(sessionId).not.toBe(""); await res.text(); - await mcp(token, { jsonrpc: "2.0", method: "notifications/initialized" }, sessionId); + await mcp(token, { jsonrpc: "2.0", method: "notifications/initialized" }, sessionId, browser); return sessionId; }; +const account = (token: string, path: string, init?: RequestInit) => + handler( + new Request(`${BASE}${path}`, { + ...init, + headers: { + ...Object.fromEntries(new Headers(init?.headers)), + authorization: `Bearer ${token}`, + }, + }), + ); + test("an authenticated MCP client initializes, lists tools, and executes code", async () => { const token = await signUp("alice@mcp.test"); const sessionId = await initSession(token); @@ -172,3 +216,142 @@ test("GET /mcp without a session id is 400; DELETE without a session id is 204", expect(del.status).toBe(204); expect(await del.text()).toBe(""); }); + +test("a browser approval uses the bootstrap admin's demoted membership at the sink", async () => { + const controller = await signInBootstrap(); + const actorEmail = "approval-role-actor@mcp.test"; + const actor = await signUpIdentity(actorEmail); + + const members = async (token: string) => { + const response = await account(token, "/api/account/members"); + expect(response.status).toBe(200); + return (await response.json()) as { + readonly members: ReadonlyArray<{ + readonly id: string; + readonly userId: string; + readonly email: string; + readonly role: string; + }>; + }; + }; + const setRole = (token: string, memberId: string, roleSlug: "admin" | "member") => + account(token, `/api/account/members/${encodeURIComponent(memberId)}/role`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ roleSlug }), + }); + + const actorMember = (await members(controller.token)).members.find( + (member) => member.email === actorEmail, + ); + expect(actorMember?.role).toBe("member"); + if (!actorMember) return; + expect((await setRole(controller.token, actorMember.id, "admin")).status).toBe(200); + const globalAdmin = await account(controller.token, "/api/auth/admin/set-role", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: actorMember.userId, role: "admin" }), + }); + expect(globalAdmin.status).toBe(200); + + const actorSession = await account(actor.token, "/api/auth/get-session"); + expect(actorSession.status).toBe(200); + expect( + ((await actorSession.json()) as { readonly user?: { readonly role?: string } }).user?.role, + ).toBe("admin"); + + const gateResponse = await account(actor.token, "/api/policies", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + owner: "org", + pattern: "executor.coreTools.policies.create", + action: "require_approval", + }), + }); + expect(gateResponse.status).toBe(200); + const gate = (await gateResponse.json()) as { readonly id: string }; + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: async HTTP integration test guarantees role and policy cleanup after every assertion failure + try { + const sessionId = await initSession(actor.token, true); + const pausedResponse = await mcp( + actor.token, + { + jsonrpc: "2.0", + id: 10, + method: "tools/call", + params: { + name: "execute", + arguments: { + code: [ + "return await tools.executor.coreTools.policies.create({", + ' owner: "org",', + ' pattern: "selfhost-live-role-regression.*",', + ' action: "block"', + "});", + ].join("\n"), + }, + }, + }, + sessionId, + true, + ); + const paused = (await pausedResponse.json()) as { + readonly result?: { + readonly structuredContent?: { readonly executionId?: string }; + }; + }; + const executionId = paused.result?.structuredContent?.executionId; + expect(executionId).toBeTruthy(); + if (!executionId) return; + + expect((await setRole(controller.token, actorMember.id, "member")).status).toBe(200); + + const resumePromise = mcp( + actor.token, + { + jsonrpc: "2.0", + id: 11, + method: "tools/call", + params: { name: "resume", arguments: { executionId } }, + }, + sessionId, + true, + ); + await Promise.resolve(); + const decision = await handler( + new Request( + `${BASE}/api/mcp-sessions/${encodeURIComponent(sessionId)}/executions/${encodeURIComponent(executionId)}/resume`, + { + method: "POST", + headers: { "content-type": "application/json", cookie: actor.cookie }, + body: JSON.stringify({ action: "accept", content: {} }), + }, + ), + ); + expect(decision.status).toBe(200); + const resumed = await resumePromise; + expect(resumed.status).toBe(200); + await resumed.text(); + + const policiesResponse = await account(controller.token, "/api/policies"); + expect(policiesResponse.status).toBe(200); + const policies = (await policiesResponse.json()) as ReadonlyArray<{ readonly pattern: string }>; + expect(policies.some((policy) => policy.pattern === "selfhost-live-role-regression.*")).toBe( + false, + ); + } finally { + await setRole(controller.token, actorMember.id, "admin"); + await account(actor.token, `/api/policies/${encodeURIComponent(gate.id)}`, { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ owner: "org" }), + }); + await account(controller.token, "/api/auth/admin/set-role", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: actorMember.userId, role: "user" }), + }); + } +}); diff --git a/apps/host-selfhost/src/mcp/org-path.test.ts b/apps/host-selfhost/src/mcp/org-path.test.ts index 5c7460fa9e..cf46fd17a5 100644 --- a/apps/host-selfhost/src/mcp/org-path.test.ts +++ b/apps/host-selfhost/src/mcp/org-path.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { stripMcpOrgSegment } from "./org-path"; +import { isMcpServingPath, stripMcpOrgSegment } from "./org-path"; describe("stripMcpOrgSegment", () => { it("strips a single org segment before /mcp", () => { @@ -32,3 +32,20 @@ describe("stripMcpOrgSegment", () => { expect(stripMcpOrgSegment("/a/b/mcp")).toBeNull(); // deeper than one segment }); }); + +describe("isMcpServingPath", () => { + it("recognizes bare and org-scoped MCP serving routes", () => { + expect(isMcpServingPath("/mcp")).toBe(true); + expect(isMcpServingPath("/mcp/")).toBe(true); + expect(isMcpServingPath("/mcp/toolkits/deploy")).toBe(true); + expect(isMcpServingPath("/default/mcp")).toBe(true); + expect(isMcpServingPath("/default/mcp/toolkits/deploy")).toBe(true); + }); + + it("does not claim discovery, OAuth, or unrelated paths", () => { + expect(isMcpServingPath("/.well-known/oauth-protected-resource")).toBe(false); + expect(isMcpServingPath("/api/auth/mcp/authorize")).toBe(false); + expect(isMcpServingPath("/mcp/unknown")).toBe(false); + expect(isMcpServingPath("/")).toBe(false); + }); +}); diff --git a/apps/host-selfhost/src/mcp/org-path.ts b/apps/host-selfhost/src/mcp/org-path.ts index 15a2d89e89..9ab3f29f42 100644 --- a/apps/host-selfhost/src/mcp/org-path.ts +++ b/apps/host-selfhost/src/mcp/org-path.ts @@ -72,6 +72,18 @@ export const MCP_ORIGINAL_PATH_HEADER = "x-executor-mcp-original-path"; export const isRecognizedMcpOrgPath = (pathname: string): boolean => stripMcpOrgSegment(pathname) !== null; +/** + * Whether `pathname` targets one of self-host's MCP serving endpoints, in + * either bare or org-scoped form. Used by the production HTTP boundary to + * reject HEAD explicitly before the SPA's GET/HEAD fallback can claim it. + */ +export const isMcpServingPath = (pathname: string): boolean => { + const routed = stripMcpOrgSegment(pathname) ?? pathname; + if (routed === "/mcp" || routed === "/mcp/") return true; + const segments = routed.split("/").filter((segment) => segment.length > 0); + return segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits"; +}; + /** * Given a recognized original pathname (a `MCP_ORIGINAL_PATH_HEADER` value — * either the org-scoped MCP path itself, or its PRM-prefixed discovery-doc diff --git a/apps/host-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts index ff005cf623..f9d29b1f05 100644 --- a/apps/host-selfhost/src/mcp/session-store.ts +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -8,6 +8,7 @@ import { type InMemoryMcpSessionStore, } from "@executor-js/host-mcp/in-memory-session-store"; +import { selfHostAnalytics } from "../analytics"; import { ErrorCaptureLive } from "../observability"; import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; import { SelfHostExecutionStackLayer } from "../execution"; @@ -22,6 +23,9 @@ import { SelfHostExecutionStackLayer } from "../execution"; // identical seam with its own stack layer. // --------------------------------------------------------------------------- +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; +import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; + export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-store"; /** @@ -32,12 +36,23 @@ export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-sto export const makeSelfHostMcpSessionStore = ( db: SelfHostDbHandle, webBaseUrl?: string, + sessionIdleTtlMs?: number, ): InMemoryMcpSessionStore => makeInMemoryMcpSessionStore( makeMcpBuildServer( SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), + { + loadAppShellHtml: loadMcpAppsShellHtml, + smokeRenderArtifact, + // Artifact operations on the MCP plane come from an agent's tools. + onArtifactUsage: (action) => + selfHostAnalytics.record(`artifact_${action}`, { via: "agent" }), + }, ), - { webBaseUrl }, + { + ...(webBaseUrl === undefined ? {} : { webBaseUrl }), + ...(sessionIdleTtlMs === undefined ? {} : { sessionIdleTtlMs }), + }, ); /** The `McpSessionStore` envelope seam over a freshly built in-process store. */ diff --git a/apps/host-selfhost/src/multi-user.test.ts b/apps/host-selfhost/src/multi-user.test.ts index 6d54393667..c105e4ba31 100644 --- a/apps/host-selfhost/src/multi-user.test.ts +++ b/apps/host-selfhost/src/multi-user.test.ts @@ -42,8 +42,8 @@ const TINY_SPEC = JSON.stringify({ }, }); -const signUp = async (email: string): Promise => { - const inviteCode = await mintInviteCode(handler); +const signUp = async (email: string, role: "admin" | "member" = "member"): Promise => { + const inviteCode = await mintInviteCode(handler, role); const res = await handler( new Request(`${BASE}/api/auth/sign-up/email`, { method: "POST", @@ -136,7 +136,9 @@ const runCode = async (token: string, code: string) => { }; test("multiple accounts share one org but isolate per-user connections", async () => { - const alice = await signUp("alice@multi.test"); + // Workspace-level setup (the catalog, org-shared connections) is admin-only, + // so Alice joins as an admin; Bob stays a plain member. + const alice = await signUp("alice@multi.test", "admin"); const bob = await signUp("bob@multi.test"); // Same single org for both members. @@ -147,6 +149,32 @@ test("multiple accounts share one org but isolate per-user connections", async ( // The integration is tenant-scoped; register it once. expect((await addIntegration(alice, "tiny")).status).toBe(200); + // A plain member cannot register integrations or mint Workspace connections, + // but may still add a Personal credential. + expect((await addIntegration(bob, "tiny2")).status).toBe(403); + expect( + ( + await createConnection(bob, { + owner: "org", + name: "bob-shared", + integration: "tiny", + template: "bearer", + value: "bob-token", + }) + ).status, + ).toBe(403); + expect( + ( + await createConnection(bob, { + owner: "user", + name: "bob-private", + integration: "tiny", + template: "bearer", + value: "bob-token", + }) + ).status, + ).toBe(200); + // Alice attaches a USER-owned connection (private to her) and an ORG-owned // connection (shared across the tenant). expect( @@ -181,13 +209,16 @@ test("multiple accounts share one org but isolate per-user connections", async ( aliceConns.some((a) => a.includes("org") && a.includes(connectionName("team-shared"))), ).toBe(true); - // Bob — a different user in the SAME org — sees the org connection but NOT - // Alice's user-owned one. + // Bob — a different user in the SAME org — sees the org connection and his + // own Personal connection, but NOT Alice's user-owned one. const bobConns = await connectionAddresses(bob); expect(bobConns.some((a) => a.includes("org") && a.includes(connectionName("team-shared")))).toBe( true, ); expect(bobConns.some((a) => a.includes(connectionName("alice-private")))).toBe(false); + expect( + bobConns.some((a) => a.includes("user") && a.includes(connectionName("bob-private"))), + ).toBe(true); }); test("each account can execute code in its own scoped sandbox", async () => { diff --git a/apps/host-selfhost/src/platform-credential.test.ts b/apps/host-selfhost/src/platform-credential.test.ts new file mode 100644 index 0000000000..97244cb52a --- /dev/null +++ b/apps/host-selfhost/src/platform-credential.test.ts @@ -0,0 +1,210 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, expect, test } from "@effect/vitest"; + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-platform-")); + +// --------------------------------------------------------------------------- +// The shared middleware's PLATFORM branch, end-to-end over the real router: an +// org-level credential (the shape cloud's org-scoped API key resolves to) gets +// tenant-level READS on the ordinary product paths, and nothing else. +// +// Served through the self-host test app because it runs the SAME +// `makeExecutionStackMiddleware` cloud's protected plane uses — the branch +// under test is host-neutral, and this is the harness that can drive it +// without WorkOS. The `x-test-platform-org` header resolves the platform +// principal (see testing/test-app.ts). +// +// Pinned here: +// 1. GET /api/integrations answers the tenant catalog — the read that used +// to require a second, member credential (the customer-reported gap); +// 2. GET /api/connections answers org-owned rows WITHOUT any member's +// personal connections (bound reach, no subject); +// 3. any non-GET is refused 403 before a handler runs; +// 4. the credential mints NO subject row — an org key must never appear as a +// user of the workspace it observes. +// --------------------------------------------------------------------------- + +let handler!: (request: Request) => Promise; +let dispose: () => Promise = async () => {}; + +beforeAll(async () => { + const { makeSelfHostTestApp, headerIdentityLayer } = await import("./testing/test-app"); + const app = await makeSelfHostTestApp({ + identity: headerIdentityLayer, + }); + handler = app.handler; + dispose = app.dispose; +}); +afterAll(() => dispose()); + +const ORG = "platform-org"; +const MEMBER = "member-user"; + +const TINY_SPEC = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Tiny", version: "1.0.0" }, + servers: [{ url: "https://httpbin.org" }], + paths: { + "/get": { + get: { + operationId: "httpGet", + summary: "GET", + responses: { "200": { description: "ok" } }, + }, + }, + }, +}); + +const memberHeaders: Record = { + "x-test-user": MEMBER, + "x-test-org": ORG, + "content-type": "application/json", +}; + +const platformHeaders: Record = { + "x-test-platform-org": ORG, + "content-type": "application/json", +}; + +/** Seed as the MEMBER: a catalog row, an org-owned connection, and a personal + * one — so the platform reads below have both a hit and a must-miss. */ +beforeAll(async () => { + const add = await handler( + new Request("http://localhost/api/openapi/specs", { + method: "POST", + headers: memberHeaders, + body: JSON.stringify({ spec: { kind: "blob", value: TINY_SPEC }, slug: "cat", baseUrl: "" }), + }), + ); + expect(add.status).toBe(200); + const orgConn = await handler( + new Request("http://localhost/api/connections", { + method: "POST", + headers: memberHeaders, + body: JSON.stringify({ + owner: "org", + name: "shared", + integration: "cat", + template: "bearer", + value: "org-shared-token", + }), + }), + ); + expect(orgConn.status).toBe(200); + const userConn = await handler( + new Request("http://localhost/api/connections", { + method: "POST", + headers: memberHeaders, + body: JSON.stringify({ + owner: "user", + name: "personal", + integration: "cat", + template: "bearer", + value: "member-personal-token", + }), + }), + ); + expect(userConn.status).toBe(200); +}); + +test("a platform credential reads the tenant catalog on the ordinary product path", async () => { + const res = await handler( + new Request("http://localhost/api/integrations", { headers: platformHeaders }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as ReadonlyArray<{ readonly slug: string }>; + expect( + body.map((integration) => integration.slug), + "the catalog the member configured is readable with the org credential alone", + ).toContain("cat"); +}); + +test("connection reads answer org-owned rows and never a member's personal ones", async () => { + const res = await handler( + new Request("http://localhost/api/connections", { headers: platformHeaders }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as ReadonlyArray<{ readonly name: string }>; + const raw = JSON.stringify(body); + expect( + body.map((connection) => connection.name), + "the org-owned connection is visible", + ).toContain("shared"); + expect( + body.map((connection) => connection.name), + "a member's personal connection is not — the platform view binds no subject", + ).not.toContain("personal"); + expect(raw, "no credential material either way").not.toContain("token"); +}); + +test("the OAuth callback is refused despite being a GET", async () => { + // The one core GET with side effects: completing it would burn an org-owned + // in-flight authorization code in an outbound token exchange before the + // storage policy could refuse the final write. The safe-request gate excludes + // it by name; a platform credential landing here is a clear 403, not a + // half-executed flow. + const res = await handler( + new Request("http://localhost/api/oauth/callback?code=x&state=y", { + headers: platformHeaders, + }), + ); + expect(res.status, "the callback is not a safe read").toBe(403); + expect(await res.text()).toContain("read-only"); +}); + +test("every non-GET is refused before a handler runs", async () => { + const post = await handler( + new Request("http://localhost/api/connections", { + method: "POST", + headers: platformHeaders, + body: JSON.stringify({ + owner: "org", + name: "sneaky", + integration: "cat", + template: "bearer", + value: "nope", + }), + }), + ); + expect(post.status, "a write with a read-only credential is a clear 403").toBe(403); + expect(await post.text()).toContain("read-only"); + + const del = await handler( + new Request("http://localhost/api/integrations/cat", { + method: "DELETE", + headers: platformHeaders, + }), + ); + expect(del.status).toBe(403); + + // The refusal happened before any handler: the connection it tried to write + // does not exist. + const after = await handler( + new Request("http://localhost/api/connections", { headers: platformHeaders }), + ); + const names = ((await after.json()) as ReadonlyArray<{ readonly name: string }>).map( + (connection) => connection.name, + ); + expect(names).not.toContain("sneaky"); +}); + +test("the credential never mints a subject row in the workspace it observes", async () => { + // Several platform reads have run by now. If any of them touched the subject + // table, the org would report a phantom user; only the seeding member may + // appear. + const { withQueryContext } = await import("@executor-js/fumadb/query"); + const { createSelfHostDb } = await import("./db/self-host-db"); + const { loadConfig } = await import("./config"); + const db = await createSelfHostDb({ path: loadConfig().dbPath }); + const rows = await withQueryContext(db.db, { tenant: ORG, subject: null }).findMany("subject", { + orderBy: ["external_id", "asc"], + }); + await db.close(); + expect( + rows.map((row) => String(row.external_id)), + "only the member has a footprint", + ).toEqual([MEMBER]); +}); diff --git a/apps/host-selfhost/src/serve.ts b/apps/host-selfhost/src/serve.ts index 1803e7bb04..37939da929 100644 --- a/apps/host-selfhost/src/serve.ts +++ b/apps/host-selfhost/src/serve.ts @@ -25,7 +25,9 @@ import { } from "effect/unstable/http"; import { BunFileSystem, BunHttpServer, BunPath, BunRuntime } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; +import { jsonRpcErrorBody } from "@executor-js/host-mcp"; +import { disposeAnalytics } from "./analytics"; import { makeSelfHostApp } from "./app"; import { loadConfig } from "./config"; import type { BetterAuthHandle } from "./auth"; @@ -33,7 +35,8 @@ import { OAUTH_CALLBACK_PATH, oauthCallbackSignInRedirectLocation, } from "./auth/oauth-callback-login"; -import { MCP_ORIGINAL_PATH_HEADER, stripMcpOrgSegment } from "./mcp/org-path"; +import { isMcpServingPath, MCP_ORIGINAL_PATH_HEADER, stripMcpOrgSegment } from "./mcp/org-path"; +import { makeTelemetryLive } from "./telemetry"; const distDir = fileURLToPath(new URL("../dist/", import.meta.url)); const assetsDir = fileURLToPath(new URL("../dist/assets/", import.meta.url)); @@ -52,6 +55,19 @@ const selfHostHttpMiddleware = (betterAuth: BetterAuthHandle) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const url = new URL(request.url, "http://host.internal"); + // Streamable HTTP does not define HEAD. Reject it before the SPA's + // GET/HEAD fallback can claim `/mcp` and return a misleading empty + // `200 application/octet-stream` response. The shared MCP envelope + // rejects unsupported methods the same way, but the static route wins + // HEAD routing at the composed production-server boundary. + if (request.method === "HEAD" && isMcpServingPath(url.pathname)) { + const response = jsonRpcErrorBody(405, -32001, "Method not allowed"); + return HttpServerResponse.raw(response, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } if ( url.pathname === OAUTH_CALLBACK_PATH && (request.method === "GET" || request.method === "HEAD") @@ -93,7 +109,7 @@ const selfHostHttpMiddleware = (betterAuth: BetterAuthHandle) => export const startServer = async (): Promise => { const config = loadConfig(); - const { AppLayer, betterAuth } = await makeSelfHostApp(); + const { AppLayer, betterAuth, closeDb } = await makeSelfHostApp(); // Serve the built SPA, split by cacheability so a redeploy is picked up at // once instead of stranding browsers on a stale shell: @@ -120,6 +136,30 @@ export const startServer = async (): Promise => { cacheControl: "no-cache", }).pipe(Layer.provide(BunFileSystem.layer), Layer.provide(BunPath.layer)); + // Server-scope finalizer: flush buffered analytics on graceful shutdown. + const AnalyticsFlushLive = Layer.effectDiscard( + Effect.addFinalizer(() => Effect.promise(() => disposeAnalytics())), + ); + + // Server-scope finalizer: release what the app opened at boot — every live + // MCP session (transport, server, and its execution engine, whose detached + // sandbox fibers keep querying the DB until the engine is shut down) and then + // the shared libSQL handle itself. `makeSelfHostApiHandler` runs this in its + // `dispose`, so tests have always released it; the long-lived server dropped + // the hook on the floor and leaked both across a graceful shutdown. + const AppResourcesLive = Layer.effectDiscard( + Effect.addFinalizer(() => Effect.promise(() => closeDb())), + ); + + // OTLP export, or `Layer.empty` when no collector is configured (see + // ./telemetry). The `http.server` envelope span each request's `withSpan` + // children parent under is NOT wired here: `HttpEffect.toHandled` already + // wraps every served app in `HttpMiddleware.tracer`, which also continues an + // inbound `traceparent` so a request arriving through a tunnel keeps one + // trace id. Adding it here as well produced two identical `http.server` + // spans per request, one the parent of the other. + const TelemetryLive = makeTelemetryLive(); + const ServerLive = HttpRouter.serve(Layer.mergeAll(AppLayer, AssetsLive, SpaLive), { middleware: selfHostHttpMiddleware(betterAuth), }).pipe( @@ -128,7 +168,16 @@ export const startServer = async (): Promise => { ), ); - await BunRuntime.runMain(Layer.launch(ServerLive)); + // Provided UNDER the server, not merged beside it: the tracer must already be + // in scope while the app's layers build, or spans created during construction + // resolve the default no-op tracer and are silently dropped. + await BunRuntime.runMain( + Layer.launch( + Layer.mergeAll(ServerLive, AnalyticsFlushLive, AppResourcesLive).pipe( + Layer.provide(TelemetryLive), + ), + ), + ); }; if (import.meta.main) { diff --git a/apps/host-selfhost/src/subject-sightings.test.ts b/apps/host-selfhost/src/subject-sightings.test.ts new file mode 100644 index 0000000000..635ea5d472 --- /dev/null +++ b/apps/host-selfhost/src/subject-sightings.test.ts @@ -0,0 +1,82 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Effect, Layer } from "effect"; +import { afterAll, beforeAll, expect, test } from "@effect/vitest"; +import { withQueryContext } from "@executor-js/fumadb/query"; + +import { makeScopedExecutor } from "@executor-js/api/server"; + +import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; +import { SelfHostScopedExecutorSeams } from "./execution"; +import type { SelfHostPlugins } from "./plugins"; + +// The `subject` table is populated at the request seam: `makeScopedExecutor` is +// what every HTTP request and MCP session on every host passes through, so +// building a scoped executor IS a sighting. Exercised here against a real host's +// seams (long-lived SQLite DbProvider + real plugins) rather than a stub, since +// the point is that the production wiring reaches the writer at all. + +const createScopedExecutor = (accountId: string, organizationId: string) => + makeScopedExecutor(accountId, organizationId, "Default").pipe( + Effect.provide(SelfHostScopedExecutorSeams), + ); + +const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-")); +process.env.EXECUTOR_DATA_DIR = dataDir; + +let dbLayer!: Layer.Layer; +let dbHandle: Awaited> | undefined; + +beforeAll(async () => { + dbHandle = await createSelfHostDb({ + path: join(dataDir, "data.db"), + namespace: "executor_selfhost", + version: "1.0.0", + }); + dbLayer = Layer.succeed(SelfHostDb)(dbHandle); +}); +afterAll(async () => { + await dbHandle?.close(); +}); + +const subjectsOf = async (tenant: string): Promise => { + const rows = await withQueryContext(dbHandle!.db, { + tenant, + subject: null, + }).findMany("subject", { + orderBy: ["external_id", "asc"], + }); + return rows.map((row) => String(row.external_id)); +}; + +test("building a scoped executor records the acting principal", async () => { + const before = Date.now(); + await Effect.runPromise( + createScopedExecutor("alice", "sightings-org").pipe(Effect.provide(dbLayer), Effect.asVoid), + ); + + expect(await subjectsOf("sightings-org")).toEqual(["alice"]); + + const [row] = await withQueryContext(dbHandle!.db, { + tenant: "sightings-org", + subject: null, + }).findMany("subject", {}); + expect(Number(row?.last_seen_at)).toBeGreaterThanOrEqual(before); +}); + +test("repeat requests from several members converge on one row each", async () => { + const org = "sightings-org-2"; + await Effect.runPromise( + Effect.forEach( + ["alice", "alice", "bob", "alice"], + (accountId) => createScopedExecutor(accountId, org).pipe(Effect.asVoid), + { discard: true }, + ).pipe(Effect.provide(dbLayer)), + ); + + // Four requests, two principals, two rows: the sighting is an upsert, and + // the repeat sightings are absorbed by the throttle rather than duplicating. + expect(await subjectsOf(org)).toEqual(["alice", "bob"]); +}); diff --git a/apps/host-selfhost/src/system/api.ts b/apps/host-selfhost/src/system/api.ts index 4b6200dc8d..01d8424cd3 100644 --- a/apps/host-selfhost/src/system/api.ts +++ b/apps/host-selfhost/src/system/api.ts @@ -7,8 +7,10 @@ import { Schema } from "effect"; // GET /api/health readiness probe (used by the container healthcheck) // GET /api/setup-status whether the instance still needs first-run setup, so // the pre-login SPA can route a fresh operator to /setup +// GET /api/auth-config which SSO sign-in providers are configured, so the +// login page knows which provider buttons to render // -// Both are deliberately unauthenticated and return only booleans/status — no +// All are deliberately unauthenticated and return only booleans/status — no // sensitive data — so they can be read before anyone has signed in. // --------------------------------------------------------------------------- @@ -21,6 +23,10 @@ export class SystemError extends Schema.TaggedErrorClass()( export const HealthResponse = Schema.Struct({ status: Schema.String }); export const SetupStatusResponse = Schema.Struct({ needsSetup: Schema.Boolean }); export const InviteStatusResponse = Schema.Struct({ valid: Schema.Boolean }); +// Provider ids + display names only — never credentials or allowlists. +export const AuthConfigResponse = Schema.Struct({ + ssoProviders: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String })), +}); const InviteStatusParams = { code: Schema.String }; @@ -37,6 +43,12 @@ export const SystemApi = HttpApiGroup.make("system") error: [SystemError], }), ) + .add( + HttpApiEndpoint.get("authConfig", "/auth-config", { + success: AuthConfigResponse, + error: [SystemError], + }), + ) .add( HttpApiEndpoint.get("inviteStatus", "/invite-status/:code", { params: InviteStatusParams, diff --git a/apps/host-selfhost/src/system/handlers.ts b/apps/host-selfhost/src/system/handlers.ts index 8ecb4199b8..ece96d8215 100644 --- a/apps/host-selfhost/src/system/handlers.ts +++ b/apps/host-selfhost/src/system/handlers.ts @@ -6,6 +6,7 @@ import { SystemError, SystemHttpApi } from "./api"; import { BetterAuth, countOrgMembers, type BetterAuthHandle } from "../auth/better-auth"; import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; import { findRedeemableCode } from "../auth/invites"; +import { loadConfig } from "../config"; // --------------------------------------------------------------------------- // Handlers for the public system API. Unauthenticated; every DB touch is an @@ -40,6 +41,15 @@ export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (han return { needsSetup: count === 0 }; }), ) + .handle("authConfig", () => + // Which SSO provider the operator configured — id + display name only, so + // the pre-login page knows which button to render. Config is env-derived + // and boot-validated, so this read cannot fail. + Effect.sync(() => { + const sso = loadConfig().sso; + return { ssoProviders: sso ? [{ id: sso.providerId, name: sso.providerName }] : [] }; + }), + ) .handle("inviteStatus", ({ params }) => Effect.gen(function* () { const { client } = yield* SelfHostDb; diff --git a/apps/host-selfhost/src/telemetry.test.ts b/apps/host-selfhost/src/telemetry.test.ts new file mode 100644 index 0000000000..e0fecf246c --- /dev/null +++ b/apps/host-selfhost/src/telemetry.test.ts @@ -0,0 +1,237 @@ +import { expect, test } from "@effect/vitest"; +import { FetchHttpClient } from "effect/unstable/http"; +import { Cause, Effect, Layer } from "effect"; + +import { makeTelemetryLive } from "./telemetry"; + +// The layer is opaque, so these assert the observable consequence rather than +// its shape: what the exporter puts on the wire, and — for the default — that +// nothing goes on the wire at all. +test("sends nothing when no OTLP endpoint is configured", async () => { + expect(await captureExports({}, { log: true })).toEqual([]); +}); + +test("exports spans when the base endpoint is set", async () => { + const requests = await captureExports({ OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector.test" }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); +}); + +// The signal endpoint is already a full URL; appending /v1/traces to it would +// POST to /v1/traces/v1/traces, which a collector answers with a 404 the +// operator never sees. Asserted on the request the exporter actually makes. +test("does not append a second signal path to an explicit traces endpoint", async () => { + const urls = await captureExportUrls({ + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://collector.test/v1/traces", + }); + expect(urls).toEqual(["http://collector.test/v1/traces"]); +}); + +test("appends the signal path to a base endpoint, trailing slash or not", async () => { + expect(await captureExportUrls({ OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector.test" })).toEqual( + ["http://collector.test/v1/traces"], + ); + expect( + await captureExportUrls({ OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector.test///" }), + ).toEqual(["http://collector.test/v1/traces"]); +}); + +test("sends configured headers so a hosted collector can authenticate", async () => { + const headers = await captureExportHeaders({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector.test", + OTEL_EXPORTER_OTLP_HEADERS: "authorization=Bearer tok, x-dataset=executor", + }); + expect(headers?.authorization).toBe("Bearer tok"); + expect(headers?.["x-dataset"]).toBe("executor"); +}); + +// A collector that is down, unreachable through the tunnel, or answering 500 +// must never take the request that produced the span with it — the operator +// enabled tracing to diagnose a problem, not to acquire a new one. +test("a failing collector does not fail the traced effect", async () => { + const result = await Effect.runPromise( + Effect.succeed("served").pipe( + Effect.withSpan("probe"), + Effect.provide( + makeTelemetryLive({ OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector.test" }).pipe( + Layer.provide(stubFetch(() => new Response("boom", { status: 500 }))), + ), + ), + Effect.scoped, + ), + ); + expect(result).toBe("served"); +}); + +test("log export stays off unless separately opted in", async () => { + const base = { OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector.test" }; + expect(await captureExportUrls(base, { log: true })).toEqual(["http://collector.test/v1/traces"]); + expect( + await captureExportUrls({ ...base, EXECUTOR_OTEL_EXPORT_LOGS: "true" }, { log: true }), + ).toEqual( + expect.arrayContaining(["http://collector.test/v1/traces", "http://collector.test/v1/logs"]), + ); +}); + +// With only a traces endpoint there is no base to derive a logs URL from. +// Guessing one would POST logs to a path the operator never named. +test("log export needs its own endpoint when only traces is configured", async () => { + const urls = await captureExportUrls( + { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://collector.test/v1/traces", + EXECUTOR_OTEL_EXPORT_LOGS: "true", + }, + { log: true }, + ); + expect(urls).toEqual(["http://collector.test/v1/traces"]); +}); + +// The self-host exporter is its own path to the wire — no cloud span processor +// runs here — so the credential scrub is asserted on the serialized OTLP +// payload the collector actually receives. +test("the exported payload carries no query values, userinfo, or fragments", async () => { + const SECRET = "synthetic-selfhost-canary"; + const seen: Array = []; + await Effect.runPromise( + Effect.gen(function* () { + // A span whose URL attributes carry every credential shape at once. + yield* Effect.void.pipe( + Effect.withSpan("canary.request", { + attributes: { + "url.full": `https://svc:${SECRET}-userinfo@api.test/graphql?owner=${SECRET}-query#access_token=${SECRET}-fragment`, + "url.query": `owner=${SECRET}-query`, + "error.message": `request to https://api.test/graphql?key=${SECRET}-text failed`, + }, + }), + ); + // A failed span: the error message (with its raw URL) becomes the + // exported status message and exception event. + yield* Effect.fail( + `Transport: fetch failed (GET https://u:${SECRET}-err@api.test/graphql?key=${SECRET}-errq)`, + ).pipe(Effect.withSpan("canary.failure"), Effect.exit); + }).pipe( + Effect.provide( + makeTelemetryLive({ OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector.test" }).pipe( + Layer.provide( + stubFetch((request) => { + seen.push(request); + return new Response(null, { status: 200 }); + }), + ), + ), + ), + Effect.scoped, + ), + ); + + const body = await seen[0]?.text(); + expect(body).toBeDefined(); + // Non-vacuous: both spans made it onto the wire with their host and path. + expect(body).toContain("canary.request"); + expect(body).toContain("canary.failure"); + expect(body).toContain("/graphql"); + expect(body).not.toContain(SECRET); + // No exported url.full keeps a query string. + expect(body).not.toContain("graphql?"); +}); + +// Logs are their own OTLP signal with their own serialization path: the +// OtlpLogger exports `Cause.pretty` output and every log annotation, and the +// server logs OAuth callback failure causes whose error messages embed the raw +// URL. The scrub is asserted on the logs payload the collector receives. +test("the exported logs payload carries no credential-bearing URLs", async () => { + const SECRET = "synthetic-selfhost-log-canary"; + const seen: Array = []; + await Effect.runPromise( + Effect.gen(function* () { + yield* Effect.logError( + "oauth callback failed", + Cause.fail( + `Transport: fetch failed (GET https://u:${SECRET}-userinfo@api.test/api/oauth/callback?code=${SECRET}-code)`, + ), + ).pipe( + Effect.annotateLogs( + "request.url", + `https://api.test/api/oauth/callback?code=${SECRET}-annotation`, + ), + ); + }).pipe( + Effect.provide( + makeTelemetryLive({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector.test", + EXECUTOR_OTEL_EXPORT_LOGS: "true", + }).pipe( + Layer.provide( + stubFetch((request) => { + seen.push(request); + return new Response(null, { status: 200 }); + }), + ), + ), + ), + Effect.scoped, + ), + ); + + const body = await seen.find((request) => request.url.endsWith("/v1/logs"))?.text(); + expect(body).toBeDefined(); + // Non-vacuous: the record, its cause, and the scrubbed URL are on the wire. + expect(body).toContain("oauth callback failed"); + expect(body).toContain("/api/oauth/callback"); + expect(body).not.toContain(SECRET); +}); + +// --- helpers --------------------------------------------------------------- + +// `Fetch` is a Context.Reference with `globalThis.fetch` as its default, so +// setting it discharges no requirement — the layer is `Layer` and works +// by overriding the default in whatever context it is provided to. +const stubFetch = (respond: (request: Request) => Response): Layer.Layer => + Layer.succeed(FetchHttpClient.Fetch)(((input: RequestInfo | URL, init?: RequestInit) => + Promise.resolve( + respond(input instanceof Request ? input : new Request(String(input), init)), + )) as typeof globalThis.fetch); + +/** + * Runs a traced effect against a recording fetch and returns the URLs the + * exporter posted to. The layer's shutdown flush is what forces the export, so + * the effect is scoped and the scope closed before reading. + */ +const captureExportUrls = async ( + env: Record, + options?: { readonly log?: boolean }, +): Promise> => (await captureExports(env, options)).map((request) => request.url); + +const captureExportHeaders = async ( + env: Record, +): Promise | undefined> => { + const requests = await captureExports(env); + const headers = requests[0]?.headers; + return headers ? Object.fromEntries(headers.entries()) : undefined; +}; + +const captureExports = async ( + env: Record, + options?: { readonly log?: boolean }, +): Promise> => { + const seen: Array = []; + await Effect.runPromise( + Effect.gen(function* () { + if (options?.log) yield* Effect.logInfo("probe log"); + }).pipe( + Effect.withSpan("probe"), + Effect.provide( + makeTelemetryLive(env).pipe( + Layer.provide( + stubFetch((request) => { + seen.push(request); + return new Response(null, { status: 200 }); + }), + ), + ), + ), + Effect.scoped, + ), + ); + return seen; +}; diff --git a/apps/host-selfhost/src/telemetry.ts b/apps/host-selfhost/src/telemetry.ts new file mode 100644 index 0000000000..8fed1c612f --- /dev/null +++ b/apps/host-selfhost/src/telemetry.ts @@ -0,0 +1,130 @@ +// --------------------------------------------------------------------------- +// Self-host OTLP export. +// +// The shared packages this server runs are already instrumented — `withSpan` +// wraps tool execution, OAuth, connection health, and storage — but without a +// tracer those spans go nowhere. That is why a slow request on someone else's +// hardware can only be reported as "it takes five seconds": nothing the +// operator can read says which phase the time went to. Pointing this at a local +// collector turns that into a waterfall. +// +// Export is opt-in and off by default. Self-host runs on the operator's own +// machine, so sending their traces anywhere without being asked is not ours to +// decide; the default is no exporter, no HTTP client, no buffer. +// +// This uses Effect's own OTLP modules rather than the OpenTelemetry SDK. Cloud +// needs `@effect/opentelemetry` because workerd forces a hand-built +// WebTracerProvider, but self-host has no such constraint, and the built-in +// path needs only an HttpClient — no `@opentelemetry/*` dependencies, nothing +// extra in the container image. +// --------------------------------------------------------------------------- + +import { FetchHttpClient } from "effect/unstable/http"; +import { OtlpLogger, OtlpTracer } from "effect/unstable/observability"; +import { Layer } from "effect"; + +import { UrlRedactingOtlpSerializationJson } from "@executor-js/sdk"; + +import packageJson from "../package.json" with { type: "json" }; + +const SERVICE_NAME = "executor-selfhost"; +const SERVICE_VERSION: string = packageJson.version; + +/** Resolved OTLP export settings, or `undefined` when export is off. */ +interface TelemetryTarget { + readonly tracesUrl: string; + /** `undefined` when log export is off, or when no URL can be built for it. */ + readonly logsUrl: string | undefined; + readonly headers: Record | undefined; +} + +// Signal URLs follow the OTLP spec: OTEL_EXPORTER_OTLP_ENDPOINT is a base that +// each signal appends its own path to, while OTEL_EXPORTER_OTLP__ENDPOINT +// is already the full URL for that one signal. Appending unconditionally would +// POST to /v1/traces/v1/traces, which a collector answers with a 404 that never +// reaches the operator — the export just silently produces nothing. +const resolveTarget = (env: Record): TelemetryTarget | undefined => { + const base = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim().replace(/\/+$/, ""); + const tracesUrl = + env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim() ?? (base ? `${base}/v1/traces` : undefined); + if (!tracesUrl) return undefined; + // Logs are opt-in separately from traces: they are the noisier and more + // sensitive of the two, carrying every request line and whatever the + // operator's own log calls hold. Traces answer "where did the time go" by + // themselves, so shipping logs stays a second, deliberate choice. Set the + // signal endpoint explicitly if only the traces endpoint is configured — + // there is no base to derive a logs URL from, and guessing one would export + // to a path the operator never named. + const logsUrl = isTruthy(env.EXECUTOR_OTEL_EXPORT_LOGS) + ? (env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT?.trim() ?? (base ? `${base}/v1/logs` : undefined)) + : undefined; + return { tracesUrl, logsUrl, headers: parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS) }; +}; + +const isTruthy = (value: string | undefined): boolean => + value === "1" || value === "true" || value === "yes"; + +// OTEL_EXPORTER_OTLP_HEADERS carries auth for a hosted collector, in the spec's +// "k=v,k2=v2" form. A malformed pair is skipped rather than failing the boot: +// losing traces must never cost the operator their server. +const parseHeaders = (raw: string | undefined): Record | undefined => { + const entries = (raw ?? "") + .split(",") + .map((pair) => pair.trim()) + .filter((pair) => pair.length > 0) + .flatMap((pair) => { + const separator = pair.indexOf("="); + if (separator <= 0) return []; + return [[pair.slice(0, separator).trim(), pair.slice(separator + 1).trim()] as const]; + }); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +}; + +/** + * OTLP export layer for the self-hosted server, or {@link Layer.empty} when no + * endpoint is configured — so the default deployment neither exports nor pays + * for an exporter it will not use. + * + * Provide this UNDER the app layer (`Layer.provide`), not merged beside it: the + * tracer has to be in scope while the app's own layers build, or spans created + * during construction find the no-op tracer instead. + */ +export const makeTelemetryLive = ( + env: Record = process.env, +): Layer.Layer => { + const target = resolveTarget(env); + if (!target) return Layer.empty; + + const resource = { serviceName: SERVICE_NAME, serviceVersion: SERVICE_VERSION }; + + const TracerLive = OtlpTracer.layer({ + url: target.tracesUrl, + resource, + headers: target.headers, + }); + + const logsUrl = target.logsUrl; + const LoggerLive = logsUrl + ? OtlpLogger.layer({ url: logsUrl, resource, headers: target.headers }) + : Layer.empty; + + console.info( + `[executor] exporting traces to ${target.tracesUrl}${logsUrl ? ` and logs to ${logsUrl}` : ""}`, + ); + + return Layer.merge(TracerLive, LoggerLive).pipe( + // The redacting serialization is the scrub for this exporter path: no + // cloud span processor runs here, so credential-bearing URL components + // (query values, userinfo, fragments) are stripped from the trace and log + // payloads at the serialization seam every exported span and log record + // passes through. Removal only — this path adds no stripped-keys + // diagnostic. + Layer.provide(UrlRedactingOtlpSerializationJson), + // The exporter gets a plain fetch client, deliberately not the guarded + // hosted client: the collector is an address the operator configured, so + // the SSRF guard's job (stopping agent-chosen URLs from reaching the + // private network) does not apply, and applying it would block the most + // common setup of all — a collector on localhost. + Layer.provide(FetchHttpClient.layer), + ); +}; diff --git a/apps/host-selfhost/src/testing/test-app.ts b/apps/host-selfhost/src/testing/test-app.ts index ddc20bbc80..23be89ff44 100644 --- a/apps/host-selfhost/src/testing/test-app.ts +++ b/apps/host-selfhost/src/testing/test-app.ts @@ -5,8 +5,10 @@ import { composePluginApi, ExecutorApp, IdentityProvider, + isPlatformPrincipal, PluginsProvider, type Principal, + type ResolvedPrincipal, textFailureStrategy, Unauthorized, } from "@executor-js/api/server"; @@ -79,6 +81,7 @@ export const singleAdminIdentityLayer = ( IdentityProvider.of({ authenticate: () => Effect.succeed({ + kind: "member", accountId: options.userId, organizationId: options.organizationId, organizationName: options.organizationName, @@ -86,6 +89,8 @@ export const singleAdminIdentityLayer = ( name: "Admin", avatarUrl: null, roles: ["admin"], + orgRoleModel: "organization", + orgRole: "admin", }), }), ); @@ -98,10 +103,24 @@ export const headerIdentityLayer: Layer.Layer = Layer.succeed( IdentityProvider, IdentityProvider.of({ authenticate: (request) => { + // `x-test-platform-org` resolves the org-level PLATFORM credential — the + // neutral shape cloud's org-scoped API key produces — so the shared + // middleware's platform branch is testable against this harness even + // though self-host's real identity never mints one. + const platformOrg = request.headers.get("x-test-platform-org"); + if (platformOrg) { + return Effect.succeed({ + kind: "platform", + organizationId: platformOrg, + organizationName: `Org ${platformOrg}`, + keyId: "test_platform_key", + }); + } const userId = request.headers.get("x-test-user"); const organizationId = request.headers.get("x-test-org"); if (!userId || !organizationId) return Effect.fail(new Unauthorized()); return Effect.succeed({ + kind: "member", accountId: userId, organizationId, organizationName: `Org ${organizationId}`, @@ -109,6 +128,8 @@ export const headerIdentityLayer: Layer.Layer = Layer.succeed( name: userId, avatarUrl: null, roles: ["admin"], + orgRoleModel: "organization", + orgRole: "admin", }); }, }), @@ -143,12 +164,17 @@ const stubMcpAuth: Layer.Layer = Layer resourceMetadataUrl: resourceMetadataUrlFor, authenticate: (request: Request): Effect.Effect => fallback.authenticate(request).pipe( + // The test identity never resolves a platform credential; narrow it + // away (as the production selfHostMcpAuth does) rather than asserting. Effect.map((principal) => - principal ? authenticated(principal) : unauthorized(challengeFor(request)), + principal && !isPlatformPrincipal(principal) + ? authenticated(principal) + : unauthorized(challengeFor(request)), ), Effect.catchTags({ Unauthorized: () => Effect.succeed(unauthorized(challengeFor(request))), NoOrganization: () => Effect.succeed(unauthorized(challengeFor(request))), + ReadOnlyCredential: () => Effect.succeed(unauthorized(challengeFor(request))), }), ), }; diff --git a/apps/host-selfhost/tsconfig.json b/apps/host-selfhost/tsconfig.json index e214693ed9..18cfacc0ab 100644 --- a/apps/host-selfhost/tsconfig.json +++ b/apps/host-selfhost/tsconfig.json @@ -3,6 +3,10 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", + // This host opts into the create-time artifact smoke render, whose entry + // dynamically imports a `.tsx` component graph. Nothing here renders + // anything; the compiler still resolves that graph. + "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/apps/host-selfhost/vite.config.ts b/apps/host-selfhost/vite.config.ts index 9cf280edd8..48061de1a6 100644 --- a/apps/host-selfhost/vite.config.ts +++ b/apps/host-selfhost/vite.config.ts @@ -7,6 +7,7 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { innerRendererPlugin, mcpAppsShellAsset } from "@executor-js/mcp-apps-shell/vite"; import { routes } from "./tsr.routes"; import { MCP_ORIGINAL_PATH_HEADER, stripMcpOrgSegment } from "./src/mcp/org-path"; @@ -157,7 +158,13 @@ function executorApiPlugin(): Plugin { const response = await handler(webRequest); res.statusCode = response.status; - response.headers.forEach((value, key) => res.setHeader(key, value)); + response.headers.forEach((value, key) => { + if (key.toLowerCase() !== "set-cookie") res.setHeader(key, value); + }); + // Set-Cookie is repeatable, not comma-joinable. In Bun, forEach visits + // each cookie separately; setHeader would overwrite all but the last. + const cookies = response.headers.getSetCookie(); + if (cookies.length > 0) res.setHeader("set-cookie", cookies); if (response.body) { const reader = response.body.getReader(); for (;;) { @@ -230,6 +237,12 @@ export default defineConfig({ plugins: [ executorApiPlugin(), tailwindcss(), + // The artifact page hosts the MCP-Apps shell as a sandboxed iframe over the + // MCP-Apps protocol: `mcpAppsShellAsset` emits the built shell document and + // hands the page its URL, and `innerRendererPlugin` inlines the shell's own + // sandboxed inner frame from `virtual:executor-inner-renderer`. + mcpAppsShellAsset() as Plugin, + innerRendererPlugin(), executorVitePlugin({ configPath: fileURLToPath(new URL("./executor.config.ts", import.meta.url)), }), diff --git a/apps/host-selfhost/web/auth-client.ts b/apps/host-selfhost/web/auth-client.ts index 729a231442..5a1cbfb632 100644 --- a/apps/host-selfhost/web/auth-client.ts +++ b/apps/host-selfhost/web/auth-client.ts @@ -5,6 +5,7 @@ import { genericOAuthClient } from "better-auth/client/plugins"; // origin); the session cookie it sets is what the shared AuthProvider's // /account/me query and all API calls authenticate with. Only the login form // and sign-out use this — auth STATE comes from the shared AuthProvider. +// `genericOAuthClient` adds `signIn.oauth2` for the configured SSO provider. export const authClient = createAuthClient({ baseURL: `${window.location.origin}/api/auth`, plugins: [genericOAuthClient()], diff --git a/apps/host-selfhost/web/auth-config.ts b/apps/host-selfhost/web/auth-config.ts new file mode 100644 index 0000000000..4fe7549923 --- /dev/null +++ b/apps/host-selfhost/web/auth-config.ts @@ -0,0 +1,29 @@ +// Pre-login read of which SSO providers the operator configured, so the login +// page knows which provider buttons to render. Same boundary as setup-status: a +// plain same-origin fetch that runs before the atom registry exists. Fails soft +// to "no providers" — the email/password form is always available, so a hiccup +// here degrades to the baseline login, never a lockout. + +export interface SsoProvider { + readonly id: string; + readonly name: string; +} + +const isSsoProvider = (value: unknown): value is SsoProvider => + typeof value === "object" && + value !== null && + typeof (value as { id?: unknown }).id === "string" && + typeof (value as { name?: unknown }).name === "string"; + +export const fetchSsoProviders = async (): Promise => { + const response = await fetch("/api/auth-config", { credentials: "same-origin" }).then( + (r) => r, + () => null, + ); + if (!response?.ok) return []; + const data = (await response.json().then( + (d) => d, + () => ({}), + )) as { ssoProviders?: unknown }; + return Array.isArray(data.ssoProviders) ? data.ssoProviders.filter(isSsoProvider) : []; +}; diff --git a/apps/host-selfhost/web/login.tsx b/apps/host-selfhost/web/login.tsx index 14b6a94290..6b6f7f9580 100644 --- a/apps/host-selfhost/web/login.tsx +++ b/apps/host-selfhost/web/login.tsx @@ -5,36 +5,46 @@ import { Input } from "@executor-js/react/components/input"; import { Label } from "@executor-js/react/components/label"; import { authClient } from "./auth-client"; +import { fetchSsoProviders, type SsoProvider } from "./auth-config"; import { AuthLayout } from "./auth-layout"; -import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to"; +import { loginErrorCallback, postLoginTarget } from "../src/auth/return-to"; const EXTERNAL_OIDC_PROVIDER_ID = "external-oidc"; -// Self-host login: email + password sign-in via Better Auth. On success we -// reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps -// in the app. (Cloud's equivalent is a WorkOS redirect — this is the -// provider-specific piece injected into the shared shell.) +// Self-host login: email + password sign-in via Better Auth, plus a provider +// button per configured SSO provider (read from /api/auth-config). On +// success we reload so the shared AuthProvider re-reads /account/me and the +// AuthGate swaps in the app. (Cloud's equivalent is a WorkOS redirect — this is +// the provider-specific piece injected into the shared shell.) // // There is no self-signup here: open registration is closed. New people join by // redeeming an invite — either the full /join/ link, or by entering the // code here ("Have an invite code?"), which forwards to the same join page. export const LoginPage = () => { const search = window.location.search; - // Where to go after sign-in: resume an interrupted MCP OAuth authorize if we - // arrived from one (Better Auth redirects it here with the OAuth params), - // otherwise honor a safe returnTo (e.g. an integration OAuth callback), else - // land on the dashboard. - const postLogin = - mcpAuthorizeResumeTarget(search) ?? - safeReturnTo(new URLSearchParams(search).get("returnTo")) ?? - "/"; + // Where to go after sign-in. The gate renders this page IN PLACE of the + // requested route without navigating, so the live location is what carries a + // deep link (`/connect/linear`) across sign-in — see `postLoginTarget`. + const postLogin = postLoginTarget(window.location); const [mode, setMode] = useState<"signin" | "code" | "link">("signin"); + const [linkProviderId, setLinkProviderId] = useState(EXTERNAL_OIDC_PROVIDER_ID); const [oidcEnabled, setOidcEnabled] = useState(false); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [code, setCode] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [ssoProviders, setSsoProviders] = useState([]); + + useEffect(() => { + let cancelled = false; + void fetchSsoProviders().then((providers) => { + if (!cancelled) setSsoProviders(providers); + }); + return () => { + cancelled = true; + }; + }, []); useEffect(() => { let active = true; @@ -69,7 +79,7 @@ export const LoginPage = () => { } }, [search]); - const oidcErrorCallback = `/login?error=oidc&returnTo=${encodeURIComponent(postLogin)}`; + const oidcErrorCallback = loginErrorCallback(postLogin); const startOidcSignIn = async () => { setBusy(true); @@ -98,7 +108,7 @@ export const LoginPage = () => { } if (mode === "link") { const link = await authClient.oauth2.link({ - providerId: EXTERNAL_OIDC_PROVIDER_ID, + providerId: linkProviderId, callbackURL: postLogin, errorCallbackURL: oidcErrorCallback, }); @@ -111,6 +121,23 @@ export const LoginPage = () => { window.location.href = postLogin; }; + const signInWithSso = async (providerId: string) => { + setBusy(true); + setError(null); + // Better Auth redirects the browser to the IdP; on callback it lands on + // `callbackURL`, so the deep-link target survives the round trip the same + // way it does for the email form's post-login navigation. + const result = await authClient.signIn.oauth2({ + providerId, + callbackURL: postLogin, + errorCallbackURL: oidcErrorCallback, + }); + if (result.error) { + setBusy(false); + setError(result.error.message ?? "Sign in failed"); + } + }; + const redeem = (event: FormEvent) => { event.preventDefault(); const trimmed = code.trim(); @@ -170,6 +197,41 @@ export const LoginPage = () => { + {mode === "signin" && ssoProviders.length > 0 && ( + <> +
+
+ or +
+
+ {ssoProviders.map((provider) => ( +
+ + +
+ ))} + + )} ) : (
@@ -205,6 +267,7 @@ export const LoginPage = () => { variant="ghost" disabled={busy} onClick={() => { + setLinkProviderId(EXTERNAL_OIDC_PROVIDER_ID); setMode("link"); setError(null); }} diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index 7c70514667..c79ac4fa09 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -11,16 +11,21 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LoginRouteImport } from './routes/public/login' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as AdminRouteImport } from './routes/app/admin' import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug' +import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport } from './../../../packages/react/src/routes/plugins.$pluginId.$' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' @@ -35,6 +40,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = path: '/{-$orgSlug}/', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({ + id: '/{-$orgSlug}/users', + path: '/{-$orgSlug}/users', + getParentRoute: () => rootRouteImport, + } as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({ id: '/{-$orgSlug}/tools', @@ -59,6 +70,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ + id: '/{-$orgSlug}/artifacts', + path: '/{-$orgSlug}/artifacts', + getParentRoute: () => rootRouteImport, + } as any) const ApiKeysRoute = ApiKeysRouteImport.update({ id: '/{-$orgSlug}/api-keys', path: '/{-$orgSlug}/api-keys', @@ -91,6 +108,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update( + { + id: '/{-$orgSlug}/integrations/browse', + path: '/{-$orgSlug}/integrations/browse', + getParentRoute: () => rootRouteImport, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update( { @@ -99,6 +124,23 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRout getParentRoute: () => rootRouteImport, } as any, ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport.update( + { + id: '/{-$orgSlug}/connect/$integrationSlug', + path: '/{-$orgSlug}/connect/$integrationSlug', + getParentRoute: () => rootRouteImport, + } as any, + ) +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport.update( + { + id: '/$artifactId', + path: '/$artifactId', + getParentRoute: () => + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute, + } as any, + ) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRouteImport.update( { @@ -121,12 +163,17 @@ export interface FileRoutesByFullPath { '/join/$code': typeof JoinDotcodeRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute + '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -137,12 +184,17 @@ export interface FileRoutesByTo { '/join/$code': typeof JoinDotcodeRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute + '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -154,12 +206,17 @@ export interface FileRoutesById { '/join/$code': typeof JoinDotcodeRoute '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute + '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute + '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute + '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute @@ -172,12 +229,17 @@ export interface FileRouteTypes { | '/join/$code' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' + | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' + | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -188,12 +250,17 @@ export interface FileRouteTypes { | '/join/$code' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' + | '/{-$orgSlug}/users' | '/{-$orgSlug}' + | '/{-$orgSlug}/artifacts/$artifactId' + | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -204,12 +271,17 @@ export interface FileRouteTypes { | '/join/$code' | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' + | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' | '/{-$orgSlug}/tools' + | '/{-$orgSlug}/users' | '/{-$orgSlug}/' + | '/{-$orgSlug}/artifacts/$artifactId' + | '/{-$orgSlug}/connect/$integrationSlug' | '/{-$orgSlug}/integrations/$namespace' + | '/{-$orgSlug}/integrations/browse' | '/{-$orgSlug}/resume/$executionId' | '/{-$orgSlug}/toolkits/$toolkitSlug' | '/{-$orgSlug}/integrations/add/$pluginKey' @@ -221,12 +293,16 @@ export interface RootRouteChildren { JoinDotcodeRoute: typeof JoinDotcodeRoute AdminRoute: typeof AdminRoute ApiKeysRoute: typeof ApiKeysRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute @@ -248,6 +324,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/users': { + id: '/{-$orgSlug}/users' + path: '/{-$orgSlug}/users' + fullPath: '/{-$orgSlug}/users' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/tools': { id: '/{-$orgSlug}/tools' path: '/{-$orgSlug}/tools' @@ -276,6 +359,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/artifacts': { + id: '/{-$orgSlug}/artifacts' + path: '/{-$orgSlug}/artifacts' + fullPath: '/{-$orgSlug}/artifacts' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/api-keys': { id: '/{-$orgSlug}/api-keys' path: '/{-$orgSlug}/api-keys' @@ -311,6 +401,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/integrations/browse': { + id: '/{-$orgSlug}/integrations/browse' + path: '/{-$orgSlug}/integrations/browse' + fullPath: '/{-$orgSlug}/integrations/browse' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/integrations/$namespace': { id: '/{-$orgSlug}/integrations/$namespace' path: '/{-$orgSlug}/integrations/$namespace' @@ -318,6 +415,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/connect/$integrationSlug': { + id: '/{-$orgSlug}/connect/$integrationSlug' + path: '/{-$orgSlug}/connect/$integrationSlug' + fullPath: '/{-$orgSlug}/connect/$integrationSlug' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport + parentRoute: typeof rootRouteImport + } + '/{-$orgSlug}/artifacts/$artifactId': { + id: '/{-$orgSlug}/artifacts/$artifactId' + path: '/$artifactId' + fullPath: '/{-$orgSlug}/artifacts/$artifactId' + preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport + parentRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute + } '/{-$orgSlug}/plugins/$pluginId/$': { id: '/{-$orgSlug}/plugins/$pluginId/$' path: '/{-$orgSlug}/plugins/$pluginId/$' @@ -335,6 +446,21 @@ declare module '@tanstack/react-router' { } } +interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute +} + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren = + { + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute, + } + +const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren = + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute._addFileChildren( + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteChildren, + ) + interface DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteChildren { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute } @@ -355,6 +481,8 @@ const rootRouteChildren: RootRouteChildren = { JoinDotcodeRoute: JoinDotcodeRoute, AdminRoute: AdminRoute, ApiKeysRoute: ApiKeysRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: @@ -363,10 +491,16 @@ const rootRouteChildren: RootRouteChildren = { DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute, + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: + DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx index f80045010b..dd40169cac 100644 --- a/apps/host-selfhost/web/routes/__root.tsx +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; import { Toaster } from "@executor-js/react/components/sonner"; @@ -16,6 +17,7 @@ import { } from "@executor-js/react/components/card"; import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context"; import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; +import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import { authClient } from "../auth-client"; @@ -32,6 +34,13 @@ import { fetchNeedsSetup } from "../setup-status"; // Auth), injected here. No billing, Sentry, or PostHog. // --------------------------------------------------------------------------- +// The MCP-Apps shell is browser-only — it imports `@tailwindcss/browser`, which +// touches `document` at import scope. It is registered as a dynamic import the +// artifact page resolves in the browser, never a static one, so it stays out of +// any server graph. Module scope keeps the loader identity stable, so the lazy +// component behind it never remounts. +const artifactRendererLoader = () => import("@executor-js/mcp-apps-shell/shell/artifact-renderer"); + export const Route = createRootRoute({ notFoundComponent: NotFoundPage, component: RootComponent, @@ -46,6 +55,13 @@ const selfHostNavItems = [ { to: "/admin", label: "Admin" }, ]; +// Sections only an owner/admin of the instance may open. Users reads the +// tenant-wide admin plane, gated on a Better Auth owner/admin member, so a +// plain member is not shown a link that would only refuse them. (The existing +// /admin entry predates this and stays unconditional — it is this instance's +// member/invite page, and its own notice covers a non-admin who opens it.) +const selfHostAdminNavItems = [{ to: "/users", label: "Users" }]; + const signOut = async () => { await authClient.signOut(); window.location.href = "/"; @@ -150,12 +166,13 @@ function AuthGate({ children }: { children: ReactNode }) { function AuthenticatedApp() { const auth = useAuth(); const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null; + const navItems = useAdminNavItems(selfHostNavItems, selfHostAdminNavItems); // Single-org instance: a bare URL canonicalizes onto the instance org's // slug. There's only ever one org, so no other slug is reachable. const gated = ( <> - + ); @@ -167,7 +184,13 @@ function AuthenticatedApp() { a slug-pinned URL would 404, and a single-org instance has nothing to select anyway. */} - {organization ? {gated} : gated} + + {organization ? ( + {gated} + ) : ( + gated + )} + @@ -188,6 +211,18 @@ function RootComponent() { ); } + // A failed account-link callback still has its local session. Keep the + // recovery form visible instead of rendering the empty public login leaf. + if (pathname === "/login") { + return ( + + + + + + ); + } + // The MCP OAuth approval screen: chromeless (no shell) but inside the auth // gate — the user is already signed in (Better Auth's authorize requires a // session before redirecting here). Rendered directly (static import), not as diff --git a/apps/local/CHANGELOG.md b/apps/local/CHANGELOG.md index fe8c6e9188..dc730e6ff3 100644 --- a/apps/local/CHANGELOG.md +++ b/apps/local/CHANGELOG.md @@ -1 +1,313 @@ # @executor-js/local + +## 1.6.7 + +### Patch Changes + +- Updated dependencies [[`75b3674`](https://github.com/UsefulSoftwareCo/executor/commit/75b3674136b44a2e43fb23eb7a058e7e51528527), [`98d6c6a`](https://github.com/UsefulSoftwareCo/executor/commit/98d6c6ad3272fca371fc2d8b14b2e332100d8322)]: + - @executor-js/plugin-mcp@1.6.7 + - @executor-js/sdk@1.6.7 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.14 + - @executor-js/api@1.4.70 + - @executor-js/config@1.6.7 + - @executor-js/execution@1.6.7 + - @executor-js/vite-plugin@0.0.67 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.18 + - @executor-js/plugin-desktop-settings@1.6.7 + - @executor-js/plugin-example@1.6.7 + - @executor-js/plugin-file-secrets@1.6.7 + - @executor-js/plugin-graphql@1.6.7 + - @executor-js/plugin-keychain@1.6.7 + - @executor-js/plugin-onepassword@1.6.7 + - @executor-js/plugin-openapi@1.6.7 + - @executor-js/plugin-provider-service-split@0.0.21 + - @executor-js/plugin-toolkits@1.5.42 + - @executor-js/react@1.4.70 + - @executor-js/runtime-quickjs@1.6.7 + +## 1.6.6 + +### Patch Changes + +- [#1865](https://github.com/UsefulSoftwareCo/executor/pull/1865) [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Desktop OAuth connects finish the moment the provider redirects** + + When the desktop app runs an OAuth flow in the system browser, the app learned about completion by polling the local server once a second. The completed result sat in memory while the user watched the "Connecting…" spinner for up to a second more — about half a second wasted on average, on every connect. + + The await endpoint now long-polls: the server holds the request open (up to 25 seconds per hold) and answers the instant the flow completes. The client polls one request at a time and reconnects after each answer, so requests never stack. Mixed versions stay compatible in both directions: an old client still gets its answer within one poll of a new server, and a new client against an old server behaves exactly as before. + +- Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed), [`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d), [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]: + - @executor-js/plugin-mcp@1.6.6 + - @executor-js/execution@1.6.6 + - @executor-js/react@1.4.69 + - @executor-js/analytics@0.1.13 + - @executor-js/api@1.4.69 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.17 + - @executor-js/app@1.4.4 + - @executor-js/plugin-graphql@1.6.6 + - @executor-js/plugin-onepassword@1.6.6 + - @executor-js/plugin-openapi@1.6.6 + - @executor-js/plugin-toolkits@1.5.41 + - @executor-js/plugin-provider-service-split@0.0.20 + - @executor-js/sdk@1.6.6 + - @executor-js/runtime-quickjs@1.6.6 + - @executor-js/config@1.6.6 + - @executor-js/plugin-file-secrets@1.6.6 + - @executor-js/plugin-keychain@1.6.6 + - @executor-js/plugin-example@1.6.6 + - @executor-js/plugin-desktop-settings@1.6.6 + - @executor-js/vite-plugin@0.0.66 + +## 1.6.5 + +### Patch Changes + +- Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]: + - @executor-js/plugin-mcp@1.6.5 + - @executor-js/sdk@1.6.5 + - @executor-js/runtime-quickjs@1.6.5 + - @executor-js/execution@1.6.5 + - @executor-js/config@1.6.5 + - @executor-js/plugin-file-secrets@1.6.5 + - @executor-js/plugin-graphql@1.6.5 + - @executor-js/plugin-keychain@1.6.5 + - @executor-js/plugin-onepassword@1.6.5 + - @executor-js/plugin-openapi@1.6.5 + - @executor-js/plugin-example@1.6.5 + - @executor-js/plugin-desktop-settings@1.6.5 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.12 + - @executor-js/api@1.4.68 + - @executor-js/vite-plugin@0.0.65 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.16 + - @executor-js/plugin-provider-service-split@0.0.19 + - @executor-js/plugin-toolkits@1.5.40 + - @executor-js/react@1.4.68 + +## 1.6.4 + +### Patch Changes + +- Updated dependencies [[`ffcfbc0`](https://github.com/UsefulSoftwareCo/executor/commit/ffcfbc0de27d0ae55215839fb70395b0b7d9a65c), [`10e16a5`](https://github.com/UsefulSoftwareCo/executor/commit/10e16a5baa2648657b70038e7d11429c58e4d242), [`9dcfaa5`](https://github.com/UsefulSoftwareCo/executor/commit/9dcfaa5ee8ad2ebc17407caf94d8d4dcf55e3562), [`939b96f`](https://github.com/UsefulSoftwareCo/executor/commit/939b96f694a420cd6151c4e402cff7f1ab4b327a), [`515d6aa`](https://github.com/UsefulSoftwareCo/executor/commit/515d6aa391a04a3579a7b10f974ec316a563cf7a), [`06bf742`](https://github.com/UsefulSoftwareCo/executor/commit/06bf74254f3432e8d75fd8b493ef7a435ea4bc84), [`2cad774`](https://github.com/UsefulSoftwareCo/executor/commit/2cad7745dea1afb9282c3888f4e9c59ce6fe4332)]: + - @executor-js/plugin-mcp@1.6.4 + - @executor-js/sdk@1.6.4 + - @executor-js/react@1.4.67 + - @executor-js/plugin-file-secrets@1.6.4 + - @executor-js/plugin-keychain@1.6.4 + - @executor-js/plugin-onepassword@1.6.4 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.11 + - @executor-js/api@1.4.67 + - @executor-js/config@1.6.4 + - @executor-js/execution@1.6.4 + - @executor-js/vite-plugin@0.0.64 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.15 + - @executor-js/plugin-desktop-settings@1.6.4 + - @executor-js/plugin-example@1.6.4 + - @executor-js/plugin-graphql@1.6.4 + - @executor-js/plugin-openapi@1.6.4 + - @executor-js/plugin-provider-service-split@0.0.18 + - @executor-js/plugin-toolkits@1.5.39 + - @executor-js/runtime-quickjs@1.6.4 + +## 1.6.3 + +### Patch Changes + +- [#1458](https://github.com/UsefulSoftwareCo/executor/pull/1458) [`1908dd6`](https://github.com/UsefulSoftwareCo/executor/commit/1908dd6d7611489362d451f7594adca542c13ba1) Thanks [@tylergibbs1](https://github.com/tylergibbs1)! - **An unsupported method probed before `initialize` no longer kills the MCP connection** + + Only `initialize` can open a session, so the streamable-HTTP transport answered every other pre-session method with HTTP 400 + `-32000 Server not initialized`. A 400 is a transport-level failure, so clients dropped the connection instead of treating it as one request failing — a client that opens with an optional probe (MCP 2026-07-28 clients lead with `server/discover`) was disconnected before it could fall back to `initialize`. Over `executor mcp`, which bridges this endpoint to stdio, that closed the client's pipe outright. + + Pre-session dispatch now answers any method other than `initialize` with `-32601 Method not found` on a normal 200, which is a per-request error, so the connection survives and the handshake proceeds. This replaces only that one answer: a POST with a bad `Accept` or `Content-Type` still gets the transport's 406 or 415, and a message that is not a valid JSON-RPC request still gets its parse error. + +- Updated dependencies [[`66fb1a4`](https://github.com/UsefulSoftwareCo/executor/commit/66fb1a4154226d28691ca83bdf6f3daa417ef0ce), [`4b0fbf6`](https://github.com/UsefulSoftwareCo/executor/commit/4b0fbf68550516af9235c9267f91a962da993946), [`ba62f1a`](https://github.com/UsefulSoftwareCo/executor/commit/ba62f1a5d14b7002ba0a4686a9e1ae43bd77f54f), [`8324e1e`](https://github.com/UsefulSoftwareCo/executor/commit/8324e1eb8b03965050147309f049bdb52be6fcad), [`6305b6d`](https://github.com/UsefulSoftwareCo/executor/commit/6305b6d11505358fa73ec2b3e768ec4256c36435), [`c1f51b7`](https://github.com/UsefulSoftwareCo/executor/commit/c1f51b7f96328b795669bb3d241667660dc2b060), [`d7e4b73`](https://github.com/UsefulSoftwareCo/executor/commit/d7e4b73a86b8e413af70e0fcb26f38a35a3f4546), [`85b1955`](https://github.com/UsefulSoftwareCo/executor/commit/85b1955b4d24c332e637e15a025d64455e28a626), [`02b52cd`](https://github.com/UsefulSoftwareCo/executor/commit/02b52cd01b09d3601ffe88d1f9c0b777f26e76ae)]: + - @executor-js/react@1.4.66 + - @executor-js/plugin-mcp@1.6.3 + - @executor-js/sdk@1.6.3 + - @executor-js/plugin-openapi@1.6.3 + - @executor-js/fumadb@1.5.8 + - @executor-js/app@1.4.4 + - @executor-js/mcp-apps-shell@1.4.14 + - @executor-js/plugin-graphql@1.6.3 + - @executor-js/plugin-onepassword@1.6.3 + - @executor-js/plugin-toolkits@1.5.38 + - @executor-js/analytics@0.1.10 + - @executor-js/api@1.4.66 + - @executor-js/config@1.6.3 + - @executor-js/execution@1.6.3 + - @executor-js/vite-plugin@0.0.63 + - @executor-js/host-mcp@1.4.4 + - @executor-js/plugin-desktop-settings@1.6.3 + - @executor-js/plugin-example@1.6.3 + - @executor-js/plugin-file-secrets@1.6.3 + - @executor-js/plugin-keychain@1.6.3 + - @executor-js/plugin-provider-service-split@0.0.17 + - @executor-js/runtime-quickjs@1.6.3 + +## 1.6.2 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.2 + - @executor-js/runtime-quickjs@1.6.2 + - @executor-js/execution@1.6.2 + - @executor-js/config@1.6.2 + - @executor-js/plugin-file-secrets@1.6.2 + - @executor-js/plugin-graphql@1.6.2 + - @executor-js/plugin-keychain@1.6.2 + - @executor-js/plugin-mcp@1.6.2 + - @executor-js/plugin-onepassword@1.6.2 + - @executor-js/plugin-openapi@1.6.2 + - @executor-js/plugin-example@1.6.2 + - @executor-js/plugin-desktop-settings@1.6.2 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.9 + - @executor-js/api@1.4.65 + - @executor-js/vite-plugin@0.0.62 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.13 + - @executor-js/plugin-provider-service-split@0.0.16 + - @executor-js/plugin-toolkits@1.5.37 + - @executor-js/react@1.4.65 + +## 1.6.1 + +### Patch Changes + +- Updated dependencies [[`9dff4e8`](https://github.com/UsefulSoftwareCo/executor/commit/9dff4e8e6598e7d3108634a71269245ba9b480bb), [`7c12aee`](https://github.com/UsefulSoftwareCo/executor/commit/7c12aeea390225291ce4c97865b392237ee7934d), [`ddbf0fe`](https://github.com/UsefulSoftwareCo/executor/commit/ddbf0feba38c8502d78fa20c3081391b8ba3d112), [`91062c2`](https://github.com/UsefulSoftwareCo/executor/commit/91062c2b1d7b8edbc8470ca5eaa544045652afaa), [`9c35f26`](https://github.com/UsefulSoftwareCo/executor/commit/9c35f269dd5de3548111fe5c83cf1e877f23c80d), [`62748e8`](https://github.com/UsefulSoftwareCo/executor/commit/62748e86122b747226c76c2e112c5c4d2b4f7095), [`0007474`](https://github.com/UsefulSoftwareCo/executor/commit/0007474602d8da3642648f216bfdb0f09eb0914f), [`d4afe0c`](https://github.com/UsefulSoftwareCo/executor/commit/d4afe0c79f146dd169a00988a2d5d0469297be19), [`55180cb`](https://github.com/UsefulSoftwareCo/executor/commit/55180cb1487f9a3a28ddc0ee0bedfab8464c1f72)]: + - @executor-js/react@1.4.64 + - @executor-js/plugin-openapi@1.6.1 + - @executor-js/plugin-mcp@1.6.1 + - @executor-js/execution@1.6.1 + - @executor-js/sdk@1.6.1 + - @executor-js/api@1.4.64 + - @executor-js/app@1.4.4 + - @executor-js/mcp-apps-shell@1.4.12 + - @executor-js/plugin-graphql@1.6.1 + - @executor-js/plugin-onepassword@1.6.1 + - @executor-js/plugin-toolkits@1.5.36 + - @executor-js/plugin-provider-service-split@0.0.15 + - @executor-js/analytics@0.1.8 + - @executor-js/host-mcp@1.4.4 + - @executor-js/config@1.6.1 + - @executor-js/vite-plugin@0.0.61 + - @executor-js/plugin-desktop-settings@1.6.1 + - @executor-js/plugin-example@1.6.1 + - @executor-js/plugin-file-secrets@1.6.1 + - @executor-js/plugin-keychain@1.6.1 + - @executor-js/runtime-quickjs@1.6.1 + +## 1.6.0 + +### Patch Changes + +- Updated dependencies [[`c11bef2`](https://github.com/UsefulSoftwareCo/executor/commit/c11bef2cd049db7bbf51b15e18761b14acccb534), [`46cea2c`](https://github.com/UsefulSoftwareCo/executor/commit/46cea2cbb1f414ae58ac876819a51b11967909a6), [`a2d1417`](https://github.com/UsefulSoftwareCo/executor/commit/a2d141758e478274813c8c24d354e1fd0f66af49), [`2bdbedf`](https://github.com/UsefulSoftwareCo/executor/commit/2bdbedf257f54d7c209e8c856c618174c10d6bb3), [`0b0b74f`](https://github.com/UsefulSoftwareCo/executor/commit/0b0b74f673b8098c5248159be36c648097f3c87b), [`256e25e`](https://github.com/UsefulSoftwareCo/executor/commit/256e25e7b291b0c023bc7547d092004b66781bba)]: + - @executor-js/plugin-mcp@1.6.0 + - @executor-js/plugin-openapi@1.6.0 + - @executor-js/sdk@1.6.0 + - @executor-js/react@1.4.63 + - @executor-js/plugin-provider-service-split@0.0.14 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.7 + - @executor-js/api@1.4.63 + - @executor-js/config@1.6.0 + - @executor-js/execution@1.6.0 + - @executor-js/vite-plugin@0.0.60 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.11 + - @executor-js/plugin-desktop-settings@1.6.0 + - @executor-js/plugin-example@1.6.0 + - @executor-js/plugin-file-secrets@1.6.0 + - @executor-js/plugin-graphql@1.6.0 + - @executor-js/plugin-keychain@1.6.0 + - @executor-js/plugin-onepassword@1.6.0 + - @executor-js/plugin-toolkits@1.5.35 + - @executor-js/runtime-quickjs@1.6.0 + +## 1.5.42 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be), [`9ecc7cb`](https://github.com/UsefulSoftwareCo/executor/commit/9ecc7cb8b30375ffa960e3fefe4d211e0254e691)]: + - @executor-js/sdk@1.5.42 + - @executor-js/plugin-openapi@1.5.42 + - @executor-js/plugin-mcp@1.5.42 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.6 + - @executor-js/api@1.4.62 + - @executor-js/config@1.5.42 + - @executor-js/execution@1.5.42 + - @executor-js/vite-plugin@0.0.59 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.10 + - @executor-js/plugin-desktop-settings@1.5.42 + - @executor-js/plugin-example@1.5.42 + - @executor-js/plugin-file-secrets@1.5.42 + - @executor-js/plugin-graphql@1.5.42 + - @executor-js/plugin-keychain@1.5.42 + - @executor-js/plugin-onepassword@1.5.42 + - @executor-js/plugin-provider-service-split@0.0.13 + - @executor-js/plugin-toolkits@1.5.34 + - @executor-js/react@1.4.62 + - @executor-js/runtime-quickjs@1.5.42 + +## 1.5.41 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]: + - @executor-js/sdk@1.5.41 + - @executor-js/plugin-mcp@1.5.41 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.5 + - @executor-js/api@1.4.61 + - @executor-js/config@1.5.41 + - @executor-js/execution@1.5.41 + - @executor-js/vite-plugin@0.0.58 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.9 + - @executor-js/plugin-desktop-settings@1.5.41 + - @executor-js/plugin-example@1.5.41 + - @executor-js/plugin-file-secrets@1.5.41 + - @executor-js/plugin-graphql@1.5.41 + - @executor-js/plugin-keychain@1.5.41 + - @executor-js/plugin-onepassword@1.5.41 + - @executor-js/plugin-openapi@1.5.41 + - @executor-js/plugin-provider-service-split@0.0.12 + - @executor-js/plugin-toolkits@1.5.33 + - @executor-js/react@1.4.61 + - @executor-js/runtime-quickjs@1.5.41 + +## 1.5.40 + +### Patch Changes + +- [#1534](https://github.com/UsefulSoftwareCo/executor/pull/1534) [`80e5530`](https://github.com/UsefulSoftwareCo/executor/commit/80e553026278b1ecd7807f1ba99ba13b19d2c336) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Report the real product surface and version in the integrations.sh registry user-agent. The daemon previously sent `local` with a version frozen at 1.4.4; it now reports `cli` or `desktop` (matching analytics surfaces) and `@executor-js/local` is versioned with the release train. + +- Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a)]: + - @executor-js/sdk@1.5.40 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.4 + - @executor-js/api@1.4.60 + - @executor-js/config@1.5.40 + - @executor-js/execution@1.5.40 + - @executor-js/vite-plugin@0.0.57 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.8 + - @executor-js/plugin-desktop-settings@1.5.40 + - @executor-js/plugin-example@1.5.40 + - @executor-js/plugin-file-secrets@1.5.40 + - @executor-js/plugin-graphql@1.5.40 + - @executor-js/plugin-keychain@1.5.40 + - @executor-js/plugin-mcp@1.5.40 + - @executor-js/plugin-onepassword@1.5.40 + - @executor-js/plugin-openapi@1.5.40 + - @executor-js/plugin-provider-service-split@0.0.11 + - @executor-js/plugin-toolkits@1.5.32 + - @executor-js/react@1.4.60 + - @executor-js/runtime-quickjs@1.5.40 diff --git a/apps/local/drizzle/0005_oauth_client_token_auth.sql b/apps/local/drizzle/0005_oauth_client_token_auth.sql new file mode 100644 index 0000000000..2abd95772c --- /dev/null +++ b/apps/local/drizzle/0005_oauth_client_token_auth.sql @@ -0,0 +1 @@ +ALTER TABLE `oauth_client` ADD `token_endpoint_auth_method` text; \ No newline at end of file diff --git a/apps/local/drizzle/0006_bored_landau.sql b/apps/local/drizzle/0006_bored_landau.sql new file mode 100644 index 0000000000..050ecbf687 --- /dev/null +++ b/apps/local/drizzle/0006_bored_landau.sql @@ -0,0 +1,2 @@ +ALTER TABLE `connection` ADD `credential_write` text;--> statement-breakpoint +ALTER TABLE `oauth_client` ADD `credential_write` text; \ No newline at end of file diff --git a/apps/local/drizzle/meta/0005_snapshot.json b/apps/local/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000000..f67103bb00 --- /dev/null +++ b/apps/local/drizzle/meta/0005_snapshot.json @@ -0,0 +1,934 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e917e9d4-f3b5-453d-8549-540e2ecc986f", + "prevId": "85f30981-cda9-45df-b757-b2836fb70e07", + "tables": { + "blob": { + "name": "blob", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": ["id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connection": { + "name": "connection", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_ids": { + "name": "item_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_state": { + "name": "provider_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": ["tenant", "owner", "subject", "integration", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "definition": { + "name": "definition", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "can_remove": { + "name": "can_remove", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "can_refresh": { + "name": "can_refresh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": ["tenant", "slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client": { + "name": "oauth_client", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": ["tenant", "owner", "subject", "slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_session": { + "name": "oauth_session", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": ["tenant", "state"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection": { + "name": "collection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": ["tenant", "owner", "subject", "plugin_id", "collection", "key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool": { + "name": "tool", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_schema": { + "name": "input_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_schema": { + "name": "output_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool_policy": { + "name": "tool_policy", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": ["tenant", "owner", "subject", "id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/local/drizzle/meta/0006_snapshot.json b/apps/local/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000000..4ebf2ff210 --- /dev/null +++ b/apps/local/drizzle/meta/0006_snapshot.json @@ -0,0 +1,948 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "89a4fd1b-f0f6-4482-a991-0db78c859f76", + "prevId": "e917e9d4-f3b5-453d-8549-540e2ecc986f", + "tables": { + "blob": { + "name": "blob", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": ["id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connection": { + "name": "connection", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_ids": { + "name": "item_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_write": { + "name": "credential_write", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_state": { + "name": "provider_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": ["tenant", "owner", "subject", "integration", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "definition": { + "name": "definition", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "can_remove": { + "name": "can_remove", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "can_refresh": { + "name": "can_refresh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": ["tenant", "slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client": { + "name": "oauth_client", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_write": { + "name": "credential_write", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": ["tenant", "owner", "subject", "slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_session": { + "name": "oauth_session", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": ["tenant", "state"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection": { + "name": "collection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": ["tenant", "owner", "subject", "plugin_id", "collection", "key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool": { + "name": "tool", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_schema": { + "name": "input_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_schema": { + "name": "output_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool_policy": { + "name": "tool_policy", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": ["tenant", "owner", "subject", "id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/local/drizzle/meta/_journal.json b/apps/local/drizzle/meta/_journal.json index 8b4b73e4ab..dbe786204c 100644 --- a/apps/local/drizzle/meta/_journal.json +++ b/apps/local/drizzle/meta/_journal.json @@ -36,6 +36,20 @@ "when": 1784581228503, "tag": "0004_cultured_shadowcat", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1788164746371, + "tag": "0005_oauth_client_token_auth", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1788255902609, + "tag": "0006_bored_landau", + "breakpoints": true } ] } diff --git a/apps/local/executor.config.ts b/apps/local/executor.config.ts index ad14b85247..fa44b48c72 100644 --- a/apps/local/executor.config.ts +++ b/apps/local/executor.config.ts @@ -1,9 +1,4 @@ import { defineExecutorConfig } from "@executor-js/sdk"; -import { - makeWorkerBundlerBackend, - makeWorkerdAppToolExecutor, -} from "@executor-js/plugin-apps/selfhost"; -import { appsHttpPlugin } from "@executor-js/plugin-apps/api"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { googleCatalog, @@ -43,15 +38,14 @@ export default defineExecutorConfig({ }), mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), graphqlHttpPlugin(), - appsHttpPlugin({ - executor: makeWorkerdAppToolExecutor(), - bundler: makeWorkerBundlerBackend(), - sourceKinds: ["git", "local-directory"], - allowPrivateGitHosts: true, - }), toolkitsPlugin({ activeToolkitSlug }), - keychainPlugin(), + // The durable file store must register before keychain: the first + // writable provider becomes the default for minted OAuth tokens, and on + // sandbox/headless hosts the keychain is an in-memory keyring that a + // stop/recreate wipes while only EXECUTOR_DATA_DIR is persisted. + // Keychain stays registered for explicit external refs. fileSecretsPlugin(), + keychainPlugin(), onepasswordHttpPlugin(), desktopSettingsPlugin({ webBaseUrl: diff --git a/apps/local/package.json b/apps/local/package.json index 5f8edaefcc..ae2b4bb12f 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/local", - "version": "1.4.4", + "version": "1.6.7", "private": true, "type": "module", "exports": { @@ -10,7 +10,7 @@ "scripts": { "dev": "bun run dev:proxy && bun run dev:vite", "dev:proxy": "portless proxy start --multiplex --port 1355 || true", - "dev:vite": "EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name executor-local bunx --bun vite dev", + "dev:vite": "EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name \"executor-local-$(basename \"$(cd ../.. && pwd)\")\" bunx --bun vite dev", "build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build", "start": "bun run src/serve.ts", "db:generate": "drizzle-kit generate", @@ -22,6 +22,7 @@ "dependencies": { "@effect/atom-react": "catalog:", "@effect/platform-node": "catalog:", + "@executor-js/analytics": "workspace:*", "@executor-js/api": "workspace:*", "@executor-js/app": "workspace:*", "@executor-js/config": "workspace:*", @@ -29,7 +30,7 @@ "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/integrations-registry": "workspace:*", - "@executor-js/plugin-apps": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-desktop-settings": "workspace:*", "@executor-js/plugin-example": "workspace:*", "@executor-js/plugin-file-secrets": "workspace:*", @@ -45,7 +46,7 @@ "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@libsql/client": "catalog:", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", diff --git a/apps/local/src/analytics.ts b/apps/local/src/analytics.ts new file mode 100644 index 0000000000..3f98e20048 --- /dev/null +++ b/apps/local/src/analytics.ts @@ -0,0 +1,68 @@ +import { Effect, ManagedRuntime } from "effect"; + +import { + Analytics, + defaultLayer as analyticsDefaultLayer, + type AnalyticsService, + type AnalyticsSurface, +} from "@executor-js/analytics"; + +import { resolveExecutorDataDir } from "./auth"; +import { CHANNEL, VERSION } from "./installation"; + +// --------------------------------------------------------------------------- +// Local product analytics: one anonymous per-install service for the whole +// daemon lifetime, shared by every surface (HTTP API, in-process MCP, stdio). +// +// The surface is cli|desktop — never "local": the desktop main process marks +// its sidecar via EXECUTOR_CLIENT=desktop (same signal the sidecar sentinels +// key on), everything else hosting apps/local is the CLI. Module-singleton +// runtime mirroring ./integrations.ts, so concurrent surfaces share one +// buffer + one flush loop, and disposeAnalytics() is the shutdown flush. +// --------------------------------------------------------------------------- + +const resolveSurface = (): AnalyticsSurface => + process.env.EXECUTOR_CLIENT === "desktop" ? "desktop" : "cli"; + +let analyticsRuntime: ManagedRuntime.ManagedRuntime | null = null; + +const getAnalyticsRuntime = (): ManagedRuntime.ManagedRuntime => { + if (analyticsRuntime) return analyticsRuntime; + analyticsRuntime = ManagedRuntime.make( + analyticsDefaultLayer({ + surface: resolveSurface(), + version: VERSION, + channel: CHANNEL, + dataDir: resolveExecutorDataDir(), + }), + ); + return analyticsRuntime; +}; + +/** + * Lazy facade over the daemon's shared service, usable from boot paths that + * compose engines outside Effect. `record` forks into the module runtime + * (fire-and-forget, mirroring ./integrations.ts) so callers never wait on the + * layer build; ordering within the runtime preserves event order. + */ +export const localAnalytics: AnalyticsService = { + record: (name, properties) => + Effect.sync(() => { + getAnalyticsRuntime().runFork( + Effect.flatMap(Analytics.asEffect(), (analytics) => analytics.record(name, properties)), + ); + }), + flush: Effect.promise(() => + getAnalyticsRuntime().runPromise( + Effect.flatMap(Analytics.asEffect(), (analytics) => analytics.flush), + ), + ), +}; + +/** Flush and dispose the analytics runtime (daemon shutdown). */ +export const disposeAnalytics = async (): Promise => { + const runtime = analyticsRuntime; + if (!runtime) return; + analyticsRuntime = null; + await Effect.runPromise(Effect.promise(() => runtime.dispose()).pipe(Effect.ignoreCause())); +}; diff --git a/apps/local/src/app.ts b/apps/local/src/app.ts index a9915daf50..c565c14d4b 100644 --- a/apps/local/src/app.ts +++ b/apps/local/src/app.ts @@ -2,14 +2,17 @@ import { HttpApiSwagger } from "effect/unstable/httpapi"; import { Layer } from "effect"; import { + ArtifactUsageObserver, composePluginApi, ExecutorApp, FixedExecutionProvider, textFailureStrategy, } from "@executor-js/api/server"; +import { withExecutionAnalytics } from "@executor-js/analytics"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; +import { localAnalytics } from "./analytics"; import { getExecutorBundle, type LocalExecutor } from "./executor"; import { makeLocalIdentityLayer } from "./identity"; import { ErrorCaptureLive } from "./observability"; @@ -53,10 +56,16 @@ import { ErrorCaptureLive } from "./observability"; const localFixedExecutionLayer = (executor: LocalExecutor): Layer.Layer => Layer.succeed(FixedExecutionProvider)({ executor, - engine: createExecutionEngine({ - executor, - codeExecutor: makeQuickJsExecutor(), - }), + // This engine serves the HTTP executions API (`executor call`/`resume`, + // the web console) — the wrap binds the "api" plane structurally. + engine: withExecutionAnalytics( + createExecutionEngine({ + executor, + codeExecutor: makeQuickJsExecutor(), + }), + localAnalytics, + { plane: "api", toolkit: false }, + ), // The executor IS its own plugin-extension map (`executor[pluginId]`); the // fixed middleware reads `executor[id]` to satisfy each plugin's // `*ExtensionService` Tag per request — identical binding to the prior @@ -118,8 +127,16 @@ export const makeLocalApiHandler = async (token: string): Promise + localAnalytics.record(`artifact_${action}`, { via: "ui" }), + ), + ), }); const web = toWebHandler(); diff --git a/apps/local/src/db/data-migrations.ts b/apps/local/src/db/data-migrations.ts index 522a7321dd..0534b26f44 100644 --- a/apps/local/src/db/data-migrations.ts +++ b/apps/local/src/db/data-migrations.ts @@ -7,6 +7,7 @@ import { Effect, + bigintStorageClassSqliteMigration, oauthClientGcSqliteMigration, sqliteDataMigration, type SqliteDataMigration, @@ -30,6 +31,11 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ // stamped atomically inside the staged v2 build; fresh/pre-v2-native DBs get // the same stamp here so future boots skip legacy shape probing. { name: LOCAL_V1_V2_LEDGER_NAME, run: () => Effect.void }, + // FIRST, because it un-bricks reads every later migration and the whole app + // depend on: `bigint` columns an older build left in SQLite's INTEGER storage + // class cannot be read by the bigint row mapper, so a single legacy + // `connection.expires_at` failed every catalog read (issue #1771). + bigintStorageClassSqliteMigration, // Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs) // into the shared placements model. sqliteDataMigration("2026-06-05-auth-config-placements", (client) => diff --git a/apps/local/src/db/db-file-permissions.test.ts b/apps/local/src/db/db-file-permissions.test.ts new file mode 100644 index 0000000000..2b560d7fc9 --- /dev/null +++ b/apps/local/src/db/db-file-permissions.test.ts @@ -0,0 +1,99 @@ +// The local database sits in the same directory as `auth.json` and +// `server-connections.json`, both of which are deliberately 0600. SQLite +// creates its own files with the process umask instead, so on a default macOS +// or Linux install the database and its WAL sidecars land at 0644 — readable +// by every other account on the machine. +// +// These drive the real `openLocalLibsql` against a real file, because the +// property under test is what the filesystem ends up holding, not what the +// code appears to ask for. + +import { chmodSync, existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Client } from "@libsql/client"; +import { createClient } from "@libsql/client"; +import { afterEach, describe, expect, it } from "@effect/vitest"; + +import { openLocalLibsql } from "./libsql"; + +const dirs: string[] = []; +const clients: Client[] = []; + +afterEach(() => { + for (const client of clients.splice(0)) client.close(); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** POSIX permission bits of `path`, or null when it does not exist. */ +const modeOf = (path: string): number | null => + existsSync(path) ? statSync(path).mode & 0o777 : null; + +const tempDir = (label: string): string => { + const dir = mkdtempSync(join(tmpdir(), `executor-db-perms-${label}-`)); + dirs.push(dir); + return dir; +}; + +/** Every file in `dir` that any account other than the owner can read. */ +const groupOrWorldReadable = (dir: string): readonly string[] => + readdirSync(dir).filter((name) => ((modeOf(join(dir, name)) ?? 0) & 0o044) !== 0); + +describe("local database file permissions", () => { + it("leaves nothing in the data directory readable by other accounts", async () => { + const dir = tempDir("create"); + const path = join(dir, "data.db"); + + const client = await openLocalLibsql(path); + clients.push(client); + // Force a write so WAL materializes its sidecars. + await client.execute("CREATE TABLE probe (id INTEGER PRIMARY KEY)"); + + expect(modeOf(path)).toBe(0o600); + // Asserting over the whole directory rather than a fixed list of suffixes: + // it does not depend on which sidecars SQLite happened to create, and it + // names the offenders when it fails. + expect(groupOrWorldReadable(dir)).toEqual([]); + }); + + it("tightens a database left world-readable by an earlier version", async () => { + // The upgrade path is the one that matters: a `mode` at creation cannot + // help a file that already exists, which is why the fix chmods on open. + const dir = tempDir("upgrade"); + const path = join(dir, "data.db"); + + const seed = createClient({ url: `file:${path}` }); + await seed.execute("CREATE TABLE probe (id INTEGER PRIMARY KEY)"); + seed.close(); + // Put the file into the exact state a pre-fix install leaves behind. + chmodSync(path, 0o644); + expect(modeOf(path)).toBe(0o644); + + const client = await openLocalLibsql(path); + clients.push(client); + + expect(modeOf(path)).toBe(0o600); + expect(groupOrWorldReadable(dir)).toEqual([]); + }); + + it("POSITIVE CONTROL: a raw libSQL open leaves the database world-readable", async () => { + // Proves the assertions above can fail. Without this, a chmod that + // silently stopped running would still leave them green on a machine with + // a strict umask — this is the check that the check works. + const dir = tempDir("control"); + const path = join(dir, "data.db"); + + const client = createClient({ url: `file:${path}` }); + await client.execute("CREATE TABLE probe (id INTEGER PRIMARY KEY)"); + client.close(); + + expect(modeOf(path)).not.toBe(0o600); + expect(groupOrWorldReadable(dir)).toContain("data.db"); + }); + + it("does not throw for an in-memory database", async () => { + const client = await openLocalLibsql(":memory:"); + clients.push(client); + await expect(client.execute("SELECT 1")).resolves.toBeDefined(); + }); +}); diff --git a/apps/local/src/db/executor-schema.ts b/apps/local/src/db/executor-schema.ts index baa4180300..a7f6ceb48e 100644 --- a/apps/local/src/db/executor-schema.ts +++ b/apps/local/src/db/executor-schema.ts @@ -37,6 +37,7 @@ export const connection = sqliteTable( template: text("template").notNull(), provider: text("provider").notNull(), item_ids: text("item_ids").notNull(), + credential_write: text("credential_write"), identity_label: text("identity_label"), oauth_client: text("oauth_client"), oauth_client_owner: text("oauth_client_owner"), @@ -72,6 +73,8 @@ export const oauth_client = sqliteTable( grant: text("grant").notNull(), client_id: text("client_id").notNull(), client_secret_item_id: text("client_secret_item_id"), + credential_write: text("credential_write"), + token_endpoint_auth_method: text("token_endpoint_auth_method"), resource: text("resource"), origin_kind: text("origin_kind"), origin_integration: text("origin_integration"), diff --git a/apps/local/src/db/legacy-bigint-boot.test.ts b/apps/local/src/db/legacy-bigint-boot.test.ts new file mode 100644 index 0000000000..aaec50eb7b --- /dev/null +++ b/apps/local/src/db/legacy-bigint-boot.test.ts @@ -0,0 +1,120 @@ +// --------------------------------------------------------------------------- +// Boot-level proof for issue #1771: a local database holding a legacy INTEGER +// `connection.expires_at` is unreadable, and the local boot sequence heals it. +// +// The migration body is unit-tested in the SDK. What this pins is the WIRING — +// that `localDataMigrations` actually carries the entry, early enough that the +// catalog reads which follow the ledger run see repaired rows. That wiring is +// the part that recovers a user's install; a correct migration nobody runs +// would leave the gateway just as empty. +// --------------------------------------------------------------------------- + +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Effect } from "effect"; +import { withQueryContext } from "@executor-js/fumadb/query"; + +import { collectTables } from "@executor-js/api/server"; +import { runSqliteDataMigrations } from "@executor-js/sdk"; + +import { localDataMigrations } from "./data-migrations"; +import { createSqliteFumaDb } from "./sqlite-fumadb"; + +const TENANT = "executor-workspace-1771"; +const SUBJECT = "local"; +// Epoch millis, the shape an OAuth token expiry takes. +const LEGACY_EXPIRES_AT = 1787321623456; + +let workDir: string; + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "executor-legacy-bigint-")); +}); + +afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); +}); + +const openDb = (dbPath: string) => + createSqliteFumaDb({ + tables: collectTables(), + namespace: "executor_local", + path: dbPath, + }); + +/** Write the row a pre-`bigint` build left behind: `expires_at` as a bare + * integer literal, which SQLite keeps in the INTEGER storage class. */ +const seedLegacyConnection = async (dbPath: string): Promise => { + const sqlite = await openDb(dbPath); + await sqlite.client.execute({ + sql: `INSERT INTO connection + (row_id, tenant, owner, subject, integration, name, template, provider, item_ids, + expires_at, created_at, updated_at) + VALUES ('c1', ?, 'user', ?, 'acme', 'default', 'oauth2', 'file', ?, + ${LEGACY_EXPIRES_AT}, ?, ?)`, + args: [ + TENANT, + SUBJECT, + JSON.stringify({ token: "item_1" }), + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000), + ], + }); + await sqlite.close(); +}; + +describe("local boot over a legacy bigint database", () => { + it("cannot read the connection table before the migrations run", async () => { + const dbPath = join(workDir, "data.db"); + await seedLegacyConnection(dbPath); + + const sqlite = await openDb(dbPath); + const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT }); + // The reported symptom: not a wrong value, a throw — so the gateway lost + // every saved integration at once. + await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/); + await sqlite.close(); + }); + + it("heals it through the local data-migration registry", async () => { + const dbPath = join(workDir, "data.db"); + await seedLegacyConnection(dbPath); + + const sqlite = await openDb(dbPath); + const applied = await Effect.runPromise( + runSqliteDataMigrations(sqlite.client, localDataMigrations), + ); + expect(applied).toContain("2026-08-28-bigint-storage-class"); + + const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT }); + const rows = await scoped.findMany("connection", {}); + expect(rows.map((row) => [row.name, Number(row.expires_at)])).toEqual([ + ["default", LEGACY_EXPIRES_AT], + ]); + await sqlite.close(); + }); + + it("stays readable across a reboot", async () => { + const dbPath = join(workDir, "data.db"); + await seedLegacyConnection(dbPath); + + const first = await openDb(dbPath); + await Effect.runPromise(runSqliteDataMigrations(first.client, localDataMigrations)); + await first.close(); + + // Second boot: the entry is stamped, so it is skipped — the data must + // already be in the shape the mapper reads. + const second = await openDb(dbPath); + const applied = await Effect.runPromise( + runSqliteDataMigrations(second.client, localDataMigrations), + ); + expect(applied).not.toContain("2026-08-28-bigint-storage-class"); + + const scoped = withQueryContext(second.db, { tenant: TENANT, subject: SUBJECT }); + const rows = await scoped.findMany("connection", {}); + expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]); + await second.close(); + }); +}); diff --git a/apps/local/src/db/libsql.ts b/apps/local/src/db/libsql.ts index 63844bffd0..a8552c199c 100644 --- a/apps/local/src/db/libsql.ts +++ b/apps/local/src/db/libsql.ts @@ -1,4 +1,5 @@ import { createClient, type Client, type InArgs, type ResultSet } from "@libsql/client"; +import { chmodSync } from "node:fs"; import { resolve } from "node:path"; // --------------------------------------------------------------------------- @@ -16,6 +17,41 @@ import { resolve } from "node:path"; const toLibsqlFileUrl = (path: string): string => path === ":memory:" ? path : `file:${resolve(path)}`; +/** The database and the two sidecars WAL mode creates beside it. */ +const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; + +/** + * Restrict the database and its WAL sidecars to owner-only. + * + * SQLite creates these files with the process umask, which on a default macOS + * or Linux install means 0644. The secret files next to them are deliberately + * 0600 (`auth.json`, `server-connections.json`), and the database is not the + * less sensitive of the two: it holds live `oauth_session.pkce_verifier` + * values, health-check response samples, artifact previews, and — for stdio + * MCP integrations created before the auth-method revamp — plaintext secret + * env in `integration.config`. + * + * Called on open rather than on create, so a database written by an earlier + * version is tightened too. That mirrors the `mode` + `chmod` pair in + * `writeToken`, and for the same reason: `mode` only applies at creation. + * + * Best-effort per file. A filesystem with no POSIX modes, or a read-only + * mount, must not stop the server booting over a permission it cannot set. + */ +const restrictDbFilePermissions = (path: string): void => { + if (path === ":memory:") return; + const base = resolve(path); + for (const suffix of DB_FILE_SUFFIXES) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: chmod on a file that may not exist yet or a filesystem without POSIX modes; hardening must never block boot + try { + chmodSync(`${base}${suffix}`, 0o600); + } catch { + // Sidecars only exist once WAL has been used, and not every filesystem + // supports chmod. Either way the open proceeds. + } + } +}; + /** * Open a libSQL client for a local on-disk DB and apply the per-connection * PRAGMAs (foreign_keys + WAL). Used for the long-lived FumaDB handle. @@ -26,6 +62,8 @@ export const openLocalLibsql = async (path: string): Promise => { // first enabling. Re-apply both since libSQL gives no shared handle. await client.execute("PRAGMA foreign_keys = ON"); await client.execute("PRAGMA journal_mode = WAL"); + // After the WAL pragma, so `-wal`/`-shm` exist to be tightened. + restrictDbFilePermissions(path); // busy_timeout is per-connection (default 0 = fail immediately on a lock). // Under the supervised-daemon model a single process owns this file, but a // second OS process can still transiently hold the write lock (e.g. a CLI diff --git a/apps/local/src/executor.test.ts b/apps/local/src/executor.test.ts index 8156bf6776..2ae8f90172 100644 --- a/apps/local/src/executor.test.ts +++ b/apps/local/src/executor.test.ts @@ -4,8 +4,15 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; -import { disposeExecutor, getExecutor, reloadExecutor } from "./executor"; +import { + createExecutorHandle, + disposeExecutor, + getExecutor, + getExecutorBundle, + reloadExecutor, +} from "./executor"; const withIsolatedExecutorDataDir = async (body: () => Promise): Promise => { const previousDataDir = process.env.EXECUTOR_DATA_DIR; @@ -52,3 +59,47 @@ describe("reloadExecutor", () => { }); }); }); + +describe("toolkit-scoped executors", () => { + it("derives a toolkit-scoped executor while the shared bundle holds the data dir", async () => { + await withIsolatedExecutorDataDir(async () => { + const bundle = await getExecutorBundle(); + + // The bundle holds the data dir's ownership lock (a `BEGIN EXCLUSIVE` on + // `data.db.owner-lock`, per-connection, `busy_timeout = 0`) for its whole + // lifetime. Building this executor without `borrowedDb` opens a second + // owned database, which hits SQLITE_BUSY against that lock and rejects — + // that is what made every `/mcp/toolkits/` request 500. + const scoped = await createExecutorHandle({ + activeToolkitSlug: "scoped-slug", + borrowedDb: bundle.db, + }); + + expect(scoped.executor).toBeDefined(); + await scoped.dispose(); + }); + }); + + it("leaves the shared database open when a scoped executor is disposed", async () => { + await withIsolatedExecutorDataDir(async () => { + const bundle = await getExecutorBundle(); + const scoped = await createExecutorHandle({ + activeToolkitSlug: "scoped-slug", + borrowedDb: bundle.db, + }); + + await scoped.dispose(); + + // A scoped executor borrows the bundle's open handle, so disposing one + // must close its own plugins and nothing else. `createExecutor` closes the + // database only when it was handed the owning `{ db, close }` wrapper, so + // the layer passes the inner handle (`sqlite.db`) instead. Hand it the + // wrapper and the daemon loses `/mcp` and `/api` the moment any toolkit + // session ends — the type system does not catch the swap, because + // `SqliteFumaDb` structurally satisfies `ExecutorDb`. This read is what + // catches it. + const integrations = await Effect.runPromise(bundle.executor.integrations.list()); + expect(Array.isArray(integrations)).toBe(true); + }); + }); +}); diff --git a/apps/local/src/executor.ts b/apps/local/src/executor.ts index 5fa7c9a0f1..1ec7ebbf68 100644 --- a/apps/local/src/executor.ts +++ b/apps/local/src/executor.ts @@ -17,8 +17,9 @@ import { loadPluginsFromJsonc } from "@executor-js/config"; import type { McpPluginExtension } from "@executor-js/plugin-mcp"; import executorConfig from "../executor.config"; +import { localAnalytics } from "./analytics"; import { localDataMigrations } from "./db/data-migrations"; -import { openOwnedLocalDatabase } from "./db/owned-database"; +import { openOwnedLocalDatabase, type OwnedLocalDatabase } from "./db/owned-database"; interface ResolvedStorage { readonly dataDir: string; @@ -55,6 +56,16 @@ type LocalPlugins = readonly AnyPlugin[]; export interface LocalExecutorOptions { readonly activeToolkitSlug?: string; + /** + * Reuse an already-open owned database instead of opening (and locking) the + * data dir again. A toolkit-scoped MCP session differs from the default one + * only in its plugin set, so it must ride the running server's DB handle: + * `openOwnedLocalDatabase` takes an EXCLUSIVE lock, and a second open from + * inside the same process contends with the lock this process already holds. + * The borrowed handle is NOT closed when the derived executor disposes — + * whoever opened it still owns its lifetime. + */ + readonly borrowedDb?: OwnedLocalDatabase; } const loadLocalPlugins = (options: LocalExecutorOptions = {}) => @@ -91,6 +102,14 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => interface LocalExecutorBundle { readonly executor: Executor; readonly plugins: LocalPlugins; + /** The owned DB this bundle opened (or borrowed). Surfaced so a + * toolkit-scoped executor can ride the SAME handle instead of contending + * with this process's own exclusive data-dir lock. */ + readonly db: OwnedLocalDatabase; + /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced + * so callers building user-facing links (MCP artifact deep links) use the + * same origin the executor itself was configured with. */ + readonly webBaseUrl: string; } class LocalExecutorTag extends Context.Service()( @@ -146,23 +165,27 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { const tenantId = makeTenantId(cwd); const tables = collectTables(); - const owned = yield* Effect.acquireRelease( - Effect.tryPromise({ - try: () => - openOwnedLocalDatabase({ - dataDir: storage.dataDir, - tables, - namespace: localNamespace, - tenantId, + // A borrowed handle is owned by its opener, so it is used as-is and left + // open on release; only a handle opened here is closed here. + const owned = options.borrowedDb + ? options.borrowedDb + : yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => + openOwnedLocalDatabase({ + dataDir: storage.dataDir, + tables, + namespace: localNamespace, + tenantId, + }), + catch: (cause) => + new LocalExecutorCreateError({ + message: CREATE_SQLITE_ERROR_MESSAGE, + cause, + }), }), - catch: (cause) => - new LocalExecutorCreateError({ - message: CREATE_SQLITE_ERROR_MESSAGE, - cause, - }), - }), - (database) => Effect.promise(() => database.close()).pipe(Effect.ignore), - ); + (database) => Effect.promise(() => database.close()).pipe(Effect.ignore), + ); const sqlite = owned.db; const migration = owned.migration; @@ -191,6 +214,11 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { subject: Subject.make(LOCAL_SUBJECT), db: sqlite.db, plugins, + onIntegrationChange: (event) => + localAnalytics.record( + event.kind === "added" ? "integration_added" : "integration_removed", + { plugin_key: event.pluginKey }, + ), onElicitation: "accept-all", oauthEndpointUrlPolicy: { allowHttp: true }, // EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback` @@ -233,7 +261,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { ); } - return { executor, plugins }; + return { executor, plugins, webBaseUrl, db: owned }; }), ); }; @@ -246,6 +274,8 @@ export const createExecutorHandle = async (options: LocalExecutorOptions = {}) = return { executor: bundle.executor, plugins: bundle.plugins, + webBaseUrl: bundle.webBaseUrl, + db: bundle.db, dispose: async () => { await Effect.runPromise(Effect.ignore(bundle.executor.close())); await ignorePromiseFailure("disposeRuntime", () => runtime.dispose()); diff --git a/apps/local/src/identity.ts b/apps/local/src/identity.ts index 562d96dc7a..27a7e901c9 100644 --- a/apps/local/src/identity.ts +++ b/apps/local/src/identity.ts @@ -31,6 +31,7 @@ import { safeEqual } from "./serve-shared"; * cwd-derived (in `app.ts`), independent of these ids. */ export const LOCAL_PRINCIPAL: Principal = { + kind: "member", accountId: "local", organizationId: "local", organizationName: "Local", @@ -38,6 +39,7 @@ export const LOCAL_PRINCIPAL: Principal = { name: "Local", avatarUrl: null, roles: [], + orgRoleModel: "none", }; const bearerToken = (headers: Headers): string | undefined => { diff --git a/apps/local/src/installation.ts b/apps/local/src/installation.ts index 90f7ea50ce..317ca11259 100644 --- a/apps/local/src/installation.ts +++ b/apps/local/src/installation.ts @@ -15,11 +15,12 @@ const resolveChannel = (version: string): InstallationChannel => { return "stable"; }; -// The desktop main process sets `EXECUTOR_CLIENT=desktop` when it spawns the -// sidecar so PostHog can slice desktop installs from headless apps/local -// (CLI `executor web`, `daemon run --foreground`, etc.). +// The surface is cli|desktop — never a "local" catch-all: the desktop main +// process marks its sidecar via EXECUTOR_CLIENT=desktop; everything else +// hosting apps/local (`executor web`, `daemon run --foreground`, stdio MCP) +// is the CLI. Mirrors resolveSurface in ./analytics.ts. const resolveClient = (): SurfaceClient => - process.env.EXECUTOR_CLIENT === "desktop" ? "desktop" : "local"; + process.env.EXECUTOR_CLIENT === "desktop" ? "desktop" : "cli"; export const CHANNEL: InstallationChannel = resolveChannel(LOCAL_VERSION); export const VERSION: string = LOCAL_VERSION; diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index 750cd72f64..2ef674c572 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -1,7 +1,12 @@ import { Context, Data, Effect, Layer, ManagedRuntime } from "effect"; +import { withExecutionAnalytics } from "@executor-js/analytics"; import { createExecutionEngine } from "@executor-js/execution"; +import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; +import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; +import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; +import { localAnalytics } from "./analytics"; import { makeLocalApiHandler } from "./app"; import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor"; import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; @@ -81,24 +86,79 @@ export const createServerHandlers = async (token: string): Promise + localAnalytics.record(`artifact_${action}`, { via: "agent" }), + }; mcp = createMcpRequestHandler({ - defaultConfig: { engine }, + defaultConfig: { + engine, + artifacts: executor.artifacts, + connections: executor.connections, + ...appsConfig, + }, createConfigForResource: async (resource) => { - if (resource.kind === "default") return { config: { engine } }; + if (resource.kind === "default") { + return { + config: { + engine, + artifacts: executor.artifacts, + connections: executor.connections, + ...appsConfig, + }, + }; + } + // Borrow the running server's DB handle: this process already holds the + // data dir's exclusive ownership lock, so opening it a second time here + // fails against ourselves. The toolkit executor differs only in its + // plugin set, and the borrowed handle stays open when it disposes. const handle = await createExecutorHandle({ activeToolkitSlug: resource.slug, + borrowedDb: (await getExecutorBundle()).db, }); - const toolkitEngine = createExecutionEngine({ - executor: handle.executor, - codeExecutor: makeQuickJsExecutor(), - }); + const toolkitEngine = withExecutionAnalytics( + createExecutionEngine({ + executor: handle.executor, + codeExecutor: makeQuickJsExecutor(), + }), + localAnalytics, + { plane: "mcp", toolkit: true }, + ); return { - config: { engine: toolkitEngine }, + config: { + engine: toolkitEngine, + artifacts: handle.executor.artifacts, + connections: handle.executor.connections, + ...appsConfig, + }, close: handle.dispose, }; }, diff --git a/apps/local/src/mcp-pre-initialize.test.ts b/apps/local/src/mcp-pre-initialize.test.ts new file mode 100644 index 0000000000..2a97e1c8e9 --- /dev/null +++ b/apps/local/src/mcp-pre-initialize.test.ts @@ -0,0 +1,110 @@ +// --------------------------------------------------------------------------- +// Local app × the pre-initialize dispatch guard — through the real handler +// --------------------------------------------------------------------------- +// +// `executor mcp` bridges a stdio client to this handler's `/mcp` endpoint, so +// what the handler answers on a session-less POST decides whether the client's +// connection survives its first probe: +// +// test → createMcpRequestHandler().handleRequest(Request) +// → pre-initialize guard (an unknown method -> -32601 on a 200) +// → WebStandardStreamableHTTPServerTransport (everything else) +// +// The guard replaces exactly ONE transport answer — the connection-killing +// `400 -32000 Server not initialized` for a method that is not `initialize`. +// These assert that replacement AND that no other transport answer is shadowed: +// a request that fails content negotiation still gets the transport's 415/406, +// and a structurally invalid JSON-RPC message still gets its parse error, not a +// "method not found" that was never true. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import type { ExecutionEngine } from "@executor-js/execution"; + +import { createMcpRequestHandler } from "./mcp"; + +/** The headers a streamable-HTTP client must send on a POST; less is a 406/415. */ +const MCP_POST_HEADERS = { + "content-type": "application/json", + accept: "application/json, text/event-stream", +} as const; + +/** No code ever runs here: these requests are answered before any tool call. */ +const stubEngine: ExecutionEngine = { + execute: () => Effect.succeed({ result: "unused" }), + executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "unused" } }), + resume: () => Effect.succeed(null), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test executor"), + shutdown: Effect.void, +}; + +const post = ( + body: unknown, + headers: Record = MCP_POST_HEADERS, +): Promise => + createMcpRequestHandler({ engine: stubEngine }).handleRequest( + new Request("http://local.test/mcp", { method: "POST", headers, body: JSON.stringify(body) }), + ); + +interface JsonRpcErrorBody { + readonly error: { readonly code: number; readonly message: string }; +} + +describe("local MCP handler, pre-initialize", () => { + it("answers a valid unknown pre-session method with -32601 on a 200", async () => { + const response = await post({ jsonrpc: "2.0", id: 7, method: "server/discover", params: {} }); + + // 200, not the transport's 400: a per-request error the bridged client + // survives, so it can still fall back to `initialize`. + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + jsonrpc: "2.0", + id: 7, + error: { code: -32601, message: "Method not found" }, + }); + }); + + it("passes a pre-session notification to the transport", async () => { + const response = await post({ jsonrpc: "2.0", method: "notifications/initialized" }); + + // A notification carries no id, so the guard may not answer it at all; + // whatever comes back is the transport's own answer. + const body = (await response.json()) as JsonRpcErrorBody; + expect(body.error.code).not.toBe(-32601); + expect(body.error.code).toBe(-32000); + }); + + it("leaves a structurally invalid request to the transport's parse error", async () => { + // A fractional id is not a JSON-RPC id, so this is not a request the guard + // may report an unknown method for. + const response = await post({ jsonrpc: "2.0", id: 1.5, method: "server/discover" }); + + expect(response.status).toBe(400); + const body = (await response.json()) as JsonRpcErrorBody; + expect(body.error.code).toBe(-32700); + expect(body.error.code).not.toBe(-32601); + }); + + it("leaves a wrong Content-Type to the transport's 415", async () => { + const response = await post( + { jsonrpc: "2.0", id: 1, method: "server/discover" }, + { "content-type": "text/plain", accept: MCP_POST_HEADERS.accept }, + ); + + expect(response.status).toBe(415); + }); + + it("leaves an incomplete Accept to the transport's 406", async () => { + const response = await post( + { jsonrpc: "2.0", id: 1, method: "server/discover" }, + { "content-type": "application/json", accept: "application/json" }, + ); + + expect(response.status).toBe(406); + }); +}); diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index e74912998c..caa5548d4a 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -7,6 +7,7 @@ import { defaultMcpResource, jsonRpcErrorBody, mcpResourceKey, + preInitializeMethodNotFound, type McpResource, } from "@executor-js/host-mcp"; import { @@ -17,7 +18,9 @@ import { approvalUrlForRequest, decodeResumeResponse, formatResumeAcknowledgement, + readArtifactsEnabled, readElicitationMode, + readSearchToolsEnabled, } from "@executor-js/host-mcp/browser-approval"; import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store"; import { @@ -179,12 +182,27 @@ export const createMcpRequestHandler = ( return transport.handleRequest(request); } + // Pre-initialize dispatch: only `initialize` opens a session here, so a + // probe for anything else is answered -32601 instead of the transport's + // fatal 400. `executor mcp` bridges this endpoint to stdio, so that 400 + // would close the client's pipe before it could fall back to initialize. + const unsupported = await Effect.runPromise(preInitializeMethodNotFound(request)); + if (unsupported) return unsupported; + let created: McpServer | undefined; let createdSessionId: string | null = null; let resourceConfig: LocalMcpServerConfig | null = null; const transport = new WebStandardStreamableHTTPServerTransport({ + // SSE streaming (the spec default), NOT `enableJsonResponse: true`. + // JSON mode holds the POST open as a bare Promise and resolves it with + // one buffered body once every response is ready, so the transport has + // no open stream to write on: a server-to-client `elicitation/create` + // issued DURING a `tools/call` is dropped, and native elicitation dies + // on the client's request timeout (#1555). Streaming keeps the tool + // call's own stream writable in both directions, which is what native + // elicitation rides. Clients must already accept `text/event-stream` + // (the transport rejects a POST otherwise), so this costs nothing. sessionIdGenerator: () => crypto.randomUUID(), - enableJsonResponse: true, onsessioninitialized: (sid) => { createdSessionId = sid; transports.set(sid, transport); @@ -210,6 +228,8 @@ export const createMcpRequestHandler = ( createExecutorMcpServer({ ...resourceConfig.config, browserApprovalStore: approvals.store, + artifactsEnabled: readArtifactsEnabled(request), + searchToolsEnabled: readSearchToolsEnabled(request), elicitationMode: elicitationMode === "browser" ? { @@ -267,7 +287,12 @@ export const createMcpRequestHandler = ( const response = await readResumeResponse(request); if (!response) return json({ error: "Invalid approval response" }, 400); - await Effect.runPromise(approvals.recordResponse(executionId, response)); + await Effect.runPromise( + approvals.recordResponse(executionId, { + response, + orgWriteAccess: "allowed", + }), + ); return json(resumeApprovalResult(executionId, response)); }, diff --git a/apps/local/src/oauth-result-store.test.ts b/apps/local/src/oauth-result-store.test.ts new file mode 100644 index 0000000000..97d3c63e70 --- /dev/null +++ b/apps/local/src/oauth-result-store.test.ts @@ -0,0 +1,222 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +// oxlint-disable-next-line executor/no-vitest-import -- boundary: deterministic timer control comes from vitest itself +import { vi } from "vitest"; +import { OAUTH_POPUP_MESSAGE_TYPE, type OAuthPopupResult } from "@executor-js/sdk"; + +import { + __oauthAwaitHeldWaiterTotalForTests, + __oauthAwaitWaiterCountForTests, + __resetOAuthResultStoreForTests, + consumeOAuthResult, + publishOAuthResult, + waitForOAuthResult, +} from "./oauth-result-store"; + +const sampleResult = (sessionId: string): OAuthPopupResult => ({ + type: OAUTH_POPUP_MESSAGE_TYPE, + ok: false, + sessionId, + error: "access denied", +}); + +afterEach(() => { + __resetOAuthResultStoreForTests(); + vi.useRealTimers(); +}); + +// Waiter registration, publish wake-ups, aborts, and the over-cap instant +// answer are all synchronous, so every ordering below is exact — no sleeps. + +describe("waitForOAuthResult", () => { + it("resolves immediately and consumes when the result is already published", async () => { + publishOAuthResult(sampleResult("s-ready")); + + const result = await waitForOAuthResult("s-ready", { timeoutMs: 5000 }); + + expect(result).toMatchObject({ sessionId: "s-ready" }); + // One-shot: the wait consumed the entry. + expect(consumeOAuthResult("s-ready")).toBeNull(); + expect(__oauthAwaitWaiterCountForTests("s-ready")).toBe(0); + }); + + it("resolves a held wait the moment the result is published", async () => { + const pending = waitForOAuthResult("s-mid", { timeoutMs: 5000 }); + expect(__oauthAwaitWaiterCountForTests("s-mid")).toBe(1); + + const publishedAt = Date.now(); + publishOAuthResult(sampleResult("s-mid")); + const result = await pending; + + // Resolved by the publish, not the 5s deadline. + expect(Date.now() - publishedAt).toBeLessThan(1000); + expect(result).toMatchObject({ sessionId: "s-mid" }); + expect(consumeOAuthResult("s-mid")).toBeNull(); + expect(__oauthAwaitWaiterCountForTests("s-mid")).toBe(0); + }); + + it("returns null at the deadline and removes the waiter", async () => { + vi.useFakeTimers(); + const pending = waitForOAuthResult("s-deadline", { timeoutMs: 25_000 }); + expect(__oauthAwaitWaiterCountForTests("s-deadline")).toBe(1); + + vi.advanceTimersByTime(25_000); + + expect(await pending).toBeNull(); + expect(__oauthAwaitWaiterCountForTests("s-deadline")).toBe(0); + }); + + it("resolves immediately when the signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + const result = await waitForOAuthResult("s-pre-aborted", { + timeoutMs: 5000, + signal: controller.signal, + }); + + expect(result).toBeNull(); + expect(__oauthAwaitWaiterCountForTests("s-pre-aborted")).toBe(0); + }); +}); + +describe("waitForOAuthResult publish/abort races", () => { + it("abort settles first; a publish arriving after leaves the result for the next consumer", async () => { + const controller = new AbortController(); + const pending = waitForOAuthResult("s-abort-first", { + timeoutMs: 5000, + signal: controller.signal, + }); + expect(__oauthAwaitWaiterCountForTests("s-abort-first")).toBe(1); + + controller.abort(); + expect(await pending).toBeNull(); + expect(__oauthAwaitWaiterCountForTests("s-abort-first")).toBe(0); + + // The publish lands after the abort settled: the dead waiter must not + // consume it — it stays in the store for the client's next poll. + publishOAuthResult(sampleResult("s-abort-first")); + expect(consumeOAuthResult("s-abort-first")).toMatchObject({ sessionId: "s-abort-first" }); + }); + + it("publish resolves the waiter; an abort firing immediately after does not double-consume", async () => { + const controller = new AbortController(); + const pending = waitForOAuthResult("s-pub-first", { + timeoutMs: 5000, + signal: controller.signal, + }); + expect(__oauthAwaitWaiterCountForTests("s-pub-first")).toBe(1); + + publishOAuthResult(sampleResult("s-pub-first")); + controller.abort(); + + expect(await pending).toMatchObject({ sessionId: "s-pub-first" }); + expect(__oauthAwaitWaiterCountForTests("s-pub-first")).toBe(0); + + // The late abort must not have consumed or dropped anything: a second + // publish for the session is still delivered intact. + publishOAuthResult(sampleResult("s-pub-first")); + expect(consumeOAuthResult("s-pub-first")).toMatchObject({ sessionId: "s-pub-first" }); + }); +}); + +describe("waitForOAuthResult publish/timeout races", () => { + it("deadline fires first; a publish just after leaves the result consumable", async () => { + vi.useFakeTimers(); + const pending = waitForOAuthResult("s-late-pub", { timeoutMs: 25_000 }); + expect(__oauthAwaitWaiterCountForTests("s-late-pub")).toBe(1); + + vi.advanceTimersByTime(25_000); + expect(await pending).toBeNull(); + expect(__oauthAwaitWaiterCountForTests("s-late-pub")).toBe(0); + + publishOAuthResult(sampleResult("s-late-pub")); + expect(consumeOAuthResult("s-late-pub")).toMatchObject({ sessionId: "s-late-pub" }); + }); + + it("publish resolves the waiter; the stale deadline timer is inert afterwards", async () => { + vi.useFakeTimers(); + const pending = waitForOAuthResult("s-early-pub", { timeoutMs: 25_000 }); + expect(__oauthAwaitWaiterCountForTests("s-early-pub")).toBe(1); + + publishOAuthResult(sampleResult("s-early-pub")); + expect(await pending).toMatchObject({ sessionId: "s-early-pub" }); + expect(__oauthAwaitWaiterCountForTests("s-early-pub")).toBe(0); + + // Run the clock past the original deadline: the settled waiter's timer + // was cleared, so nothing re-fires, re-registers, or consumes again. + vi.advanceTimersByTime(25_000); + expect(__oauthAwaitWaiterCountForTests("s-early-pub")).toBe(0); + publishOAuthResult(sampleResult("s-early-pub")); + expect(consumeOAuthResult("s-early-pub")).toMatchObject({ sessionId: "s-early-pub" }); + }); +}); + +describe("waitForOAuthResult held-waiter caps", () => { + it("holds one waiter per session; stacked requests answer null immediately (old-client behavior)", async () => { + // An unpatched desktop client polls every second and would stack ~25 + // concurrent requests per flow against a holding server. Only the first + // may hold; the rest get the pre-long-poll instant "still pending". + const held = waitForOAuthResult("s-stack", { timeoutMs: 5000 }); + expect(__oauthAwaitWaiterCountForTests("s-stack")).toBe(1); + + const stacked = [ + waitForOAuthResult("s-stack", { timeoutMs: 5000 }), + waitForOAuthResult("s-stack", { timeoutMs: 5000 }), + waitForOAuthResult("s-stack", { timeoutMs: 5000 }), + ]; + // The stacked requests settle null BEFORE any publish — instant answers. + for (const request of stacked) expect(await request).toBeNull(); + expect(__oauthAwaitWaiterCountForTests("s-stack")).toBe(1); + expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(1); + + publishOAuthResult(sampleResult("s-stack")); + // Exactly one consumer receives the one-shot result: the held waiter. + expect(await held).toMatchObject({ sessionId: "s-stack" }); + expect(consumeOAuthResult("s-stack")).toBeNull(); + // No leaked waiters after the flow. + expect(__oauthAwaitWaiterCountForTests("s-stack")).toBe(0); + expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(0); + }); + + it("re-arms the per-session hold after the held waiter settles", async () => { + const controller = new AbortController(); + const first = waitForOAuthResult("s-rearm", { timeoutMs: 5000, signal: controller.signal }); + // Over the per-session cap while the first is held. + expect(await waitForOAuthResult("s-rearm", { timeoutMs: 5000 })).toBeNull(); + + controller.abort(); + expect(await first).toBeNull(); + expect(__oauthAwaitWaiterCountForTests("s-rearm")).toBe(0); + + // The slot is free again: the next request holds and gets the result. + const second = waitForOAuthResult("s-rearm", { timeoutMs: 5000 }); + expect(__oauthAwaitWaiterCountForTests("s-rearm")).toBe(1); + publishOAuthResult(sampleResult("s-rearm")); + expect(await second).toMatchObject({ sessionId: "s-rearm" }); + expect(__oauthAwaitWaiterCountForTests("s-rearm")).toBe(0); + }); + + it("caps total held waiters globally; over-cap sessions answer instantly but stored results still deliver", async () => { + const held = Array.from({ length: 64 }, (_, index) => + waitForOAuthResult(`s-global-${index}`, { timeoutMs: 5000 }), + ); + expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(64); + + // A distinct pending session over the global cap answers null instantly + // instead of holding. + expect(await waitForOAuthResult("s-global-over", { timeoutMs: 5000 })).toBeNull(); + expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(64); + + // Over-cap behavior matches the pre-long-poll server exactly: a result + // already in the store is still consumed and answered immediately. + publishOAuthResult(sampleResult("s-global-stored")); + expect(await waitForOAuthResult("s-global-stored", { timeoutMs: 5000 })).toMatchObject({ + sessionId: "s-global-stored", + }); + + // Settle every held waiter and confirm the registry drains completely. + __resetOAuthResultStoreForTests(); + for (const request of held) expect(await request).toBeNull(); + expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(0); + }); +}); diff --git a/apps/local/src/oauth-result-store.ts b/apps/local/src/oauth-result-store.ts index c9023368d6..89698d4caf 100644 --- a/apps/local/src/oauth-result-store.ts +++ b/apps/local/src/oauth-result-store.ts @@ -28,6 +28,37 @@ const RESULT_TTL_MS = 10 * 60 * 1000; // 10 minutes — long enough for slow MFA const store = new Map(); +/** + * Long-poll waiters, keyed by sessionId. Each entry is the wake callback for + * the single held `/api/oauth/await/:sessionId` request for that session. + * `publishOAuthResult` wakes the waiter, which runs `consumeOAuthResult` and + * answers with the result. + * + * Held waiters are bounded — each pins a connection, a registry entry, and a + * deadline timer, and the route only needs a bearer, so unbounded holds are a + * DoS surface. At most one waiter is held per session (the patched client + * polls sequentially; an unpatched client polling every second would + * otherwise stack ~25 holds per flow against a 25s deadline), and at most + * `MAX_HELD_WAITERS_TOTAL` across all sessions. Over either bound, + * `waitForOAuthResult` degrades to the pre-long-poll behavior: it answers the + * current store value immediately (null = still pending) instead of holding, + * so over-cap callers see exactly what an old server would have sent. + */ +const MAX_HELD_WAITERS_TOTAL = 64; + +const waiters = new Map void>(); + +const removeWaiter = (sessionId: string, wake: () => void): void => { + if (waiters.get(sessionId) === wake) waiters.delete(sessionId); +}; + +const wakeWaiter = (sessionId: string): void => { + const wake = waiters.get(sessionId); + if (!wake) return; + waiters.delete(sessionId); + wake(); +}; + const cleanupExpired = (now: number) => { for (const [sessionId, entry] of store) { if (entry.expiresAt < now) store.delete(sessionId); @@ -44,6 +75,7 @@ export const publishOAuthResult = (result: AnyResult): void => { const now = Date.now(); cleanupExpired(now); store.set(sessionId, { result, expiresAt: now + RESULT_TTL_MS }); + wakeWaiter(sessionId); }; /** @@ -59,7 +91,57 @@ export const consumeOAuthResult = (sessionId: string): AnyResult | null => { return entry.result; }; -/** Test-only — clears the entire store between tests. */ +/** + * Long-poll for a result. Consumes and resolves immediately when a result + * is already stored; otherwise holds until `publishOAuthResult` fires for + * the sessionId, the deadline elapses, or `signal` aborts (client gone). + * The latter two resolve `null` — the same "still pending" answer an + * immediate poll gives — so the caller's retry loop keeps working. A + * waiter that times out or aborts is always removed from the registry. + * + * Holding is bounded (see the waiter registry above): when the session + * already has a held waiter, or the global held-waiter ceiling is reached, + * this answers `null` immediately instead of holding. + */ +export const waitForOAuthResult = ( + sessionId: string, + opts: { readonly timeoutMs: number; readonly signal?: AbortSignal }, +): Promise => { + const immediate = consumeOAuthResult(sessionId); + if (immediate !== null) return Promise.resolve(immediate); + if (opts.timeoutMs <= 0 || opts.signal?.aborted === true) return Promise.resolve(null); + if (waiters.has(sessionId) || waiters.size >= MAX_HELD_WAITERS_TOTAL) { + return Promise.resolve(null); + } + + return new Promise((resolve) => { + let done = false; + const finish = (result: AnyResult | null) => { + if (done) return; + done = true; + clearTimeout(timer); + opts.signal?.removeEventListener("abort", onAbort); + removeWaiter(sessionId, wake); + resolve(result); + }; + const wake = () => finish(consumeOAuthResult(sessionId)); + const onAbort = () => finish(null); + const timer = setTimeout(() => finish(null), opts.timeoutMs); + + waiters.set(sessionId, wake); + opts.signal?.addEventListener("abort", onAbort, { once: true }); + }); +}; + +/** Test-only — clears the store and resolves any held waiters as pending. */ export const __resetOAuthResultStoreForTests = (): void => { store.clear(); + for (const sessionId of [...waiters.keys()]) wakeWaiter(sessionId); }; + +/** Test-only — number of held long-poll waiters for a sessionId (0 or 1). */ +export const __oauthAwaitWaiterCountForTests = (sessionId: string): number => + waiters.has(sessionId) ? 1 : 0; + +/** Test-only — total held long-poll waiters across all sessions. */ +export const __oauthAwaitHeldWaiterTotalForTests = (): number => waiters.size; diff --git a/apps/local/src/serve.test.ts b/apps/local/src/serve.test.ts index b7c7e1fc9b..9492c792d0 100644 --- a/apps/local/src/serve.test.ts +++ b/apps/local/src/serve.test.ts @@ -3,7 +3,16 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } f import { tmpdir } from "node:os"; import { join } from "node:path"; +import { OAUTH_POPUP_MESSAGE_TYPE, type OAuthPopupResult } from "@executor-js/sdk"; + import { acquireDataDirOwnership } from "./db/data-dir-ownership"; +import { + __oauthAwaitHeldWaiterTotalForTests, + __oauthAwaitWaiterCountForTests, + __resetOAuthResultStoreForTests, + consumeOAuthResult, + publishOAuthResult, +} from "./oauth-result-store"; import { startServer, type ServerInstance } from "./serve"; let clientDir: string; @@ -219,6 +228,121 @@ describe("startServer bearer auth", () => { }); }); +describe("startServer OAuth await long-poll", () => { + afterEach(() => { + __resetOAuthResultStoreForTests(); + }); + + const untilWaiterCount = async (sessionId: string, count: number): Promise => { + const deadline = Date.now() + 2000; + while (__oauthAwaitWaiterCountForTests(sessionId) !== count && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(__oauthAwaitWaiterCountForTests(sessionId)).toBe(count); + }; + + const awaitResult = (sessionId: string): OAuthPopupResult => ({ + type: OAUTH_POPUP_MESSAGE_TYPE, + ok: false, + sessionId, + error: "access denied", + }); + + it("holds the await request open and answers the moment the result publishes", async () => { + const baseUrl = await startTestServer(); + + const pending = fetch(`${baseUrl}/api/oauth/await/session-hold`, { + headers: { authorization: `Bearer ${TOKEN}` }, + }); + // The request is being held, not answered null immediately. + await untilWaiterCount("session-hold", 1); + + const publishedAt = Date.now(); + publishOAuthResult(awaitResult("session-hold")); + const response = await pending; + const body = (await response.json()) as unknown; + + // Resolved by the publish, not a poll tick or the 25s deadline. + expect(Date.now() - publishedAt).toBeLessThan(1000); + expect(body).toMatchObject({ sessionId: "session-hold", ok: false }); + expect(__oauthAwaitWaiterCountForTests("session-hold")).toBe(0); + }); + + it("answers a pre-published result without waiting", async () => { + const baseUrl = await startTestServer(); + publishOAuthResult(awaitResult("session-ready")); + + const response = await fetch(`${baseUrl}/api/oauth/await/session-ready`, { + headers: { authorization: `Bearer ${TOKEN}` }, + }); + const body = (await response.json()) as unknown; + + expect(body).toMatchObject({ sessionId: "session-ready" }); + // One-shot: a second poll for the same session finds nothing. + expect(consumeOAuthResult("session-ready")).toBeNull(); + }); + + it("drops the waiter when the client disconnects", async () => { + const baseUrl = await startTestServer(); + + const controller = new AbortController(); + const pending = fetch(`${baseUrl}/api/oauth/await/session-gone`, { + headers: { authorization: `Bearer ${TOKEN}` }, + signal: controller.signal, + }); + await untilWaiterCount("session-gone", 1); + + controller.abort(); + await expect(pending).rejects.toThrow(); + await untilWaiterCount("session-gone", 0); + + // The dead waiter must not consume a result published afterwards. + publishOAuthResult(awaitResult("session-gone")); + expect(consumeOAuthResult("session-gone")).toMatchObject({ sessionId: "session-gone" }); + }); + + it("holds one request per session; stacked requests answer null instantly and one consumer wins", async () => { + // The mixed-version rollout hazard: an unpatched desktop client polls + // every second and would stack ~25 concurrent requests per flow against + // a holding server. Only the first request may hold — the rest must get + // the pre-long-poll instant "still pending" answer. + const baseUrl = await startTestServer(); + + const held = fetch(`${baseUrl}/api/oauth/await/session-stacked`, { + headers: { authorization: `Bearer ${TOKEN}` }, + }); + // Deterministic ordering: the first request is registered before any + // stacked request is issued. + await untilWaiterCount("session-stacked", 1); + + const stacked = await Promise.all( + [1, 2, 3].map(() => + fetch(`${baseUrl}/api/oauth/await/session-stacked`, { + headers: { authorization: `Bearer ${TOKEN}` }, + }), + ), + ); + // The stacked requests settled BEFORE any publish — instant null answers, + // not held connections. + for (const response of stacked) { + expect(response.status).toBe(200); + expect(await response.json()).toBeNull(); + } + expect(__oauthAwaitWaiterCountForTests("session-stacked")).toBe(1); + expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(1); + + publishOAuthResult(awaitResult("session-stacked")); + const response = await held; + // Exactly one consumer overall receives the one-shot result: the held + // request. Nothing is left behind for a later poll. + expect(await response.json()).toMatchObject({ sessionId: "session-stacked" }); + expect(consumeOAuthResult("session-stacked")).toBeNull(); + // The registry drains completely — no leaked waiters. + expect(__oauthAwaitWaiterCountForTests("session-stacked")).toBe(0); + expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(0); + }); +}); + describe("startServer CORS hardening", () => { it("reflects credentialed CORS only for allowed loopback origins", async () => { const baseUrl = await startTestServer(); diff --git a/apps/local/src/serve.ts b/apps/local/src/serve.ts index 0ecdc609c0..71725b09cc 100644 --- a/apps/local/src/serve.ts +++ b/apps/local/src/serve.ts @@ -13,7 +13,8 @@ import type { Subprocess } from "bun"; import { setOAuthCompletionListener } from "@executor-js/api"; import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server"; import { loadOrMintLocalAuthToken } from "./auth"; -import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store"; +import { publishOAuthResult, waitForOAuthResult } from "./oauth-result-store"; +import { disposeAnalytics } from "./analytics"; import { startIntegrationsRefresh } from "./integrations"; import { disposeServerHandlers, getServerHandlers } from "./main"; import { @@ -26,6 +27,11 @@ import { normalizeCredential, } from "./serve-shared"; +// How long one /api/oauth/await request is held open waiting for the OAuth +// completion to be published. Shorter than the client's overall timeout; the +// client reconnects after each deadline, so this only bounds a single hold. +const OAUTH_AWAIT_LONG_POLL_DEADLINE_MS = 25_000; + // --------------------------------------------------------------------------- // Static files // --------------------------------------------------------------------------- @@ -112,6 +118,8 @@ interface ViteChild { readonly stop: () => Promise; } +const viteChildSignals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; + async function allocatePort(): Promise { const probe = Bun.serve({ port: 0, @@ -126,15 +134,15 @@ async function allocatePort(): Promise { async function startViteChild(): Promise { const vitePort = await allocatePort(); const cwd = resolve(import.meta.dirname, ".."); + const viteEntrypoint = resolve(cwd, "node_modules/vite/bin/vite.js"); const env = { ...process.env }; delete env.PORT; - // `bunx --bun vite` runs vite under Bun, matching the `dev:vite` script - // already in apps/local. --strictPort keeps the URL we hand back stable. + // Run Vite directly under Bun, matching the `dev:vite` script without a + // bunx wrapper that can outlive its child. --strictPort keeps the URL stable. const child: Subprocess = Bun.spawn( [ - "bunx", - "--bun", - "vite", + process.execPath, + viteEntrypoint, "dev", "--port", String(vitePort), @@ -157,20 +165,45 @@ async function startViteChild(): Promise { }, ); + let stopping = false; + const stop = async (): Promise => { + if (stopping) { + await child.exited; + return; + } + stopping = true; + for (const signal of viteChildSignals) process.off(signal, stopOnParentSignal); + if (child.exitCode === null) child.kill(); + await Promise.race([child.exited, Bun.sleep(5_000)]); + if (child.exitCode === null) child.kill("SIGKILL"); + await child.exited; + }; + const stopOnParentSignal = (): void => { + // A PTY/session teardown can signal the CLI while Vite is still optimizing + // dependencies, before the server's normal stop handle exists. Reap the + // owned child immediately; the CLI's signal waiter performs full cleanup + // once startup has completed. + void stop(); + }; + for (const signal of viteChildSignals) process.once(signal, stopOnParentSignal); + const url = `http://127.0.0.1:${vitePort}`; const deadline = Date.now() + 30_000; while (Date.now() < deadline) { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing a child process that may not be listening yet try { - const r = await fetch(`${url}/`, { redirect: "manual" }); + const r = await fetch(`${url}/`, { + redirect: "manual", + // A listening socket is not proof that Vite can answer. Bound each + // probe so one accepted-but-stalled request cannot defeat the 30s boot + // deadline and wedge the entire local e2e suite. + signal: AbortSignal.timeout(5_000), + }); if (r.status < 500) { await r.body?.cancel(); return { url, - stop: async () => { - child.kill(); - await child.exited; - }, + stop, }; } await r.body?.cancel(); @@ -178,12 +211,13 @@ async function startViteChild(): Promise { // not up yet } if (child.exitCode !== null) { + await stop(); // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready throw new Error(`vite dev exited with code ${child.exitCode} before becoming ready`); } await Bun.sleep(150); } - child.kill(); + await stop(); // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: vite never became reachable throw new Error(`vite dev did not become reachable on ${url} within 30s`); } @@ -324,6 +358,8 @@ export async function startServer(opts: StartServerOptions = {}): Promise > " form from that directory. Point +// the bare specifier at the package the plugin already resolves, so `root` can +// name it. It is the same copy either way, so nothing about resolution changes. +const JS_YAML_DIR = dirname( + createRequire(fileURLToPath(new URL("../../packages/plugins/openapi/", import.meta.url))).resolve( + "js-yaml", + ), +); + const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response => new Response( JSON.stringify( @@ -215,6 +229,25 @@ export default defineConfig({ outDir: resolve(import.meta.dirname, "dist"), emptyOutDir: true, }, + // Deps vite only discovers once a lazy-loaded React chunk actually renders + // (e.g. opening the integration Edit sheet, or the MCP/OpenAPI add-source + // flow). Discovering one mid-run forces a re-optimize and a full page + // reload, which throws away whatever the person was doing — an open sheet + // and its form state included. Pre-bundle them at boot so vite never + // discovers them mid-run. Keep in sync with apps/host-selfhost and + // apps/cloud. + optimizeDeps: { + include: [ + "effect/Match", + "effect/Predicate", + "effect/Exit", + "effect/Option", + "effect/Cause", + "effect/Data", + "effect/Schema", + "js-yaml", + ], + }, define: { "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), // The local app IS the npm-installed CLI, so its update card shows the npm @@ -229,6 +262,7 @@ export default defineConfig({ }, resolve: { tsconfigPaths: true, + alias: { "js-yaml": JS_YAML_DIR }, }, server: { port: parseInt(process.env.PORT ?? "5173", 10), diff --git a/apps/marketing/src/assets/yc-backed-by.svg b/apps/marketing/src/assets/yc-backed-by.svg index f3b4d3e3c2..6606921ff2 100755 --- a/apps/marketing/src/assets/yc-backed-by.svg +++ b/apps/marketing/src/assets/yc-backed-by.svg @@ -1,5 +1,4 @@ - - + @@ -14,17 +13,4 @@ - - - - - - - - - - - - - diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 603613436c..d791ecda8c 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -50,9 +50,16 @@ const canonical = new URL(Astro.url.pathname, Astro.site ?? Astro.url).toString( // deploy env, so it's a no-op in dev / unconfigured builds (and the SDK // isn't even shipped, thanks to the dynamic import). Events are proxied // first-party through src/middleware.ts to survive adblockers. - // autocapture and pageviews are off on purpose: we only send the explicit - // events the page fires (e.g. the "Set up with your agent" copy). Flip - // capture_pageview on if you want a denominator for conversion. + // autocapture stays off on purpose: beyond $pageview we only send the + // explicit events the page fires (e.g. the "Set up with your agent" copy). + // + // capture_pageview is ON because this is the campaign landing surface. + // With it off, a visit to executor.sh/?utm_source=... produced no + // $pageview at all, so every UTM-tagged campaign read as zero traffic in + // PostHog — the params only ever reached $web_vitals, which no funnel or + // web-analytics view is built on. Astro serves a full page load per + // navigation, so plain `true` (capture once on init) is right here; + // `history_change` is for SPAs like apps/cloud. const phKey = import.meta.env.PUBLIC_POSTHOG_KEY; if (phKey) { // api_host rides the `/_astro` prefix that the cloud edge forwards to @@ -62,7 +69,7 @@ const canonical = new URL(Astro.url.pathname, Astro.site ?? Astro.url).toString( api_host: `${window.location.origin}/_astro/_ph`, ui_host: import.meta.env.PUBLIC_POSTHOG_HOST ?? "https://us.posthog.com", autocapture: false, - capture_pageview: false, + capture_pageview: true, persistence: "localStorage", }); return posthog; diff --git a/apps/marketing/src/pages/about-executor.astro b/apps/marketing/src/pages/about-executor.astro new file mode 100644 index 0000000000..bcc4d52282 --- /dev/null +++ b/apps/marketing/src/pages/about-executor.astro @@ -0,0 +1,158 @@ +--- +const pageTitle = "Executor"; +const pageDescription = + "Executor is a web application that lets people connect AI assistants to Google Workspace, related Google services, and other software, then control the actions those assistants can take."; +--- + + + + + + + + + + + + + + + + Executor + + + +
+

Executor

+

+ Executor is a web application that lets people connect AI assistants to their software + and data. It gives those assistants specific tools to complete tasks the person requests, + while letting the person approve or block actions. +

+
+ +
+
+

What Executor does

+

+ A person can use Executor to connect an AI assistant to Google Workspace and related + Google services. The person can then ask the assistant to manage schedules, email, + files, documents, contacts, tasks, meetings, photos, or website data through Executor. +

+

+ Executor performs only the actions the person requests and permits. It does not give an + AI assistant an unrestricted session in the person's account. +

+
+ +
+

How Executor uses Google data

+

+ When a person chooses to connect a Google service, Executor requests the permissions + needed to provide that connection and uses the resulting data to carry out that + person's instructions. +

+
    +
  • Google Calendar: read, create, update, or remove calendars and events, and manage calendar sharing rules.
  • +
  • Gmail: read and search messages, compose and send mail, manage labels, archive or trash messages, or permanently delete messages only when explicitly requested.
  • +
  • Google Sheets: read spreadsheet data, update cells, ranges, and worksheets, and write Drive-file smart chips.
  • +
  • Google Drive, Docs, Slides, and Forms: find and manage files and folders, edit documents and presentations, and create or read forms and responses.
  • +
  • Google Contacts and Tasks: read or update contacts and contact groups, read available profile fields, other contacts, or a Workspace directory, and manage task lists and tasks.
  • +
  • Google Meet: create and configure meeting spaces or read meeting records, participants, recordings, and transcripts.
  • +
  • Google Photos: upload and manage app-created media or read media explicitly selected through Google Photos Picker.
  • +
  • Google Search Console: inspect verified sites, sitemaps, indexed URLs, and search-performance data.
  • +
+
+ +
+

Privacy and user control

+

+ Executor does not sell Google user data. A person can remove a connection in Executor or + revoke it from their Google Account permissions. More information is available in the + Executor Privacy Policy. +

+

+ Executor's use and transfer of information received from Google APIs adheres to the + Google API Services User Data Policy, including the Limited Use requirements. +

+
+
+ + + + diff --git a/apps/marketing/src/pages/google-oauth.astro b/apps/marketing/src/pages/google-oauth.astro new file mode 100644 index 0000000000..a4b0802484 --- /dev/null +++ b/apps/marketing/src/pages/google-oauth.astro @@ -0,0 +1,554 @@ +--- +import Layout from "../layouts/Layout.astro"; + +const googleServices = [ + { + index: "01", + name: "Google Calendar", + purpose: + "Executor can read, create, update, or remove calendars and events, and manage calendar sharing rules, when you ask an agent to manage your schedule.", + scope: "googleapis.com/auth/calendar", + }, + { + index: "02", + name: "Gmail", + purpose: + "Executor can read, search, compose, send, organize, trash, and permanently delete messages, and manage filters and other basic Gmail settings, only when you explicitly instruct an agent to work with your email.", + scope: "mail.google.com · gmail.settings.basic", + }, + { + index: "03", + name: "Google Sheets", + purpose: + "Executor can read spreadsheet data, update cells, ranges, and worksheets, and write Drive-file smart chips when you ask an agent to work with a spreadsheet.", + scope: "googleapis.com/auth/spreadsheets · drive.file", + }, + { + index: "04", + name: "Google Drive", + purpose: + "Executor can find, create, download, organize, share, or delete files and folders when you ask an agent to manage your Drive.", + scope: "googleapis.com/auth/drive", + }, + { + index: "05", + name: "Google Docs, Slides, and Forms", + purpose: + "Executor can read and edit documents and presentations, and create or read forms and responses, when you ask an agent to work with that content.", + scope: "documents · presentations · forms.body · forms.responses.readonly", + }, + { + index: "06", + name: "Google Contacts and Tasks", + purpose: + "Executor can read or update contacts and contact groups, read available profile fields, other contacts, or a Workspace directory, and manage task lists and tasks when you ask an agent to organize people or work.", + scope: "contacts · user profile fields · directory.readonly · tasks", + }, + { + index: "07", + name: "Google Meet", + purpose: + "Executor can create and configure meeting spaces or read meeting records, participants, recordings, and transcripts when you request meeting-related work.", + scope: "meetings.space.created · readonly · settings", + }, + { + index: "08", + name: "Google Photos", + purpose: + "Executor can upload and manage app-created media or read media that you explicitly select through Google Photos Picker.", + scope: "photoslibrary.app-created read/write · photospicker.readonly", + }, + { + index: "09", + name: "Google Search Console", + purpose: + "Executor can inspect verified sites, sitemaps, indexed URLs, and search-performance data when you request website analysis.", + scope: "googleapis.com/auth/webmasters", + }, +] as const; +--- + + +
+ + + + +
+
+ + Google Workspace connections +
+

Executor

+

+ Executor is an integration platform and MCP gateway for AI agents. It lets you + securely connect Google Workspace and related Google services so an agent can read + data and take the actions you request. +

+
+ One connection + Explicit permission + User-directed actions +
+
+ +
+
+

01 / Google data

+
+

What Executor accesses—and why

+

+ You choose which Google service to connect. Executor requests only the + permissions needed for that connection and uses them to carry out your + instructions. +

+
+
+ +
+ { + googleServices.map((service) => ( +
+
{service.index}
+
+

{service.name}

+

{service.purpose}

+
+ {service.scope} +
+ )) + } +
+
+ +
+
+

02 / Your control

+
+

Your Google account stays yours

+

+ Connecting Google does not give Executor permission to act on its own. An + agent can use a Google tool only when you direct it to, subject to the + policies and approvals you configure in Executor. +

+
+
+ +
+
+ +

See the action

+

Executor exposes concrete Google actions with their inputs, not an open-ended account session.

+
+
+ +

Set the policy

+

Allow an action, require approval, or block it before an agent can run it.

+
+ +
+
+ +
+

03 / Privacy

+
+
+

Clear limits on Google data

+

+ Executor does not sell Google user data. We use Google data only to provide + and improve the user-facing integration features you request, in accordance + with our Privacy Policy. Executor's use and transfer of information received + from Google APIs adheres to the + Google API Services User Data Policy, including the Limited Use requirements. +

+
+ +
+
+ +
+ +
+
+
+ + diff --git a/apps/marketing/src/pages/google-workspace.astro b/apps/marketing/src/pages/google-workspace.astro new file mode 100644 index 0000000000..75133113ab --- /dev/null +++ b/apps/marketing/src/pages/google-workspace.astro @@ -0,0 +1,218 @@ +--- +const services = [ + { + name: "Google Calendar", + description: + "Read, create, update, or remove calendars and events, and manage calendar sharing rules, when you ask an agent to manage your schedule.", + }, + { + name: "Gmail", + description: + "Read, search, compose, send, label, archive, trash, or permanently delete messages, and manage filters and other basic Gmail settings, when you explicitly ask an agent to work with your email.", + }, + { + name: "Google Sheets", + description: + "Read spreadsheet data, update cells, ranges, or worksheets, and write Drive-file smart chips when you ask an agent to work with a spreadsheet.", + }, + { + name: "Google Drive, Docs, Slides, and Forms", + description: + "Find and manage files and folders, edit documents and presentations, and create or read forms and responses when you ask an agent to work with them.", + }, + { + name: "Google Contacts and Tasks", + description: + "Read or update contacts and contact groups, read available profile fields, other contacts, or a Workspace directory, and manage task lists and tasks when you ask an agent to organize people or work.", + }, + { + name: "Google Meet", + description: + "Create and configure meeting spaces or read meeting records, participants, recordings, and transcripts when you request meeting-related work.", + }, + { + name: "Google Photos", + description: + "Upload and manage app-created photos or read media you explicitly select through Google Photos Picker.", + }, + { + name: "Google Search Console", + description: + "Read and manage verified sites, sitemaps, URL inspection results, and search-performance data when you request website analysis.", + }, +] as const; +--- + + + + + + + + + + Executor + + + +
+

Useful Software Company

+

Executor

+

+ Executor is an integration platform and MCP gateway for AI agents. Executor lets you + securely connect Google Workspace and related Google services so an agent can read your + Google data and take only the actions you request. +

+
+ +
+
+

Why Executor requests access to Google data

+

+ You choose which Google service to connect. Executor requests the permissions needed + to provide that connection and uses the resulting Google data to carry out your + instructions. Connecting Google does not give Executor permission to act on its own. +

+
    + { + services.map((service) => ( +
  • +

    {service.name}

    +

    {service.description}

    +
  • + )) + } +
+
+ +
+

You control the connection

+

+ Executor exposes specific Google actions and their inputs. You can allow an action, + require approval, or block it through the policies you configure in Executor. You can + remove a connection in Executor or revoke it from your + Google Account permissions. +

+
+ +
+

Privacy and Google API data

+

+ Executor does not sell Google user data. Executor uses Google data only to provide and + improve the user-facing integration features you request, as described in the + Executor Privacy Policy. +

+

+ Executor's use and transfer of information received from Google APIs adheres to the + Google API Services User Data Policy, including the Limited Use requirements. +

+
+
+ + + + diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index cd0940b879..71ad6c4f41 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -112,6 +112,11 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo class="text-[14px] font-medium text-ink-2 hover:text-ink transition-colors hidden sm:inline" >Blog +
@@ -588,7 +596,7 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo
Pricing

- Start free, pay as you run. + Start free, pay per member.

@@ -615,8 +623,7 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo
    {[ "Up to 3 members", - "10,000 included executions per month", - "$0.20 per 1,000 additional executions", + "100,000 executions per month", "Unlimited integrations", ].map((f) => (
  • @@ -647,21 +654,18 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo
    $150$15 - / org / month + / member / month
    Start free trial →
      {[ - "14-day free trial, then $150 / month", - "Unlimited members", - "250,000 included executions per month", - "5 minute execution timeout", - "Join by team domain", - "$0.20 per 1,000 additional executions", + "14-day free trial, then $15 / member / month", + "Unlimited executions", + "Verified domains & join by team domain", ].map((f) => (
    • +

      Google Workspace API data

      +

      + If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you + request through that integration. Depending on the service and permissions you choose, this may include accessing + or modifying Google Calendar calendars, events, and sharing rules; Gmail messages, drafts, threads, attachments, labels, and settings; Google + Drive files and folders; Docs documents; Sheets spreadsheets; Slides presentations; Forms and responses; Contacts, + profile fields, other contacts, and an available Workspace directory; Tasks; Meet spaces, participants, recordings, and transcripts; app-created or user-selected Photos media; Search + Console sites, sitemaps, indexed URLs, and performance data; or other Google content made available by the service + you connect. For Gmail, this can include permanently deleting messages or threads only when you explicitly request + that irreversible action. +

      +

      + Executor stores OAuth credentials and connection metadata so the integration can continue working. It processes + Google Workspace content only while carrying out your requested tool calls and returning their results to you or + the agent you directed to make the call. That content can appear in the resulting agent session or in another + service when you explicitly direct Executor to send it there. +

      +

      + We do not use Google Workspace API data for advertising or to train generalized artificial-intelligence or + machine-learning models. We disclose it only as needed to provide the user-requested integration, operate and secure + the Services, comply with law, or as otherwise described in this Policy with the user's consent. +

      +

      + Executor's use and transfer of information received from Google Workspace APIs adheres to the + Google API Services User Data Policy, + including its Limited Use requirements. +

      +

      + You can stop Executor's access by deleting the Google connection in Executor or revoking Executor from your + Google Account permissions. To request deletion of your Executor account, stored OAuth credentials, connection + metadata, or other personal information, email rhys@executor.sh. +

      +

      Website inputs and support communications

      If you submit a URL to our website's API detection tool, contact us by email, or otherwise communicate with us, diff --git a/assets/executor-demo.gif b/assets/executor-demo.gif new file mode 100644 index 0000000000..4fe7d66ba3 Binary files /dev/null and b/assets/executor-demo.gif differ diff --git a/autumn.config.ts b/autumn.config.ts index 279dc40fa1..38bbb2be50 100644 --- a/autumn.config.ts +++ b/autumn.config.ts @@ -19,7 +19,6 @@ export const members = feature({ name: "Members", type: "metered", consumable: false, - archived: true, }); // Plans @@ -29,9 +28,13 @@ export const free = plan({ addOn: false, autoEnable: true, items: [ + item({ + featureId: members.id, + included: 3, + }), item({ featureId: executions.id, - included: 10000, + included: 100000, reset: { interval: "month", }, @@ -47,7 +50,7 @@ export const freePayAsYouGo = plan({ items: [ item({ featureId: executions.id, - included: 10000, + included: 100000, price: { amount: 0.2, billingUnits: 1000, @@ -63,26 +66,31 @@ export const team = plan({ name: "Team", addOn: false, autoEnable: false, - price: { - amount: 150, - interval: "month", - }, freeTrial: { durationLength: 14, durationType: "day", cardRequired: true, }, items: [ + // Billed in arrears on the seat count the app reports (active members + // only — see apps/cloud/src/extensions/billing/member-seats.ts). item({ - featureId: executions.id, - included: 250000, + featureId: members.id, + included: 0, price: { - amount: 0.2, - billingUnits: 1000, + amount: 15, + billingUnits: 1, billingMethod: "usage_based", interval: "month", }, }), + item({ + featureId: executions.id, + unlimited: true, + reset: { + interval: "month", + }, + }), item({ featureId: domainVerification.id, }), @@ -95,6 +103,12 @@ export const enterprise = plan({ addOn: false, autoEnable: false, items: [ + // Seat usage is tracked for visibility; pricing is set per contract at + // attach time, so the plan itself carries no price. + item({ + featureId: members.id, + unlimited: true, + }), item({ featureId: executions.id, unlimited: true, diff --git a/bun.lock b/bun.lock index 4d18fb3c3d..d6959334af 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,7 @@ }, "apps/cli": { "name": "executor", - "version": "1.5.35", + "version": "1.6.7", "bin": { "executor": "./bin/executor.ts", }, @@ -49,16 +49,18 @@ "quickjs-emscripten": "catalog:", }, "devDependencies": { + "@cloudflare/worker-bundler": "0.2.1", "@effect/vitest": "catalog:", "@types/node": "catalog:", "bun-types": "catalog:", + "esbuild": "^0.27.3", "typescript": "catalog:", "vitest": "catalog:", }, }, "apps/cloud": { "name": "@executor-js/cloud", - "version": "1.4.53", + "version": "1.4.68", "dependencies": { "@cloudflare/vite-plugin": "^1.31.1", "@effect/atom-react": "catalog:", @@ -68,7 +70,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", - "@executor-js/plugin-apps": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", @@ -79,6 +81,7 @@ "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", + "@jitl/quickjs-wasmfile-release-sync": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "~1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", @@ -100,6 +103,7 @@ "jose": "^5.6.3", "postgres": "^3.4.9", "posthog-js": "^1.372.5", + "quickjs-emscripten-core": "0.31.0", "react": "catalog:", "react-dom": "catalog:", "sonner": "^2.0.7", @@ -129,7 +133,7 @@ }, "apps/desktop": { "name": "@executor-js/desktop", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@sentry/bun": "^10.57.0", "@sentry/electron": "^7.13.0", @@ -175,6 +179,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -182,6 +187,7 @@ "@executor-js/plugin-provider-service-split": "workspace:*", "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/react": "workspace:*", + "@executor-js/runtime-dynamic-worker": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", "@jitl/quickjs-wasmfile-release-sync": "catalog:", @@ -205,6 +211,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", + "jsonc-parser": "^3.3.1", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", @@ -213,17 +220,19 @@ }, "apps/host-selfhost": { "name": "@executor-js/host-selfhost", - "version": "0.0.34", + "version": "0.0.49", "dependencies": { "@better-auth/api-key": "^1.6.11", + "@cloudflare/worker-bundler": "0.2.1", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", + "@executor-js/analytics": "workspace:*", "@executor-js/api": "workspace:*", "@executor-js/app": "workspace:*", "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", - "@executor-js/plugin-apps": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -240,12 +249,14 @@ "better-auth": "^1.6.11", "drizzle-orm": "catalog:", "effect": "catalog:", + "esbuild": "^0.27.3", "react": "catalog:", "react-dom": "catalog:", + "workerd": "1.20260708.1", }, "devDependencies": { "@effect/vitest": "catalog:", - "@executor-js/emulate": "^0.13.6", + "@executor-js/emulate": "^0.14.0", "@executor-js/vite-plugin": "workspace:*", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", @@ -262,10 +273,11 @@ }, "apps/local": { "name": "@executor-js/local", - "version": "1.4.4", + "version": "1.6.7", "dependencies": { "@effect/atom-react": "catalog:", "@effect/platform-node": "catalog:", + "@executor-js/analytics": "workspace:*", "@executor-js/api": "workspace:*", "@executor-js/app": "workspace:*", "@executor-js/config": "workspace:*", @@ -273,7 +285,7 @@ "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/integrations-registry": "workspace:*", - "@executor-js/plugin-apps": "workspace:*", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/plugin-desktop-settings": "workspace:*", "@executor-js/plugin-example": "workspace:*", "@executor-js/plugin-file-secrets": "workspace:*", @@ -289,7 +301,7 @@ "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@libsql/client": "catalog:", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", @@ -341,10 +353,10 @@ }, "e2e": { "name": "@executor-js/e2e", - "version": "0.0.32", + "version": "0.0.47", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.6", + "@executor-js/emulate": "^0.14.1", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -376,7 +388,7 @@ }, "examples/all-plugins": { "name": "@executor-js/example-all-plugins", - "version": "0.0.53", + "version": "0.0.68", "dependencies": { "@executor-js/plugin-file-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", @@ -395,7 +407,7 @@ }, "examples/docs-sdk-quickstart": { "name": "@executor-js/example-docs-sdk-quickstart", - "version": "0.0.38", + "version": "0.0.53", "dependencies": { "@executor-js/plugin-openapi": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -425,6 +437,7 @@ "version": "1.4.4", "dependencies": { "@effect/atom-react": "catalog:", + "@executor-js/mcp-apps-shell": "workspace:*", "@executor-js/react": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", @@ -436,6 +449,7 @@ "react-dom": "catalog:", }, "devDependencies": { + "@effect/vitest": "catalog:", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", "@tanstack/virtual-file-routes": "^1.162.0", @@ -445,11 +459,28 @@ "react-grab": "^0.1.31", "typescript": "catalog:", "vite": "catalog:", + "vitest": "catalog:", + }, + }, + "packages/core/analytics": { + "name": "@executor-js/analytics", + "version": "0.1.14", + "dependencies": { + "@effect/platform-node": "catalog:", + "@executor-js/execution": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/sdk": "workspace:*", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", }, }, "packages/core/api": { "name": "@executor-js/api", - "version": "1.4.55", + "version": "1.4.70", "dependencies": { "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", @@ -466,7 +497,7 @@ }, "packages/core/cli": { "name": "@executor-js/cli", - "version": "0.2.42", + "version": "0.2.57", "bin": { "executor-sdk": "./dist/index.js", }, @@ -487,7 +518,7 @@ }, "packages/core/config": { "name": "@executor-js/config", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@executor-js/sdk": "workspace:*", "jiti": "^2.6.1", @@ -508,7 +539,7 @@ }, "packages/core/execution": { "name": "@executor-js/execution", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@executor-js/codemode-core": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -529,7 +560,7 @@ }, "packages/core/fumadb": { "name": "@executor-js/fumadb", - "version": "1.5.7", + "version": "1.5.8", "dependencies": { "@clack/prompts": "^1.3.0", "@paralleldrive/cuid2": "^3.3.0", @@ -574,7 +605,7 @@ }, "packages/core/sdk": { "name": "@executor-js/sdk", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@executor-js/fumadb": "workspace:*", "@standard-schema/spec": "^1.1.0", @@ -627,7 +658,7 @@ }, "packages/core/vite-plugin": { "name": "@executor-js/vite-plugin", - "version": "0.0.52", + "version": "0.0.67", "dependencies": { "@executor-js/sdk": "workspace:*", "jiti": "^2.6.1", @@ -647,14 +678,14 @@ }, "packages/hosts/cloudflare": { "name": "@executor-js/cloudflare", - "version": "0.0.34", + "version": "0.0.49", "dependencies": { "@executor-js/api": "workspace:*", "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", - "agents": "^0.17.3", + "agents": "0.17.3", "effect": "catalog:", }, "devDependencies": { @@ -674,7 +705,8 @@ "@cfworker/json-schema": "^4.1.1", "@executor-js/execution": "workspace:*", "@executor-js/sdk": "workspace:*", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/sdk": "^1.29.0", "effect": "catalog:", "zod": "4.3.6", }, @@ -685,9 +717,47 @@ "vitest": "catalog:", }, }, + "packages/hosts/mcp-apps-shell": { + "name": "@executor-js/mcp-apps-shell", + "version": "1.4.18", + "dependencies": { + "@executor-js/react": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/sdk": "^1.29.0", + "@tanstack/react-query": "^5.99.0", + "effect": "catalog:", + "esbuild": "^0.27.7", + "sucrase": "^3.35.1", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/execution": "workspace:*", + "@executor-js/host-mcp": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@tailwindcss/browser": "^4.2.2", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "bun-types": "catalog:", + "lucide-react": "^1.7.0", + "playwright-core": "^1.59.1", + "react": "catalog:", + "react-dom": "catalog:", + "recharts": "3.8.0", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-singlefile": "^2.3.2", + "vitest": "catalog:", + "zod": "4.3.6", + }, + }, "packages/kernel/core": { "name": "@executor-js/codemode-core", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@babel/parser": "^7.29.2", "@standard-schema/spec": "^1.0.0", @@ -760,7 +830,7 @@ }, "packages/kernel/runtime-quickjs": { "name": "@executor-js/runtime-quickjs", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@executor-js/codemode-core": "workspace:*", "quickjs-emscripten": "catalog:", @@ -780,7 +850,7 @@ }, "packages/kernel/runtime-workerd-subprocess": { "name": "@executor-js/runtime-workerd-subprocess", - "version": "0.0.7", + "version": "0.0.22", "dependencies": { "@executor-js/codemode-core": "workspace:*", "effect": "catalog:", @@ -793,48 +863,33 @@ "vitest": "catalog:", }, }, - "packages/plugins/apps": { - "name": "@executor-js/plugin-apps", - "version": "0.1.6", + "packages/onboarding-demo": { + "name": "@executor-js/onboarding-demo", + "version": "0.0.2", "dependencies": { - "@cloudflare/worker-bundler": "0.2.1", - "@executor-js/api": "workspace:*", - "@executor-js/runtime-workerd-subprocess": "workspace:*", + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/react": "workspace:*", "@executor-js/sdk": "workspace:*", - "esbuild": "^0.27.3", - "zod": "4.3.6", + "lucide-react": "^1.7.0", + "react": "catalog:", + "react-dom": "catalog:", + "sonner": "^2.0.7", + "tldts": "^7.0.28", }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "^0.15.0", - "@effect/atom-react": "catalog:", - "@effect/vitest": "catalog:", - "@executor-js/react": "workspace:*", - "@types/node": "catalog:", + "@tailwindcss/vite": "catalog:", "@types/react": "catalog:", - "bun-types": "catalog:", - "effect": "catalog:", - "react": "catalog:", - "tsup": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", "vite": "catalog:", - "vitest": "catalog:", }, - "peerDependencies": { - "@effect/atom-react": "catalog:", - "@executor-js/react": "workspace:*", - "@tanstack/react-router": "catalog:", - "effect": "catalog:", - "react": "catalog:", - }, - "optionalPeers": [ - "@effect/atom-react", - "@executor-js/react", - "@tanstack/react-router", - "react", - ], }, "packages/plugins/desktop-settings": { "name": "@executor-js/plugin-desktop-settings", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@executor-js/sdk": "workspace:*", "react": "catalog:", @@ -847,7 +902,7 @@ }, "packages/plugins/encrypted-secrets": { "name": "@executor-js/plugin-encrypted-secrets", - "version": "0.0.34", + "version": "0.0.49", "dependencies": { "@executor-js/sdk": "workspace:*", "effect": "catalog:", @@ -862,7 +917,7 @@ }, "packages/plugins/example": { "name": "@executor-js/plugin-example", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -885,7 +940,7 @@ }, "packages/plugins/file-secrets": { "name": "@executor-js/plugin-file-secrets", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -902,7 +957,7 @@ }, "packages/plugins/graphql": { "name": "@executor-js/plugin-graphql", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", @@ -941,7 +996,7 @@ }, "packages/plugins/keychain": { "name": "@executor-js/plugin-keychain", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@executor-js/sdk": "workspace:*", "@napi-rs/keyring": "^1.2.0", @@ -960,13 +1015,16 @@ }, "packages/plugins/mcp": { "name": "@executor-js/plugin-mcp", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@cfworker/json-schema": "^4.1.1", "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", + "lucide-react": "^1.7.0", "zod": "4.3.6", }, "devDependencies": { @@ -974,6 +1032,7 @@ "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", + "@modelcontextprotocol/server": "2.0.0", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", @@ -1000,9 +1059,8 @@ }, "packages/plugins/onepassword": { "name": "@executor-js/plugin-onepassword", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { - "@1password/op-js": "^0.1.13", "@1password/sdk": "^0.4.1-beta.1", "@effect/atom-react": "catalog:", "@executor-js/sdk": "workspace:*", @@ -1034,7 +1092,7 @@ }, "packages/plugins/openapi": { "name": "@executor-js/plugin-openapi", - "version": "1.5.35", + "version": "1.6.7", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", @@ -1075,7 +1133,7 @@ }, "packages/plugins/provider-service-split": { "name": "@executor-js/plugin-provider-service-split", - "version": "0.0.6", + "version": "0.0.21", "dependencies": { "@executor-js/plugin-openapi": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -1092,7 +1150,7 @@ }, "packages/plugins/toolkits": { "name": "@executor-js/plugin-toolkits", - "version": "1.5.27", + "version": "1.5.42", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -1161,7 +1219,7 @@ }, "packages/react": { "name": "@executor-js/react", - "version": "1.4.55", + "version": "1.4.70", "dependencies": { "@base-ui/react": "^1.3.0", "@effect/atom-react": "catalog:", @@ -1200,7 +1258,9 @@ "@tanstack/router-generator": "^1.167.17", "@types/node": "catalog:", "@types/react": "catalog:", + "@types/react-dom": "catalog:", "hast-util-to-jsx-runtime": "^2.3.6", + "react-dom": "catalog:", "tailwindcss": "catalog:", "tsup": "^8.5.1", "typescript": "catalog:", @@ -1210,10 +1270,11 @@ }, "patchedDependencies": { "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", - "libsql@0.5.29": "patches/libsql@0.5.29.patch", - "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", "agents@0.17.3": "patches/agents@0.17.3.patch", "postgres@3.4.9": "patches/postgres@3.4.9.patch", + "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch", + "libsql@0.5.29": "patches/libsql@0.5.29.patch", + "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", }, "catalog": { "@effect/atom-react": "4.0.0-beta.59", @@ -1254,18 +1315,10 @@ "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], - "@1password/op-js": ["@1password/op-js@0.1.13", "", { "dependencies": { "lookpath": "^1.2.2", "semver": "^7.6.2" } }, "sha512-ZZBLxVqywFdvIbLv2xWw2N1ImSi183rRKf90vV19KRMReNyLwuD0dv6IrKrIdrJU33IuV3Gz85Z4K2a1PJTBDg=="], - "@1password/sdk": ["@1password/sdk@0.4.1-beta.1", "", { "dependencies": { "@1password/sdk-core": "0.4.1-beta.1" } }, "sha512-QWY6eIIEs9at6kAFbgjZ1vVxg7ygp/MwXJ6/BoBdPkqIk8vfulXu4pjDsVQ87HhddJAD5LVGBXSbQIa+svI3bA=="], "@1password/sdk-core": ["@1password/sdk-core@0.4.1-beta.1", "", {}, "sha512-/otbg1JVhsEn6oUIeReoT9TmFr8J7KBwr9UuRVfJFwwGG3bHPF8ewT+LhRimQeJtypqQ69ZVuOYkxknD4iQHxw=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.99", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8/UuzFY8p+T8j4XP/9m841pUb5bhnFt8cecSnJpd2zhBttNZ6GbfjZTmsqnvM/RwJOvzIsdFULZrU+E9QFREsQ=="], - - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], "@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="], @@ -1360,24 +1413,6 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], - "@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="], - - "@azure/core-auth": ["@azure/core-auth@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg=="], - - "@azure/core-client": ["@azure/core-client@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w=="], - - "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.23.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ=="], - - "@azure/core-tracing": ["@azure/core-tracing@1.3.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ=="], - - "@azure/core-util": ["@azure/core-util@1.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A=="], - - "@azure/identity": ["@azure/identity@4.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^5.1.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw=="], - - "@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="], - - "@azure/msal-browser": ["@azure/msal-browser@5.6.3", "", { "dependencies": { "@azure/msal-common": "16.4.1" } }, "sha512-sTjMtUm+bJpENU/1WlRzHEsgEHppZDZ1EtNyaOODg/sQBtMxxJzGB+MOCM+T2Q5Qe1fKBrdxUmjyRxm0r7Ez9w=="], - "@azure/msal-common": ["@azure/msal-common@16.10.0", "", {}, "sha512-iYtjpanlv6963Jprs0MvzIap07V+QhultjQctfbEDQCflsDAEeO3R7XnVA5gk30fhoBFLdgJT7VqO0TGsEsN9w=="], "@azure/msal-node": ["@azure/msal-node@5.3.0", "", { "dependencies": { "@azure/msal-common": "16.10.0", "jsonwebtoken": "^9.0.0" } }, "sha512-fXtJX811pX8y8QlrQqBSH6+plvWyKZDI0IxkheAcyAw9OtcpXyFivmTC7eGUqutLWaDlKXuQ3yOESD4zAmkjHg=="], @@ -1438,7 +1473,7 @@ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@babel/runtime-corejs3": ["@babel/runtime-corejs3@7.29.2", "", { "dependencies": { "core-js-pure": "^3.48.0" } }, "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw=="], + "@babel/runtime-corejs3": ["@babel/runtime-corejs3@7.29.7", "", { "dependencies": { "core-js-pure": "^3.48.0" } }, "sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ=="], "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], @@ -1532,7 +1567,7 @@ "@clerk/shared": ["@clerk/shared@4.22.0", "", { "dependencies": { "@tanstack/query-core": "^5.100.6", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.7" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-GZ56kzUB2UBb8MCF+Eo8WIey+W4RP1tAkyOL+geiHxrQxJlUcgD+xalY2YPJLH8WVFwtOnfIl8KPEo0M0e/DRg=="], - "@cloudflare/codemode": ["@cloudflare/codemode@0.4.2", "", { "dependencies": { "@types/json-schema": "^7.0.15", "acorn": "^8.17.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.0", "@tanstack/ai": ">=0.8.0 <1.0.0", "ai": "^6.0.0", "zod": "^4.0.0" }, "optionalPeers": ["@modelcontextprotocol/sdk", "@tanstack/ai", "ai", "zod"] }, "sha512-6sLMZDRY2USbXirrI4tjmGSMYAYM+E/PJIaDQLLbMdvQjk1z1TmJNSLomrZwErO1zFPXvGX2kaqkkxvixkfV2w=="], + "@cloudflare/codemode": ["@cloudflare/codemode@0.4.4", "", { "dependencies": { "@types/json-schema": "^7.0.15", "acorn": "^8.17.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.0", "@tanstack/ai": ">=0.8.0 <1.0.0", "ai": "^6.0.0", "zod": "^4.0.0" }, "optionalPeers": ["@modelcontextprotocol/sdk", "@tanstack/ai", "ai", "zod"] }, "sha512-GL0IF7NiHwAC222riEYGX75NHcnhTornAPfRI0Mj3Ig7C1y9H89FL0xs+pdflIF74eecNkzynsA+JdCIkGvKeA=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], @@ -1724,6 +1759,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + "@executor-js/analytics": ["@executor-js/analytics@workspace:packages/core/analytics"], + "@executor-js/api": ["@executor-js/api@workspace:packages/core/api"], "@executor-js/app": ["@executor-js/app@workspace:packages/app"], @@ -1742,7 +1779,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.13.6", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-FwR2RvO5DnwYgGO2w3JKPCpUx8o+AzSPJeei/oYMIrb4PYg2bQceucxiw52698t9SDEDwAOHkdC/Vv1Tv1+MJQ=="], + "@executor-js/emulate": ["@executor-js/emulate@0.14.1", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-MO72WgLgjyOJnWEiL8DrT6jAVs8C2VDz9oGUFkZ/x2GciEkiOkYG67u5/o+nqtPoxJTR06cqpkHjfrzCm1dqww=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], @@ -1768,11 +1805,13 @@ "@executor-js/marketing": ["@executor-js/marketing@workspace:apps/marketing"], + "@executor-js/mcp-apps-shell": ["@executor-js/mcp-apps-shell@workspace:packages/hosts/mcp-apps-shell"], + "@executor-js/mcporter": ["@executor-js/mcporter@0.11.4", "", { "dependencies": { "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.29.0", "acorn": "^8.16.0", "commander": "^14.0.3", "es-toolkit": "^1.47.0", "jsonc-parser": "^3.3.1", "ora": "^9.4.0", "rolldown": "1.0.1", "zod": "^4.3.6" }, "bin": { "mcporter": "dist/cli.js" } }, "sha512-KewlNnqmzSkJAH7JS/2le9WxPWzL/ND7Pt/G5KGgfrSK9K9cE8UPIsEYZYZUJYoFnbCBzhlU9ugg59ptv1h3Ow=="], "@executor-js/motel": ["@executor-js/motel@0.2.5-executor.1", "", { "dependencies": { "@effect/atom-react": "^4.0.0-beta.49", "@effect/opentelemetry": "^4.0.0-beta.49", "@effect/platform-bun": "^4.0.0-beta.50", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@opentelemetry/sdk-logs": "^0.214.0", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentui/core": "^0.1.99", "@opentui/react": "^0.1.99", "effect": "^4.0.0-beta.49", "react": "^19.2.5", "scheduler": "^0.27.0" }, "bin": { "motel": "src/motel.ts", "motel-mcp": "src/mcp.ts" } }, "sha512-fLGJ5FIIBYd+4L2OurpYFjWzvcCbACvmdNofh6THXQJE1Gku93EKURtgn27/XddyM9SKGST6/znTRcmwKiYdXw=="], - "@executor-js/plugin-apps": ["@executor-js/plugin-apps@workspace:packages/plugins/apps"], + "@executor-js/onboarding-demo": ["@executor-js/onboarding-demo@workspace:packages/onboarding-demo"], "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"], @@ -2094,8 +2133,16 @@ "@mishieck/ink-titled-box": ["@mishieck/ink-titled-box@0.3.0", "", { "peerDependencies": { "ink": "^6.0.0", "react": "^19.1.0", "typescript": "^5" } }, "sha512-ugzVH9hixp3hwKfQ8On/qnsrdAxS3y9rTu/aGOFed4zVUvtZyGZNIR4rxAwXult8HKI4vJEh0OM8wib9NPrwUg=="], + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="], + + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], + + "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], @@ -2272,7 +2319,7 @@ "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="], - "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="], + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], "@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/configuration": "0.214.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/exporter-logs-otlp-grpc": "0.214.0", "@opentelemetry/exporter-logs-otlp-http": "0.214.0", "@opentelemetry/exporter-logs-otlp-proto": "0.214.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.214.0", "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.214.0", "@opentelemetry/exporter-prometheus": "0.214.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.214.0", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/exporter-trace-otlp-proto": "0.214.0", "@opentelemetry/exporter-zipkin": "2.6.1", "@opentelemetry/instrumentation": "0.214.0", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/propagator-b3": "2.6.1", "@opentelemetry/propagator-jaeger": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-gl2XvQBJuPjhGcw9SsnQO5qxChAPMuGRPFaD8lqtF+Cey91NgGUQ0sio2vlDFOSm3JOLzc44vL+OAfx1dXuZjg=="], @@ -2914,6 +2961,8 @@ "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], + "@tailwindcss/browser": ["@tailwindcss/browser@4.3.3", "", {}, "sha512-fFaqX18SiyiukLoEI7/cVc0B65mkTslDNo2TxazSDZ9rKs5umOEoujmEkpy8z6GrMdoVzBDbWD8j5DTKLyAWWw=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], @@ -3212,8 +3261,6 @@ "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], - "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.5", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], @@ -3222,8 +3269,6 @@ "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], - "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], - "@vercel/sdk": ["@vercel/sdk@1.28.4", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-3r3nopI45UmoOC6MlNzZhB2l/Itn6X14uonAbaOZ5W5zkNUi7mhJc3CncRia/FE/N1mvsx6La9G5C90dRZrdEg=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], @@ -3280,8 +3325,6 @@ "ahooks": ["ahooks@3.9.7", "", { "dependencies": { "@babel/runtime": "^7.21.0", "@types/js-cookie": "^3.0.6", "dayjs": "^1.9.1", "intersection-observer": "^0.12.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "react-fast-compare": "^3.2.2", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-S0lvzhbdlhK36RFBkGv+RbOM/dbbweym+BIHM/bwwuWVSVN5TuVErHPMWo4w0t1NDYg5KPp2iEf7Y7E5LASYiw=="], - "ai": ["ai@6.0.162", "", { "dependencies": { "@ai-sdk/gateway": "3.0.99", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-1PSvNEK1PEbpUXahnFrcey6l7DJXMVWmg0ibQ8h8oMSe9V1Vx5d+R3xNu0hzBtwqfxYj21ddZo+EUYVs6GOEyA=="], - "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -3506,7 +3549,7 @@ "chevrotain-allstar": ["chevrotain-allstar@0.4.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^12.0.0" } }, "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA=="], - "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -3602,7 +3645,7 @@ "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], - "core-js-pure": ["core-js-pure@3.49.0", "", {}, "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw=="], + "core-js-pure": ["core-js-pure@3.50.0", "", {}, "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA=="], "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], @@ -4526,8 +4569,6 @@ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], - "lookpath": ["lookpath@1.2.3", "", { "bin": { "lookpath": "bin/lookpath.js" } }, "sha512-kthRVhf4kH4+HW3anM4UBHxsw/XFESf13euCEldhXr6GpBdmBoa7rDd7WO5G0Mhd4G5XtKTcEy8OR0iRZXpS3Q=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], @@ -4898,7 +4939,7 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "partyserver": ["partyserver@0.5.8", "", { "dependencies": { "nanoid": "^5.1.9" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" } }, "sha512-htgSwiBcBu9zIYLrsxBAOvdkjukHvncbTk0nDrJgfruvZ08rxtEN1Ab4T7j9osykP80Bq3zA2oWFd3ngc4Z9uw=="], + "partyserver": ["partyserver@0.5.10", "", { "dependencies": { "nanoid": "^5.1.9" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1 || ^5.20260703.1" } }, "sha512-t2B3mhTL1IOxCsj8rZgn4TxTuub7oLhypjc1/fGPNsbgnn9OsHms6uR3QEd5bFJ/7xrFo771j3DdUlll8cxgBg=="], "partysocket": ["partysocket@1.3.0", "", { "dependencies": { "event-target-polyfill": "^0.0.4" }, "peerDependencies": { "react": ">=17" }, "optionalPeers": ["react"] }, "sha512-1zToNyolZFK/7nuAw/K2bZrNzFqaZyRoCEkS+9vG6WSC5ikrN6qWRe96q6ImU51uptz2r+dAwSkwhJVdQi4LiA=="], @@ -4930,22 +4971,12 @@ "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], - "pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="], - - "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], - - "pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="], - "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], - "pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="], - "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], - "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], - "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -5154,7 +5185,7 @@ "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], - "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="], @@ -5402,16 +5433,12 @@ "split-string": ["split-string@3.1.0", "", { "dependencies": { "extend-shallow": "^3.0.0" } }, "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw=="], - "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "spotify-web-api-node": ["spotify-web-api-node@5.0.2", "", { "dependencies": { "superagent": "^6.1.0" } }, "sha512-r82dRWU9PMimHvHEzL0DwEJrzFk+SMCVfq249SLt3I7EFez7R+jeoKQd+M1//QcnjqlXPs2am4DFsGk8/GCsrA=="], "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], "sql-highlight": ["sql-highlight@6.1.0", "", {}, "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA=="], - "sql.js": ["sql.js@1.14.1", "", {}, "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A=="], - "srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="], "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], @@ -5718,6 +5745,8 @@ "vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], + "vite-plugin-singlefile": ["vite-plugin-singlefile@2.3.3", "", { "dependencies": { "micromatch": "^4.0.8" }, "peerDependencies": { "rollup": "^4.59.0", "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg=="], + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], @@ -5830,8 +5859,6 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@1password/op-js/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@antfu/install-pkg/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "@astrojs/cloudflare/vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], @@ -5842,37 +5869,31 @@ "@astrojs/react/vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], - "@azure/identity/@azure/msal-node": ["@azure/msal-node@5.1.2", "", { "dependencies": { "@azure/msal-common": "16.4.1", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-DoeSJ9U5KPAIZoHsPywvfEj2MhBniQe0+FSpjLUTdWoIkI999GB5USkW6nNEHnIaLVxROHXvprWA1KzdS1VQ4A=="], - - "@azure/msal-browser/@azure/msal-common": ["@azure/msal-common@16.4.1", "", {}, "sha512-Bl8f+w37xkXsYh7QRkAKCFGYtWMYuOVO7Lv+BxILrvGz3HbIEF22Pt0ugyj0QPOl6NLrHcnNUQ9yeew98P/5iw=="], - "@babel/core/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/generator/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-create-class-features-plugin/@babel/traverse": ["@babel/traverse@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/types": "^8.0.0", "obug": "^2.1.1" } }, "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw=="], - - "@babel/helper-create-class-features-plugin/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@babel/helper-create-class-features-plugin/@babel/traverse": ["@babel/traverse@8.0.4", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.4", "@babel/template": "^8.0.0", "@babel/types": "^8.0.4", "obug": "^2.1.1" } }, "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg=="], - "@babel/helper-member-expression-to-functions/@babel/traverse": ["@babel/traverse@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/types": "^8.0.0", "obug": "^2.1.1" } }, "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw=="], + "@babel/helper-member-expression-to-functions/@babel/traverse": ["@babel/traverse@8.0.4", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.4", "@babel/template": "^8.0.0", "@babel/types": "^8.0.4", "obug": "^2.1.1" } }, "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg=="], - "@babel/helper-member-expression-to-functions/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + "@babel/helper-member-expression-to-functions/@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], - "@babel/helper-optimise-call-expression/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + "@babel/helper-optimise-call-expression/@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], - "@babel/helper-replace-supers/@babel/traverse": ["@babel/traverse@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/types": "^8.0.0", "obug": "^2.1.1" } }, "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw=="], + "@babel/helper-replace-supers/@babel/traverse": ["@babel/traverse@8.0.4", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.4", "@babel/template": "^8.0.0", "@babel/types": "^8.0.4", "obug": "^2.1.1" } }, "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg=="], - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse": ["@babel/traverse@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/types": "^8.0.0", "obug": "^2.1.1" } }, "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw=="], + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse": ["@babel/traverse@8.0.4", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.4", "@babel/template": "^8.0.0", "@babel/types": "^8.0.4", "obug": "^2.1.1" } }, "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg=="], - "@babel/helper-skip-transparent-expression-wrappers/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + "@babel/helper-skip-transparent-expression-wrappers/@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], "@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], @@ -5902,7 +5923,7 @@ "@clerk/shared/js-cookie": ["js-cookie@3.0.7", "", {}, "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw=="], - "@cloudflare/codemode/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + "@cloudflare/codemode/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "@cloudflare/vite-plugin/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="], @@ -5916,7 +5937,7 @@ "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@cloudflare/worker-bundler/es-module-lexer": ["es-module-lexer@2.3.0", "", {}, "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw=="], + "@cloudflare/worker-bundler/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], "@cloudflare/worker-bundler/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], @@ -6080,8 +6101,16 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@modelcontextprotocol/client/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + + "@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], @@ -6090,20 +6119,12 @@ "@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], - "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], - "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], - "@opentelemetry/exporter-prometheus/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/exporter-prometheus/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], - "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], @@ -6138,24 +6159,18 @@ "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], - "@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="], - - "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="], + "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], "@opentelemetry/sdk-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.6.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ=="], "@opentelemetry/sdk-node/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/sdk-node/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], - "@opentelemetry/sdk-node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], "@opentelemetry/sdk-node/@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="], @@ -6340,6 +6355,8 @@ "@tanstack/router-plugin/@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], + "@tanstack/router-plugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@tanstack/router-utils/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], @@ -6382,13 +6399,11 @@ "@vercel/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "agents/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "agents/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "agents/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "agents/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], - - "ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "agents/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -6676,8 +6691,6 @@ "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "recharts/es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], "request/form-data": ["form-data@2.3.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", "mime-types": "^2.1.12" } }, "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ=="], @@ -6742,8 +6755,6 @@ "to-regex-range/is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - "tsup/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "typeorm/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -6778,13 +6789,9 @@ "@astrojs/react/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], - "@azure/identity/@azure/msal-node/@azure/msal-common": ["@azure/msal-common@16.4.1", "", {}, "sha512-Bl8f+w37xkXsYh7QRkAKCFGYtWMYuOVO7Lv+BxILrvGz3HbIEF22Pt0ugyj0QPOl6NLrHcnNUQ9yeew98P/5iw=="], - - "@azure/identity/@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], - "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -6794,11 +6801,11 @@ "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], @@ -6806,17 +6813,17 @@ "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], @@ -6824,11 +6831,11 @@ "@babel/helper-replace-supers/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], - "@babel/helper-replace-supers/@babel/traverse/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], + "@babel/helper-replace-supers/@babel/traverse/@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], "@babel/helper-replace-supers/@babel/traverse/@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - "@babel/helper-replace-supers/@babel/traverse/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], + "@babel/helper-replace-supers/@babel/traverse/@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], @@ -6836,13 +6843,13 @@ "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], @@ -7146,6 +7153,8 @@ "@tanstack/router-generator/@tanstack/router-utils/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "@tanstack/start-plugin-core/@tanstack/router-generator/@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], "@types/better-sqlite3/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], @@ -7172,61 +7181,61 @@ "@types/yauzl/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - "agents/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + "agents/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], - "agents/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + "agents/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], - "agents/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + "agents/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], - "agents/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + "agents/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], - "agents/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + "agents/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], - "agents/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + "agents/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], - "agents/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + "agents/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], - "agents/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + "agents/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], - "agents/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + "agents/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], - "agents/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + "agents/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], - "agents/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + "agents/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], - "agents/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + "agents/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], - "agents/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + "agents/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], - "agents/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + "agents/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], - "agents/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + "agents/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], - "agents/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + "agents/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], - "agents/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + "agents/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], - "agents/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + "agents/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], - "agents/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + "agents/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], - "agents/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + "agents/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], - "agents/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + "agents/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], - "agents/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + "agents/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], - "agents/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + "agents/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], - "agents/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + "agents/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], - "agents/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + "agents/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], - "agents/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "agents/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], "agents/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - "agents/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "agents/yargs/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -7498,8 +7507,6 @@ "temp/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "tsup/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], @@ -7576,27 +7583,27 @@ "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], + "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], @@ -7736,9 +7743,11 @@ "@slack/web-api/axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "agents/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "agents/yargs/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "agents/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "agents/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "agents/yargs/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -7782,6 +7791,8 @@ "@react-grab/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "agents/yargs/cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "agents/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "agents/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/e2e/CHANGELOG.md b/e2e/CHANGELOG.md index 85c65bbaf8..a361a6dc8e 100644 --- a/e2e/CHANGELOG.md +++ b/e2e/CHANGELOG.md @@ -1,5 +1,185 @@ # @executor-js/e2e +## 0.0.47 + +### Patch Changes + +- Updated dependencies [[`75b3674`](https://github.com/UsefulSoftwareCo/executor/commit/75b3674136b44a2e43fb23eb7a058e7e51528527), [`98d6c6a`](https://github.com/UsefulSoftwareCo/executor/commit/98d6c6ad3272fca371fc2d8b14b2e332100d8322)]: + - @executor-js/plugin-mcp@1.6.7 + - @executor-js/sdk@1.6.7 + - @executor-js/api@1.4.70 + - @executor-js/plugin-graphql@1.6.7 + - @executor-js/plugin-openapi@1.6.7 + - @executor-js/plugin-toolkits@1.5.42 + +## 0.0.46 + +### Patch Changes + +- Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed)]: + - @executor-js/plugin-mcp@1.6.6 + - @executor-js/api@1.4.69 + - @executor-js/plugin-graphql@1.6.6 + - @executor-js/plugin-openapi@1.6.6 + - @executor-js/plugin-toolkits@1.5.41 + - @executor-js/sdk@1.6.6 + +## 0.0.45 + +### Patch Changes + +- Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]: + - @executor-js/plugin-mcp@1.6.5 + - @executor-js/sdk@1.6.5 + - @executor-js/plugin-graphql@1.6.5 + - @executor-js/plugin-openapi@1.6.5 + - @executor-js/api@1.4.68 + - @executor-js/plugin-toolkits@1.5.40 + +## 0.0.44 + +### Patch Changes + +- Updated dependencies [[`ffcfbc0`](https://github.com/UsefulSoftwareCo/executor/commit/ffcfbc0de27d0ae55215839fb70395b0b7d9a65c), [`10e16a5`](https://github.com/UsefulSoftwareCo/executor/commit/10e16a5baa2648657b70038e7d11429c58e4d242), [`515d6aa`](https://github.com/UsefulSoftwareCo/executor/commit/515d6aa391a04a3579a7b10f974ec316a563cf7a), [`06bf742`](https://github.com/UsefulSoftwareCo/executor/commit/06bf74254f3432e8d75fd8b493ef7a435ea4bc84)]: + - @executor-js/plugin-mcp@1.6.4 + - @executor-js/sdk@1.6.4 + - @executor-js/api@1.4.67 + - @executor-js/plugin-graphql@1.6.4 + - @executor-js/plugin-openapi@1.6.4 + - @executor-js/plugin-toolkits@1.5.39 + +## 0.0.43 + +### Patch Changes + +- Updated dependencies [[`4b0fbf6`](https://github.com/UsefulSoftwareCo/executor/commit/4b0fbf68550516af9235c9267f91a962da993946), [`ba62f1a`](https://github.com/UsefulSoftwareCo/executor/commit/ba62f1a5d14b7002ba0a4686a9e1ae43bd77f54f), [`8324e1e`](https://github.com/UsefulSoftwareCo/executor/commit/8324e1eb8b03965050147309f049bdb52be6fcad), [`6305b6d`](https://github.com/UsefulSoftwareCo/executor/commit/6305b6d11505358fa73ec2b3e768ec4256c36435), [`c1f51b7`](https://github.com/UsefulSoftwareCo/executor/commit/c1f51b7f96328b795669bb3d241667660dc2b060), [`85b1955`](https://github.com/UsefulSoftwareCo/executor/commit/85b1955b4d24c332e637e15a025d64455e28a626), [`02b52cd`](https://github.com/UsefulSoftwareCo/executor/commit/02b52cd01b09d3601ffe88d1f9c0b777f26e76ae)]: + - @executor-js/plugin-mcp@1.6.3 + - @executor-js/sdk@1.6.3 + - @executor-js/plugin-openapi@1.6.3 + - @executor-js/plugin-graphql@1.6.3 + - @executor-js/plugin-toolkits@1.5.38 + - @executor-js/api@1.4.66 + +## 0.0.42 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.6.2 + - @executor-js/plugin-graphql@1.6.2 + - @executor-js/plugin-mcp@1.6.2 + - @executor-js/plugin-openapi@1.6.2 + - @executor-js/api@1.4.65 + - @executor-js/plugin-toolkits@1.5.37 + +## 0.0.41 + +### Patch Changes + +- Updated dependencies [[`7c12aee`](https://github.com/UsefulSoftwareCo/executor/commit/7c12aeea390225291ce4c97865b392237ee7934d), [`ddbf0fe`](https://github.com/UsefulSoftwareCo/executor/commit/ddbf0feba38c8502d78fa20c3081391b8ba3d112), [`91062c2`](https://github.com/UsefulSoftwareCo/executor/commit/91062c2b1d7b8edbc8470ca5eaa544045652afaa), [`9c35f26`](https://github.com/UsefulSoftwareCo/executor/commit/9c35f269dd5de3548111fe5c83cf1e877f23c80d), [`0007474`](https://github.com/UsefulSoftwareCo/executor/commit/0007474602d8da3642648f216bfdb0f09eb0914f), [`55180cb`](https://github.com/UsefulSoftwareCo/executor/commit/55180cb1487f9a3a28ddc0ee0bedfab8464c1f72)]: + - @executor-js/plugin-openapi@1.6.1 + - @executor-js/plugin-mcp@1.6.1 + - @executor-js/sdk@1.6.1 + - @executor-js/api@1.4.64 + - @executor-js/plugin-graphql@1.6.1 + - @executor-js/plugin-toolkits@1.5.36 + +## 0.0.40 + +### Patch Changes + +- Updated dependencies [[`c11bef2`](https://github.com/UsefulSoftwareCo/executor/commit/c11bef2cd049db7bbf51b15e18761b14acccb534), [`46cea2c`](https://github.com/UsefulSoftwareCo/executor/commit/46cea2cbb1f414ae58ac876819a51b11967909a6), [`a2d1417`](https://github.com/UsefulSoftwareCo/executor/commit/a2d141758e478274813c8c24d354e1fd0f66af49), [`0b0b74f`](https://github.com/UsefulSoftwareCo/executor/commit/0b0b74f673b8098c5248159be36c648097f3c87b), [`256e25e`](https://github.com/UsefulSoftwareCo/executor/commit/256e25e7b291b0c023bc7547d092004b66781bba)]: + - @executor-js/plugin-mcp@1.6.0 + - @executor-js/plugin-openapi@1.6.0 + - @executor-js/sdk@1.6.0 + - @executor-js/api@1.4.63 + - @executor-js/plugin-graphql@1.6.0 + - @executor-js/plugin-toolkits@1.5.35 + +## 0.0.39 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be), [`9ecc7cb`](https://github.com/UsefulSoftwareCo/executor/commit/9ecc7cb8b30375ffa960e3fefe4d211e0254e691)]: + - @executor-js/sdk@1.5.42 + - @executor-js/plugin-openapi@1.5.42 + - @executor-js/plugin-mcp@1.5.42 + - @executor-js/api@1.4.62 + - @executor-js/plugin-graphql@1.5.42 + - @executor-js/plugin-toolkits@1.5.34 + +## 0.0.38 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]: + - @executor-js/sdk@1.5.41 + - @executor-js/plugin-mcp@1.5.41 + - @executor-js/api@1.4.61 + - @executor-js/plugin-graphql@1.5.41 + - @executor-js/plugin-openapi@1.5.41 + - @executor-js/plugin-toolkits@1.5.33 + +## 0.0.37 + +### Patch Changes + +- Updated dependencies [[`8ba64f6`](https://github.com/UsefulSoftwareCo/executor/commit/8ba64f675f6d6ab5302d4f68390c0b055d006f4a)]: + - @executor-js/sdk@1.5.40 + - @executor-js/api@1.4.60 + - @executor-js/plugin-graphql@1.5.40 + - @executor-js/plugin-mcp@1.5.40 + - @executor-js/plugin-openapi@1.5.40 + - @executor-js/plugin-toolkits@1.5.32 + +## 0.0.36 + +### Patch Changes + +- Updated dependencies [[`6c316c7`](https://github.com/UsefulSoftwareCo/executor/commit/6c316c77a9efc98784976236852b58c6156e016e)]: + - @executor-js/sdk@1.5.39 + - @executor-js/api@1.4.59 + - @executor-js/plugin-graphql@1.5.39 + - @executor-js/plugin-mcp@1.5.39 + - @executor-js/plugin-openapi@1.5.39 + - @executor-js/plugin-toolkits@1.5.31 + +## 0.0.35 + +### Patch Changes + +- Updated dependencies [[`df01d91`](https://github.com/UsefulSoftwareCo/executor/commit/df01d9197e7b4fca9bd0adaca0705a80435e188c), [`6a924dd`](https://github.com/UsefulSoftwareCo/executor/commit/6a924dd98de916d6ff8cea2329bf672f149b64f4)]: + - @executor-js/plugin-openapi@1.5.38 + - @executor-js/sdk@1.5.38 + - @executor-js/api@1.4.58 + - @executor-js/plugin-graphql@1.5.38 + - @executor-js/plugin-mcp@1.5.38 + - @executor-js/plugin-toolkits@1.5.30 + +## 0.0.34 + +### Patch Changes + +- Updated dependencies [[`657b913`](https://github.com/UsefulSoftwareCo/executor/commit/657b9135b8b841495b362936bf60bdca998c16eb)]: + - @executor-js/sdk@1.5.37 + - @executor-js/api@1.4.57 + - @executor-js/plugin-graphql@1.5.37 + - @executor-js/plugin-mcp@1.5.37 + - @executor-js/plugin-openapi@1.5.37 + - @executor-js/plugin-toolkits@1.5.29 + +## 0.0.33 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.36 + - @executor-js/plugin-graphql@1.5.36 + - @executor-js/plugin-mcp@1.5.36 + - @executor-js/plugin-openapi@1.5.36 + - @executor-js/api@1.4.56 + - @executor-js/plugin-toolkits@1.5.28 + ## 0.0.32 ### Patch Changes diff --git a/e2e/cli/custom-tools-packed.test.ts b/e2e/cli/custom-tools-packed.test.ts deleted file mode 100644 index 604e45b6a7..0000000000 --- a/e2e/cli/custom-tools-packed.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -/* oxlint-disable executor/no-try-catch-or-throw -- boundary: e2e SSH and raw HTTP assertions */ -// Proves the compiled executor binary's bundled workerd + worker-bundler path -// can package local-directory custom tools on every CLI guest OS. The sync -// assertion covers npm install inside workerd, including the Windows TLS trust -// path fixed by f0365449, and the execution assertion proves the published tool -// is invoked, not just synced. -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; - -import { scenario } from "../src/scenario"; -import { Target } from "../src/services"; -import type { Identity, Target as TargetShape } from "../src/target"; - -const execFileAsync = promisify(execFile); - -const SSH_OPTS = [ - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "ConnectTimeout=15", - "-o", - "ServerAliveInterval=5", - "-o", - "LogLevel=ERROR", -] as const; - -type GuestOs = "macos" | "linux" | "windows"; - -interface SyncResponse { - readonly status: "published" | "up-to-date" | "failed"; - readonly tools: readonly string[]; -} - -interface ExecuteResponse { - readonly structured: unknown; - readonly text: string; -} - -const guestOs = (): GuestOs => { - const os = process.env.E2E_VM_OS; - if (os === "macos" || os === "linux" || os === "windows") return os; - throw new Error(`Unsupported E2E_VM_OS: ${os ?? ""}`); -}; - -const sshInvocation = (command: string): { command: string; args: ReadonlyArray } => { - const host = process.env.E2E_CLI_VM_HOST; - if (!host) throw new Error("E2E_CLI_VM_HOST is not set"); - const os = guestOs(); - const wrapped = - os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command; - const keyPath = process.env.E2E_CLI_SSH_KEY; - const user = os === "windows" ? "Administrator" : "admin"; - return keyPath - ? { command: "ssh", args: ["-i", keyPath, ...SSH_OPTS, `${user}@${host}`, wrapped] } - : { - command: process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass", - args: ["-p", "admin", "ssh", ...SSH_OPTS, `${user}@${host}`, wrapped], - }; -}; - -const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => { - const invocation = sshInvocation(command); - try { - const { stdout, stderr } = await execFileAsync(invocation.command, [...invocation.args], { - maxBuffer: 64 * 1024 * 1024, - }); - return { stdout, stderr, code: 0 }; - } catch (error) { - const err = error as { stdout?: string; stderr?: string; code?: number }; - return { - stdout: err.stdout ?? "", - stderr: err.stderr ?? "", - code: typeof err.code === "number" ? err.code : 1, - }; - } -}; - -const fixturePath = (slug: string): string => - guestOs() === "windows" - ? `C:/Users/Administrator/apps-packed-fixture-${slug}` - : `/tmp/apps-packed-fixture-${slug}`; - -const windowsFixtureCommand = (path: string): string => ` -$ErrorActionPreference = 'Stop' -Remove-Item -Recurse -Force '${path}' -ErrorAction SilentlyContinue -New-Item -ItemType Directory -Force -Path '${path}/tools' | Out-Null -Set-Content -NoNewline -Encoding UTF8 -Path '${path}/tools/greet.ts' -Value @' -${"import"} { z } from "zod"; -${"import"} { defineTool } from "executor:app"; - -const Input = z.object({ name: z.string() }); - -export default defineTool({ - description: "Greet", - input: Input, - async handler(input) { - return { greeting: "hello " + input.name }; - }, -}); -'@ -Set-Content -NoNewline -Encoding UTF8 -Path '${path}/package.json' -Value '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' -Set-Content -NoNewline -Encoding UTF8 -Path '${path}/bun.lock' -Value '' -`; - -const unixFixtureCommand = (path: string): string => ` -set -eu -rm -rf '${path}' -mkdir -p '${path}/tools' -cat >'${path}/tools/greet.ts' <<'EOF' -${"import"} { z } from "zod"; -${"import"} { defineTool } from "executor:app"; - -const Input = z.object({ name: z.string() }); - -export default defineTool({ - description: "Greet", - input: Input, - async handler(input) { - return { greeting: "hello " + input.name }; - }, -}); -EOF -printf '%s' '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' >'${path}/package.json' -: >'${path}/bun.lock' -`; - -const writeFixture = async (path: string): Promise => { - const result = await ssh( - guestOs() === "windows" ? windowsFixtureCommand(path) : unixFixtureCommand(path), - ); - expect( - result.code, - `fixture is written into the guest\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, - ).toBe(0); -}; - -const removeFixture = (path: string): Effect.Effect => - Effect.promise(() => - ssh( - guestOs() === "windows" - ? `Remove-Item -Recurse -Force '${path}' -ErrorAction SilentlyContinue` - : `rm -rf '${path}'`, - ).then(() => undefined), - ).pipe(Effect.ignore); - -const request = async ( - target: TargetShape, - identity: Identity, - path: string, - init: RequestInit = {}, - expectedStatus = 200, - timeoutMs = 30_000, -): Promise => { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const headers = new Headers(init.headers); - for (const [name, value] of Object.entries(identity.headers ?? {})) headers.set(name, value); - if (init.body !== undefined && !headers.has("content-type")) { - headers.set("content-type", "application/json"); - } - const response = await fetch(new URL(path, target.baseUrl), { - ...init, - headers, - signal: controller.signal, - }); - const text = await response.text(); - expect(response.status, `${init.method ?? "GET"} ${path}: ${text}`).toBe(expectedStatus); - return (text.length > 0 ? JSON.parse(text) : null) as T; - } finally { - clearTimeout(timeout); - } -}; - -const postJson = ( - target: TargetShape, - identity: Identity, - path: string, - body?: unknown, - timeoutMs?: number, -): Promise => - request( - target, - identity, - path, - { - method: "POST", - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - }, - 200, - timeoutMs, - ); - -const deleteSource = (target: TargetShape, identity: Identity, slug: string): Effect.Effect => - Effect.promise(() => - request(target, identity, `/api/apps/sources/${slug}`, { method: "DELETE" }).then( - () => undefined, - ), - ).pipe(Effect.ignore); - -const syncAndInvoke = (input: { - readonly target: TargetShape; - readonly identity: Identity; - readonly slug: string; - readonly path: string; -}): Effect.Effect => - Effect.promise(async () => { - await postJson(input.target, input.identity, "/api/apps/sources", { - kind: "local-directory", - slug: input.slug, - app: input.slug, - path: input.path, - }); - - const sync = await postJson( - input.target, - input.identity, - `/api/apps/sources/${input.slug}/sync`, - undefined, - 180_000, - ); - expect(sync.status, "the packed binary bundled and published the tool").toBe("published"); - expect(sync.tools, "the packed binary bundled and published the tool").toContain("greet"); - - const execution = await postJson( - input.target, - input.identity, - "/api/executions", - { - code: `export default await tools["${input.slug}.org.published.greet"]({ name: "Ada" })`, - autoApprove: true, - }, - ); - expect( - JSON.stringify(execution.structured ?? execution.text), - "the published tool executes and returns the greeting", - ).toContain('"greeting":"hello Ada"'); - }); - -scenario( - "CLI packed binary · local-directory custom tool publishes and executes", - { timeout: 300_000 }, - Effect.gen(function* () { - const target = yield* Target; - const identity = yield* target.newIdentity(); - const slug = `packed-smoke-${crypto.randomUUID().slice(0, 8)}`; - const path = fixturePath(slug); - - yield* Effect.promise(() => writeFixture(path)).pipe( - Effect.andThen(syncAndInvoke({ target, identity, slug, path })), - Effect.ensuring( - Effect.all([deleteSource(target, identity, slug), removeFixture(path)], { - concurrency: "unbounded", - discard: true, - }), - ), - ); - }), -); diff --git a/e2e/cloud/admin-users-console.test.ts b/e2e/cloud/admin-users-console.test.ts new file mode 100644 index 0000000000..4882e35985 --- /dev/null +++ b/e2e/cloud/admin-users-console.test.ts @@ -0,0 +1,460 @@ +// Cloud-only: the admin Users PAGE — the console surface over `/api/admin/users*`. +// +// The sibling `admin-users.test.ts` pins the API's guarantees. This one pins +// what an operator actually sees, which the API scenario cannot: that the page +// renders every member of the workspace, that each row's connection summary +// distinguishes connected from merely available, that opening a user shows that +// user's own connections with their health, and — the failure path that matters +// most here — that a plain member who reaches the URL is told they don't have +// access rather than shown an empty workspace. +// +// Two members are built through the REAL flows (login → create-organization → +// invite → accept-invitation, in `./support/session`). Each connects a Personal +// credential; the plain member's Workspace attempt is refused, so the directory +// and connection UI prove the owner-aware permission boundary together. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { HttpApiClient } from "effect/unstable/httpapi"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { accountIdOf, forBrowser, joinOrg } from "./support/session"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +type Client = HttpApiClient.ForApi; + +declare global { + interface Window { + // The copy assertions stash what the page handed to the clipboard here, so + // it can be read back out of the browser context without needing a + // clipboard-read permission a headless context won't grant. + __e2eCopied?: Array; + } +} + +const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); +const INTEGRATION_TITLE = "Ping API"; + +/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ +const pingSpec = JSON.stringify({ + openapi: "3.0.3", + info: { title: INTEGRATION_TITLE, version: "1.0.0" }, + paths: { + "/ping": { + get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, + }, + }, +}); + +const noProbeSpec = JSON.stringify({ + openapi: "3.0.3", + info: { title: INTEGRATION_TITLE, version: "1.0.0" }, + paths: {}, +}); + +/** Registers a fresh apiKey-authenticated integration for connections to bind to. */ +const registerIntegration = (client: Client, label: string, spec = pingSpec) => + Effect.gen(function* () { + const slug = IntegrationSlug.make(`${label}-${randomBytes(4).toString("hex")}`); + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: spec }, + slug, + baseUrl: "http://127.0.0.1:59999", // never contacted during registration + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + return slug; + }); + +const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); + +scenario( + "Admin · the Users page shows every member and what each has connected, and refuses a plain member", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: apiClient } = yield* Api; + + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + + const adminClient = yield* apiClient(api, admin); + const memberClient = yield* apiClient(api, member); + const adminId = yield* accountIdOf(target, admin); + const memberId = yield* accountIdOf(target, member); + + // Two integrations so the member's summary has a real zero-of-two state. + const connectedIntegration = yield* registerIntegration(adminClient, "admin-ui-conn"); + const availableIntegration = yield* registerIntegration( + adminClient, + "admin-ui-avail", + noProbeSpec, + ); + const adminConnection = freshConnectionName(); + const memberConnection = freshConnectionName(); + yield* Effect.ensuring( + Effect.gen(function* () { + // Both roles may store Personal credentials. A member cannot promote + // theirs into a Workspace credential by bypassing the owner picker. + yield* adminClient.connections.create({ + payload: { + owner: "user", + name: adminConnection, + integration: connectedIntegration, + template: TEMPLATE_API_KEY, + value: "admin-personal-token", + }, + }); + const workspaceRefusal = yield* memberClient.connections + .create({ + payload: { + owner: "org", + name: memberConnection, + integration: connectedIntegration, + template: TEMPLATE_API_KEY, + value: "member-personal-token", + }, + }) + .pipe(Effect.flip); + expect(workspaceRefusal).toMatchObject({ _tag: "OrgWriteDeniedError" }); + yield* memberClient.connections.create({ + payload: { + owner: "user", + name: memberConnection, + integration: connectedIntegration, + template: TEMPLATE_API_KEY, + value: "member-personal-token", + }, + }); + + // ── The admin's view ──────────────────────────────────────────────── + yield* browser.session(forBrowser(admin), async ({ page, step }) => { + let slug = ""; + + await step("Land in the workspace and canonicalize onto the org slug", async () => { + await visit(page, "/"); + await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { + timeout: 30_000, + }); + slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? ""; + expect(slug, "the URL settled on an org slug").not.toBe(""); + }); + + await step("Open Users from the sidebar", async () => { + // The section is admin-only, so an admin must actually see the link + // — reaching the page by URL would not prove the nav gate opened. + await page.getByRole("link", { name: "Users" }).click(); + await page.waitForURL((url) => url.pathname === `/${slug}/users`, { timeout: 30_000 }); + await page.locator("[data-slot='admin-users-table']").waitFor({ timeout: 30_000 }); + }); + + await step("Both members of the workspace are listed", async () => { + const rows = page.locator("[data-slot='admin-user-row']"); + await rows.first().waitFor({ timeout: 30_000 }); + // The id is opaque and rendered shortened, so the row's full value + // lives in the title the operator hovers for. + const ids = await rows + .locator("[data-slot='admin-user-id']") + .evaluateAll((elements) => + elements.map((element) => element.getAttribute("title") ?? ""), + ); + expect(ids, "the admin's own row is listed").toContain(adminId); + expect( + ids, + "the invited member is listed too, though the admin's product view cannot see them", + ).toContain(memberId); + + // The host joins identity server-side, so an operator reads people + // by name rather than by opaque id. The emails are the ones these + // identities were actually minted with — asserting the seeded values + // is what proves the join key lines up, since a wrong key would + // leave every row unnamed while the id assertions above still pass. + const names = await rows + .locator("[data-slot='admin-user-name']") + .evaluateAll((elements) => elements.map((element) => element.textContent ?? "")); + expect(names, "the admin's row leads with their email").toContain( + admin.credentials?.email, + ); + expect(names, "and the invited member's with theirs").toContain( + member.credentials?.email, + ); + }); + + await step("Each row summarizes connected vs available integrations", async () => { + const memberRow = page + .locator("[data-slot='admin-user-row']") + .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) }); + await memberRow.waitFor({ state: "visible", timeout: 30_000 }); + // A fresh cloud org's catalog is exactly the two integrations this + // scenario registered: the built-in Executor integration is not + // connectable, so it is neither a slot nor a denominator. The + // denominator is therefore pinned, not read — a third slot here + // would mean the built-in leaked back into the view. + const summary = memberRow.locator("[data-slot='admin-user-connection-count']"); + await summary.waitFor({ state: "visible", timeout: 30_000 }); + expect( + await summary.textContent(), + "one connectable integration is connected, with the built-in out of both numbers", + ).toBe("1/2"); + expect( + await memberRow.locator("[data-integration='executor']").count(), + "the built-in integration has no connect flow, so it gets no slot", + ).toBe(0); + expect( + await memberRow + .locator(`[data-integration='${connectedIntegration}'][data-connected='true']`) + .count(), + "the member's Personal credential is lit in their summary", + ).toBe(1); + expect( + await memberRow + .locator(`[data-integration='${availableIntegration}'][data-connected='false']`) + .count(), + "the one they haven't connected still has a slot — that absence is the view", + ).toBe(1); + }); + + await step("Open the member's detail and confirm their Personal connection", async () => { + await page + .locator("[data-slot='admin-user-row']") + .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) }) + .click(); + const detail = page.getByRole("dialog"); + await detail.waitFor({ state: "visible", timeout: 30_000 }); + + await detail + .getByText(memberConnection, { exact: true }) + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await detail.getByLabel("Status: Unchecked").count(), + "a never-probed connection reads as unchecked, not healthy", + ).toBe(1); + // The other member's credential is not this member's business. + expect( + await detail.getByText(adminConnection, { exact: true }).count(), + "one user's detail never shows another user's connection", + ).toBe(0); + }); + + await step("The detail header copies the member's email and their id", async () => { + const detail = page.getByRole("dialog"); + // Record what reaches the clipboard rather than reading it back: + // a headless context grants no clipboard-read permission, and the + // assertion is about what the button hands over, not about the + // browser's store. + await page.evaluate(() => { + const copied: Array = []; + window.__e2eCopied = copied; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + get: () => ({ + writeText: (text: string) => { + copied.push(text); + return Promise.resolve(); + }, + }), + }); + }); + + // Two buttons, not one: the email is what an operator pastes into a + // mail client, the id is what they paste into a log search. + await detail.getByRole("button", { name: "Copy email" }).click(); + await detail.getByRole("button", { name: "Copy ID" }).click(); + + const copied = await page.evaluate(() => window.__e2eCopied ?? []); + expect( + copied, + "the email button copies the address the member signed up with", + ).toContain(member.credentials?.email); + expect(copied, "and the id button copies the opaque principal id").toContain(memberId); + }); + + await step("The not-connected integration offers a copyable connect link", async () => { + const detail = page.getByRole("dialog"); + const origin = new URL(target.baseUrl).origin; + // The link carries THIS admin's org slug. A recipient who belongs + // to several orgs would otherwise resolve the bare form against + // their default org and connect in the wrong workspace. That + // multi-org round trip is its own scenario + // (`connect-link-multi-org.test.ts`), which needs a second org to + // land in by mistake; here the concern is what this page RENDERS, + // plus that the rendered link actually resolves (below). + await detail + .getByText(`${origin}/${slug}/connect/${availableIntegration}`, { exact: true }) + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await detail + .getByText(`${origin}/connect/${availableIntegration}`, { exact: true }) + .count(), + "the org-free form is never rendered on a host that has orgs", + ).toBe(0); + // Only the unconnected integration is available; the member's + // Personal connection consumes the other slot. The built-in still + // offers no link at all. + expect( + await detail.getByRole("button", { name: "Copy link" }).count(), + "one link for the not-connected integration, and none for the built-in", + ).toBe(1); + expect( + await detail.getByText("/connect/executor", { exact: false }).count(), + "the built-in integration is never offered as a connect link", + ).toBe(0); + }); + + await step("The rendered connect link actually resolves", async () => { + // Following the link the page just rendered, rather than only + // reading it back. A rendered string proves the page composed a URL; + // it cannot tell a working link from a 404 — which is the failure an + // operator would discover only after sending it to someone. + const origin = new URL(target.baseUrl).origin; + await page.goto(`${origin}/${slug}/connect/${availableIntegration}`, { + waitUntil: "domcontentloaded", + }); + // It forwards into that integration's own detail route with the + // add-account handoff, still inside this admin's org. + await page.waitForURL( + (url) => url.pathname === `/${slug}/integrations/${availableIntegration}`, + { timeout: 30_000 }, + ); + expect( + new URL(page.url()).searchParams.get("addAccount"), + "the link lands in the connect flow, not just on the page", + ).toBe("1"); + const dialog = page.getByRole("dialog"); + await dialog + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) + .waitFor({ state: "visible", timeout: 30_000 }); + const credential = dialog.getByRole("textbox", { name: "authorization" }); + await credential.waitFor({ state: "visible", timeout: 90_000 }); + await credential.fill("admin-scope-proof-token"); + await dialog.getByRole("button", { name: "Continue" }).click(); + const owner = dialog.getByRole("combobox"); + await owner.waitFor({ state: "visible", timeout: 30_000 }); + expect(await owner.textContent(), "an admin is offered Personal scope").toContain( + "Personal", + ); + await owner.click(); + await page + .getByRole("option", { name: "Workspace", exact: true }) + .waitFor({ state: "visible", timeout: 30_000 }); + await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); + }); + }); + + // ── The plain member's view ───────────────────────────────────────── + yield* browser.session(forBrowser(member), async ({ page, step }) => { + let slug = ""; + + await step("Sign in as a plain member of the same workspace", async () => { + await visit(page, "/"); + await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { + timeout: 30_000, + }); + slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? ""; + expect(slug, "the member landed in the workspace").not.toBe(""); + }); + + await step("The Users section is not offered to them", async () => { + await page + .getByRole("link", { name: "Integrations" }) + .first() + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await page.getByRole("link", { name: "Users" }).count(), + "a plain member is not shown a section that would only refuse them", + ).toBe(0); + }); + + await step( + "Reaching the URL directly says no access, not an empty workspace", + async () => { + await visit(page, `/${slug}/users`); + // The denial is the assertion: an empty table here would misreport + // a populated workspace as having no users. + await page + .getByText("You don't have access to this workspace's users") + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await page.locator("[data-slot='admin-users-table']").count(), + "the refusal replaces the table rather than rendering it empty", + ).toBe(0); + expect( + await page.getByText(adminId, { exact: true }).count(), + "no other member's identity leaks to a refused viewer", + ).toBe(0); + }, + ); + + await step( + "The member can add Personal connections without a scope dropdown", + async () => { + await visit(page, `/${slug}/integrations/${availableIntegration}?tab=accounts`); + const add = page.getByRole("button", { name: "Add connection" }); + await add.waitFor({ state: "visible", timeout: 30_000 }); + await add.click(); + const dialog = page.getByRole("dialog"); + await dialog + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) + .waitFor({ state: "visible", timeout: 30_000 }); + expect( + await dialog.getByText("Workspace", { exact: true }).count(), + "the member is forced to Personal rather than offered a scope picker", + ).toBe(0); + await page.keyboard.press("Escape"); + }, + ); + + await step("Their connect deep link opens the Personal add flow", async () => { + await visit(page, `/${slug}/connect/${availableIntegration}`); + await page.waitForURL( + (url) => url.pathname === `/${slug}/integrations/${availableIntegration}`, + { timeout: 30_000 }, + ); + expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1"); + await page + .getByRole("dialog") + .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false }) + .waitFor({ state: "visible", timeout: 30_000 }); + }); + }); + }), + Effect.all( + [ + adminClient.connections + .remove({ + params: { owner: "user", integration: connectedIntegration, name: adminConnection }, + }) + .pipe(Effect.ignore), + memberClient.connections + .remove({ + params: { owner: "user", integration: connectedIntegration, name: memberConnection }, + }) + .pipe(Effect.ignore), + adminClient.openapi + .removeSpec({ params: { slug: connectedIntegration } }) + .pipe(Effect.ignore), + adminClient.openapi + .removeSpec({ params: { slug: availableIntegration } }) + .pipe(Effect.ignore), + ], + { discard: true }, + ), + ); + }), +); diff --git a/e2e/cloud/admin-users.test.ts b/e2e/cloud/admin-users.test.ts new file mode 100644 index 0000000000..2be5a29301 --- /dev/null +++ b/e2e/cloud/admin-users.test.ts @@ -0,0 +1,321 @@ +// Cloud-only: the OWNER's view of a workspace — `/api/admin/users*`. The +// product plane answers "what can I, this member, reach"; this plane answers +// "who uses my workspace, and what have they connected", reading across every +// member of the tenant instead of binding to one. +// +// Two members are built through the REAL flows (login → create-organization → +// invite → accept-invitation), each connects their own Personal credential, and +// the admin then reads the joined view — the exact shape a customer dashboard's +// icon grid consumes. The guarantees pinned here: +// +// 1. the joined view reports BOTH members and each one's own connections, +// even though no single product-view caller can see another member's; +// 2. it never carries credential material; +// 3. a plain member is refused (403) and an anonymous caller too (401); +// 4. another tenant's admin sees none of it. +// +// EMULATOR GAP: the WorkOS emulator hardcodes `owner.type: "user"` in its api-key +// serializer and exposes no organization-key mint route, so an org-scoped API +// key — the machine credential for this plane — cannot exist end-to-end here. +// This scenario therefore drives the ADMIN SESSION path, which is the other +// credential the plane accepts. The org-key half is covered at unit level in +// `apps/cloud/src/auth/org-api-key-mint.node.test.ts` (against the real WorkOS +// response shape) and `org-api-key-auth.node.test.ts` (PlatformAuth resolution). +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { HttpApiClient } from "effect/unstable/httpapi"; +import { AdminUsersHttpApi } from "@executor-js/api"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; +import { accountIdOf, cookieOf, joinOrg } from "./support/session"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +type Client = HttpApiClient.ForApi; + +const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); + +/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ +const pingSpec = JSON.stringify({ + openapi: "3.0.3", + info: { title: "Ping API", version: "1.0.0" }, + paths: { + "/ping": { + get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, + }, + }, +}); + +/** Registers a fresh apiKey-authenticated integration for connections to bind to. */ +const registerIntegration = (client: Client) => + Effect.gen(function* () { + const slug = IntegrationSlug.make(`admin-scn-${randomBytes(4).toString("hex")}`); + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: pingSpec }, + slug, + baseUrl: "http://127.0.0.1:59999", // never contacted during registration + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + return slug; + }); + +const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`); + +scenario( + "Admin · the owner sees every member of the workspace and what each has connected", + {}, + Effect.gen(function* () { + const target = yield* Target; + const { client: apiClient } = yield* Api; + + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + + const adminClient = yield* apiClient(api, admin); + const memberClient = yield* apiClient(api, member); + const adminId = yield* accountIdOf(target, admin); + const memberId = yield* accountIdOf(target, member); + + const integration = yield* registerIntegration(adminClient); + const adminConnection = freshConnectionName(); + const memberConnection = freshConnectionName(); + + yield* Effect.ensuring( + Effect.gen(function* () { + // Each member stores their OWN Personal credential. Neither can see the + // other's through the product plane — that is the admin view's job. + yield* adminClient.connections.create({ + payload: { + owner: "user", + name: adminConnection, + integration, + template: TEMPLATE_API_KEY, + value: "admin-personal-token", + }, + }); + yield* memberClient.connections.create({ + payload: { + owner: "user", + name: memberConnection, + integration, + template: TEMPLATE_API_KEY, + value: "member-personal-token", + }, + }); + const client = yield* apiClient(AdminUsersHttpApi, admin); + + // (1) The joined view: both members, each with their own connection. + const joined = yield* client.adminUsers.listUsersWithConnections({ query: {} }); + const byId = new Map(joined.users.map((user) => [user.externalId, user])); + + expect([...byId.keys()].sort(), "both members of the workspace are reported").toEqual( + [adminId, memberId].sort(), + ); + expect( + byId.get(adminId)?.connections.map((connection) => connection.name), + "the admin's own connection is attributed to them", + ).toContain(adminConnection); + expect( + byId.get(memberId)?.connections.map((connection) => connection.name), + "the member's connection is visible to the owner, though not to the admin's product view", + ).toContain(memberConnection); + + // The host identity join: the WorkOS email lands on the right row. + // + // The join key is the membership's `userId` — the same WorkOS `user_...` + // the subject table records — so this proves the id spaces line up. A + // mismatch would leave every user unnamed while the id assertions above + // still passed, which is exactly the failure this asserts against. + expect( + byId.get(adminId)?.email, + "the admin's row carries the email they signed in with", + ).toBe(admin.credentials?.email); + expect(byId.get(memberId)?.email, "and the invited member's row carries theirs").toBe( + member.credentials?.email, + ); + + // The flat list agrees with the joined one. + const flat = yield* client.adminUsers.listUsers({ query: {} }); + expect( + flat.users.map((user) => user.externalId).sort(), + "the flat list reports the same members", + ).toEqual([adminId, memberId].sort()); + expect( + flat.users.map((user) => user.email).sort(), + "with the same identities joined onto it", + ).toEqual([admin.credentials?.email, member.credentials?.email].sort()); + + // Per-user connections resolve the same rows. + const memberConnections = yield* client.adminUsers.listUserConnections({ + params: { externalId: memberId }, + }); + expect( + memberConnections.connections.map((connection) => connection.name), + "the member's connections are readable by external id", + ).toContain(memberConnection); + + // The single-user read, addressed by EMAIL, against real WorkOS-shaped + // identity: the reverse directory lookup (email → user id) has to agree + // with the forward join above, and the answer must carry identity AND + // connections in the one call a per-user check makes. + const memberEmail = member.credentials?.email ?? ""; + const single = yield* client.adminUsers.getUser({ + params: { identifier: memberEmail }, + }); + expect(single.user.externalId, "the email resolves to the same member").toBe(memberId); + expect(single.user.email, "and carries the identity the list reported").toBe(memberEmail); + expect( + single.user.connections.map((connection) => connection.name), + "with their connections joined in the same response", + ).toContain(memberConnection); + + // The same read by opaque id agrees, so neither identifier is a + // different code path with a different answer. + const byOpaqueId = yield* client.adminUsers.getUser({ + params: { identifier: memberId }, + }); + expect(byOpaqueId.user.email).toBe(memberEmail); + expect(byOpaqueId.user.connections.map((connection) => connection.name)).toEqual( + single.user.connections.map((connection) => connection.name), + ); + + // The bulk filter selects the same member out of the workspace. + const filtered = yield* client.adminUsers.listUsers({ + query: { email: memberEmail }, + }); + expect( + filtered.users.map((user) => user.externalId), + "?email= narrows the list to exactly that member", + ).toEqual([memberId]); + + // (2) No credential material anywhere in the joined view. + expect( + JSON.stringify(joined), + "the admin view never carries a stored credential", + ).not.toContain("member-personal-token"); + expect(JSON.stringify(joined)).not.toContain("admin-personal-token"); + + // (3) A plain member is refused: this plane reports on everyone. + const asMember = yield* Effect.promise(() => + fetch(new URL("/api/admin/users", target.baseUrl), { + headers: { cookie: cookieOf(member) }, + }), + ); + expect(asMember.status, "a non-admin member cannot read the workspace view").toBe(403); + + // And an anonymous caller has no credential at all. + const anonymous = yield* Effect.promise(() => + fetch(new URL("/api/admin/users", target.baseUrl)), + ); + expect(anonymous.status, "no session → unauthorized").toBe(401); + }), + Effect.all( + [ + adminClient.connections + .remove({ params: { owner: "user", integration, name: adminConnection } }) + .pipe(Effect.ignore), + memberClient.connections + .remove({ params: { owner: "user", integration, name: memberConnection } }) + .pipe(Effect.ignore), + adminClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore), + ], + { discard: true }, + ), + ); + }), +); + +scenario( + "Admin · one workspace's owner sees nothing of another workspace", + {}, + Effect.gen(function* () { + const target = yield* Target; + const { client: apiClient } = yield* Api; + + const mine = yield* target.newIdentity(); + const theirs = yield* target.newIdentity(); + const myId = yield* accountIdOf(target, mine); + const theirId = yield* accountIdOf(target, theirs); + + const myClient = yield* apiClient(api, mine); + // A user earns their row in the workspace the first time they touch the + // EXECUTOR plane (the account plane doesn't bind an executor), so the other + // owner makes one product read — otherwise their own workspace is empty and + // the isolation assertion below would pass vacuously. + yield* (yield* apiClient(api, theirs)).connections.list({ query: {} }); + const integration = yield* registerIntegration(myClient); + const connection = freshConnectionName(); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* myClient.connections.create({ + payload: { + owner: "user", + name: connection, + integration, + template: TEMPLATE_API_KEY, + value: "my-private-token", + }, + }); + + const theirAdmin = yield* apiClient(AdminUsersHttpApi, theirs); + const theirView = yield* theirAdmin.adminUsers.listUsersWithConnections({ query: {} }); + + expect( + theirView.users.map((user) => user.externalId), + "another workspace's owner never sees my members", + ).not.toContain(myId); + expect( + theirView.users.map((user) => user.externalId), + "they do see their own", + ).toContain(theirId); + expect( + JSON.stringify(theirView), + "and none of my connections leak across the tenant boundary", + ).not.toContain(connection); + + // Naming my account id directly returns nothing rather than my rows. + const probe = yield* theirAdmin.adminUsers.listUserConnections({ + params: { externalId: myId }, + }); + expect(probe.connections, "a cross-tenant id resolves to nothing").toEqual([]); + + // And the single-user read refuses to confirm I exist at all — by my + // id or by my email, both of which are real, just not theirs. + for (const identifier of [myId, mine.credentials?.email ?? ""]) { + const response = yield* Effect.promise(() => + fetch(new URL(`/api/admin/users/${encodeURIComponent(identifier)}`, target.baseUrl), { + headers: { cookie: cookieOf(theirs) }, + }), + ); + expect(response.status, "a cross-tenant lookup is a plain 404, revealing nothing").toBe( + 404, + ); + } + }), + Effect.all( + [ + myClient.connections + .remove({ params: { owner: "user", integration, name: connection } }) + .pipe(Effect.ignore), + myClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore), + ], + { discard: true }, + ), + ); + }), +); diff --git a/e2e/cloud/auth-routing-flow.test.ts b/e2e/cloud/auth-routing-flow.test.ts index 63210d8933..30efb17cdd 100644 --- a/e2e/cloud/auth-routing-flow.test.ts +++ b/e2e/cloud/auth-routing-flow.test.ts @@ -23,6 +23,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { settle } from "../src/surfaces/browser"; scenario( "Auth routing · signed out → login → onboarding → dashboard, one browser session", @@ -58,7 +59,7 @@ scenario( await step("Create the first org → canonical dashboard at /", async () => { const orgName = "Flow Test Org"; const orgNameInput = page.getByPlaceholder("Northwind Labs"); - await page.waitForLoadState("networkidle"); + await settle(page); await orgNameInput.fill(orgName); await page.waitForTimeout(250); if ((await orgNameInput.inputValue()) !== orgName) { diff --git a/e2e/cloud/billing-customer-provisioning.test.ts b/e2e/cloud/billing-customer-provisioning.test.ts new file mode 100644 index 0000000000..ab98cfd27d --- /dev/null +++ b/e2e/cloud/billing-customer-provisioning.test.ts @@ -0,0 +1,281 @@ +// Cloud-only (billing): an organization must EXIST as a customer at the billing +// provider, or every billing call for it answers `customer_not_found` forever. +// +// That state is invisible from the product side and catastrophic underneath it: +// the balance gate fails open (by design — a billing outage must not stop +// executions), and usage tracking is fire-and-forget, so the org runs unlimited +// executions and NONE of them reach the meter. The product looks perfectly +// healthy while every execution is unbilled and unmetered. +// +// Three guarantees are pinned here, all read from Autumn's own state rather than +// from the execution's response (which can never show any of them): +// +// 1. creating an organization provisions its billing customer up front, +// 2. if a customer is missing anyway, the billing seam heals it in place and +// the execution still lands on the meter, and +// 3. any OTHER billing failure is left alone — the repair is scoped to the +// "no such customer" answer, so a real provider failure is still reported +// rather than quietly retried away. +// +// The missing customer is produced with the emulator's fault injector — one +// 404 `customer_not_found` per billing endpoint — because the emulator, like +// real Autumn's SDK flow, otherwise auto-creates customers on contact. +import { expect } from "@effect/vitest"; +import { Effect, Schedule } from "effect"; + +import { scenario } from "../src/scenario"; +import { Autumn, Billing, Mcp, Target } from "../src/services"; +import type { Identity } from "../src/target"; + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; + +/** The org the bearer is scoped to — the Autumn customer id every billing call + * is made against — read from the JWT's public claims. */ +const orgIdOf = (bearer: string): string => { + const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as { + readonly org_id?: string; + }; + if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim"); + return claims.org_id; +}; + +/** A 404 `customer_not_found`, byte-for-byte the shape Autumn answers with for + * an organization it has no customer record for. */ +const CUSTOMER_NOT_FOUND = { + status: 404, + body: { message: "Customer not found", code: "customer_not_found" }, +} as const; + +/** A 404 that is NOT a missing customer — a moved route, a proxy, a gateway. + * Autumn carries a different code, and that code is the entire safety margin + * between "provision and retry" and "quietly retry away a real failure". */ +const UNRELATED_NOT_FOUND = { + status: 404, + body: { message: "Not Found", code: "not_found" }, +} as const; + +scenario( + "Billing · creating an organization provisions it as a billing customer", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const mcp = yield* Mcp; + + // A brand-new user creating their first organization: none of the + // opportunistic billing lookups (the over-the-free-limit check, the members + // page's seat count, the rate limiter's paid-plan exemption) are reached on + // this journey, so org creation is the only chance to provision. + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const organizationId = orgIdOf(bearer); + + const customerIds = yield* autumn.customerIds(); + expect( + customerIds, + "the new organization exists as a customer at the billing provider", + ).toContain(organizationId); + }), +); + +scenario( + "Billing · a missing billing customer is healed in place and the execution is still metered", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const mcp = yield* Mcp; + + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const customerId = orgIdOf(bearer); + + const before = yield* autumn.usageEvents({ customerId, featureId: "executions" }); + expect(before.length, "a brand-new org starts with zero metered executions").toBe(0); + + yield* Effect.gen(function* () { + // One 404 each: the next balance check and the next usage track both + // answer "this customer does not exist". A single `times: 1` fault means + // a seam that heals and retries gets through, while one that gives up + // loses the usage permanently — exactly the production failure. + yield* autumn.armFault({ + match: { operationId: "balances.check" }, + response: CUSTOMER_NOT_FOUND, + times: 1, + }); + yield* autumn.armFault({ + match: { operationId: "balances.track" }, + response: CUSTOMER_NOT_FOUND, + times: 1, + }); + + const session = mcp.session(identity); + const result = yield* session.call("execute", { code: "return 6 * 7;" }); + + // The gate still fails open: a billing problem never blocks a customer. + expect(result.ok, "the execution runs despite the missing billing customer").toBe(true); + expect(result.text, "it returns its value").toContain("42"); + + // Both billing calls really did reach Autumn and really were rejected — + // without this the scenario could pass on a fault that never armed. The + // ledger is shared by the whole run, so attribute to THIS org: another + // scenario's faulted call must not stand in for this one's. + const checks = (yield* autumn.ledgerFor("balances.check")).filter( + (entry) => entry.customerId === customerId, + ); + expect( + checks.some((entry) => entry.faulted), + "the balance check reached Autumn and was answered customer_not_found", + ).toBe(true); + const tracks = (yield* autumn.ledgerFor("balances.track")).filter( + (entry) => entry.customerId === customerId, + ); + expect( + tracks.some((entry) => entry.faulted), + "the usage track reached Autumn and was answered customer_not_found", + ).toBe(true); + + // The guarantee: the seam provisions the customer and retries, so the + // execution lands on the meter. Before the fix this ledger stays empty + // forever — the execution ran, and nobody was ever billed for it. + const metered = yield* autumn.expectUsage({ + customerId, + featureId: "executions", + count: 1, + }); + expect(metered.length, "the execution is metered exactly once").toBe(1); + expect(metered[0]?.value, "it meters a single unit").toBe(1); + + // Healing the customer is what made the retry possible, so the customer + // must exist at the provider afterwards. + const customerIds = yield* autumn.customerIds(); + expect(customerIds, "the organization has a billing customer afterwards").toContain( + customerId, + ); + }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); + }), +); + +scenario( + "Billing · an unrelated billing failure is reported, not retried away", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const mcp = yield* Mcp; + + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const customerId = orgIdOf(bearer); + + yield* Effect.gen(function* () { + // A 404 that is not "no such customer". If the seam treated every 404 as + // a provisioning gap it would create a customer and retry, turning a real + // provider failure into a silent success — and the alert never fires. + yield* autumn.armFault({ + match: { operationId: "balances.track" }, + response: UNRELATED_NOT_FOUND, + times: 1, + }); + + const session = mcp.session(identity); + const faulted = yield* session.call("execute", { code: "return 1 + 1;" }); + expect(faulted.ok, "the execution runs — billing never blocks a customer").toBe(true); + + // A second, unfaulted execution. Its usage landing is the barrier: the + // first execution's track (and any retry of it) is already done by the + // time this one is on the meter, so the counts below are settled without + // waiting on a clock. + const clean = yield* session.call("execute", { code: "return 2 + 2;" }); + expect(clean.ok, "the follow-up execution runs too").toBe(true); + yield* autumn.expectUsage({ customerId, featureId: "executions", count: 1 }); + + const tracks = (yield* autumn.ledgerFor("balances.track")).filter( + (entry) => entry.customerId === customerId, + ); + expect( + tracks.filter((entry) => entry.faulted).length, + "the first usage track was answered with the unrelated 404", + ).toBe(1); + // Two executions, two attempts: the failed one was reported and dropped, + // not repaired and replayed. + expect(tracks.length, "the rejected usage track is not retried").toBe(2); + + const metered = yield* autumn.usageEvents({ customerId, featureId: "executions" }); + expect(metered.length, "only the unfaulted execution reaches the meter").toBe(1); + }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); + }), +); + +scenario( + "Billing · a customer that cannot be provisioned is given up on, not retried in a loop", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const mcp = yield* Mcp; + + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const customerId = orgIdOf(bearer); + + // Attempts against THIS org's meter, polled until the seam has settled on + // `atLeast` of them — the ledger is the only place the retry is visible. + const trackAttempts = (atLeast: number) => + autumn.ledgerFor("balances.track").pipe( + Effect.map((entries) => entries.filter((entry) => entry.customerId === customerId)), + Effect.filterOrFail( + (entries) => entries.length >= atLeast, + (entries) => `only ${entries.length}/${atLeast} usage-track attempts for ${customerId}`, + ), + Effect.retry(Schedule.both(Schedule.spaced("250 millis"), Schedule.recurs(40))), + ); + + yield* Effect.gen(function* () { + // "No such customer" that does NOT go away when the customer is created — + // a provisioning gap the repair genuinely cannot close (a rejected id, a + // provider that never materializes the record). The seam must repair, + // retry ONCE, report, and stop: a repair loop here runs in a forked, + // untimed fibre, so an unbounded one hammers the provider forever for a + // single execution. + yield* autumn.armFault({ + match: { operationId: "balances.track" }, + response: CUSTOMER_NOT_FOUND, + times: 20, + }); + + const session = mcp.session(identity); + const stuck = yield* session.call("execute", { code: "return 7 * 6;" }); + expect(stuck.ok, "the execution runs — billing never blocks a customer").toBe(true); + expect(stuck.text, "it returns its value").toContain("42"); + + // The original attempt plus the one post-repair retry. + yield* trackAttempts(2); + + // Clearing the fault re-opens the meter, and the next execution's usage + // landing is the barrier: whatever the first execution was going to do to + // the ledger is finished by then, so the count below is settled without + // waiting on a clock. + yield* autumn.clearFaults(); + const clean = yield* session.call("execute", { code: "return 2 + 2;" }); + expect(clean.ok, "the follow-up execution runs too").toBe(true); + yield* autumn.expectUsage({ customerId, featureId: "executions", count: 1 }); + + const tracks = (yield* autumn.ledgerFor("balances.track")).filter( + (entry) => entry.customerId === customerId, + ); + expect( + tracks.filter((entry) => entry.faulted).length, + "the unrepairable customer is attempted exactly twice: once, then once after the repair", + ).toBe(2); + expect(tracks.length, "and no further attempt beyond the next execution's own").toBe(3); + + const metered = yield* autumn.usageEvents({ customerId, featureId: "executions" }); + expect(metered.length, "only the execution Autumn accepted reaches the meter").toBe(1); + }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); + }), +); diff --git a/e2e/cloud/billing-trial-checkout-stale.test.ts b/e2e/cloud/billing-trial-checkout-stale.test.ts index ba64ae461d..453a7bfd1f 100644 --- a/e2e/cloud/billing-trial-checkout-stale.test.ts +++ b/e2e/cloud/billing-trial-checkout-stale.test.ts @@ -20,6 +20,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Autumn, Billing, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; scenario( "Billing · completing the trial checkout shows the new plan without a reload", @@ -51,12 +52,12 @@ scenario( // "/undefined/billing/plans" — the billing fetches fire under the bogus // slug, fail, and are never refetched (autumn-js staleTime 60s), leaving // the plans grid empty forever. - await page.goto("/", { waitUntil: "networkidle" }); + await visit(page, "/"); await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000, }); const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]; - await page.goto(`/${slug}/billing/plans`, { waitUntil: "networkidle" }); + await visit(page, `/${slug}/billing/plans`); await page.getByRole("heading", { name: "Choose a plan" }).waitFor(); await startTrial.waitFor(); }); diff --git a/e2e/cloud/connect-card-origin.test.ts b/e2e/cloud/connect-card-origin.test.ts new file mode 100644 index 0000000000..da1e80f57b --- /dev/null +++ b/e2e/cloud/connect-card-origin.test.ts @@ -0,0 +1,55 @@ +// Cloud-specific: the integrations landing page's connect card prints an +// `npx add-mcp ` install command — the first thing a new user is told to +// copy. That URL's origin must be the host actually serving the console, never +// the desktop/CLI fallback `http://127.0.0.1:4000` the server-connection +// module defaults to when no origin is known. +// +// Under SPA serving there is no per-request document render: the shell is a +// static asset and the card renders client-side, deriving its origin from +// `window.location.origin`. This scenario pins the rendered card (the DOM the +// user actually copies from) to the serving host, against the real WorkOS +// emulator session. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; + +scenario( + "Connect card · the rendered install command uses the real host, never the 127.0.0.1 default", + {}, + Effect.gen(function* () { + const browser = yield* Browser; + const target = yield* Target; + const expectedOrigin = new URL(target.baseUrl).origin; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + let commandText = ""; + await step("Open the console and read the connect card's command", async () => { + await page.goto("/", { waitUntil: "commit" }); + const command = page.getByText(/add-mcp\s+https?:\/\//).first(); + await command.waitFor({ timeout: 30_000 }); + commandText = (await command.textContent()) ?? ""; + }); + + const endpoint = /add-mcp\s+(https?:\/\/\S+\/mcp)/.exec(commandText)?.[1] ?? null; + expect(endpoint, "the connect card renders an install command").not.toBeNull(); + + expect(new URL(endpoint!).origin, "the install URL uses the real serving origin").toBe( + expectedOrigin, + ); + expect(endpoint!, "…and not the desktop/CLI default").not.toContain("127.0.0.1:4000"); + // It's still the org-scoped path the user actually needs. Since #974 + // ("Org-slug console URLs across cloud, self-host, and cloudflare + // hosts"), the install card prints the org's URL SLUG (e.g. + // /org-user-xxx/mcp), not the legacy WorkOS org_ form — + // mount.ts's classifyMcpPath still accepts either shape, but the slug + // form is what ships, so accept both rather than pinning on the + // retired id-only shape. + expect(endpoint!, "the install URL stays org-scoped").toMatch( + /\/(?:org_[^/]+|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\/mcp$/, + ); + }); + }), +); diff --git a/e2e/cloud/connect-card-ssr-origin.test.ts b/e2e/cloud/connect-card-ssr-origin.test.ts deleted file mode 100644 index c8ffd4725f..0000000000 --- a/e2e/cloud/connect-card-ssr-origin.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Cloud-specific: now that the gate renders the REAL shell during SSR (no -// skeleton), the integrations landing page's connect card is part of the very -// first painted HTML — including its `npx add-mcp ` install command. That -// URL is built from the server connection's origin, and the SPA only learns -// `window.location.origin` after it mounts; the SSR default is the desktop/CLI -// fallback `http://127.0.0.1:4000`. If SSR rendered with that default, the -// command would paint `127.0.0.1:4000` and then flip to the real host at -// hydration — a visible flash of the wrong URL on the first thing the user is -// told to copy. -// -// The gate now threads the request origin to the render, so the command is -// SSR'd against the REAL host from the first byte. These scenarios pin that on -// the raw document (pre-JS), where a flash would be observable, against the -// real WorkOS emulator session. -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; - -import { scenario } from "../src/scenario"; -import { Api, Target } from "../src/services"; - -/** A document navigation request — what the SSR gate keys on. */ -const documentRequest = (url: URL, cookie: string) => - Effect.promise(() => - fetch(url, { redirect: "manual", headers: { accept: "text/html", cookie } }), - ); - -/** - * The install command is rendered inside a

       (shiki's grammar isn't loaded
      - * during SSR, so the code block falls back to plain text). Stripping tags
      - * reassembles the literal command even if hydration would later tokenize it
      - * into colored spans.
      - */
      -const installEndpointFromHtml = (html: string): string | null => {
      -  const text = html.replace(/<[^>]+>/g, " ");
      -  return /add-mcp\s+(https?:\/\/\S+\/mcp)/.exec(text)?.[1] ?? null;
      -};
      -
      -scenario(
      -  "Connect card · the install command SSRs against the real host, never the 127.0.0.1 default",
      -  {},
      -  Effect.gen(function* () {
      -    // Gate: the REST API plane is mounted on this target.
      -    yield* Api;
      -    const target = yield* Target;
      -    const expectedOrigin = new URL(target.baseUrl).origin;
      -
      -    const identity = yield* target.newIdentity();
      -    const response = yield* documentRequest(
      -      new URL("/", target.baseUrl),
      -      identity.headers!.cookie!,
      -    );
      -    expect(response.status, "the authenticated landing page is served").toBe(200);
      -
      -    const html = yield* Effect.promise(() => response.text());
      -    // The card is part of the first paint (the whole point of deleting the
      -    // skeleton) — if it weren't SSR'd, there'd be no flash to fix and this
      -    // guard would be vacuous, so assert it's actually there.
      -    const endpoint = installEndpointFromHtml(html);
      -    expect(endpoint, "the connect card's install command is in the SSR'd HTML").not.toBeNull();
      -
      -    // The fix: the command's origin is the host that served the document, not
      -    // the client-side fallback the SPA would otherwise paint before mounting.
      -    expect(new URL(endpoint!).origin, "the install URL uses the real serving origin").toBe(
      -      expectedOrigin,
      -    );
      -    expect(endpoint!, "…and not the desktop/CLI default that used to flash").not.toContain(
      -      "127.0.0.1:4000",
      -    );
      -    // It's still the org-scoped path the user actually needs. Since #974
      -    // ("Org-slug console URLs across cloud, self-host, and cloudflare hosts"),
      -    // the install card prints the org's URL SLUG (e.g. /org-user-xxx/mcp), not
      -    // the legacy WorkOS org_ form — mount.ts's classifyMcpPath still
      -    // accepts either shape, but the slug form is what ships, so accept both
      -    // rather than pinning on the retired id-only shape.
      -    expect(endpoint!, "the install URL stays org-scoped").toMatch(
      -      /\/(?:org_[^/]+|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\/mcp$/,
      -    );
      -  }),
      -);
      diff --git a/e2e/cloud/connect-link-multi-org.test.ts b/e2e/cloud/connect-link-multi-org.test.ts
      new file mode 100644
      index 0000000000..6d219142d9
      --- /dev/null
      +++ b/e2e/cloud/connect-link-multi-org.test.ts
      @@ -0,0 +1,215 @@
      +// Cloud-only: the connect deep link's REASON FOR CARRYING AN ORG SLUG.
      +//
      +// An operator on the admin Users page copies a connect link for a member who
      +// hasn't connected something yet and sends it to them. `connectLinkUrl` builds
      +// that link org-PREFIXED (`//connect/`) rather than bare, and the
      +// failure that choice prevents is specific and silent: a recipient who belongs
      +// to several workspaces has ONE session-default org, and a bare `/connect/x`
      +// resolves against THAT one. They would land in the wrong workspace, connect a
      +// real credential there, and nothing anywhere would report a problem — the
      +// connection succeeds, it is just in the wrong tenant. The sender then keeps
      +// seeing an unconnected user.
      +//
      +// The existing coverage cannot catch this. `scenarios/connect-deep-link.test.ts`
      +// deliberately accepts EITHER URL form (it runs on hosts with and without an
      +// org segment) and its user belongs to one org, so both forms resolve to the
      +// same place. `packages/react/src/routes/connect-deep-link.test.ts` only proves
      +// the router parses the param. Neither has a second org to land in by mistake.
      +//
      +// So: a recipient who is a plain member of TWO orgs, whose session defaults to
      +// org B, follows an org-A-scoped link. The request must open the forced-Personal
      +// connection flow in org A — never enter a connection flow in B.
      +//
      +// Both orgs register an integration under the SAME slug — that is what makes
      +// this a real test. With distinct slugs, org B's catalog would simply not
      +// contain the link's slug and the page would show its not-found state, so the
      +// scenario would pass for the wrong reason (a lookup miss rather than correct
      +// scoping). Sharing the slug means BOTH orgs can satisfy the link, and only the
      +// org prefix decides which one does.
      +import { randomBytes } from "node:crypto";
      +
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +import type { HttpApiClient } from "effect/unstable/httpapi";
      +import { composePluginApi } from "@executor-js/api/server";
      +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
      +import { IntegrationSlug } from "@executor-js/sdk/shared";
      +
      +import { scenario } from "../src/scenario";
      +import { Api, Browser, Target } from "../src/services";
      +import { activeOrg, forBrowser, joinOrg, organizationsOf } from "./support/session";
      +
      +const api = composePluginApi([openApiHttpPlugin()] as const);
      +type Client = HttpApiClient.ForApi;
      +
      +const INTEGRATION_TITLE = "Ping API";
      +
      +/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
      +const pingSpec = JSON.stringify({
      +  openapi: "3.0.3",
      +  info: { title: INTEGRATION_TITLE, version: "1.0.0" },
      +  paths: {
      +    "/ping": {
      +      get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
      +    },
      +  },
      +});
      +
      +/** Register an apiKey-authenticated integration under an EXACT slug, so both
      + *  orgs can hold the same catalog identity. */
      +const registerIntegration = (client: Client, slug: IntegrationSlug) =>
      +  client.openapi.addSpec({
      +    payload: {
      +      spec: { kind: "blob", value: pingSpec },
      +      slug,
      +      baseUrl: "http://127.0.0.1:59999", // never contacted during registration
      +      authenticationTemplate: [
      +        {
      +          slug: "apiKey",
      +          type: "apiKey",
      +          headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
      +        },
      +      ],
      +    },
      +  });
      +
      +scenario(
      +  "Connect · an org-scoped connect link resolves a multi-org recipient in the SENDING org",
      +  { timeout: 180_000 },
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const browser = yield* Browser;
      +    const { client: apiClient } = yield* Api;
      +
      +    // Two workspaces with two different owners, and one person invited into
      +    // both. Built through the real invite flow, so the memberships are the ones
      +    // WorkOS actually issues.
      +    const ownerA = yield* target.newIdentity();
      +    const ownerB = yield* target.newIdentity();
      +    const recipientSeed = yield* target.newIdentity({ org: false });
      +
      +    const joinedA = yield* joinOrg(target, ownerA, recipientSeed);
      +    // Accepting B's invitation SECOND re-seals the session onto org B, which is
      +    // exactly the hazard: the recipient's default is now the wrong workspace
      +    // for the link they are about to follow.
      +    const inOrgB = yield* joinOrg(target, ownerB, joinedA);
      +
      +    // `joinedA`'s cookie was re-sealed by the SECOND accept, so it is stale and
      +    // must not be used to read anything — the recipient's live session is
      +    // `inOrgB`, pointed at whichever org a given read is about.
      +    const orgA = yield* activeOrg(target, ownerA);
      +    const orgB = yield* activeOrg(target, inOrgB);
      +    expect(orgA.id, "the two workspaces are genuinely different").not.toBe(orgB.id);
      +
      +    const memberships = yield* organizationsOf(target, inOrgB);
      +    expect(
      +      memberships.organizations.map((org) => org.id).sort(),
      +      "the recipient really is a member of BOTH workspaces",
      +    ).toEqual([orgA.id, orgB.id].sort());
      +    expect(
      +      memberships.activeOrganizationId,
      +      "and their session defaults to org B — the org the link does NOT point at",
      +    ).toBe(orgB.id);
      +
      +    const ownerAClient = yield* apiClient(api, ownerA);
      +    const ownerBClient = yield* apiClient(api, ownerB);
      +
      +    // ONE slug, registered in BOTH catalogs (see the header note).
      +    const slug = IntegrationSlug.make(`multiorg-${randomBytes(4).toString("hex")}`);
      +
      +    yield* Effect.ensuring(
      +      Effect.gen(function* () {
      +        yield* registerIntegration(ownerAClient, slug);
      +        yield* registerIntegration(ownerBClient, slug);
      +
      +        // The link an operator copies off org A's Users page. Built here the
      +        // same way the page builds it (origin + org prefix + slug); the page's
      +        // own rendering of it is asserted in `admin-users-console.test.ts`.
      +        const origin = new URL(target.baseUrl).origin;
      +        const connectLink = `${origin}/${orgA.slug}/connect/${slug}`;
      +
      +        // The recipient follows the link with their org-B-default session.
      +        yield* browser.session(forBrowser(inOrgB), async ({ page, step }) => {
      +          await step("Follow the org-A connect link with an org-B session", async () => {
      +            // `domcontentloaded`, not `networkidle`: the console holds live
      +            // connections open, so idle is not a state this page reaches. The
      +            // real wait is the redirect assertion below.
      +            await page.goto(connectLink, { waitUntil: "domcontentloaded" });
      +            // A plain member can add a Personal connection. The org-A prefix
      +            // still proves the link resolved against the SENDING workspace
      +            // rather than session org B.
      +            await page.waitForURL((url) => url.pathname === `/${orgA.slug}/integrations/${slug}`, {
      +              timeout: 30_000,
      +            });
      +            expect(
      +              new URL(page.url()).pathname.split("/").filter(Boolean)[0],
      +              "the Personal add flow is scoped to the SENDING org, not the session default",
      +            ).toBe(orgA.slug);
      +            expect(
      +              new URL(page.url()).searchParams.get("addAccount"),
      +              "the member enters the add-account route",
      +            ).toBe("1");
      +            const dialog = page.getByRole("dialog");
      +            await dialog
      +              .getByText(`Add connection · ${INTEGRATION_TITLE}`, { exact: false })
      +              .waitFor({ state: "visible", timeout: 30_000 });
      +            expect(
      +              await dialog.getByText("Workspace", { exact: true }).count(),
      +              "the member is forced to Personal scope",
      +            ).toBe(0);
      +          });
      +
      +          await step("Complete the Personal connection in org A", async () => {
      +            const dialog = page.getByRole("dialog");
      +            const credential = dialog.getByRole("textbox", { name: "authorization" });
      +            await credential.waitFor({ state: "visible", timeout: 90_000 });
      +            await credential.fill("recipient-personal-token");
      +            await dialog.getByRole("button", { name: "Continue" }).click();
      +            await dialog.getByRole("button", { name: "Add connection" }).click();
      +            await page
      +              .getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true })
      +              .first()
      +              .waitFor({ state: "visible", timeout: 90_000 });
      +            expect(
      +              new URL(page.url()).pathname.split("/").filter(Boolean)[0],
      +              "the submitted Personal connection stayed in the sending org",
      +            ).toBe(orgA.slug);
      +          });
      +
      +          // ── The other half: it is NOT in the session's default org ─────────
      +          //
      +          // Same person, same session, same integration slug — the only thing
      +          // that differs is the org in the URL. Org B registered the SAME slug,
      +          // so this page exists and must resolve an empty connection list.
      +          await step("Org B has no persisted connection", async () => {
      +            await page.goto(`${origin}/${orgB.slug}/integrations/${slug}?tab=accounts`, {
      +              waitUntil: "domcontentloaded",
      +            });
      +            const add = page.getByRole("button", { name: "Add connection" });
      +            await add.waitFor({ state: "visible", timeout: 90_000 });
      +            await page
      +              .getByText("No connections", { exact: false })
      +              .first()
      +              .waitFor({ state: "visible", timeout: 90_000 });
      +            expect(
      +              new URL(page.url()).pathname.split("/").filter(Boolean)[0],
      +              "navigating explicitly to org B changes the active add-flow scope",
      +            ).toBe(orgB.slug);
      +            expect(
      +              await page.getByText(`Personal ${INTEGRATION_TITLE}`, { exact: true }).count(),
      +              "the submitted connection did not land in the session-default org",
      +            ).toBe(0);
      +          });
      +        });
      +      }),
      +      // Remove the same-slug fixtures from both organizations.
      +      Effect.all(
      +        [
      +          ownerAClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
      +          ownerBClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
      +        ],
      +        { discard: true },
      +      ),
      +    );
      +  }),
      +);
      diff --git a/e2e/cloud/connect-panel.test.ts b/e2e/cloud/connect-panel.test.ts
      index 0c5e906c79..f31d1b067e 100644
      --- a/e2e/cloud/connect-panel.test.ts
      +++ b/e2e/cloud/connect-panel.test.ts
      @@ -6,6 +6,7 @@ import { Effect } from "effect";
       
       import { scenario } from "../src/scenario";
       import { Browser, Target } from "../src/services";
      +import { visit, settle } from "../src/surfaces/browser";
       
       scenario(
         "Connect · the agent-connect panel gives working copy for both transports",
      @@ -17,7 +18,7 @@ scenario(
       
           yield* browser.session(identity, async ({ page, step }) => {
             await step("Open the Integrations page", async () => {
      -        await page.goto("/", { waitUntil: "networkidle" });
      +        await visit(page, "/");
               await page.getByText("Connect an agent").first().waitFor();
             });
       
      @@ -35,7 +36,7 @@ scenario(
       
             await step("Switch to Standard I/O", async () => {
               await page.getByRole("tab", { name: "Standard I/O" }).click();
      -        await page.waitForLoadState("networkidle");
      +        await settle(page);
             });
             const stdioCommand = await command();
             expect(stdioCommand, "the command changed for stdio").not.toBe(httpCommand);
      @@ -43,11 +44,54 @@ scenario(
               "--transport http",
             );
       
      -      await step("Switch back to Remote HTTP", async () => {
      +      await step("Choose connection-specific install options", async () => {
               await page.getByRole("tab", { name: "Remote HTTP" }).click();
      -        await page.waitForLoadState("networkidle");
      +        await page.getByRole("button", { name: "Advanced" }).click();
      +        await page.getByRole("switch", { name: "Artifacts" }).click();
      +        await page.getByRole("switch", { name: "Integration search tools" }).click();
      +        await page.getByRole("combobox", { name: "Elicitation mode" }).selectOption("browser");
      +        await settle(page);
             });
      -      expect(await command(), "the HTTP command is restored").toContain("--transport http");
      +      const customizedHttpCommand = await command();
      +      expect(customizedHttpCommand, "the HTTP command is restored").toContain("--transport http");
      +      expect(customizedHttpCommand, "disabled artifacts are encoded per connection").toContain(
      +        "artifacts=false",
      +      );
      +      expect(customizedHttpCommand, "integration search is encoded per connection").toContain(
      +        "search_tools=true",
      +      );
      +      expect(customizedHttpCommand, "browser approval is encoded per connection").toContain(
      +        "elicitation_mode=browser",
      +      );
      +
      +      await step("Reload with the install options preserved", async () => {
      +        await page.reload({ waitUntil: "networkidle" });
      +        await page.getByText("Connect an agent").first().waitFor();
      +        await page.getByRole("button", { name: "Advanced" }).click();
      +      });
      +      expect(await page.getByRole("switch", { name: "Artifacts" }).isChecked()).toBe(false);
      +      expect(await page.getByRole("switch", { name: "Integration search tools" }).isChecked()).toBe(
      +        true,
      +      );
      +      expect(await page.getByRole("combobox", { name: "Elicitation mode" }).inputValue()).toBe(
      +        "browser",
      +      );
      +      expect(await command(), "the same customized command survives a reload").toBe(
      +        customizedHttpCommand,
      +      );
      +
      +      await step("Preserve the selected transport across another reload", async () => {
      +        await page.getByRole("tab", { name: "Standard I/O" }).click();
      +        await settle(page);
      +        await page.reload({ waitUntil: "networkidle" });
      +        await page.getByText("Connect an agent").first().waitFor();
      +      });
      +      expect(
      +        await page.getByRole("tab", { name: "Standard I/O" }).getAttribute("aria-selected"),
      +      ).toBe("true");
      +      expect(await command(), "the persisted stdio command retains compatible options").toContain(
      +        "mcp --no-artifacts --search-tools",
      +      );
           });
         }),
       );
      diff --git a/e2e/cloud/connection-modal-oauth-abandon.test.ts b/e2e/cloud/connection-modal-oauth-abandon.test.ts
      index 2b33bb82a9..124ba45e96 100644
      --- a/e2e/cloud/connection-modal-oauth-abandon.test.ts
      +++ b/e2e/cloud/connection-modal-oauth-abandon.test.ts
      @@ -21,6 +21,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing";
       
       import { scenario } from "../src/scenario";
       import { Api, Browser, Target } from "../src/services";
      +import { visit, settle } from "../src/surfaces/browser";
       
       const api = composePluginApi([openApiHttpPlugin()] as const);
       
      @@ -98,11 +99,15 @@ scenario(
               const connecting = dialog.getByRole("button", { name: "Connecting…" });
       
               await step("Open the integration and start a new connection", async () => {
      -          await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" });
      +          await visit(page, `/integrations/${integration}`);
                 await addConnection.click();
                 // The registered app is auto-selected, so the OAuth connect button is
      -          // present and enabled.
      +          // present and enabled. Auto-selection resolves asynchronously after
      +          // the modal opens, so the button legitimately renders disabled for a
      +          // beat first — wait for actionability (trial click waits for
      +          // enabled + stable) rather than reading that transient first paint.
                 await connectWithOAuth.waitFor({ state: "visible", timeout: 15_000 });
      +          await connectWithOAuth.click({ trial: true, timeout: 15_000 });
                 expect(
                   await connectWithOAuth.isDisabled(),
                   "the auto-selected app makes Connect with OAuth actionable",
      @@ -133,7 +138,7 @@ scenario(
                 async () => {
                   await addConnection.click();
                   await dialog.waitFor({ state: "visible", timeout: 15_000 });
      -            await page.waitForLoadState("networkidle");
      +            await settle(page);
       
                   // The guarantee: the reopened modal is reset. Before the fix it stays
                   // wedged on "Connecting…" (the abandoned flow's busy state survived the
      diff --git a/e2e/cloud/connection-owner-isolation.test.ts b/e2e/cloud/connection-owner-isolation.test.ts
      index e88c9dd2a0..4a04b3bad7 100644
      --- a/e2e/cloud/connection-owner-isolation.test.ts
      +++ b/e2e/cloud/connection-owner-isolation.test.ts
      @@ -65,16 +65,17 @@ const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toSt
       // These mirror what the product web app does: cookie-authenticated calls whose
       // responses re-seal the session when the active org changes.
       
      -const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
      -
       const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) =>
         Effect.promise(async () => {
           const response = await fetch(new URL(path, target.baseUrl), {
             method: "POST",
             headers: {
      +        // The identity's own headers (session cookie + org selector) exactly
      +        // as its API client would send them — org-scoped endpoints fail
      +        // closed without the selector.
      +        ...(identity.headers ?? {}),
               "content-type": "application/json",
               origin: new URL(target.baseUrl).origin,
      -        cookie: cookieOf(identity),
             },
             body: JSON.stringify(body),
           });
      @@ -84,17 +85,34 @@ const postJson = (target: TargetShape, path: string, identity: Identity, body: u
           return response;
         });
       
      -/** The identity re-bound to the refreshed session cookie a response set. */
      -const withRefreshedSession = (identity: Identity, response: Response): Identity => {
      +/** The identity re-bound to the refreshed session cookie a response set,
      + *  scoped to `orgSelector` via the selector header (see switchOrg). */
      +const withRefreshedSession = (
      +  identity: Identity,
      +  response: Response,
      +  orgSelector: string,
      +): Identity => {
         const refreshed = (response.headers.getSetCookie?.() ?? [])
           .find((header) => header.startsWith("wos-session="))
           ?.split(";")[0];
         if (!refreshed) throw new Error("response did not refresh the session cookie");
      -  return { ...identity, headers: { cookie: refreshed } };
      +  return {
      +    ...identity,
      +    headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector },
      +  };
      +};
      +
      +/** The org selector this identity's requests carry — the same header the web
      + *  client derives from the console URL (identities minted with an org carry
      + *  it; org-scoped reads fail closed without one). */
      +const orgSelectorOf = (identity: Identity): string => {
      +  const selector = identity.headers?.[ORG_SELECTOR_HEADER];
      +  if (!selector) throw new Error(`identity ${identity.label} carries no org selector header`);
      +  return selector;
       };
       
       /** Invite `member` into `admin`'s org and accept — the real invite flow.
      - *  Returns the member identity with its session re-bound to that org. */
      + *  Returns the member identity with its requests scoped to that org. */
       const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>
         Effect.gen(function* () {
           const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {
      @@ -104,14 +122,15 @@ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>
           const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
             invitationId: invitation.id,
           });
      -    return withRefreshedSession(member, acceptResponse);
      +    return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin));
         });
       
       /** Create another org for this account; returns the identity bound to it. */
       const createAnotherOrg = (target: TargetShape, identity: Identity, name: string) =>
         Effect.gen(function* () {
           const response = yield* postJson(target, "/api/auth/create-organization", identity, { name });
      -    return withRefreshedSession(identity, response);
      +    const created = (yield* Effect.promise(() => response.clone().json())) as { id: string };
      +    return withRefreshedSession(identity, response, created.id);
         });
       
       // `/api/auth/switch-organization` (session-cookie-based org switching) was
      @@ -119,10 +138,12 @@ const createAnotherOrg = (target: TargetShape, identity: Identity, name: string)
       // the session. A request picks its active org via the `x-executor-organization`
       // header (apps/cloud/src/auth/organization.ts's `ORG_SELECTOR_HEADER`,
       // `EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization"` in
      -// packages/core/sdk/src/server-connection.ts), falling back to the session's
      -// own org when absent. The header is a SELECTOR, not a trust boundary — the
      -// server re-checks live membership — so attaching it directly to the identity
      -// here is exactly what the real web client does from the console URL's slug.
      +// packages/core/sdk/src/server-connection.ts). Org-scoped API reads FAIL
      +// CLOSED without it — there is deliberately no fallback to the session's
      +// cookie-pinned org (that fallback served wrong-tenant data to multi-org
      +// users). The header is a SELECTOR, not a trust boundary — the server
      +// re-checks live membership — so attaching it directly to the identity here
      +// is exactly what the real web client does from the console URL's slug.
       const ORG_SELECTOR_HEADER = "x-executor-organization";
       
       /** Switch this account's active org; returns the identity scoped to it via
      @@ -137,11 +158,12 @@ const switchOrg = (
           headers: { ...identity.headers, [ORG_SELECTOR_HEADER]: organizationId },
         });
       
      -/** The org this identity's session is currently bound to. */
      +/** The org this identity's requests are scoped to (selector header included,
      + *  mirroring the web client). */
       const activeOrganizationId = (target: TargetShape, identity: Identity) =>
         Effect.promise(async () => {
           const response = await fetch(new URL("/api/auth/me", target.baseUrl), {
      -      headers: { cookie: cookieOf(identity) },
      +      headers: identity.headers ?? {},
           });
           if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`);
           const body = (await response.json()) as { organization: { id: string } | null };
      diff --git a/e2e/cloud/connections-credentials.test.ts b/e2e/cloud/connections-credentials.test.ts
      index d94ce374a8..5f970a0a86 100644
      --- a/e2e/cloud/connections-credentials.test.ts
      +++ b/e2e/cloud/connections-credentials.test.ts
      @@ -3,9 +3,11 @@
       // identified by (owner, integration, name), with its value stored through the
       // real vault. The product promises under test: the secret goes in but NEVER
       // comes back out of any endpoint; metadata round-trips; re-creating the same
      -// connection replaces it instead of duplicating; removal really removes; and
      -// unknown connections fail with a typed not-found error.
      +// connection is rejected as a conflict instead of silently replacing it;
      +// removal really removes; and unknown connections fail with a typed
      +// not-found error.
       import { randomBytes } from "node:crypto";
      +import { createServer } from "node:http";
       
       import { expect } from "@effect/vitest";
       import { Effect } from "effect";
      @@ -22,7 +24,8 @@ type Client = HttpApiClient.ForApi;
       
       const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
       
      -/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
      +/** Minimal OpenAPI spec with a single GET /ping — only the conflict scenario
      + *  ever contacts it, to prove which stored secret the connection resolves. */
       const pingSpec = JSON.stringify({
         openapi: "3.0.3",
         info: { title: "Ping API", version: "1.0.0" },
      @@ -33,15 +36,58 @@ const pingSpec = JSON.stringify({
         },
       });
       
      -/** Registers a fresh apiKey-authenticated integration for connections to bind to. */
      -const registerIntegration = (client: Client) =>
      +type CaptureUpstream = {
      +  readonly url: string;
      +  /** Every Authorization header `GET /ping` has received, in order. */
      +  readonly authorizationHeaders: () => readonly string[];
      +  readonly close: () => void;
      +};
      +
      +/** Upstream on 127.0.0.1 that records the Authorization header of every
      + *  `GET /ping`. This is how a scenario proves WHICH stored secret a connection
      + *  resolves, since no endpoint ever echoes the value itself. */
      +const serveCaptureUpstream = () =>
      +  Effect.acquireRelease(
      +    Effect.callback((resume) => {
      +      const headers: string[] = [];
      +      const server = createServer((request, response) => {
      +        if (request.method === "GET" && (request.url ?? "").startsWith("/ping")) {
      +          headers.push(request.headers.authorization ?? "");
      +          response.writeHead(200, { "content-type": "application/json" });
      +          response.end(JSON.stringify({ pong: true }));
      +          return;
      +        }
      +        response.writeHead(404, { "content-type": "application/json" });
      +        response.end(JSON.stringify({ error: "not_found" }));
      +      });
      +      server.listen(0, "127.0.0.1", () => {
      +        const address = server.address();
      +        const port = typeof address === "object" && address ? address.port : 0;
      +        resume(
      +          Effect.succeed({
      +            url: `http://127.0.0.1:${port}`,
      +            authorizationHeaders: () => [...headers],
      +            close: () => {
      +              server.close();
      +              server.closeAllConnections();
      +            },
      +          }),
      +        );
      +      });
      +    }),
      +    (upstream) => Effect.sync(upstream.close),
      +  );
      +
      +/** Registers a fresh apiKey-authenticated integration for connections to bind
      + *  to. Identifier-safe slug: it becomes a property path in sandbox code. */
      +const registerIntegration = (client: Client, baseUrl = "http://127.0.0.1:59999") =>
         Effect.gen(function* () {
      -    const slug = IntegrationSlug.make(`conn-scn-${randomBytes(4).toString("hex")}`);
      +    const slug = IntegrationSlug.make(`connscn${randomBytes(4).toString("hex")}`);
           yield* client.openapi.addSpec({
             payload: {
               spec: { kind: "blob", value: pingSpec },
               slug,
      -        baseUrl: "http://127.0.0.1:59999", // never contacted during registration
      +        baseUrl, // the default is never contacted during registration
               authenticationTemplate: [
                 {
                   slug: "apiKey",
      @@ -102,48 +148,82 @@ scenario(
       );
       
       scenario(
      -  "Connections · re-creating the same connection replaces it instead of duplicating",
      +  "Connections · re-creating the same connection is rejected and leaves the original intact",
         {},
      -  Effect.gen(function* () {
      -    const target = yield* Target;
      -    const { client: apiClient } = yield* Api;
      -    const identity = yield* target.newIdentity();
      -    const client = yield* apiClient(api, identity);
      -    const integration = yield* registerIntegration(client);
      -    const name = freshConnectionName();
      +  Effect.scoped(
      +    Effect.gen(function* () {
      +      const target = yield* Target;
      +      const { client: apiClient } = yield* Api;
      +      const identity = yield* target.newIdentity();
      +      const client = yield* apiClient(api, identity);
      +      const upstream = yield* serveCaptureUpstream();
      +      const integration = yield* registerIntegration(client, upstream.url);
      +      const name = freshConnectionName();
       
      -    yield* client.connections.create({
      -      payload: {
      -        owner: "org",
      -        name,
      -        integration,
      -        template: TEMPLATE_API_KEY,
      -        identityLabel: "first key",
      -        value: "first-value",
      -      },
      -    });
      -    const first = yield* client.connections.list({ query: { integration } });
      -    expect(
      -      first.filter((connection) => connection.name === name).map((c) => c.identityLabel),
      -      "the first create stores one row with its label",
      -    ).toEqual(["first key"]);
      +      yield* client.connections.create({
      +        payload: {
      +          owner: "org",
      +          name,
      +          integration,
      +          template: TEMPLATE_API_KEY,
      +          identityLabel: "first key",
      +          value: "first-value",
      +        },
      +      });
      +      const first = yield* client.connections.list({ query: { integration } });
      +      expect(
      +        first.filter((connection) => connection.name === name).map((c) => c.identityLabel),
      +        "the first create stores one row with its label",
      +      ).toEqual(["first key"]);
       
      -    yield* client.connections.create({
      -      payload: {
      -        owner: "org",
      -        name,
      -        integration,
      -        template: TEMPLATE_API_KEY,
      -        identityLabel: "rotated key",
      -        value: "second-value",
      -      },
      -    });
      -    const second = yield* client.connections.list({ query: { integration } });
      -    expect(
      -      second.filter((connection) => connection.name === name).map((c) => c.identityLabel),
      -      "re-creating the same (owner, integration, name) updates the row in place",
      -    ).toEqual(["rotated key"]);
      -  }),
      +      const error = yield* client.connections
      +        .create({
      +          payload: {
      +            owner: "org",
      +            name,
      +            integration,
      +            template: TEMPLATE_API_KEY,
      +            identityLabel: "clobber attempt",
      +            value: "second-value",
      +          },
      +        })
      +        .pipe(Effect.flip);
      +      expect(
      +        (error as { _tag?: string })._tag,
      +        "re-creating the same (owner, integration, name) fails with the typed conflict",
      +      ).toBe("ConnectionAlreadyExistsError");
      +
      +      const second = yield* client.connections.list({ query: { integration } });
      +      expect(
      +        second.filter((connection) => connection.name === name).map((c) => c.identityLabel),
      +        "the rejected create left the original row untouched",
      +      ).toEqual(["first key"]);
      +
      +      // The stored SECRET is intact too, not just the metadata: invoking
      +      // through the original connection must still authenticate upstream with
      +      // the first value. The pasted value's provider item id is derived from
      +      // the connection name, so a rejected create that wrote before losing
      +      // would have replaced the credential while every metadata read above
      +      // still looked untouched.
      +      const tools = yield* client.tools.list({ query: { integration } });
      +      const address = tools
      +        .map((tool) => String(tool.address))
      +        .find((toolAddress) => toolAddress.includes(".ping."));
      +      expect(address, "the ping tool is in the catalog").toBeDefined();
      +      if (address === undefined) return;
      +
      +      const execution = yield* client.executions.execute({
      +        payload: {
      +          code: [`const result = await ${address}({});`, "return result;"].join("\n"),
      +        },
      +      });
      +      expect(execution.status, "the invoke completes").toBe("completed");
      +      expect(
      +        upstream.authorizationHeaders(),
      +        "the original connection still authenticates with the first value",
      +      ).toEqual(["Bearer first-value"]);
      +    }),
      +  ),
       );
       
       scenario(
      diff --git a/e2e/cloud/credential-write-durability.test.ts b/e2e/cloud/credential-write-durability.test.ts
      new file mode 100644
      index 0000000000..913ce4ac9c
      --- /dev/null
      +++ b/e2e/cloud/credential-write-durability.test.ts
      @@ -0,0 +1,666 @@
      +// Cloud: a refreshed OAuth credential has to be PERSISTED, and persisting it is
      +// a pair of version-checked writes into WorkOS Vault — the rotated refresh
      +// token and the new access token, one after the other, not atomically. Three
      +// production failures live in that gap.
      +//
      +//  1. Contention. Two concurrent probes of one connection each run a refresh and
      +//     each write the same two objects, so a version-checked write comes back 409
      +//     ("Current version does not match expected version"). A write that retries
      +//     with no wait re-collides inside the peer's own round trip, drains its
      +//     attempts in microseconds, and the refresh fails outright — the user saw a
      +//     500 on a connection health request.
      +//  2. Ordering. If the write that fails is the one carrying the rotated refresh
      +//     token, the credential is gone for good: minting already spent the refresh
      +//     token we sent, so nothing can mint again and every later use of the
      +//     connection comes back `invalid_grant`. The access token, by contrast, is
      +//     disposable — one more grant re-mints it.
      +//  3. Writability. Ordering bounds the damage but cannot remove it: when the
      +//     store refuses writes outright, a grant that has already run has spent the
      +//     stored refresh token and there is nowhere to put its successor. The
      +//     refresh must therefore be gated on a store that is proven writable BEFORE
      +//     the grant, so a storage outage costs the user nothing but the wait. The
      +//     gate writes an object of its own that holds no credential — proving the
      +//     store on the refresh token's own object would mean writing a value read
      +//     moments earlier, which is how a peer's rotated token gets overwritten.
      +//
      +// Both are pinned here at the product surface, black box. Failures are armed on
      +// the WorkOS emulator that the product's own WorkOS client talks to; no product
      +// code, stubs, or internals are touched. Contention is modelled twice, because
      +// the two halves of the write policy fail differently: by a COUNT of collisions
      +// (does the loop have enough attempts, and does it still land the value when it
      +// runs out?) and by a WINDOW of time (does it wait long enough between attempts
      +// to still be trying when the peer lets go?). Every scenario also reads the
      +// emulator's ledger back to prove the collisions it armed were really served —
      +// a fault whose pattern stopped matching would otherwise leave a test that
      +// passes without ever contending.
      +import { randomBytes } from "node:crypto";
      +import { createServer } from "node:http";
      +
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +import { composePluginApi } from "@executor-js/api/server";
      +import {
      +  type ArmedFault,
      +  connectEmulator,
      +  type EmulatorClient,
      +  type FaultArmInput,
      +} from "@executor-js/emulate";
      +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
      +import {
      +  AuthTemplateSlug,
      +  ConnectionName,
      +  IntegrationSlug,
      +  OAuthClientSlug,
      +} from "@executor-js/sdk/shared";
      +import { serveOAuthTestServer } from "@executor-js/sdk/testing";
      +
      +import { scenario } from "../src/scenario";
      +import { Api, Mcp, Target } from "../src/services";
      +import { WORKOS_EMULATOR_PORT } from "../targets/cloud";
      +
      +const api = composePluginApi([openApiHttpPlugin()] as const);
      +
      +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
      +
      +const VAULT_CONFLICT_RESPONSE = {
      +  status: 409,
      +  body: { code: "conflict", message: "Current version does not match expected version" },
      +} as const;
      +
      +/** Every PUT the vault serves for a stored object — the version-checked write. */
      +const VAULT_WRITE = { method: "PUT", pathPattern: "/vault/v1/kv/*" } as const;
      +
      +type UpstreamHandle = {
      +  readonly url: string;
      +  /** Stop honouring the bearer(s) seen so far, exactly as a revocation would —
      +   *  the deterministic way to make the very next call run a refresh. */
      +  readonly revokeSeenBearers: () => void;
      +  readonly close: () => void;
      +};
      +
      +/** Upstream on 127.0.0.1 that authenticates for real: `GET /issues` is 200 for
      + *  any bearer it has not been told to reject, and 401 for one it has. */
      +const serveUpstream = () =>
      +  Effect.acquireRelease(
      +    Effect.callback((resume) => {
      +      const seen: string[] = [];
      +      const revoked = new Set();
      +      const server = createServer((request, response) => {
      +        const bearer = (request.headers.authorization ?? "").replace(/^Bearer\s+/i, "");
      +        if (request.method === "GET" && (request.url ?? "").startsWith("/issues")) {
      +          seen.push(bearer);
      +          if (revoked.has(bearer)) {
      +            response.writeHead(401, { "content-type": "application/json" });
      +            response.end(JSON.stringify({ error: "invalid_token" }));
      +            return;
      +          }
      +          response.writeHead(200, { "content-type": "application/json" });
      +          response.end(JSON.stringify({ issues: [{ id: 1, title: "first" }] }));
      +          return;
      +        }
      +        response.writeHead(404, { "content-type": "application/json" });
      +        response.end(JSON.stringify({ error: "not_found" }));
      +      });
      +      server.listen(0, "127.0.0.1", () => {
      +        const address = server.address();
      +        const port = typeof address === "object" && address ? address.port : 0;
      +        resume(
      +          Effect.succeed({
      +            url: `http://127.0.0.1:${port}`,
      +            revokeSeenBearers: () => {
      +              for (const bearer of seen) revoked.add(bearer);
      +            },
      +            close: () => {
      +              server.close();
      +              server.closeAllConnections();
      +            },
      +          }),
      +        );
      +      });
      +    }),
      +    (server) => Effect.sync(server.close),
      +  );
      +
      +const spec = (
      +  baseUrl: string,
      +  oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string },
      +): string =>
      +  JSON.stringify({
      +    openapi: "3.0.3",
      +    info: { title: "Issues API", version: "1.0.0" },
      +    servers: [{ url: baseUrl }],
      +    paths: {
      +      "/issues": {
      +        get: {
      +          operationId: "listIssues",
      +          summary: "List issues",
      +          security: [{ oauth: ["issues.read"] }],
      +          responses: { "200": { description: "issues" } },
      +        },
      +      },
      +    },
      +    components: {
      +      securitySchemes: {
      +        oauth: {
      +          type: "oauth2",
      +          flows: {
      +            authorizationCode: {
      +              authorizationUrl: oauth.authorizationEndpoint,
      +              tokenUrl: oauth.tokenEndpoint,
      +              scopes: { "issues.read": "Read issues" },
      +            },
      +          },
      +        },
      +      },
      +    },
      +  });
      +
      +const invokeByAddressCode = (address: string, args: unknown) => `
      +const segments = ${JSON.stringify(address)}.split(".").slice(1);
      +let node = tools;
      +for (const segment of segments) node = node[segment];
      +const result = await node(${JSON.stringify(args)});
      +return JSON.stringify(result);
      +`;
      +
      +/** One vault object as the emulated WorkOS itself sees it. Read from the
      + *  emulator control plane — upstream state, never the product's own. */
      +type VaultObject = { readonly id: string; readonly name: string };
      +
      +/** Read a vault row out of the emulator's generic store snapshot, or null when
      + *  it is not shaped like one. */
      +const readVaultObject = (row: Record): VaultObject | null =>
      +  typeof row.workos_id === "string" && typeof row.name === "string"
      +    ? { id: row.workos_id, name: row.name }
      +    : null;
      +
      +/** Every vault object this connection owns. The object name embeds the logical
      + *  item id, so the connection's own objects are the ones carrying its slug. */
      +const vaultObjectsFor = (workos: EmulatorClient, slug: string): Effect.Effect =>
      +  Effect.promise(async () => {
      +    const snapshot = await workos.state();
      +    const items = snapshot.collections["workos.vault_objects"]?.items ?? [];
      +    return items
      +      .map((item) => readVaultObject({ ...item }))
      +      .filter((object): object is VaultObject => object !== null && object.name.includes(slug));
      +  });
      +
      +/** Arm a fault on the shared emulator and take it back down with the scope, by
      + *  id — a blanket `faults.clear()` would also disarm a neighbouring scenario's. */
      +const armFault = (workos: EmulatorClient, input: FaultArmInput) =>
      +  Effect.acquireRelease(
      +    Effect.promise(() => workos.faults.arm(input)),
      +    (armed) => Effect.promise(() => workos.faults.clear(armed.id)).pipe(Effect.ignore),
      +  );
      +
      +/** How many responses this armed fault actually injected, read back from the
      + *  emulator's ledger. This is the proof that the failure under test HAPPENED:
      + *  an armed fault whose pattern never matched leaves the product's writes
      + *  untouched, and a scenario asserting only "the call worked" would pass
      + *  without ever exercising the retry it claims to cover. */
      +const faultsServed = (workos: EmulatorClient, armed: ArmedFault): Effect.Effect =>
      +  Effect.promise(async () => {
      +    const entries = await workos.ledger.list(200);
      +    return entries.filter((entry) => entry.faultId === armed.id).length;
      +  });
      +
      +/** Keep the vault's version-checked writes losing for a WINDOW of time that
      + *  starts at the first collision the product actually suffers — a peer writer
      + *  that holds the object for a while, rather than a fixed number of collisions.
      + *  This is the shape of the production failure: attempts spaced by a wait
      + *  outlast the peer's round trip; attempts fired back to back all land inside
      + *  it, drain, and the refresh dies.
      + *
      + *  Anchoring on the first collision (not on arming) is what makes it
      + *  deterministic — a refresh only begins several round trips after the fault is
      + *  armed, so a window measured from arming would already be over. */
      +const holdWritesInConflict = (workos: EmulatorClient, windowMillis: number) =>
      +  Effect.gen(function* () {
      +    // Far more conflicts than any bounded retry policy can consume, so the
      +    // window — not a counter — is what ends the contention.
      +    const armed = yield* armFault(workos, {
      +      match: VAULT_WRITE,
      +      response: VAULT_CONFLICT_RESPONSE,
      +      times: 64,
      +    });
      +    const released = (async () => {
      +      const deadline = Date.now() + 30_000;
      +      while (Date.now() < deadline) {
      +        const live = (await workos.faults.list()).find((fault) => fault.id === armed.id);
      +        if (!live || live.remaining < armed.times) break;
      +        await new Promise((resolve) => setTimeout(resolve, 15));
      +      }
      +      await new Promise((resolve) => setTimeout(resolve, windowMillis));
      +      await workos.faults.clear(armed.id);
      +    })().catch(() => undefined);
      +    return { armed, released };
      +  });
      +
      +type CallResult = { readonly ok: boolean; readonly text: string };
      +
      +type ToolEnvelope = {
      +  readonly ok: boolean;
      +  readonly data?: unknown;
      +  readonly error?: {
      +    readonly code?: string;
      +    readonly message?: string;
      +    readonly status?: number;
      +  };
      +};
      +
      +/** An OAuth-backed integration, connected and proven working, plus the handles
      + *  needed to break its credential writes: the upstream that can revoke a bearer
      + *  (the deterministic refresh trigger) and the WorkOS emulator that serves the
      + *  vault. Everything it creates is torn down by scope finalizers. */
      +const connectIntegration = Effect.gen(function* () {
      +  const target = yield* Target;
      +  const { client: makeClient } = yield* Api;
      +  const mcp = yield* Mcp;
      +  const identity = yield* target.newIdentity();
      +  const client = yield* makeClient(api, identity);
      +  const upstream = yield* serveUpstream();
      +  // Long-lived tokens: the proactive expiry check must never fire, so the
      +  // upstream's 401 is the only thing that triggers the refresh whose
      +  // persistence is under test.
      +  const oauth = yield* serveOAuthTestServer({
      +    scopes: ["issues.read"],
      +    tokenExpiresInSeconds: 3600,
      +  });
      +  const workos = yield* Effect.promise(() =>
      +    connectEmulator({ baseUrl: `http://127.0.0.1:${WORKOS_EMULATOR_PORT}` }),
      +  );
      +  const slug = unique("credwrite");
      +  const clientSlug = OAuthClientSlug.make(unique("credwriteclient"));
      +
      +  yield* client.openapi.addSpec({
      +    payload: {
      +      spec: { kind: "blob", value: spec(upstream.url, oauth) },
      +      slug,
      +      baseUrl: upstream.url,
      +      authenticationTemplate: [
      +        {
      +          slug: "oauth",
      +          kind: "oauth2",
      +          authorizationUrl: oauth.authorizationEndpoint,
      +          tokenUrl: oauth.tokenEndpoint,
      +          scopes: ["issues.read"],
      +        },
      +      ],
      +    },
      +  });
      +  yield* Effect.addFinalizer(() =>
      +    client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
      +  );
      +
      +  yield* client.oauth.createClient({
      +    payload: {
      +      owner: "org",
      +      slug: clientSlug,
      +      grant: "authorization_code",
      +      authorizationUrl: oauth.authorizationEndpoint,
      +      tokenUrl: oauth.tokenEndpoint,
      +      clientId: "test-client",
      +      clientSecret: "test-secret",
      +      originIntegration: IntegrationSlug.make(slug),
      +    },
      +  });
      +  yield* Effect.addFinalizer(() =>
      +    client.oauth
      +      .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } })
      +      .pipe(Effect.ignore),
      +  );
      +
      +  const started = yield* client.oauth.start({
      +    payload: {
      +      client: clientSlug,
      +      clientOwner: "org",
      +      owner: "org",
      +      name: ConnectionName.make("main"),
      +      integration: IntegrationSlug.make(slug),
      +      template: AuthTemplateSlug.make("oauth"),
      +    },
      +  });
      +  expect(started.status, "oauth.start redirects to the authorization server").toBe("redirect");
      +  if (started.status !== "redirect") return yield* Effect.die("no redirect");
      +
      +  // Drive the test IdP's consent by hand (authorize -> login -> code).
      +  const code = yield* Effect.promise(async () => {
      +    const authorize = await fetch(started.authorizationUrl, { redirect: "manual" });
      +    const loginUrl = authorize.headers.get("location");
      +    if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`);
      +    const login = await fetch(loginUrl, {
      +      method: "POST",
      +      headers: {
      +        authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`,
      +      },
      +      redirect: "manual",
      +    });
      +    const callbackUrl = login.headers.get("location");
      +    if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`);
      +    const minted = new URL(callbackUrl).searchParams.get("code");
      +    if (!minted) throw new Error("callback carried no authorization code");
      +    return minted;
      +  });
      +  yield* client.oauth.complete({ payload: { state: started.state, code } });
      +  yield* Effect.addFinalizer(() =>
      +    client.connections
      +      .remove({
      +        params: {
      +          owner: "org",
      +          integration: IntegrationSlug.make(slug),
      +          name: ConnectionName.make("main"),
      +        },
      +      })
      +      .pipe(Effect.ignore),
      +  );
      +
      +  const tools = yield* client.tools.list({ query: {} });
      +  const address = tools
      +    .filter((tool) => String(tool.integration) === slug)
      +    .map((tool) => String(tool.address))
      +    .find((addr) => addr.endsWith("listIssues"));
      +  expect(address, "the listIssues tool is in the catalog").toBeDefined();
      +
      +  const session = mcp.session(identity);
      +  /** Invoke the connected tool and report what came back, success or not. */
      +  const attempt = (): Effect.Effect =>
      +    Effect.gen(function* () {
      +      let called = yield* session.call("execute", {
      +        code: invokeByAddressCode(address!, {}),
      +      });
      +      let guard = 0;
      +      while (called.text.includes("executionId:") && guard < 10) {
      +        called = yield* session.approvePaused(called.text);
      +        guard += 1;
      +      }
      +      return { ok: called.ok, text: called.text };
      +    });
      +
      +  /** Invoke the tool and require it to have worked end to end. */
      +  const call = (label: string): Effect.Effect =>
      +    Effect.gen(function* () {
      +      const result = yield* attempt();
      +      expect(
      +        result.ok,
      +        `the ${label} call reached the upstream and came back (got: ${result.text.slice(0, 400)})`,
      +      ).toBe(true);
      +      const envelope = JSON.parse(result.text) as ToolEnvelope;
      +      expect(envelope.ok, `the ${label} call succeeded`).toBe(true);
      +      return envelope;
      +    });
      +
      +  return { attempt, call, oauth, slug, upstream, workos } as const;
      +});
      +
      +/** Is this one of the authorization server's refresh grants? Counting them is
      + *  how a "success" is shown to involve a real re-mint, not a cached token. */
      +const isRefreshGrant = (request: {
      +  readonly path: string;
      +  readonly method: string;
      +  readonly body: string;
      +}) =>
      +  request.path === "/token" &&
      +  request.method === "POST" &&
      +  request.body.includes("grant_type=refresh_token");
      +
      +scenario(
      +  "Credential persistence · contention on the vault write does not lose the refreshed credential",
      +  {},
      +  Effect.scoped(
      +    Effect.gen(function* () {
      +      const { call, oauth, upstream, workos } = yield* connectIntegration;
      +
      +      // Baseline: the connection works on its freshly minted token.
      +      yield* call("baseline");
      +
      +      // Transient contention: the next few version-checked writes lose their
      +      // race, exactly as a peer writer persisting the same connection's
      +      // credential would make them lose it. Three is as many as the pre-fix
      +      // policy could ever absorb.
      +      const transient = yield* Effect.scoped(
      +        Effect.gen(function* () {
      +          const armed = yield* armFault(workos, {
      +            match: VAULT_WRITE,
      +            response: VAULT_CONFLICT_RESPONSE,
      +            times: 3,
      +          });
      +          // Revoking upstream forces the very next call to refresh, so the
      +          // contended write happens inside a real user-visible request.
      +          upstream.revokeSeenBearers();
      +          yield* call("transiently-contended-refresh");
      +          return yield* faultsServed(workos, armed);
      +        }),
      +      );
      +      expect(transient, "the transiently-contended refresh really did collide three times").toBe(3);
      +
      +      // Sustained contention: every version-checked attempt loses. Dropping a
      +      // credential that has just been minted is the one unrecoverable outcome,
      +      // so the write still has to land.
      +      const sustained = yield* Effect.scoped(
      +        Effect.gen(function* () {
      +          const armed = yield* armFault(workos, {
      +            match: VAULT_WRITE,
      +            response: VAULT_CONFLICT_RESPONSE,
      +            times: 5,
      +          });
      +          upstream.revokeSeenBearers();
      +          yield* call("continuously-contended-refresh");
      +          return yield* faultsServed(workos, armed);
      +        }),
      +      );
      +      expect(
      +        sustained,
      +        "the continuously-contended refresh exhausted every version-checked attempt",
      +      ).toBe(5);
      +
      +      // The durability half: the credential those contended writes were
      +      // carrying is the rotated, single-use refresh token. If any of it had been
      +      // dropped, the connection could never mint anything again.
      +      upstream.revokeSeenBearers();
      +      yield* call("post-contention");
      +
      +      expect(
      +        (yield* oauth.requests).filter(isRefreshGrant).length,
      +        "each revocation was absorbed by a real refresh grant",
      +      ).toBeGreaterThanOrEqual(3);
      +    }),
      +  ),
      +);
      +
      +// The peer writer's round trip, in milliseconds. Long enough that a retry loop
      +// firing its attempts back to back drains all of them (and its last-resort
      +// write) inside the window; short enough that a loop waiting between attempts is
      +// still trying when the window closes — the shipped policy's fourth and fifth
      +// attempts fall no earlier than 175ms and 375ms after the first collision.
      +const CONTENTION_WINDOW_MS = 200;
      +
      +scenario(
      +  "Credential persistence · a refresh outlasts contention that lasts longer than a round trip",
      +  {},
      +  Effect.scoped(
      +    Effect.gen(function* () {
      +      const { call, oauth, upstream, workos } = yield* connectIntegration;
      +
      +      yield* call("baseline");
      +
      +      // Contention bounded by TIME rather than by a count of collisions: this is
      +      // the production shape, where the peer holds the object for as long as its
      +      // own write takes and the loser has to still be trying when it lets go.
      +      const { armed, released } = yield* holdWritesInConflict(workos, CONTENTION_WINDOW_MS);
      +      upstream.revokeSeenBearers();
      +      yield* call("refresh-under-a-contention-window");
      +      yield* Effect.promise(() => released);
      +
      +      expect(
      +        yield* faultsServed(workos, armed),
      +        "the refresh really was fighting a contended vault write",
      +      ).toBeGreaterThanOrEqual(3);
      +
      +      // And the credential that survived the window is usable: the next
      +      // revocation is absorbed by another real refresh.
      +      upstream.revokeSeenBearers();
      +      yield* call("post-window");
      +
      +      expect(
      +        (yield* oauth.requests).filter(isRefreshGrant).length,
      +        "both revocations were absorbed by real refresh grants",
      +      ).toBeGreaterThanOrEqual(2);
      +    }),
      +  ),
      +);
      +
      +scenario(
      +  "Credential persistence · a write that fails mid-refresh leaves the connection able to refresh",
      +  {},
      +  Effect.scoped(
      +    Effect.gen(function* () {
      +      const { attempt, call, oauth, slug, upstream, workos } = yield* connectIntegration;
      +
      +      yield* call("baseline");
      +
      +      // A refresh persists two objects. Break the ACCESS TOKEN's object only,
      +      // once: whichever write runs first survives and whichever runs second is
      +      // lost, so failing this one is the direct question "which of the two does
      +      // the product persist first?".
      +      const objects = yield* vaultObjectsFor(workos, slug);
      +      const refreshObject = objects.find((object) => object.name.endsWith("refresh"));
      +      expect(refreshObject, "the connection stored a refresh token in the vault").toBeDefined();
      +      const accessObjects = objects.filter((object) => object.id !== refreshObject?.id);
      +      expect(
      +        accessObjects,
      +        "the connection stored exactly one access token in the vault",
      +      ).toHaveLength(1);
      +      const accessObject = accessObjects[0];
      +      expect(accessObject, "the connection stored an access token in the vault").toBeDefined();
      +      expect(accessObject!.id, "access and refresh are distinct vault objects").not.toBe(
      +        refreshObject!.id,
      +      );
      +
      +      const interrupted = yield* Effect.scoped(
      +        Effect.gen(function* () {
      +          const armed = yield* armFault(workos, {
      +            match: { method: "PUT", pathPattern: `/vault/v1/kv/${accessObject!.id}` },
      +            response: {
      +              status: 503,
      +              body: { code: "unavailable", message: "vault unavailable" },
      +            },
      +            times: 1,
      +          });
      +
      +          upstream.revokeSeenBearers();
      +          const result = yield* attempt();
      +          expect(
      +            yield* faultsServed(workos, armed),
      +            "the access token's write is the one that broke",
      +          ).toBe(1);
      +          return result;
      +        }),
      +      );
      +      expect(
      +        interrupted.ok,
      +        `the call whose credential write was interrupted reports the failure (got: ${interrupted.text.slice(0, 200)})`,
      +      ).toBe(false);
      +
      +      // The interrupted refresh still spent the refresh token it sent: the
      +      // authorization server rotated it and will not honour the old one again.
      +      // The access token in the vault is the stale, revoked one, so the very
      +      // next call has to refresh — and can only do so if the ROTATED refresh
      +      // token is what survived the half-finished write.
      +      const recovered = yield* attempt();
      +      expect(
      +        recovered.ok,
      +        `the connection still refreshes after the interrupted write (got: ${recovered.text.slice(0, 400)})`,
      +      ).toBe(true);
      +      expect((JSON.parse(recovered.text) as ToolEnvelope).ok, "the recovered call succeeded").toBe(
      +        true,
      +      );
      +
      +      expect(
      +        (yield* oauth.requests).filter(isRefreshGrant).length,
      +        "the interrupted refresh and the recovery were both real refresh grants",
      +      ).toBeGreaterThanOrEqual(2);
      +    }),
      +  ),
      +);
      +
      +scenario(
      +  "Credential persistence · a store that cannot accept writes fails the refresh before the grant is spent",
      +  {},
      +  Effect.scoped(
      +    Effect.gen(function* () {
      +      const { attempt, call, oauth, slug, upstream, workos } = yield* connectIntegration;
      +
      +      yield* call("baseline");
      +
      +      // One healthy refresh first, so the object the writability gate uses
      +      // exists and can be named. That object is the point of this scenario:
      +      // the gate proves the store on an item of its OWN, never by rewriting
      +      // the refresh token it is about to spend.
      +      upstream.revokeSeenBearers();
      +      yield* call("pre-outage-refresh");
      +
      +      const objects = yield* vaultObjectsFor(workos, slug);
      +      const refreshObject = objects.find((object) => object.name.endsWith("refresh"));
      +      expect(refreshObject, "the connection stored a refresh token in the vault").toBeDefined();
      +      const probeObject = objects.find((object) => object.name.endsWith("store-probe"));
      +      expect(
      +        probeObject,
      +        "the writability gate wrote an object of its own, not the refresh token's",
      +      ).toBeDefined();
      +      expect(probeObject!.id, "and it is a distinct object").not.toBe(refreshObject!.id);
      +
      +      const grantsBeforeOutage = (yield* oauth.requests).filter(isRefreshGrant).length;
      +
      +      // Break that object, and only that one, with a status the write policy
      +      // cannot treat as contention — 503 is the store being down, not a peer
      +      // holding the row, so no amount of retrying can land it. This is a store
      +      // that will not accept a write at all.
      +      const duringOutage = yield* Effect.scoped(
      +        Effect.gen(function* () {
      +          const armed = yield* armFault(workos, {
      +            match: { method: "PUT", pathPattern: `/vault/v1/kv/${probeObject!.id}` },
      +            response: { status: 503, body: { code: "unavailable", message: "vault unavailable" } },
      +            times: 3,
      +          });
      +
      +          upstream.revokeSeenBearers();
      +          const result = yield* attempt();
      +          expect(
      +            yield* faultsServed(workos, armed),
      +            "the write the gate makes is the one that broke",
      +          ).toBeGreaterThanOrEqual(1);
      +          return result;
      +        }),
      +      );
      +      expect(
      +        duringOutage.ok,
      +        `the call reports the failure while the store is down (got: ${duringOutage.text.slice(0, 200)})`,
      +      ).toBe(false);
      +
      +      // The whole point: the failure landed on the writability check, not on
      +      // the persist that follows a grant. No grant ran, so the stored refresh
      +      // token was never spent and is still the one the authorization server
      +      // will honour.
      +      expect(
      +        (yield* oauth.requests).filter(isRefreshGrant).length,
      +        "no refresh grant is spent while the store cannot persist its rotated successor",
      +      ).toBe(grantsBeforeOutage);
      +
      +      // The store is back. A transient outage must cost nothing but the wait:
      +      // the connection still holds a live grant and refreshes itself, with no
      +      // human reconnecting anything.
      +      const recovered = yield* attempt();
      +      expect(
      +        recovered.ok,
      +        `the connection refreshes itself once the store recovers (got: ${recovered.text.slice(0, 400)})`,
      +      ).toBe(true);
      +      expect((JSON.parse(recovered.text) as ToolEnvelope).ok, "the recovered call succeeded").toBe(
      +        true,
      +      );
      +      expect(
      +        (yield* oauth.requests).filter(isRefreshGrant).length,
      +        "the recovery was a real refresh grant on the token the outage preserved",
      +      ).toBe(grantsBeforeOutage + 1);
      +    }),
      +  ),
      +);
      diff --git a/e2e/cloud/frontend-error-reporting.test.ts b/e2e/cloud/frontend-error-reporting.test.ts
      new file mode 100644
      index 0000000000..5c9b731281
      --- /dev/null
      +++ b/e2e/cloud/frontend-error-reporting.test.ts
      @@ -0,0 +1,134 @@
      +// Cloud (browser): a failed API request is reported as a titled error.
      +//
      +// The console reports handled UI failures to the crash reporter, and every
      +// producer of one starts from an Effect `Cause` — a plain object with no name,
      +// message or stack. Handed that directly, the reporter has nothing to title
      +// the report with, so it files a message-less one and groups it on the
      +// reporting frame: unrelated frontend failures all land in a single nameless
      +// bucket that says only which function did the reporting, never what broke.
      +//
      +// The report is the product surface here, so this scenario reads it the way
      +// the outside world does. The browser SDK is configured to POST its envelopes
      +// same-origin (`tunnel`), so the suite intercepts that request and asserts on
      +// the payload the page actually tried to send.
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +
      +import { scenario } from "../src/scenario";
      +import { Browser, Target } from "../src/services";
      +import { revisit, visit } from "../src/surfaces/browser";
      +
      +type ReportedException = {
      +  readonly type?: string;
      +  readonly value?: string;
      +  // The reporter marks an exception `synthetic` when it was handed something
      +  // that was not a real error and had to invent a stack for it — the stack of
      +  // whatever frame did the reporting.
      +  readonly mechanism?: { readonly synthetic?: boolean };
      +};
      +
      +type ReportedEvent = {
      +  readonly tags?: Record;
      +  readonly exception?: { readonly values?: ReadonlyArray };
      +};
      +
      +/**
      + * An envelope is newline-delimited JSON — a header, then `{type}` / payload
      + * pairs. Only the error payloads matter here and they are the ones carrying
      + * `exception`, so pick those out rather than modelling the whole format.
      + */
      +const errorEventsIn = (body: string): ReadonlyArray =>
      +  body
      +    .split("\n")
      +    .filter((line) => line.trim().startsWith("{"))
      +    .flatMap((line) => {
      +      try {
      +        const parsed = JSON.parse(line) as ReportedEvent;
      +        return parsed.exception ? [parsed] : [];
      +      } catch {
      +        return [];
      +      }
      +    });
      +
      +scenario(
      +  "Frontend errors · a failed API request is reported with a real message",
      +  { timeout: 120_000 },
      +  Effect.gen(function* () {
      +    const browser = yield* Browser;
      +    const target = yield* Target;
      +    const identity = yield* target.newIdentity();
      +
      +    yield* browser.session(identity, async ({ page, step }) => {
      +      const reports: Array = [];
      +      await page.route("**/api/sentry-tunnel*", async (route) => {
      +        reports.push(...errorEventsIn(route.request().postData() ?? ""));
      +        await route.fulfill({ status: 200, body: "" });
      +      });
      +
      +      // Everything the UI reports about a request it made itself.
      +      const reportedFailures = (): ReadonlyArray =>
      +        reports
      +          .filter((event) => event.tags?.["executor.ui.surface"] === "api_client")
      +          .flatMap((event) => event.exception?.values ?? []);
      +
      +      await step("Open the integrations console", async () => {
      +        await visit(page, "/integrations");
      +        // The connect dialog became the full-page picker; the header's Add
      +        // link is the page's stable loaded-signal now.
      +        await page.getByRole("link", { name: "Add integration" }).first().waitFor();
      +      });
      +
      +      let faulted = 0;
      +      await step("Reload it with the integrations API failing", async () => {
      +        await page.route("**/api/integrations", async (route) => {
      +          if (route.request().method() !== "GET") {
      +            await route.continue();
      +            return;
      +          }
      +          faulted += 1;
      +          await route.fulfill({
      +            status: 500,
      +            contentType: "text/plain",
      +            body: "upstream exploded",
      +          });
      +        });
      +        await revisit(page);
      +      });
      +
      +      expect(faulted, "the integrations request really did fail").toBeGreaterThan(0);
      +
      +      // The report must name the failure. Reports are sent as the page
      +      // notices failures, so wait for one rather than sleeping.
      +      await expect
      +        .poll(() => reportedFailures().map((failure) => failure.value ?? ""), {
      +          message: "the reported failure says which request failed, and how",
      +          timeout: 20_000,
      +        })
      +        .toContainEqual(expect.stringMatching(/500 .*\/api\/integrations/));
      +
      +      for (const failure of reportedFailures()) {
      +        // A report with no message is the bug: it cannot be titled, so it
      +        // groups on the reporting frame and swallows every other failure.
      +        expect(failure.value ?? "", "every report carries a message").not.toBe("");
      +        expect(failure.type ?? "", "every report carries an error name").not.toBe("");
      +        // What a reporter falls back to when it is handed something that is
      +        // not an error at all — the shape every message-less report had.
      +        expect(failure.value ?? "", "no report is a bag of keys").not.toMatch(
      +          /captured as exception with keys/,
      +        );
      +        // The other half of the bug, and the half a readable message can hide:
      +        // handed a non-error, the reporter still has no stack of its own to
      +        // group on and invents one from the reporting frame, so unrelated
      +        // failures keep merging into a single bucket. Only a real error clears
      +        // this flag.
      +        expect(
      +          failure.mechanism?.synthetic ?? false,
      +          "the report carries the failure's own stack, not the reporter's frame",
      +        ).toBe(false);
      +      }
      +
      +      await page.unroute("**/api/integrations");
      +      await page.unroute("**/api/sentry-tunnel*");
      +    });
      +  }),
      +);
      diff --git a/e2e/cloud/integrations-api.test.ts b/e2e/cloud/integrations-api.test.ts
      index 785fe3d09b..2b3c3ded7b 100644
      --- a/e2e/cloud/integrations-api.test.ts
      +++ b/e2e/cloud/integrations-api.test.ts
      @@ -271,7 +271,7 @@ scenario(
                 name: MAIN,
                 integration: slug,
                 template: NONE,
      -          value: "unused",
      +          value: "",
               },
             });
       
      @@ -328,7 +328,7 @@ scenario(
                 name: MAIN,
                 integration: slug,
                 template: NONE,
      -          value: "unused",
      +          value: "",
               },
             });
       
      diff --git a/e2e/cloud/integrations-refresh.test.ts b/e2e/cloud/integrations-refresh.test.ts
      index fe2b70afd8..d4c4b5d46e 100644
      --- a/e2e/cloud/integrations-refresh.test.ts
      +++ b/e2e/cloud/integrations-refresh.test.ts
      @@ -103,7 +103,7 @@ scenario(
                 name: MAIN,
                 integration: slug,
                 template: NONE,
      -          value: "unused",
      +          value: "",
               },
             });
       
      diff --git a/e2e/cloud/logout-stale-session.test.ts b/e2e/cloud/logout-stale-session.test.ts
      new file mode 100644
      index 0000000000..8701865e46
      --- /dev/null
      +++ b/e2e/cloud/logout-stale-session.test.ts
      @@ -0,0 +1,166 @@
      +// Cloud-only: signing out must ALWAYS sign the browser out. The shell's
      +// sign-out is a top-level form POST (the WorkOS hop is cross-origin, so it
      +// can't be a fetch), which means whatever `/api/auth/logout` answers is
      +// rendered AS THE PAGE. An error response is therefore not a silent failure —
      +// it is a screenful of raw API JSON where the homepage should be. Reported
      +// from the field as `{"_tag":"Unauthorized"}`.
      +//
      +// The endpoint used to sit behind SessionAuth, so a browser whose sealed
      +// session no longer authenticated AT THE MOMENT OF THE CLICK got the
      +// middleware's 401 instead of being signed out. Reaching that state needs
      +// nothing exotic: a second open tab does it (sign out in one, the other's
      +// shell is still painted but its cookie is gone), and an expired or
      +// upstream-revoked session lands on the same branch. Signing out of a session
      +// that is already over is the one request that must never fail — the user is
      +// asking for the state they are already in.
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +import type { Page } from "playwright";
      +
      +import { scenario } from "../src/scenario";
      +import { Api, Browser, Target } from "../src/services";
      +import { settle } from "../src/surfaces/browser";
      +
      +/** The display-only identity cookie the SSR gate mints (non-HttpOnly). */
      +const HINT_COOKIE = "executor-auth-hint";
      +
      +/** First `Set-Cookie` header for `name`, as the raw header string. */
      +const setCookieFor = (response: Response, name: string): string => {
      +  for (const header of response.headers.getSetCookie()) {
      +    if (header.startsWith(`${name}=`)) return header;
      +  }
      +  return "";
      +};
      +
      +// Open the account dropdown and sign out. Bounded retry: a click can land
      +// while the shell is still hydrating and the radix menu never opens (same
      +// idiom as auth-hint / org-switcher).
      +const clickSignOut = async (page: Page) => {
      +  for (let attempt = 1; ; attempt++) {
      +    try {
      +      await page.keyboard.press("Escape");
      +      await page.getByRole("button", { name: /Test User/ }).click();
      +      await page.getByRole("menuitem", { name: "Sign out" }).click({ timeout: 5_000 });
      +      return;
      +    } catch (error) {
      +      if (attempt >= 3) throw error;
      +    }
      +  }
      +};
      +
      +scenario(
      +  "Sign-out · a browser whose session is already over is signed out, not handed an API error page",
      +  {},
      +  Effect.gen(function* () {
      +    // Gate: the REST API plane is mounted on this target.
      +    yield* Api;
      +    const target = yield* Target;
      +
      +    const signOut = (cookie?: string) =>
      +      Effect.promise(() =>
      +        fetch(new URL("/api/auth/logout", target.baseUrl), {
      +          method: "POST",
      +          redirect: "manual",
      +          ...(cookie ? { headers: { cookie } } : {}),
      +        }),
      +      );
      +
      +    // A session cookie that no longer authenticates — expired, or revoked
      +    // upstream while the tab sat open. There is nothing to end at WorkOS, but
      +    // this browser must still leave signed out: a stale session cookie left in
      +    // place keeps routing / into the app instead of marketing.
      +    const stale = yield* signOut("wos-session=stale-sealed-session");
      +    const staleBody = yield* Effect.promise(() => stale.text());
      +
      +    // The response IS the page the user reads. This is the reported symptom.
      +    expect(staleBody, "sign-out never renders an API error envelope").not.toContain("_tag");
      +    expect(stale.status, "sign-out sends the browser onward").toBe(302);
      +    expect(
      +      new URL(stale.headers.get("location") ?? "", target.baseUrl).pathname,
      +      "sign-out lands the browser home",
      +    ).toBe("/");
      +    expect(setCookieFor(stale, "wos-session"), "the dead session cookie is dropped").toContain(
      +      "Max-Age=0",
      +    );
      +    expect(
      +      setCookieFor(stale, HINT_COOKIE),
      +      "the auth hint never outlives the session it describes",
      +    ).toContain("Max-Age=0");
      +
      +    // A POST carrying no cookies at all is what a CROSS-SITE form reaching
      +    // this endpoint looks like: both cookies are SameSite=Lax, so another
      +    // origin's POST arrives bare. Signing out is public, so it must answer
      +    // such a request without touching this browser's session — otherwise any
      +    // site could sign our users out.
      +    const bare = yield* signOut();
      +    const bareBody = yield* Effect.promise(() => bare.text());
      +
      +    expect(bareBody, "a session-less sign-out renders no error envelope either").not.toContain(
      +      "_tag",
      +    );
      +    expect(bare.status, "it is sent home like any other sign-out").toBe(302);
      +    expect(
      +      new URL(bare.headers.get("location") ?? "", target.baseUrl).pathname,
      +      "it lands home",
      +    ).toBe("/");
      +    expect(
      +      bare.headers.getSetCookie(),
      +      "a request that presented no session cannot expire anyone else's",
      +    ).toEqual([]);
      +  }),
      +);
      +
      +scenario(
      +  "Sign-out · a second open tab signs out too instead of showing raw JSON",
      +  {},
      +  Effect.gen(function* () {
      +    const browser = yield* Browser;
      +    const target = yield* Target;
      +    const identity = yield* target.newIdentity();
      +
      +    // `page` is the tab the user is LOOKING at when it breaks — the run's
      +    // video and screenshots follow it, so the artifacts show the reported
      +    // symptom rather than the tab that already signed out cleanly.
      +    yield* browser.session(identity, async ({ page, step }) => {
      +      const otherTab = await page.context().newPage();
      +
      +      await step("The user has the app open in two tabs", async () => {
      +        await page.goto("/", { waitUntil: "commit" });
      +        await page.getByRole("link", { name: "Policies" }).waitFor();
      +        await otherTab.goto("/", { waitUntil: "commit" });
      +        await otherTab.getByRole("link", { name: "Policies" }).waitFor();
      +      });
      +
      +      await step("Sign out in the other tab", async () => {
      +        await clickSignOut(otherTab);
      +        await otherTab.waitForURL((url) => url.pathname === "/" || url.pathname === "/login", {
      +          timeout: 15_000,
      +        });
      +      });
      +
      +      // This tab was never told: its shell is still painted, and its sign-out
      +      // button still works the only way it knows how.
      +      const before = page.url();
      +      await step("Sign out in this tab, which still shows the app", async () => {
      +        await clickSignOut(page);
      +        await page.waitForURL((url) => url.toString() !== before, {
      +          timeout: 15_000,
      +        });
      +        await settle(page);
      +      });
      +
      +      const shown = (await page.locator("body").innerText()).trim();
      +      expect(shown, "sign-out never renders the API's error envelope as a page").not.toContain(
      +        "_tag",
      +      );
      +      expect(
      +        new URL(page.url()).pathname,
      +        "this tab lands on a signed-out page like the other one",
      +      ).toMatch(/^\/(login)?$/);
      +      expect(
      +        (await page.context().cookies()).map((cookie) => cookie.name),
      +        "the browser is left with no session either way",
      +      ).not.toContain("wos-session");
      +    });
      +  }),
      +);
      diff --git a/e2e/cloud/mcp-browser-approval-org-scope.test.ts b/e2e/cloud/mcp-browser-approval-org-scope.test.ts
      index 1c931e3bfa..7c2e158f15 100644
      --- a/e2e/cloud/mcp-browser-approval-org-scope.test.ts
      +++ b/e2e/cloud/mcp-browser-approval-org-scope.test.ts
      @@ -14,6 +14,7 @@ import { composePluginApi } from "@executor-js/api/server";
       import { scenario } from "../src/scenario";
       import { Api, Browser, Mcp, Target } from "../src/services";
       import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp";
      +import { visit } from "../src/surfaces/browser";
       
       const coreApi = composePluginApi([] as const);
       
      @@ -168,13 +169,13 @@ scenario(
                 mcpSession.awaitResume(approval.executionId),
                 browser.session(identity, async ({ page, step }) => {
                   await step("Land in the original organization", async () => {
      -              await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" });
      +              await visit(page, `/${orgA.slug}`);
                     await expectOrgShell(page, orgA);
                   });
       
                   await step("The browser session is switched to another organization", async () => {
                     await setWorkosSessionCookie(page, target.baseUrl, sessionB);
      -              await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
      +              await visit(page, `/${orgB.slug}`);
                     await expectOrgShell(page, orgB);
                   });
       
      @@ -182,7 +183,7 @@ scenario(
                     const loadRequest = page.waitForRequest(approvalApiRequest(approval, "GET"), {
                       timeout: 30_000,
                     });
      -              await page.goto(approval.approvalUrl, { waitUntil: "networkidle" });
      +              await visit(page, approval.approvalUrl);
                     expect(
                       (await loadRequest).headers()["x-executor-organization"],
                       "loading the approval page scopes the paused execution lookup to org A",
      diff --git a/e2e/cloud/mcp-browser-resume-page.test.ts b/e2e/cloud/mcp-browser-resume-page.test.ts
      index 02eb39e4cd..5e82e5eb0d 100644
      --- a/e2e/cloud/mcp-browser-resume-page.test.ts
      +++ b/e2e/cloud/mcp-browser-resume-page.test.ts
      @@ -195,68 +195,75 @@ scenario(
               openBrowserApprovalSession(target.mcpUrl, bearer),
             );
             yield* Effect.gen(function* () {
      -        const paused = yield* Effect.promise(() =>
      -          session.client.callTool({ name: "execute", arguments: { code: GATED_CODE } }),
      -        );
      -        const approval = parseBrowserApproval({
      -          raw: paused,
      -          text: textOf(paused),
      -          ok: paused.isError !== true,
      -        });
      +        yield* browser.session(identity, async ({ page, step }) => {
      +          // Open Chromium before pausing. The suite intentionally compresses
      +          // the production nine-minute decision window to 30 seconds, and a
      +          // cold browser launch should not consume the user's decision time.
      +          const paused = await session.client.callTool({
      +            name: "execute",
      +            arguments: { code: GATED_CODE },
      +          });
      +          const approval = parseBrowserApproval({
      +            raw: paused,
      +            text: textOf(paused),
      +            ok: paused.isError !== true,
      +          });
       
      -        const approvalUrl = new URL(approval.approvalUrl);
      -        const mcpSessionId = approvalUrl.searchParams.get("mcp_session_id");
      -        expect(
      -          mcpSessionId,
      -          "approval URL carries the MCP session id that the browser page will query",
      -        ).toEqual(expect.any(String));
      -        expect(mcpSessionId, "approval URL points at the session that paused").toBe(
      -          session.transport.sessionId,
      -        );
      -
      -        const [resumed] = yield* Effect.all(
      -          [
      -            Effect.promise(() =>
      -              session.client.callTool({
      -                name: "resume",
      -                arguments: { executionId: approval.executionId },
      -              }),
      -            ),
      -            browser.session(identity, async ({ page, step }) => {
      -              await step("Open the paused execution approval page", async () => {
      -                await page.goto(pathWithSearch(approval.approvalUrl), { waitUntil: "networkidle" });
      -                await page.getByText("User approval required").waitFor();
      -              });
      +          const approvalUrl = new URL(approval.approvalUrl);
      +          const mcpSessionId = approvalUrl.searchParams.get("mcp_session_id");
      +          expect(
      +            mcpSessionId,
      +            "approval URL carries the MCP session id that the browser page will query",
      +          ).toEqual(expect.any(String));
      +          expect(mcpSessionId, "approval URL points at the session that paused").toBe(
      +            session.transport.sessionId,
      +          );
       
      -              await step("Review the paused tool call details", async () => {
      -                await page.getByText("Pending request").waitFor();
      -                await page.getByText(/Approve executor\.coreTools\.policies\.list\?/).waitFor();
      +          await step("Approve the paused tool call through the browser page", async () => {
      +            // The resume GET is this page's concrete readiness signal. The
      +            // generic browser `visit` helper additionally waits up to five
      +            // seconds for network-idle, which is inappropriate while this
      +            // deliberately short approval lease is ticking.
      +            const pausedExecutionLoaded = page.waitForResponse((response) => {
      +              const responseUrl = new URL(response.url());
      +              return (
      +                response.request().method() === "GET" &&
      +                responseUrl.pathname.endsWith(
      +                  `/api/mcp-sessions/${mcpSessionId}/executions/${approval.executionId}`,
      +                ) &&
      +                response.status() === 200
      +              );
      +            });
      +            await page.goto(pathWithSearch(approval.approvalUrl), { waitUntil: "load" });
      +            await pausedExecutionLoaded;
      +            await page.getByText("User approval required").waitFor();
      +            await page.getByText("Pending request").waitFor();
      +            await page.getByText(/Approve executor\.coreTools\.policies\.list\?/).waitFor();
       
      -                const approve = page.getByRole("button", { name: "Approve" });
      -                await approve.waitFor();
      -                expect(
      -                  await approve.isEnabled(),
      -                  "the approve control is enabled for the paused execution",
      -                ).toBe(true);
      -                expect(
      -                  await page.getByText(UNAVAILABLE_COPY).count(),
      -                  "the resume page does not show the expired-session failure copy",
      -                ).toBe(0);
      -              });
      +            const approve = page.getByRole("button", { name: "Approve" });
      +            await approve.waitFor();
      +            expect(
      +              await approve.isEnabled(),
      +              "the approve control is enabled for the paused execution",
      +            ).toBe(true);
      +            expect(
      +              await page.getByText(UNAVAILABLE_COPY).count(),
      +              "the resume page does not show the expired-session failure copy",
      +            ).toBe(0);
      +            await page.getByRole("button", { name: "Approve" }).click();
      +            await page.getByText("Approve sent").waitFor();
      +          });
       
      -              await step("Approve the paused tool call", async () => {
      -                await page.getByRole("button", { name: "Approve" }).click();
      -                await page.getByText("Approve sent").waitFor();
      -              });
      -            }),
      -          ],
      -          { concurrency: "unbounded" },
      -        );
      +          const resumed = await session.client.callTool({
      +            name: "resume",
      +            arguments: { executionId: approval.executionId },
      +          });
       
      -        expect(resumed.isError, "browser-mode resume completed after the UI approval").not.toBe(
      -          true,
      -        );
      -        expect(textOf(resumed), "the gated tool completed after approval").toContain(policy.id);
      +          expect(resumed.isError, "browser-mode resume completed after the UI approval").not.toBe(
      +            true,
      +          );
      +          expect(textOf(resumed), "the gated tool completed after approval").toContain(policy.id);
      +        });
             }).pipe(Effect.ensuring(closeQuietly(session)));
           }).pipe(
             Effect.ensuring(
      diff --git a/e2e/cloud/mcp-destroyed-session-envelope.test.ts b/e2e/cloud/mcp-destroyed-session-envelope.test.ts
      new file mode 100644
      index 0000000000..c461fc0359
      --- /dev/null
      +++ b/e2e/cloud/mcp-destroyed-session-envelope.test.ts
      @@ -0,0 +1,365 @@
      +// Cloud: a session id must keep answering in the MCP protocol's own vocabulary
      +// for the whole time its Durable Object is being torn down — not just in the
      +// first instant.
      +//
      +// `DELETE /mcp` condemns the session object with a durable marker and defers
      +// the real teardown to an immediate alarm; that alarm wipes the object's
      +// storage and then aborts the isolate. e2e/cloud/mcp-protocol.test.ts covers
      +// the calm case (terminate, then ask again, get a 404 reconnect). This scenario
      +// covers the violent middle of it: a client whose requests are ALREADY IN
      +// FLIGHT when the termination lands, which is what a real client with an open
      +// tool loop looks like when a session ends underneath it.
      +//
      +// Every one of those in-flight requests must come back as a well-formed
      +// JSON-RPC error — 404 (this id is dead, reconnect) or 503 (the object is
      +// restarting, retry the same id, here is how long to wait) — and never as a
      +// bare unhandled 500 from a platform failure escaping the request handler.
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +
      +import { scenario } from "../src/scenario";
      +import { Mcp, Target } from "../src/services";
      +import type { Identity } from "../src/target";
      +
      +const JSON_AND_SSE = "application/json, text/event-stream";
      +const PROTOCOL_VERSION = "2025-03-26";
      +
      +const INITIALIZE_REQUEST = {
      +  jsonrpc: "2.0" as const,
      +  id: 1,
      +  method: "initialize",
      +  params: {
      +    protocolVersion: PROTOCOL_VERSION,
      +    capabilities: {},
      +    clientInfo: { name: "executor-e2e-destroyed-session-envelope", version: "0.0.1" },
      +  },
      +};
      +
      +const INITIALIZED_NOTIFICATION = {
      +  jsonrpc: "2.0" as const,
      +  method: "notifications/initialized",
      +};
      +
      +const TOOLS_LIST_REQUEST = {
      +  jsonrpc: "2.0" as const,
      +  id: 2,
      +  method: "tools/list",
      +  params: {},
      +};
      +
      +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
      +
      +const mcpPost = (
      +  url: string | URL,
      +  init: { readonly bearer: string; readonly sessionId?: string; readonly body: unknown },
      +): Promise =>
      +  fetch(url, {
      +    method: "POST",
      +    headers: {
      +      accept: JSON_AND_SSE,
      +      "content-type": "application/json",
      +      authorization: `Bearer ${init.bearer}`,
      +      ...(init.sessionId ? { "mcp-session-id": init.sessionId } : {}),
      +    },
      +    body: JSON.stringify(init.body),
      +  });
      +
      +const openSession = async (mcpUrl: string, bearer: string): Promise => {
      +  const initialize = await mcpPost(mcpUrl, { bearer, body: INITIALIZE_REQUEST });
      +  const sessionId = initialize.headers.get("mcp-session-id");
      +  await initialize.text();
      +  if (initialize.status !== 200 || !sessionId) {
      +    throw new Error(`openSession: initialize failed (${initialize.status})`);
      +  }
      +  const initialized = await mcpPost(mcpUrl, { bearer, sessionId, body: INITIALIZED_NOTIFICATION });
      +  await initialized.text();
      +  if (initialized.status !== 202) {
      +    throw new Error(`openSession: notifications/initialized failed (${initialized.status})`);
      +  }
      +  return sessionId;
      +};
      +
      +type Probe = {
      +  readonly atMs: number;
      +  readonly status: number;
      +  readonly body: string;
      +  readonly retryAfter: string | null;
      +};
      +
      +/** One JSON-RPC error shape, or null when the body is not the protocol envelope. */
      +const jsonRpcError = (body: string): { readonly code: number; readonly message: string } | null => {
      +  // oxlint-disable-next-line executor/no-try-catch-or-throw -- test helper: a non-JSON body is itself the signal we assert on
      +  try {
      +    const parsed = JSON.parse(body) as {
      +      readonly jsonrpc?: string;
      +      readonly error?: { readonly code?: number; readonly message?: string };
      +    };
      +    if (parsed.jsonrpc !== "2.0" || typeof parsed.error?.code !== "number") return null;
      +    return { code: parsed.error.code, message: parsed.error.message ?? "" };
      +  } catch {
      +    return null;
      +  }
      +};
      +
      +/**
      + * The verdict a request gets once the destroy alarm has wiped the session's
      + * storage — i.e. the teardown this scenario is timing itself against is over.
      + * Matched on the protocol answer, not on any server internal.
      + */
      +const isWipedVerdict = (probe: Probe): boolean =>
      +  probe.status === 404 && jsonRpcError(probe.body)?.message === "Session not found";
      +
      +const describeProbe = (probe: Probe): string =>
      +  `+${probe.atMs}ms ${probe.status} ${probe.body.slice(0, 200)}`;
      +
      +scenario(
      +  "MCP protocol · in-flight requests survive their session's teardown as protocol errors",
      +  { timeout: 180_000 },
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const mcp = yield* Mcp;
      +
      +    // The fatal instant is the isolate abort at the end of the teardown, and a
      +    // session only crosses it once — so cross it many times. A single crossing
      +    // leaks the unhandled 500 only when a `validateMcpSessionOwner` RPC is
      +    // inside the object at exactly that instant, so the scenario has to make
      +    // that likely rather than hope for it: `sessions` crossings, each blanketed
      +    // by `concurrency` requests. At 6 × 8 the pre-fix bug reproduced in only
      +    // about half of runs; the numbers below are the measured point where it
      +    // reproduces every run without pushing the shared auth path into 403s.
      +    const sessions = 12;
      +    // Requests are kept continuously in flight rather than polled on a timer:
      +    // the point is to have work ALREADY inside the session object when the
      +    // termination lands, not to sample the window from outside it.
      +    const concurrency = 24;
      +    // Ceiling, not the plan: the stream normally stops as soon as the teardown
      +    // is OBSERVED to have completed (below). This only bounds a teardown whose
      +    // alarm never lands, so a hung platform fails the scenario instead of
      +    // hanging it.
      +    const streamAfterDeleteMs = 3_000;
      +    // Once the storage wipe is visible the abort has already happened, so the
      +    // window this scenario exists to cover is behind us. Keep going briefly —
      +    // the abort and the wipe are not the same instant — then stop, because
      +    // every further request is pure load on the shared auth path.
      +    const graceAfterWipeMs = 150;
      +    // Enough in-flight requests to be mid-teardown, without racing the DELETE
      +    // itself before the session is fully established.
      +    const warmupMs = 250;
      +
      +    const probes: Probe[] = [];
      +    // Per teardown, so the scenario can PROVE each crossing was straddled
      +    // rather than assume it.
      +    const perSession: Probe[][] = [];
      +
      +    // One identity per teardown, all minted BEFORE any load starts. Two
      +    // reasons, both about keeping this scenario's traffic off the shared auth
      +    // path: a single identity carrying every teardown's requests degrades the
      +    // org-membership lookup into a 403, and signing new users in while the
      +    // probe stream is running fails the sign-in itself. Neither has anything to
      +    // do with what is being tested here.
      +    const bearers: string[] = [];
      +    for (let index = 0; index < sessions; index += 1) {
      +      const identity = yield* target.newIdentity();
      +      bearers.push(yield* mcp.mintBearer(emailOf(identity)));
      +    }
      +
      +    for (const bearer of bearers) {
      +      const sessionId = yield* Effect.promise(() => openSession(target.mcpUrl, bearer));
      +
      +      const thisSession: Probe[] = [];
      +      perSession.push(thisSession);
      +
      +      yield* Effect.promise(async () => {
      +        const startedAt = Date.now();
      +        let hardStopAt = Number.POSITIVE_INFINITY;
      +        // Set the first time the wiped-storage verdict is seen, which is the
      +        // observable end of the teardown — the stream stops on this, not a
      +        // timer.
      +        let stopAfterWipeAt = Number.POSITIVE_INFINITY;
      +
      +        const probeOnce = async (): Promise => {
      +          const at = Date.now() - startedAt;
      +          const response = await mcpPost(target.mcpUrl, {
      +            bearer,
      +            sessionId,
      +            body: TOOLS_LIST_REQUEST,
      +          });
      +          const probe: Probe = {
      +            atMs: at,
      +            status: response.status,
      +            body: await response.text(),
      +            retryAfter: response.headers.get("retry-after"),
      +          };
      +          probes.push(probe);
      +          thisSession.push(probe);
      +          // "Session not found" is the post-wipe verdict: the destroy alarm has
      +          // run, storage is gone, and the object has been aborted. Anything
      +          // after this point is a request against an already-dead id.
      +          if (isWipedVerdict(probe) && stopAfterWipeAt === Number.POSITIVE_INFINITY) {
      +            stopAfterWipeAt = Date.now() + graceAfterWipeMs;
      +          }
      +        };
      +
      +        // One worker replenishes its request the moment the previous one
      +        // settles, so the session object is never idle and the DELETE has to
      +        // land on top of real traffic.
      +        const worker = async (): Promise => {
      +          while (Date.now() < hardStopAt && Date.now() < stopAfterWipeAt) await probeOnce();
      +        };
      +        const workers = Array.from({ length: concurrency }, () => worker());
      +
      +        await new Promise((resolve) => setTimeout(resolve, warmupMs));
      +        // Terminate WITHOUT draining the stream: this is the whole scenario.
      +        const terminate = await fetch(target.mcpUrl, {
      +          method: "DELETE",
      +          headers: { authorization: `Bearer ${bearer}`, "mcp-session-id": sessionId },
      +        });
      +        await terminate.text();
      +        expect(terminate.status, "the client can terminate its session").toBe(200);
      +
      +        hardStopAt = Date.now() + streamAfterDeleteMs;
      +        await Promise.all(workers);
      +      });
      +    }
      +
      +    // What the dying session actually answered, so a reviewer can see the shape
      +    // of the teardown window and not just the verdict.
      +    const bucket = new Map();
      +    for (const probe of probes) {
      +      const key = `${probe.status} ${jsonRpcError(probe.body)?.message ?? probe.body.slice(0, 80)}`;
      +      bucket.set(key, (bucket.get(key) ?? 0) + 1);
      +    }
      +    console.info(
      +      `[destroyed-session-envelope] ${probes.length} probes across ${sessions} teardowns: ${[
      +        ...bucket,
      +      ]
      +        .map(([key, count]) => `${count}× ${key}`)
      +        .join(" | ")}`,
      +    );
      +
      +    expect(probes.length, "the stream actually exercised the teardown").toBeGreaterThan(sessions);
      +
      +    const unhandled = probes.filter((probe) => probe.status >= 500 && probe.status !== 503);
      +    expect(
      +      unhandled.map(describeProbe),
      +      "no request on a terminating session produces an unhandled server error",
      +    ).toEqual([]);
      +
      +    // Only rejections are asserted on: a request the session still served
      +    // answers 200 over SSE, which is not a JSON-RPC error body and not what
      +    // this scenario is about.
      +    const malformed = probes.filter(
      +      (probe) => probe.status !== 200 && jsonRpcError(probe.body) === null,
      +    );
      +    expect(
      +      malformed.map(describeProbe),
      +      "every rejection is a JSON-RPC error envelope the client can parse",
      +    ).toEqual([]);
      +
      +    // Coverage precondition, checked per teardown rather than in aggregate: a
      +    // run in which some session's stream finished before its object was torn
      +    // down never entered the window this scenario exists to cover, and its
      +    // green is worth nothing. Requiring EVERY crossing to have been observed is
      +    // what stops the assertions above from passing vacuously.
      +    const notStraddled = perSession
      +      .map((session, index) => ({ index, wiped: session.some(isWipedVerdict) }))
      +      .filter((entry) => !entry.wiped)
      +      .map((entry) => `teardown #${entry.index + 1}`);
      +    expect(
      +      notStraddled,
      +      "every teardown was still under load when its session object was destroyed",
      +    ).toEqual([]);
      +
      +    // The disposition half of the contract, on the one failure this scenario
      +    // can actually produce. Every platform failure reachable here is the
      +    // session's own `ctx.abort` after a DELETE the client itself sent, and that
      +    // id is dead for good — so the answer has to be "reconnect", never
      +    // "restarting, retry". A retryable verdict for a deliberately terminated
      +    // session is worse than the 500 this PR removed: the client is told to keep
      +    // asking, and the loop never ends.
      +    const deadIdCalledRetryable = probes.filter((probe) => probe.status === 503);
      +    expect(
      +      deadIdCalledRetryable.map(describeProbe),
      +      "a session the client terminated is never advertised as retryable",
      +    ).toEqual([]);
      +
      +    // A 503 here means "the platform is mid-reset, come back". It is only
      +    // actionable if the client is told how long to wait, so it must carry
      +    // Retry-After AND a positive delay — a `retry-after: 0`, or a header the
      +    // renderer dropped, is the same dead end as no answer at all.
      +    const badBackoff = probes.filter((probe) => {
      +      if (probe.status !== 503) return false;
      +      const seconds = probe.retryAfter === null ? null : Number(probe.retryAfter);
      +      return seconds === null || !Number.isFinite(seconds) || seconds <= 0;
      +    });
      +    expect(
      +      badBackoff.map(describeProbe),
      +      "every retryable rejection tells the client how long to back off",
      +    ).toEqual([]);
      +
      +    // The other half of the classifier's contract, and the half a teardown
      +    // cannot demonstrate: a DEFINITIVE refusal must stay definitive. If the
      +    // platform-failure path ever widens far enough to swallow ordinary
      +    // rejections, these are the two answers that would silently turn into "the
      +    // session is restarting, retry" and send a client into a loop it can never
      +    // exit.
      +    const liveIdentity = yield* target.newIdentity();
      +    const liveBearer = yield* mcp.mintBearer(emailOf(liveIdentity));
      +    const liveSessionId = yield* Effect.promise(() => openSession(target.mcpUrl, liveBearer));
      +
      +    const strangerIdentity = yield* target.newIdentity();
      +    const strangerBearer = yield* mcp.mintBearer(emailOf(strangerIdentity));
      +
      +    yield* Effect.gen(function* () {
      +      const hijack = yield* Effect.promise(() =>
      +        mcpPost(target.mcpUrl, {
      +          bearer: strangerBearer,
      +          sessionId: liveSessionId,
      +          body: TOOLS_LIST_REQUEST,
      +        }),
      +      );
      +      const hijackBody = yield* Effect.promise(() => hijack.text());
      +      expect(
      +        hijack.status,
      +        "another account's session id is refused outright, not advertised as retryable",
      +      ).toBe(403);
      +      expect(hijack.headers.get("retry-after"), "a refusal carries no backoff advice").toBeNull();
      +      expect(jsonRpcError(hijackBody)?.code, "the refusal is the session-ownership error").toBe(
      +        -32003,
      +      );
      +
      +      const unauthenticated = yield* Effect.promise(() =>
      +        fetch(target.mcpUrl, {
      +          method: "POST",
      +          headers: { accept: JSON_AND_SSE, "content-type": "application/json" },
      +          body: JSON.stringify(TOOLS_LIST_REQUEST),
      +        }),
      +      );
      +      yield* Effect.promise(() => unauthenticated.text());
      +      expect(
      +        unauthenticated.status,
      +        "a missing credential is refused outright, not advertised as retryable",
      +      ).toBe(401);
      +      expect(
      +        unauthenticated.headers.get("retry-after"),
      +        "a refusal carries no backoff advice",
      +      ).toBeNull();
      +    }).pipe(
      +      // Runs even when an assertion above fails, so the extra session never
      +      // outlives the scenario. `tryPromise` so a refused cleanup is a failure
      +      // `ignore` can actually swallow — cleanup must not mask the real verdict.
      +      Effect.ensuring(
      +        Effect.tryPromise(async () => {
      +          const closed = await fetch(target.mcpUrl, {
      +            method: "DELETE",
      +            headers: {
      +              authorization: `Bearer ${liveBearer}`,
      +              "mcp-session-id": liveSessionId,
      +            },
      +          });
      +          await closed.text();
      +        }).pipe(Effect.ignore),
      +      ),
      +    );
      +  }),
      +);
      diff --git a/e2e/cloud/mcp-execution-limits.test.ts b/e2e/cloud/mcp-execution-limits.test.ts
      index f80dcaac23..40bbb290d2 100644
      --- a/e2e/cloud/mcp-execution-limits.test.ts
      +++ b/e2e/cloud/mcp-execution-limits.test.ts
      @@ -21,8 +21,11 @@ import {
         RATE_LIMIT_BLOCKED_MESSAGE,
       } from "../../apps/cloud/src/engine/execution-limit-messages";
       import { scenario } from "../src/scenario";
      -import { Autumn, Billing, Mcp, Target } from "../src/services";
      -import { E2E_EXECUTION_RATE_LIMIT } from "../setup/execution-limits";
      +import { Autumn, Billing, Mcp, Target, Telemetry } from "../src/services";
      +import {
      +  E2E_EXECUTION_RATE_LIMIT,
      +  E2E_EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS,
      +} from "../setup/execution-limits";
       import type { Identity } from "../src/target";
       
       const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
      @@ -162,6 +165,7 @@ scenario(
         Effect.gen(function* () {
           yield* Billing;
           const autumn = yield* Autumn;
      +    const telemetry = yield* Telemetry;
           const target = yield* Target;
           const mcp = yield* Mcp;
       
      @@ -196,5 +200,178 @@ scenario(
           expect(metered.length, "only the allowed executions are metered — the blocked one is not").toBe(
             RATE_LIMIT,
           );
      +
      +    // The block is also legible in telemetry, not just to the client. This is
      +    // the OTHER branch of the check — the allowed path is pinned by the span
      +    // scenario below — and it is the one an operator reaches for when a
      +    // customer reports being cut off mid-workload.
      +    const blockedCheck = yield* telemetry.expectSpan({
      +      operation: "rate_limit.check",
      +      attributes: { "rate_limit.organization_id": customerId, "rate_limit.blocked": "true" },
      +    });
      +    expect(
      +      blockedCheck.span.tags["rate_limit.count"],
      +      "the span carries the count that crossed the cap",
      +    ).toBe(String(RATE_LIMIT + 1));
      +    expect(
      +      blockedCheck.span.tags["rate_limit.exempt"],
      +      "the org was not exempt — that is why it was blocked",
      +    ).toBe("false");
      +    expect(
      +      blockedCheck.span.tags["rate_limit.check.failed_open"],
      +      "a real block is not a degraded check wearing a block's clothes",
      +    ).toBe("false");
      +  }),
      +);
      +
      +scenario(
      +  "Billing · the rate-limit counter check is visible in the exported spans",
      +  { timeout: 180_000 },
      +  Effect.gen(function* () {
      +    // The backstop's counter is a blocking Durable Object hop on the execute
      +    // hot path. It used to run untraced, so the only production evidence it
      +    // was slow was a fail-open warning arriving 2s later — and a counter fault
      +    // reached the error reporter as an untyped wrapper with no application
      +    // frames. This pins the measurement seam where it is actually read: the
      +    // spans the worker EXPORTS, over the real workerd + Durable Object
      +    // topology, not a span object built in a unit test.
      +    yield* Billing;
      +    const telemetry = yield* Telemetry;
      +    const target = yield* Target;
      +    const mcp = yield* Mcp;
      +
      +    const identity = yield* target.newIdentity();
      +    const bearer = yield* mcp.mintBearer(emailOf(identity));
      +    const customerId = orgIdOf(bearer);
      +
      +    const session = mcp.session(identity);
      +    const ok = yield* session.call("execute", { code: "return 4 + 5;" });
      +    expect(ok.ok, "the execution runs").toBe(true);
      +    expect(ok.text, "it returns its value").toContain("9");
      +
      +    // A second RPC on the same session, deliberately NOT an execute (it must
      +    // not move the counter this scenario asserts on). The session DO ships its
      +    // spans on a bounded, best-effort `waitUntil` flush after each RPC — a
      +    // session that makes exactly one call in its whole life is asserting on
      +    // that one flush winning a race, which it loses often enough on a loaded
      +    // machine to be useless. A real client keeps talking to its session; so
      +    // does this one.
      +    yield* session.listTools();
      +
      +    const check = yield* telemetry.expectSpan({
      +      operation: "rate_limit.check",
      +      attributes: { "rate_limit.organization_id": customerId },
      +    });
      +    expect(
      +      check.span.tags["rate_limit.count"],
      +      "the org's count for the window is on the span",
      +    ).toBe("1");
      +    expect(check.span.tags["rate_limit.limit"], "so is the cap it was measured against").toBe(
      +      String(RATE_LIMIT),
      +    );
      +    expect(check.span.tags["rate_limit.blocked"], "an allowed execution is recorded as such").toBe(
      +      "false",
      +    );
      +    // The two attributes production alerting reads. Both false on a healthy
      +    // check; a step change in either is the signal that used to arrive only as
      +    // a stream of untyped error reports.
      +    expect(check.span.tags["rate_limit.check.failed_open"], "the check did not fail open").toBe(
      +      "false",
      +    );
      +    // The budget on the span is the boot's OVERRIDE, not the compiled-in 2000ms
      +    // default — the only end-to-end proof that
      +    // EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS actually reaches the worker and
      +    // retunes the check. A knob that silently falls back to the constant would
      +    // read as healthy here and as a working knob everywhere else.
      +    expect(
      +      check.span.tags["rate_limit.check.timeout_ms"],
      +      "the budget on the span is the one the worker was booted with",
      +    ).toBe(String(E2E_EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS));
      +
      +    // The DO round trip itself is timed, inside the same trace as the check.
      +    // Polled, not read once: the parent and the child ride different export
      +    // batches, so a one-shot read here races the exporter and fails ~1 run in 5.
      +    const increment = yield* telemetry.expectSpan({
      +      operation: "rate_limit.increment",
      +      traceId: check.traceId,
      +    });
      +    expect(
      +      increment.span.tags["rate_limit.window_id"],
      +      "the counter span is for the window the check decided against",
      +    ).toBe(check.span.tags["rate_limit.window_id"]);
      +    expect(
      +      increment.span.tags["rate_limit.counter.error_code"],
      +      "a healthy counter call carries no fault classification",
      +    ).toBeUndefined();
      +  }),
      +);
      +
      +scenario(
      +  "Billing · a paid org runs past the rate-limit backstop instead of being blocked",
      +  { timeout: 180_000 },
      +  Effect.gen(function* () {
      +    // The backstop is sized for free-tier abuse, and it used to apply to every
      +    // org regardless of plan: on 2026-08-18 it blocked a paying customer
      +    // mid-workload. This pins the exemption end to end — the same run that
      +    // blocks a free org (the scenario above) must sail past the cap here.
      +    yield* Billing;
      +    const autumn = yield* Autumn;
      +    const telemetry = yield* Telemetry;
      +    const target = yield* Target;
      +    const mcp = yield* Mcp;
      +
      +    const identity = yield* target.newIdentity();
      +    const bearer = yield* mcp.mintBearer(emailOf(identity));
      +    const customerId = orgIdOf(bearer);
      +
      +    // Enterprise, not Team: it carries no price and no card-required trial, so
      +    // the emulator activates it inline rather than answering with a checkout
      +    // URL. Its executions are unlimited, so the BALANCE gate can never be what
      +    // allows or blocks here — the rate-limit backstop is isolated as the only
      +    // guard under test.
      +    yield* autumn.attachPlan(customerId, "enterprise");
      +
      +    const session = mcp.session(identity);
      +
      +    // Deliberately past E2E_EXECUTION_RATE_LIMIT: the count that blocks a free
      +    // org. Every one must run.
      +    const overCap = RATE_LIMIT + 3;
      +    for (let i = 1; i <= overCap; i++) {
      +      const ok = yield* session.call("execute", { code: `return ${i};` });
      +      expect(ok.ok, `execution ${i} succeeds for a paid org (cap is ${RATE_LIMIT})`).toBe(true);
      +      expect(
      +        ok.text,
      +        `execution ${i} returns its value rather than the backstop message`,
      +      ).not.toContain(RATE_LIMIT_BLOCKED_MESSAGE);
      +    }
      +
      +    // Nothing was withheld from the meter either: a paid org past the cap is
      +    // billed for every execution it ran.
      +    const metered = yield* autumn.expectUsage({
      +      customerId,
      +      featureId: "executions",
      +      count: overCap,
      +    });
      +    expect(metered.length, "every execution past the cap is still metered").toBe(overCap);
      +
      +    // Third branch of the check: over the cap AND allowed. Without the reason
      +    // on the span, this is indistinguishable in production from a check that
      +    // never ran — which is exactly the ambiguity that made the 2026-08-18
      +    // block hard to explain.
      +    const exemptCheck = yield* telemetry.expectSpan({
      +      operation: "rate_limit.check",
      +      attributes: { "rate_limit.organization_id": customerId, "rate_limit.exempt": "true" },
      +    });
      +    expect(
      +      Number(exemptCheck.span.tags["rate_limit.count"]),
      +      "the exemption was recorded on a check that was genuinely over the cap",
      +    ).toBeGreaterThan(RATE_LIMIT);
      +    expect(exemptCheck.span.tags["rate_limit.blocked"], "and it allowed the execution").toBe(
      +      "false",
      +    );
      +    expect(
      +      exemptCheck.span.tags["rate_limit.check.failed_open"],
      +      "the org ran because it is exempt, not because the counter fell over",
      +    ).toBe("false");
         }),
       );
      diff --git a/e2e/cloud/mcp-oversize-response.test.ts b/e2e/cloud/mcp-oversize-response.test.ts
      new file mode 100644
      index 0000000000..720cf896fc
      --- /dev/null
      +++ b/e2e/cloud/mcp-oversize-response.test.ts
      @@ -0,0 +1,136 @@
      +// Cloud: an MCP response bigger than DO storage's 128 KiB per-value cap is
      +// still delivered. The ui://executor/shell.html resource (~5 MB of inlined
      +// shell document) is the production case: the transport used to persist every
      +// outbound message with storage.put BEFORE writing the live SSE frame, so the
      +// put's failure killed the send and the client saw keepalives forever — every
      +// artifact widget in every remote MCP client hung, with no error anywhere
      +// (found live in prod, 2026-07-29). The patched transport delivers live first
      +// and treats persistence as best-effort, so this pins the whole path over the
      +// real workerd topology: POST resources/read → McpSessionDO → ASSETS-backed
      +// shell loader → event store skip (oversize) → live SSE frame → this client.
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +
      +import { scenario } from "../src/scenario";
      +import { Mcp, Target } from "../src/services";
      +import type { Identity } from "../src/target";
      +
      +const PROTOCOL_VERSION = "2025-03-26";
      +const JSON_AND_SSE = "application/json, text/event-stream";
      +
      +const SHELL_RESOURCE_URI = "ui://executor/shell.html";
      +
      +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
      +// DO storage's per-value cap; anything beyond it exercises the skip path.
      +const DO_VALUE_CAP_BYTES = 128 * 1024;
      +
      +const mcpHeaders = (bearer: string, sessionId?: string) => ({
      +  accept: JSON_AND_SSE,
      +  authorization: `Bearer ${bearer}`,
      +  "content-type": "application/json",
      +  "mcp-protocol-version": PROTOCOL_VERSION,
      +  ...(sessionId ? { "mcp-session-id": sessionId } : {}),
      +});
      +
      +const postJson = (mcpUrl: string, bearer: string, body: unknown, sessionId?: string) =>
      +  fetch(mcpUrl, {
      +    method: "POST",
      +    headers: mcpHeaders(bearer, sessionId),
      +    body: JSON.stringify(body),
      +  });
      +
      +const openSession = async (mcpUrl: string, bearer: string): Promise => {
      +  const initialized = await postJson(mcpUrl, bearer, {
      +    jsonrpc: "2.0" as const,
      +    id: "initialize",
      +    method: "initialize",
      +    params: {
      +      protocolVersion: PROTOCOL_VERSION,
      +      capabilities: {},
      +      clientInfo: { name: "executor-e2e-oversize-response", version: "0.0.1" },
      +    },
      +  });
      +  const sessionId = initialized.headers.get("mcp-session-id");
      +  await initialized.text();
      +  if (initialized.status !== 200 || !sessionId) {
      +    throw new Error(`openSession: initialize failed (${initialized.status})`);
      +  }
      +  const notification = await postJson(
      +    mcpUrl,
      +    bearer,
      +    { jsonrpc: "2.0" as const, method: "notifications/initialized" },
      +    sessionId,
      +  );
      +  await notification.text();
      +  expect(notification.status, "notifications/initialized is accepted").toBe(202);
      +  return sessionId;
      +};
      +
      +/** Read the POST's SSE body to completion and return the JSON-RPC payloads. */
      +const readSseMessages = async (
      +  response: Response,
      +): Promise> => {
      +  const text = await response.text();
      +  const messages: Array<{ readonly id?: unknown; readonly result?: unknown }> = [];
      +  for (const block of text.replace(/\r/g, "").split("\n\n")) {
      +    const data = block
      +      .split("\n")
      +      .filter((line) => line.startsWith("data:"))
      +      .map((line) => line.slice("data:".length).trimStart())
      +      .join("\n")
      +      .trim();
      +    if (data) messages.push(JSON.parse(data) as { readonly id?: unknown });
      +  }
      +  return messages;
      +};
      +
      +scenario(
      +  "MCP streamable HTTP · a response beyond the DO storage value cap is delivered live (the ui:// shell resource)",
      +  {},
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const mcp = yield* Mcp;
      +    const identity = yield* target.newIdentity();
      +    const bearer = yield* mcp.mintBearer(emailOf(identity));
      +    // Artifacts are on by default, which registers the shell resource.
      +    const mcpUrl = target.mcpUrl;
      +
      +    const sessionId = yield* Effect.promise(() => openSession(mcpUrl, bearer));
      +
      +    const read = yield* Effect.promise(() =>
      +      postJson(
      +        mcpUrl,
      +        bearer,
      +        {
      +          jsonrpc: "2.0" as const,
      +          id: "read-shell",
      +          method: "resources/read",
      +          params: { uri: SHELL_RESOURCE_URI },
      +        },
      +        sessionId,
      +      ),
      +    );
      +    expect(read.status, "resources/read answers").toBe(200);
      +
      +    // The whole regression is "the final frame never arrives": bound the read
      +    // so a hang fails the test instead of wedging the suite.
      +    const messages = yield* Effect.promise(() => readSseMessages(read)).pipe(
      +      Effect.timeoutOrElse({
      +        duration: "60 seconds",
      +        orElse: () => Effect.die(new Error("shell read hung: the response frame never arrived")),
      +      }),
      +    );
      +
      +    const response = messages.find((message) => message.id === "read-shell");
      +    expect(response, "the read's response frame arrived on the POST stream").toBeDefined();
      +    const result = response?.result as
      +      | { readonly contents?: ReadonlyArray<{ readonly text?: string }> }
      +      | undefined;
      +    const html = result?.contents?.[0]?.text ?? "";
      +    expect(
      +      html.length,
      +      "the document is larger than the DO storage cap, so it took the live-only path",
      +    ).toBeGreaterThan(DO_VALUE_CAP_BYTES);
      +    expect(html, "and it is the real shell document").toContain('name="executor-mcp-apps-shell"');
      +  }),
      +);
      diff --git a/e2e/cloud/mcp-session-cap-eviction.test.ts b/e2e/cloud/mcp-session-cap-eviction.test.ts
      new file mode 100644
      index 0000000000..8724edd5aa
      --- /dev/null
      +++ b/e2e/cloud/mcp-session-cap-eviction.test.ts
      @@ -0,0 +1,258 @@
      +// Cloud: crossing the isolate's resident-runtime soft cap evicts an idle
      +// session's runtime through a REAL cross-Durable-Object request, not a
      +// same-context call.
      +//
      +// The defect this pins: the original design ran the evicted (candidate)
      +// session's teardown — closing its postgres.js socket, storage writes, span
      +// flush — directly inside the EVICTING session's own request/IoContext. In
      +// production workerd, I/O objects are bound to the IoContext that created
      +// them, so a cross-context call like that throws "Cannot perform I/O on
      +// behalf of a different request" or silently soft-fails. That failure mode
      +// cannot reproduce against an in-process unit-test double (same JS object,
      +// same context either way) — it only shows up against a real Durable Object
      +// stub. The fix routes eviction through the candidate's OWN stub
      +// (`requestCapEviction`, an RPC method mirroring `forwardModelResumeToOwner`),
      +// so the candidate's teardown runs in the candidate's own context, and this
      +// scenario is what actually exercises that stub in workerd.
      +//
      +// e2e/setup/resident-runtime-cap.ts lowers MCP_RESIDENT_RUNTIME_SOFT_CAP for
      +// the whole boot (see that file for the value and its headroom story), so
      +// this test can cross it with a bounded number of real sessions instead of
      +// registering the production default of 32.
      +import { expect } from "@effect/vitest";
      +import { Effect, Schedule } from "effect";
      +
      +import { scenario } from "../src/scenario";
      +import { Mcp, Target, Telemetry } from "../src/services";
      +import type { Identity } from "../src/target";
      +import { E2E_MCP_RESIDENT_RUNTIME_SOFT_CAP } from "../setup/resident-runtime-cap";
      +
      +const PROTOCOL_VERSION = "2025-03-26";
      +const JSON_AND_SSE = "application/json, text/event-stream";
      +
      +// Comfortably past the cap: even if a handful of other scenarios' sessions
      +// are still incidentally resident when this file runs, enough of THESE
      +// sessions cross it that at least one eviction targets a session opened here.
      +const SESSIONS_TO_OPEN = E2E_MCP_RESIDENT_RUNTIME_SOFT_CAP + 10;
      +
      +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
      +
      +const mcpHeaders = (bearer: string, sessionId?: string) => ({
      +  accept: JSON_AND_SSE,
      +  authorization: `Bearer ${bearer}`,
      +  "content-type": "application/json",
      +  "mcp-protocol-version": PROTOCOL_VERSION,
      +  ...(sessionId ? { "mcp-session-id": sessionId } : {}),
      +});
      +
      +const postJson = (mcpUrl: string, bearer: string, body: unknown, sessionId?: string) =>
      +  fetch(mcpUrl, {
      +    method: "POST",
      +    headers: mcpHeaders(bearer, sessionId),
      +    body: JSON.stringify(body),
      +  });
      +
      +/**
      + * Opens one fresh MCP session under an already-minted bearer. `initialize`
      + * without an existing `mcp-session-id` always mints a new session, the same
      + * way separate browser tabs sharing one login would — so many of these under
      + * one identity is a cheap way to grow the isolate's resident-runtime count
      + * without a full OAuth round trip per session.
      + *
      + * `recordSession` is called the moment the session id is known — before the
      + * `notifications/initialized` round trip below, not after this function
      + * returns. A session is live on the server as soon as `initialize` responds
      + * with an `mcp-session-id`, regardless of whether the handshake ever
      + * completes; recording it only on a full return left a failed notification
      + * (or an interrupt landing between the two requests) with no cleanup entry,
      + * orphaning a real session on the target isolate.
      + */
      +const openSession = async (
      +  mcpUrl: string,
      +  bearer: string,
      +  label: string,
      +  recordSession: (sessionId: string) => void,
      +): Promise => {
      +  const initialized = await postJson(mcpUrl, bearer, {
      +    jsonrpc: "2.0" as const,
      +    id: "initialize",
      +    method: "initialize",
      +    params: {
      +      protocolVersion: PROTOCOL_VERSION,
      +      capabilities: {},
      +      clientInfo: { name: `executor-e2e-cap-eviction-${label}`, version: "0.0.1" },
      +    },
      +  });
      +  const sessionId = initialized.headers.get("mcp-session-id");
      +  if (!sessionId) {
      +    // oxlint-disable-next-line executor/no-error-constructor -- boundary: e2e setup precondition.
      +    throw new Error(`openSession (${label}): no mcp-session-id header`);
      +  }
      +  // Recorded the moment the id exists — BEFORE the body read and status
      +  // assertion below, either of which can throw with the session already live
      +  // on the server. The cleanup finalizer needs the id on every one of those
      +  // paths, not just a fully successful return.
      +  recordSession(sessionId);
      +  await initialized.text();
      +  expect(initialized.status, `initialize (${label}) opens a session`).toBe(200);
      +  const notification = await postJson(
      +    mcpUrl,
      +    bearer,
      +    { jsonrpc: "2.0" as const, method: "notifications/initialized" },
      +    sessionId,
      +  );
      +  await notification.text();
      +  expect(notification.status, `(${label}) completes the handshake`).toBe(202);
      +  return sessionId;
      +};
      +
      +const executeBody = (id: string, code: string) => ({
      +  jsonrpc: "2.0" as const,
      +  id,
      +  method: "tools/call",
      +  params: { name: "execute", arguments: { code } },
      +});
      +
      +/** Run `execute` and return the response text once the call has fully settled. */
      +const execute = async (
      +  mcpUrl: string,
      +  bearer: string,
      +  sessionId: string,
      +  id: string,
      +  code: string,
      +): Promise => {
      +  const response = await postJson(mcpUrl, bearer, executeBody(id, code), sessionId);
      +  const body = await response.text();
      +  expect(response.status, `execute ${id} is served`).toBe(200);
      +  return body;
      +};
      +
      +scenario(
      +  "MCP session · crossing the resident-runtime cap evicts a session through a real cross-DO request",
      +  { timeout: 180_000 },
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const mcp = yield* Mcp;
      +    const telemetry = yield* Telemetry;
      +
      +    const identity = yield* target.newIdentity();
      +    const bearer = yield* mcp.mintBearer(emailOf(identity));
      +
      +    // Opened sessions are recorded here as each one succeeds, so the cleanup
      +    // below can close exactly what was actually opened even if the scenario
      +    // fails partway through. Cap eviction already tears most of these down as
      +    // a side effect of the scenario itself, but termination is idempotent
      +    // (see mcp-destroyed-session-envelope.test.ts) — closing an already-torn-
      +    // -down session is a harmless no-op, not a double-free.
      +    const openedSessionIds: string[] = [];
      +
      +    const scenarioBody = Effect.gen(function* () {
      +      // Open more sessions than the cap allows. Keep admission sequential:
      +      // the cloud e2e database is one serialized PGlite instance, and this
      +      // scenario exercises resident eviction rather than concurrent cold
      +      // builds. None of the sessions run any work, so every one is immediately
      +      // eviction-eligible — crossing the cap must pick at least one and tear it
      +      // down through its own stub.
      +      const sessionIds = yield* Effect.forEach(
      +        Array.from({ length: SESSIONS_TO_OPEN }, (_, index) => index),
      +        (index) =>
      +          Effect.promise(() =>
      +            openSession(target.mcpUrl, bearer, `session-${index}`, (sessionId) => {
      +              openedSessionIds.push(sessionId);
      +            }),
      +          ),
      +        { concurrency: 1 },
      +      );
      +
      +      expect(sessionIds.length, "every session opened").toBe(SESSIONS_TO_OPEN);
      +      expect(new Set(sessionIds).size, "every session got a distinct id").toBe(SESSIONS_TO_OPEN);
      +
      +      // ---- a real cap eviction fired, against a session opened here -------
      +      // Same span the idle path emits (`mcp.session.idle_runtime_dispose`);
      +      // `mcp.session.dispose_reason` is what disambiguates the trigger.
      +      const capDisposals = yield* telemetry
      +        .searchSpans({ operation: "mcp.session.idle_runtime_dispose" })
      +        .pipe(
      +          Effect.map((spans) =>
      +            spans.filter(
      +              (span) =>
      +                span.span.tags["mcp.session.dispose_reason"] === "cap" &&
      +                sessionIds.some((id) => (span.span.tags["mcp.session.id"] ?? "").includes(id)),
      +            ),
      +          ),
      +          Effect.filterOrFail(
      +            (spans) => spans.length > 0,
      +            () => "no cap-triggered idle_runtime_dispose span exported for any session opened here",
      +          ),
      +          // The eviction request is fire-and-forget (`ctx.waitUntil`) from the
      +          // evictor's `init`, and its own span flush is off that same
      +          // background path — same polling grace the idle-disposal scenario
      +          // uses for its alarm-driven flush.
      +          Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(40))),
      +        );
      +
      +      expect(
      +        capDisposals.length,
      +        "crossing the resident-runtime cap evicted at least one session opened here",
      +      ).toBeGreaterThan(0);
      +
      +      const disposal = capDisposals[0]!;
      +      expect(
      +        disposal.span.tags["mcp.isolate.resident_runtimes"],
      +        "the cap disposal records the isolate's resident-runtime gauge, same as the idle path",
      +      ).toBeDefined();
      +
      +      // ---- the evicted session still works — restore is transparent -------
      +      const evictedSessionId = sessionIds.find((id) =>
      +        (disposal.span.tags["mcp.session.id"] ?? "").includes(id),
      +      );
      +      expect(
      +        evictedSessionId,
      +        "the disposed span's session id matches a session opened here",
      +      ).toBeDefined();
      +
      +      const marker = `after-cap-evict-${evictedSessionId}`;
      +      const restored = yield* Effect.promise(() =>
      +        execute(
      +          target.mcpUrl,
      +          bearer,
      +          evictedSessionId!,
      +          "execute-after-cap-eviction",
      +          `return ${JSON.stringify(marker)};`,
      +        ),
      +      );
      +      expect(
      +        restored,
      +        "the evicted session serves the next call correctly after restoring underneath the client",
      +      ).toContain(marker);
      +    });
      +
      +    yield* scenarioBody.pipe(
      +      // `Effect.ensuring`, not a trailing statement: a failure partway through
      +      // (an assertion above, a timed-out span search) must not leak the
      +      // sessions already opened. Read `openedSessionIds` at cleanup time, not
      +      // capture time — `Effect.suspend` so the array is read when the
      +      // finalizer actually runs, after the scenario body has finished pushing
      +      // to it, rather than snapshotted empty at construction.
      +      Effect.ensuring(
      +        Effect.suspend(() =>
      +          Effect.forEach(
      +            openedSessionIds,
      +            (sessionId) =>
      +              Effect.tryPromise(async () => {
      +                const closed = await fetch(target.mcpUrl, {
      +                  method: "DELETE",
      +                  headers: {
      +                    authorization: `Bearer ${bearer}`,
      +                    "mcp-session-id": sessionId,
      +                  },
      +                });
      +                await closed.text();
      +              }).pipe(Effect.ignore),
      +            { concurrency: 8, discard: true },
      +          ),
      +        ),
      +      ),
      +    );
      +  }),
      +);
      diff --git a/e2e/cloud/mcp-session-cold-init.test.ts b/e2e/cloud/mcp-session-cold-init.test.ts
      new file mode 100644
      index 0000000000..c24d4368bd
      --- /dev/null
      +++ b/e2e/cloud/mcp-session-cold-init.test.ts
      @@ -0,0 +1,191 @@
      +// Cloud: the org identity a session needs at init travels in the session props
      +// the worker already resolved — it is NOT re-read from Postgres inside the
      +// session Durable Object.
      +//
      +// The defect this pins: on EVERY cold DO init the DO opened a brand-new
      +// Postgres connection purely to re-read the `organizations` row the worker had
      +// just read microseconds earlier on the same request (the worker threw it away:
      +// the auth principal hardcoded an empty organization name). When that fresh
      +// connection could not be established the whole `initialize` died — a hard,
      +// client-visible failure on a healthy database, because the only unhealthy
      +// thing was a socket nothing needed to open.
      +//
      +// Two contracts, in the order a user meets them:
      +//
      +//   1. the session opens and WORKS — initialize mints a session id and the same
      +//      id then serves `tools/list`, all off the identity the props carried;
      +//   2. the whole request performs exactly ONE organization read (the worker's
      +//      own authorization check) and the DO opens no database connection of its
      +//      own to name the org.
      +//
      +// (2) is asserted on the EXPORTED spans, and the two planes — worker and
      +// Durable Object — export independently, so the count is only taken once a
      +// worker-plane span for this same request has landed. Without that wait a
      +// still-pending worker batch would make a two-read request look like a one-read
      +// request, and the assertion would pass for the wrong reason.
      +//
      +// The failure half of the fix (an unreachable directory becomes a bounded retry
      +// and a retryable 503 rather than a bare 500) is not reachable from here: the
      +// harness runs one single-process PGlite shared by the worker and the DO, and
      +// the worker's own authorization reads that same row on the same request — so
      +// freezing the database fails the request before init identically on both sides
      +// of the fix. Those branches are pinned in
      +// `apps/cloud/src/mcp/session-meta.node.test.ts`.
      +import { randomBytes } from "node:crypto";
      +
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +
      +import { scenario } from "../src/scenario";
      +import { Mcp, Target, Telemetry } from "../src/services";
      +import type { Identity } from "../src/target";
      +
      +const JSON_AND_SSE = "application/json, text/event-stream";
      +
      +const INITIALIZE_REQUEST = {
      +  jsonrpc: "2.0" as const,
      +  id: 1,
      +  method: "initialize",
      +  params: {
      +    protocolVersion: "2025-03-26",
      +    capabilities: {},
      +    clientInfo: { name: "executor-e2e-session-cold-init", version: "0.0.1" },
      +  },
      +};
      +
      +const INITIALIZED_NOTIFICATION = {
      +  jsonrpc: "2.0" as const,
      +  method: "notifications/initialized",
      +};
      +
      +const TOOLS_LIST_REQUEST = {
      +  jsonrpc: "2.0" as const,
      +  id: 2,
      +  method: "tools/list",
      +  params: {},
      +};
      +
      +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
      +
      +/** A client-supplied W3C trace context, so every span this one request produces
      + *  — worker plane and Durable Object alike — is addressable by one trace id. */
      +const newTraceContext = (): { readonly traceId: string; readonly traceparent: string } => {
      +  const traceId = randomBytes(16).toString("hex");
      +  const spanId = randomBytes(8).toString("hex");
      +  return { traceId, traceparent: `00-${traceId}-${spanId}-01` };
      +};
      +
      +const mcpPost = (
      +  url: string,
      +  init: {
      +    readonly bearer: string;
      +    readonly sessionId?: string;
      +    readonly traceparent?: string;
      +    readonly body: unknown;
      +  },
      +): Promise =>
      +  fetch(url, {
      +    method: "POST",
      +    headers: {
      +      accept: JSON_AND_SSE,
      +      "content-type": "application/json",
      +      authorization: `Bearer ${init.bearer}`,
      +      ...(init.sessionId ? { "mcp-session-id": init.sessionId } : {}),
      +      ...(init.traceparent ? { traceparent: init.traceparent } : {}),
      +    },
      +    body: JSON.stringify(init.body),
      +  });
      +
      +scenario(
      +  "MCP session cold init · the org identity rides in the session props instead of a second Postgres read",
      +  { timeout: 120_000 },
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const mcp = yield* Mcp;
      +    const telemetry = yield* Telemetry;
      +
      +    const identity = yield* target.newIdentity();
      +    const bearer = yield* mcp.mintBearer(emailOf(identity));
      +    const trace = newTraceContext();
      +
      +    // ---- 1. the session opens, and it works -------------------------------
      +    const response = yield* Effect.promise(() =>
      +      mcpPost(target.mcpUrl, {
      +        bearer,
      +        traceparent: trace.traceparent,
      +        body: INITIALIZE_REQUEST,
      +      }),
      +    );
      +    yield* Effect.promise(() => response.text());
      +    expect(response.status, "initialize opens a session").toBe(200);
      +    const sessionId = response.headers.get("mcp-session-id");
      +    expect(sessionId, "the session id is minted").toBeTruthy();
      +
      +    const initialized = yield* Effect.promise(() =>
      +      mcpPost(target.mcpUrl, {
      +        bearer,
      +        sessionId: sessionId ?? "",
      +        body: INITIALIZED_NOTIFICATION,
      +      }),
      +    );
      +    yield* Effect.promise(() => initialized.text());
      +    expect(initialized.status, "the client completes the handshake").toBe(202);
      +
      +    // The session built from the props-carried identity actually serves work —
      +    // the production symptom was an `initialize` that never got this far.
      +    const tools = yield* Effect.promise(() =>
      +      mcpPost(target.mcpUrl, {
      +        bearer,
      +        sessionId: sessionId ?? "",
      +        body: TOOLS_LIST_REQUEST,
      +      }),
      +    );
      +    const toolsBody = yield* Effect.promise(() => tools.text());
      +    expect(tools.status, "the session serves requests once open").toBe(200);
      +    expect(toolsBody, "the session advertises the execute tool").toContain("execute");
      +
      +    // ---- 2. one organization read for the whole init request --------------
      +    // The DO really did resolve meta on this request (a cold init), and it
      +    // resolved it from the props the worker handed over.
      +    const resolveSpan = yield* telemetry.expectSpan({
      +      traceId: trace.traceId,
      +      operation: "McpSessionDOSqlite.resolveSessionMeta",
      +    });
      +    expect(resolveSpan.span.status, "the cold init resolved its meta without failing").toBe("ok");
      +
      +    // The worker plane exports on its own batch, independently of the DO's.
      +    // Wait for a worker-plane span from this same request before counting, or
      +    // an unflushed worker batch would hide the very read being counted.
      +    yield* telemetry.expectSpan({ traceId: trace.traceId, operation: "mcp.request" });
      +
      +    // One org read for the whole request: the worker's own authorization
      +    // lookup. A second one means the DO reopened a connection to re-read a row
      +    // the request already had.
      +    const orgReads = yield* telemetry.searchSpans({
      +      traceId: trace.traceId,
      +      operation: "user_store.getOrganization",
      +    });
      +    expect(
      +      orgReads.length,
      +      "only the worker's authorization check reads the organization row",
      +    ).toBe(1);
      +
      +    // …and the DO's own database step never ran at all. `resolveSessionMeta`
      +    // has already landed and this span is its child, so its absence here is
      +    // absence, not lag.
      +    const doDatabaseReads = yield* telemetry.searchSpans({
      +      traceId: trace.traceId,
      +      operation: "mcp.session.resolve_organization",
      +    });
      +    expect(
      +      doDatabaseReads.length,
      +      "the session DO opens no connection of its own to name the organization",
      +    ).toBe(0);
      +
      +    // …because the identity it used is the one the worker handed it.
      +    expect(
      +      resolveSpan.span.tags["mcp.session.meta_source"],
      +      "the session meta comes from the props the worker already resolved",
      +    ).toBe("props");
      +  }),
      +);
      diff --git a/e2e/cloud/mcp-session-idle-runtime-disposal.test.ts b/e2e/cloud/mcp-session-idle-runtime-disposal.test.ts
      new file mode 100644
      index 0000000000..84188f5029
      --- /dev/null
      +++ b/e2e/cloud/mcp-session-idle-runtime-disposal.test.ts
      @@ -0,0 +1,252 @@
      +// Cloud: an idle MCP session gives its execution runtime back to the isolate
      +// even while the client is still holding a stream open.
      +//
      +// The defect this pins: the idle timeout never actually ran. The session armed
      +// an idle alarm on every request, but the agents framework recomputes the
      +// Durable Object alarm from its own schedule table and keep-alive refcount and
      +// DELETES it when it finds neither — which it does at the end of every ordinary
      +// tool call, from a `waitUntil` just after the response goes out. So a session
      +// that had just served a request was left with no alarm at all, its idle
      +// deadline silently dropped, and its execution runtime resident until the
      +// platform evicted the whole object. Durable Objects share one isolate heap, so
      +// enough never-reclaimed runtimes exhaust it and the next allocation anywhere
      +// in that isolate fails.
      +//
      +// The contract asserted here, in three parts:
      +//
      +//   1. an idle session's runtime IS released, and the exported span says so.
      +//      Before the fix this span never appeared at all, for any session;
      +//   2. the release reports the isolate's resident-runtime gauge, which is what
      +//      makes the mechanism confirmable in production rather than inferred;
      +//   3. the very next call on the SAME session works and returns the right
      +//      answer, restoring the runtime underneath the client transparently.
      +//
      +// A client stream is deliberately held open across the idle window, because
      +// that is the shape real clients have. Note what it does NOT do: it does not
      +// keep the runtime alive. The session Durable Object holds no connection for a
      +// standalone GET stream (`ctx.getWebSockets()` is empty), so the stream is not
      +// visible to the idle policy at all, and reclaiming the runtime ends it — the
      +// client reconnects and replays, which is the behaviour `mcp-sse-replay` pins.
      +//
      +// The out-of-memory failure itself is not reachable from a black-box test —
      +// filling a shared isolate heap on demand is not something a client can ask
      +// for — so reclamation, not the allocation failure, is the testable contract.
      +import { randomUUID } from "node:crypto";
      +
      +import { expect } from "@effect/vitest";
      +import { Effect, Schedule } from "effect";
      +
      +import { scenario } from "../src/scenario";
      +import { Mcp, Target, Telemetry } from "../src/services";
      +import type { Identity } from "../src/target";
      +import { configuredMcpSessionTimeoutMs } from "../setup/mcp-session-timeouts";
      +
      +const PROTOCOL_VERSION = "2025-03-26";
      +const JSON_AND_SSE = "application/json, text/event-stream";
      +
      +/** Past the idle window with room for the alarm to actually fire. */
      +const IDLE_GAP_MS = configuredMcpSessionTimeoutMs() + 4_000;
      +
      +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
      +
      +const mcpHeaders = (bearer: string, sessionId?: string) => ({
      +  accept: JSON_AND_SSE,
      +  authorization: `Bearer ${bearer}`,
      +  "content-type": "application/json",
      +  "mcp-protocol-version": PROTOCOL_VERSION,
      +  ...(sessionId ? { "mcp-session-id": sessionId } : {}),
      +});
      +
      +const postJson = (mcpUrl: string, bearer: string, body: unknown, sessionId?: string) =>
      +  fetch(mcpUrl, {
      +    method: "POST",
      +    headers: mcpHeaders(bearer, sessionId),
      +    body: JSON.stringify(body),
      +  });
      +
      +const openSession = async (mcpUrl: string, bearer: string): Promise => {
      +  const initialized = await postJson(mcpUrl, bearer, {
      +    jsonrpc: "2.0" as const,
      +    id: "initialize",
      +    method: "initialize",
      +    params: {
      +      protocolVersion: PROTOCOL_VERSION,
      +      capabilities: {},
      +      clientInfo: { name: "executor-e2e-idle-runtime-disposal", version: "0.0.1" },
      +    },
      +  });
      +  const sessionId = initialized.headers.get("mcp-session-id");
      +  await initialized.text();
      +  expect(initialized.status, "initialize opens a session").toBe(200);
      +  if (!sessionId) {
      +    // oxlint-disable-next-line executor/no-error-constructor -- boundary: e2e setup precondition.
      +    throw new Error("openSession: no mcp-session-id header");
      +  }
      +  const notification = await postJson(
      +    mcpUrl,
      +    bearer,
      +    { jsonrpc: "2.0" as const, method: "notifications/initialized" },
      +    sessionId,
      +  );
      +  await notification.text();
      +  expect(notification.status, "the client completes the handshake").toBe(202);
      +  return sessionId;
      +};
      +
      +/**
      + * The standalone GET stream a real client leaves open for the whole
      + * conversation. Held open (never aborted) across the idle window — that is the
      + * exact condition the old policy read as "busy".
      + */
      +type OpenStream = {
      +  readonly closed: () => boolean;
      +  readonly abort: () => void;
      +};
      +
      +const openGetStream = async (
      +  mcpUrl: string,
      +  bearer: string,
      +  sessionId: string,
      +): Promise => {
      +  const abortController = new AbortController();
      +  const response = await fetch(mcpUrl, {
      +    method: "GET",
      +    headers: {
      +      accept: "text/event-stream",
      +      authorization: `Bearer ${bearer}`,
      +      "mcp-protocol-version": PROTOCOL_VERSION,
      +      "mcp-session-id": sessionId,
      +    },
      +    signal: abortController.signal,
      +  });
      +  expect(response.status, "the standalone GET stream opens").toBe(200);
      +
      +  let closed = false;
      +  const reader = response.body?.getReader();
      +  // Drain in the background. The stream ending — for any reason — flips
      +  // `closed`, which is what part (2) of the contract inspects.
      +  void (async () => {
      +    if (!reader) {
      +      closed = true;
      +      return;
      +    }
      +    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: reading an aborted stream throws; either way the stream is over.
      +    try {
      +      for (;;) {
      +        const { done } = await reader.read();
      +        if (done) break;
      +      }
      +    } catch {
      +      // fall through
      +    }
      +    closed = true;
      +  })();
      +
      +  return {
      +    closed: () => closed,
      +    abort: () => abortController.abort("scenario complete"),
      +  };
      +};
      +
      +const executeBody = (id: string, code: string) => ({
      +  jsonrpc: "2.0" as const,
      +  id,
      +  method: "tools/call",
      +  params: { name: "execute", arguments: { code } },
      +});
      +
      +/** Run `execute` and return the response text once the call has fully settled. */
      +const execute = async (
      +  mcpUrl: string,
      +  bearer: string,
      +  sessionId: string,
      +  id: string,
      +  code: string,
      +): Promise => {
      +  const response = await postJson(mcpUrl, bearer, executeBody(id, code), sessionId);
      +  const body = await response.text();
      +  expect(response.status, `execute ${id} is served`).toBe(200);
      +  return body;
      +};
      +
      +scenario(
      +  "MCP session · an idle session disposes its runtime while the client stream stays open",
      +  { timeout: 180_000 },
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const mcp = yield* Mcp;
      +    const telemetry = yield* Telemetry;
      +
      +    const identity = yield* target.newIdentity();
      +    const bearer = yield* mcp.mintBearer(emailOf(identity));
      +
      +    const sessionId = yield* Effect.promise(() => openSession(target.mcpUrl, bearer));
      +
      +    // The long-lived client stream, opened before any work and never closed.
      +    const stream = yield* Effect.promise(() => openGetStream(target.mcpUrl, bearer, sessionId));
      +
      +    // ---- one call, then silence ------------------------------------------
      +    const marker = `before-${randomUUID().slice(0, 8)}`;
      +    const first = yield* Effect.promise(() =>
      +      execute(
      +        target.mcpUrl,
      +        bearer,
      +        sessionId,
      +        "execute-before-idle",
      +        `return ${JSON.stringify(marker)};`,
      +      ),
      +    );
      +    expect(first, "the session executes before going idle").toContain(marker);
      +
      +    // ---- go idle, with the stream still connected -------------------------
      +    yield* Effect.sleep(`${IDLE_GAP_MS} millis`);
      +
      +    // ---- 1. the runtime was released --------------------------------------
      +    // The alarm carries no client trace context, so this is found by operation
      +    // and matched to this session by attribute rather than by trace id.
      +    const disposals = yield* telemetry
      +      .searchSpans({ operation: "mcp.session.idle_runtime_dispose" })
      +      .pipe(
      +        Effect.map((spans) =>
      +          spans.filter((span) => (span.span.tags["mcp.session.id"] ?? "").includes(sessionId)),
      +        ),
      +        Effect.filterOrFail(
      +          (spans) => spans.length > 0,
      +          () => `no idle-runtime-disposal span exported for session ${sessionId}`,
      +        ),
      +        // The span is exported off the alarm's own flush, which is not on any
      +        // response path — same polling grace `expectSpan` uses.
      +        Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(40))),
      +      );
      +
      +    expect(disposals.length, "the idle session released its execution runtime").toBeGreaterThan(0);
      +
      +    const disposal = disposals[0]!;
      +    expect(
      +      Number(disposal.span.tags["mcp.session.idle_ms"]),
      +      "the runtime was released because the session went idle, not for some other reason",
      +    ).toBeGreaterThanOrEqual(configuredMcpSessionTimeoutMs());
      +    expect(
      +      disposal.span.tags["mcp.isolate.resident_runtimes"],
      +      "the disposal records the isolate's resident-runtime gauge",
      +    ).toBeDefined();
      +
      +    // ---- 3. the next call restores underneath the client ------------------
      +    const after = `after-${randomUUID().slice(0, 8)}`;
      +    const second = yield* Effect.promise(() =>
      +      execute(
      +        target.mcpUrl,
      +        bearer,
      +        sessionId,
      +        "execute-after-idle",
      +        `return ${JSON.stringify(after)};`,
      +      ),
      +    );
      +    expect(
      +      second,
      +      "the same session serves the next call with the right answer after restoring",
      +    ).toContain(after);
      +
      +    stream.abort();
      +  }),
      +);
      diff --git a/e2e/cloud/mcp-sse-replay.test.ts b/e2e/cloud/mcp-sse-replay.test.ts
      index 3ab6722961..a0df02bf65 100644
      --- a/e2e/cloud/mcp-sse-replay.test.ts
      +++ b/e2e/cloud/mcp-sse-replay.test.ts
      @@ -31,13 +31,20 @@ const initializedNotification = {
         method: "notifications/initialized",
       };
       
      -const executeBody = (id: string, code: string) => ({
      +const executeBody = (id: string | number, code: string) => ({
         jsonrpc: "2.0" as const,
         id,
         method: "tools/call",
         params: { name: "execute", arguments: { code } },
       });
       
      +const toolsListBody = (id: number) => ({
      +  jsonrpc: "2.0" as const,
      +  id,
      +  method: "tools/list",
      +  params: {},
      +});
      +
       const mcpHeaders = (bearer: string, sessionId?: string) => ({
         accept: JSON_AND_SSE,
         authorization: `Bearer ${bearer}`,
      @@ -67,6 +74,8 @@ const openSession = async (mcpUrl: string, bearer: string): Promise => {
         return sessionId;
       };
       
      +type JsonRpcId = string | number;
      +
       type JsonRpcMessage = {
         readonly id?: unknown;
         readonly result?: unknown;
      @@ -78,9 +87,10 @@ class SseCapture {
         readonly eventIds: string[] = [];
         private reader: ReadableStreamDefaultReader | null = null;
         private readonly waiters = new Map<
      -    string,
      +    JsonRpcId,
           Array<{ readonly resolve: (message: JsonRpcMessage) => void }>
         >();
      +  private responseWaiters: Array<{ readonly resolve: (message: JsonRpcMessage) => void }> = [];
         readonly finished: Promise;
       
         constructor(
      @@ -90,7 +100,7 @@ class SseCapture {
           this.finished = this.consume();
         }
       
      -  waitForId(id: string, timeoutMs: number): Promise {
      +  waitForId(id: JsonRpcId, timeoutMs: number): Promise {
           const existing = this.messages.find((message) => message.id === id);
           if (existing) return Promise.resolve(existing);
           return new Promise((resolve, reject) => {
      @@ -108,6 +118,25 @@ class SseCapture {
           });
         }
       
      +  waitForFirstResponse(timeoutMs: number): Promise {
      +    const existing = this.messages.find(
      +      (message) => typeof message.id === "string" || typeof message.id === "number",
      +    );
      +    if (existing) return Promise.resolve(existing);
      +    return new Promise((resolve, reject) => {
      +      const timeout = setTimeout(() => {
      +        // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: Promise timeout adapter for e2e polling.
      +        reject(new Error("timed out waiting for the first JSON-RPC response"));
      +      }, timeoutMs);
      +      this.responseWaiters.push({
      +        resolve: (message) => {
      +          clearTimeout(timeout);
      +          resolve(message);
      +        },
      +      });
      +    });
      +  }
      +
         abort(reason: string): void {
           this.abortController?.abort(reason);
           this.reader?.cancel(reason).catch(() => undefined);
      @@ -159,14 +188,22 @@ class SseCapture {
           if (!trimmed) return;
           const parsed = JSON.parse(trimmed) as JsonRpcMessage;
           this.messages.push(parsed);
      -    if (typeof parsed.id !== "string") return;
      +    if (typeof parsed.id !== "string" && typeof parsed.id !== "number") return;
      +    const responseWaiters = this.responseWaiters;
      +    this.responseWaiters = [];
      +    for (const waiter of responseWaiters) waiter.resolve(parsed);
           const waiters = this.waiters.get(parsed.id) ?? [];
           this.waiters.delete(parsed.id);
           for (const waiter of waiters) waiter.resolve(parsed);
         }
       }
       
      -const openGet = async (mcpUrl: string, bearer: string, sessionId: string): Promise => {
      +const openGet = async (
      +  mcpUrl: string,
      +  bearer: string,
      +  sessionId: string,
      +  lastEventId?: string,
      +): Promise => {
         const abortController = new AbortController();
         const response = await fetch(mcpUrl, {
           method: "GET",
      @@ -175,6 +212,7 @@ const openGet = async (mcpUrl: string, bearer: string, sessionId: string): Promi
             authorization: `Bearer ${bearer}`,
             "mcp-protocol-version": PROTOCOL_VERSION,
             "mcp-session-id": sessionId,
      +      ...(lastEventId ? { "last-event-id": lastEventId } : {}),
           },
           signal: abortController.signal,
         });
      @@ -246,6 +284,78 @@ scenario(
         }),
       );
       
      +scenario(
      +  "MCP streamable HTTP · Last-Event-ID recovery stays scoped to its originating request",
      +  { timeout: 90_000 },
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const mcp = yield* Mcp;
      +    const identity = yield* target.newIdentity();
      +    const bearer = yield* mcp.mintBearer(emailOf(identity));
      +    const sessionId = yield* Effect.promise(() => openSession(target.mcpUrl, bearer));
      +
      +    // Initialize's POST response is intentionally retained as undelivered.
      +    // Drain it through the cursorless compatibility path first so id=20 below
      +    // is the only unrelated response available to expose cross-stream replay.
      +    const initializeReplay = yield* Effect.promise(() => openGet(target.mcpUrl, bearer, sessionId));
      +    yield* Effect.promise(() => initializeReplay.waitForId("initialize", 15_000));
      +    yield* Effect.promise(() => initializeReplay.finished);
      +    yield* Effect.promise(() => delay(200));
      +
      +    const expectedId = 21;
      +    const unrelatedId = 20;
      +    const marker = `MARKER_STREAM_SCOPED_${randomUUID()}`;
      +    const interrupted = new AbortController();
      +    const expectedPost = yield* Effect.promise(() =>
      +      startPostCapture(
      +        target.mcpUrl,
      +        bearer,
      +        sessionId,
      +        executeBody(expectedId, delayedCode(marker, 5_000)),
      +        interrupted,
      +      ),
      +    );
      +
      +    // The priming event is always the first event on this POST stream. Its id
      +    // is the cursor the real SDK carries when that stream is interrupted.
      +    yield* Effect.promise(async () => {
      +      const deadline = Date.now() + 10_000;
      +      while (expectedPost.eventIds.length === 0 && Date.now() < deadline) await delay(25);
      +      if (expectedPost.eventIds.length === 0) {
      +        throw new Error("timed out waiting for the expected POST priming cursor");
      +      }
      +    });
      +    const expectedCursor = expectedPost.eventIds[0];
      +    if (!expectedCursor) return yield* Effect.die("expected POST priming cursor is missing");
      +
      +    // Fully consume another POST response. workerd cannot prove that delivery,
      +    // so Executor deliberately leaves id=20 persisted and marked undelivered.
      +    const unrelated = yield* Effect.promise(() =>
      +      postJson(target.mcpUrl, bearer, toolsListBody(unrelatedId), sessionId),
      +    );
      +    yield* Effect.promise(() => unrelated.text());
      +    expect(unrelated.status, "the unrelated tools/list completed normally").toBe(200);
      +
      +    interrupted.abort("simulate the id=21 POST stream disconnecting after priming");
      +    yield* Effect.promise(() => expectedPost.finished.catch(() => undefined));
      +
      +    const recovery = yield* Effect.promise(() =>
      +      openGet(target.mcpUrl, bearer, sessionId, expectedCursor),
      +    );
      +    const firstResponse = yield* Effect.promise(() => recovery.waitForFirstResponse(15_000));
      +    recovery.abort("scenario complete");
      +
      +    expect(
      +      firstResponse.id,
      +      "a targeted recovery never receives the unrelated persisted response",
      +    ).toBe(expectedId);
      +    expect(
      +      JSON.stringify(firstResponse),
      +      "the targeted request receives its own completed result",
      +    ).toContain(marker);
      +  }),
      +);
      +
       scenario(
         "MCP streamable HTTP · in-flight call survives the session idle timeout",
         { timeout: 90_000 },
      diff --git a/e2e/cloud/mcp-workspace-write-permissions.test.ts b/e2e/cloud/mcp-workspace-write-permissions.test.ts
      new file mode 100644
      index 0000000000..7c13c8c16e
      --- /dev/null
      +++ b/e2e/cloud/mcp-workspace-write-permissions.test.ts
      @@ -0,0 +1,82 @@
      +import { randomBytes } from "node:crypto";
      +
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +import { composePluginApi } from "@executor-js/api/server";
      +
      +import { scenario } from "../src/scenario";
      +import { Api, Mcp, Target } from "../src/services";
      +import { joinOrg } from "./support/session";
      +
      +const api = composePluginApi([] as const);
      +
      +const createPolicyCode = (pattern: string): string => `
      +const created = await tools.executor.coreTools.policies.create({
      +  owner: "org",
      +  pattern: ${JSON.stringify(pattern)},
      +  action: "approve"
      +});
      +return JSON.stringify(created);
      +`;
      +
      +scenario(
      +  "MCP workspace writes · a member session is denied while an admin session succeeds",
      +  { timeout: 180_000 },
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const mcp = yield* Mcp;
      +    const { client: makeClient } = yield* Api;
      +    const admin = yield* target.newIdentity();
      +    const invitee = yield* target.newIdentity({ org: false });
      +    const member = yield* joinOrg(target, admin, invitee);
      +    const adminClient = yield* makeClient(api, admin);
      +    const pattern = `executor.mcp-role-${randomBytes(4).toString("hex")}.*`;
      +
      +    const cleanup = adminClient.policies.list().pipe(
      +      Effect.flatMap((policies) =>
      +        Effect.forEach(
      +          policies.filter((policy) => policy.pattern === pattern),
      +          (policy) =>
      +            adminClient.policies
      +              .remove({ params: { policyId: policy.id }, payload: { owner: "org" } })
      +              .pipe(Effect.ignore({ log: false })),
      +          { discard: true },
      +        ),
      +      ),
      +      Effect.ignore({ log: false }),
      +    );
      +
      +    yield* Effect.ensuring(
      +      Effect.gen(function* () {
      +        const memberSession = mcp.session(member);
      +        let denied = yield* memberSession.call("execute", { code: createPolicyCode(pattern) });
      +        if (denied.text.includes("Execution paused")) {
      +          denied = yield* memberSession.approvePaused(denied.text);
      +        }
      +        expect(
      +          denied.text,
      +          "the denial preserves the workspace-admin authorization failure",
      +        ).toMatch(/OrgWriteDenied|administrator|admin/i);
      +        expect(
      +          (yield* adminClient.policies.list()).some((policy) => policy.pattern === pattern),
      +          "the member's denied MCP call persisted no Workspace policy",
      +        ).toBe(false);
      +
      +        const adminSession = mcp.session(admin);
      +        let allowed = yield* adminSession.call("execute", { code: createPolicyCode(pattern) });
      +        if (allowed.text.includes("Execution paused")) {
      +          allowed = yield* adminSession.approvePaused(allowed.text);
      +        }
      +        expect(allowed.ok, "the admin's MCP workspace-write call succeeds").toBe(true);
      +        expect(allowed.text, "the created policy is returned over the MCP session").toContain(
      +          pattern,
      +        );
      +        expect(
      +          (yield* adminClient.policies.list()).some((policy) => policy.pattern === pattern),
      +          "the admin's MCP call persisted the Workspace policy",
      +        ).toBe(true);
      +      }),
      +      cleanup,
      +    );
      +  }),
      +);
      diff --git a/e2e/cloud/member-invite-seat-limit.test.ts b/e2e/cloud/member-invite-seat-limit.test.ts
      index 470bb9a019..e465c1396e 100644
      --- a/e2e/cloud/member-invite-seat-limit.test.ts
      +++ b/e2e/cloud/member-invite-seat-limit.test.ts
      @@ -23,6 +23,7 @@ import { AccountHttpApi } from "@executor-js/api";
       
       import { scenario } from "../src/scenario";
       import { Api, Billing, Browser, Target } from "../src/services";
      +import { visit } from "../src/surfaces/browser";
       
       // apps/cloud/src/extensions/billing/plans.ts → MEMBER_LIMITS.free
       const FREE_MEMBER_SEATS = 3;
      @@ -45,7 +46,7 @@ scenario(
             let slug = "";
       
             await step("Land in the app and canonicalize onto the org slug", async () => {
      -        await page.goto("/", { waitUntil: "networkidle" });
      +        await visit(page, "/");
               await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
                 timeout: 30_000,
               });
      @@ -54,7 +55,7 @@ scenario(
             });
       
             await step("Open the organization members page", async () => {
      -        await page.goto(`/${slug}/org`, { waitUntil: "networkidle" });
      +        await visit(page, `/${slug}/org`);
               await page.getByRole("button", { name: "Invite member" }).waitFor();
             });
       
      diff --git a/e2e/cloud/member-seat-billing-sync.test.ts b/e2e/cloud/member-seat-billing-sync.test.ts
      new file mode 100644
      index 0000000000..28922a6dba
      --- /dev/null
      +++ b/e2e/cloud/member-seat-billing-sync.test.ts
      @@ -0,0 +1,107 @@
      +// Cloud-only (billing): seat-based pricing bills the seat count Autumn holds,
      +// so that count must actually REACH Autumn. Seats change through paths the
      +// app never sees a mutation for (invitation acceptance in AuthKit, SSO JIT
      +// provisioning, join by domain), so the app reconciles instead of tracking
      +// deltas: it recounts active members from WorkOS and SETS the members balance
      +// (`balances.update`), forked off org creation, login, invitation acceptance,
      +// and member removal.
      +//
      +// Three guarantees are pinned here, all read from Autumn's own state (the
      +// mutating responses can never show them):
      +//
      +//   1. creating an organization lands its creator's seat on the members
      +//      balance,
      +//   2. a pending invite occupies a plan-gate seat but is NOT billed — active
      +//      members only, and
      +//   3. a later membership mutation reconciles rather than increments: the
      +//      recount after removing the pending membership still reports exactly
      +//      the active seats.
      +import { expect } from "@effect/vitest";
      +import { Effect, Schedule } from "effect";
      +import { AccountHttpApi } from "@executor-js/api";
      +
      +import { scenario } from "../src/scenario";
      +import { Api, Autumn, Billing, Mcp, Target } from "../src/services";
      +import type { Identity } from "../src/target";
      +
      +// apps/cloud/src/extensions/billing/plans.ts → MEMBER_LIMITS.free, mirrored by
      +// the free plan's members item in autumn.config.ts.
      +const FREE_MEMBER_SEATS = 3;
      +
      +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
      +
      +/** The org the bearer is scoped to — the Autumn customer id every billing call
      + *  is made against — read from the JWT's public claims. */
      +const orgIdOf = (bearer: string): string => {
      +  const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as {
      +    readonly org_id?: string;
      +  };
      +  if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim");
      +  return claims.org_id;
      +};
      +
      +scenario(
      +  "Billing · the billed seat count reaches Autumn: active members only, reconciled not incremented",
      +  { timeout: 180_000 },
      +  Effect.gen(function* () {
      +    yield* Billing;
      +    const autumn = yield* Autumn;
      +    const target = yield* Target;
      +    const mcp = yield* Mcp;
      +    const { client: apiClient } = yield* Api;
      +
      +    // A fresh user who owns a brand-new free org. Creating the org forks the
      +    // first seat recount, so the creator's seat is on the meter before anyone
      +    // is invited.
      +    const identity = yield* target.newIdentity();
      +    const bearer = yield* mcp.mintBearer(emailOf(identity));
      +    const customerId = orgIdOf(bearer);
      +    const client = yield* apiClient(AccountHttpApi, identity);
      +
      +    const seats = yield* autumn.expectMemberSeats(customerId, 1);
      +    expect(seats.granted, "the free plan's members item grants the advertised seats").toBe(
      +      FREE_MEMBER_SEATS,
      +    );
      +    expect(seats.unlimited, "free-plan seats are capped, not unlimited").toBe(false);
      +
      +    // An outstanding invite: the plan gate charges it a seat (so the org
      +    // cannot invite past the cap), but billing counts active members only —
      +    // nobody pays for a person who has not joined.
      +    yield* client.account.inviteMember({ payload: { email: "invited@example.com" } });
      +    const { members, seats: gateSeats } = yield* client.account.listMembers();
      +    expect(gateSeats?.used, "the plan gate counts the pending invite as a seat").toBe(2);
      +    const billedWithPendingInvite = yield* autumn.memberSeats(customerId);
      +    expect(billedWithPendingInvite?.usage, "the pending invite is not billed").toBe(1);
      +
      +    // Removing the pending membership forks another recount. Its ledger entry
      +    // is the barrier that separates "not billed yet" from "never billed": once
      +    // the second reconciliation has landed, the balance is the recount's
      +    // answer, not a stale read.
      +    const pending = members.find((member) => member.status === "pending");
      +    expect(pending, "the invited person appears as a pending membership").toBeTruthy();
      +    yield* client.account.removeMember({ params: { membershipId: pending!.id } });
      +
      +    yield* autumn.ledgerFor("balances.update").pipe(
      +      Effect.map((entries) => entries.filter((entry) => entry.customerId === customerId)),
      +      Effect.filterOrFail(
      +        (entries) => entries.length >= 2,
      +        (entries) => `only ${entries.length}/2 seat reconciliations reached Autumn`,
      +      ),
      +      Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(40))),
      +    );
      +
      +    const reconciled = yield* autumn.memberSeats(customerId);
      +    expect(
      +      reconciled?.usage,
      +      "the recount reconciles to the active seats — it never counted the invite, so it has nothing to walk back",
      +    ).toBe(1);
      +
      +    // The balance moved by reconciliation events, not blind increments: one
      +    // adjustment for the creator's seat, and none for the no-op recount.
      +    const events = yield* autumn.usageEvents({ customerId, featureId: "members" });
      +    expect(
      +      events.map((event) => event.value),
      +      "one +1 adjustment for the creator; the no-op recount writes no event",
      +    ).toEqual([1]);
      +  }),
      +);
      diff --git a/e2e/cloud/oauth-callback-org-scope.test.ts b/e2e/cloud/oauth-callback-org-scope.test.ts
      index afe0214743..66f1f50415 100644
      --- a/e2e/cloud/oauth-callback-org-scope.test.ts
      +++ b/e2e/cloud/oauth-callback-org-scope.test.ts
      @@ -17,6 +17,7 @@ import { serveOAuthTestServer } from "@executor-js/sdk/testing";
       import { scenario } from "../src/scenario";
       import { Api, Browser, Target } from "../src/services";
       import type { Identity } from "../src/target";
      +import { visit } from "../src/surfaces/browser";
       
       const api = composePluginApi([openApiHttpPlugin()] as const);
       
      @@ -212,13 +213,13 @@ scenario(
             await installSameWindowOAuthPopup(page);
       
             await step("Land in the original organization", async () => {
      -        await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" });
      +        await visit(page, `/${orgA.slug}`);
               await expectOrgShell(page, orgA);
             });
       
             await step("The browser session is switched to another organization", async () => {
               await setWorkosSessionCookie(page, target.baseUrl, sessionB);
      -        await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
      +        await visit(page, `/${orgB.slug}`);
               await expectOrgShell(page, orgB);
             });
       
      @@ -232,9 +233,7 @@ scenario(
               // refetch, and leave "Connect with OAuth" disabled forever (the flake
               // this step used to have). Waiting for org A's shell before opening
               // the modal makes every modal fetch run under the settled org A scope.
      -        await page.goto(`/${orgA.slug}/integrations/${String(integration)}`, {
      -          waitUntil: "networkidle",
      -        });
      +        await visit(page, `/${orgA.slug}/integrations/${String(integration)}`);
               await page.getByRole("button", { name: new RegExp(escapeRegExp(orgA.name)) }).waitFor({
                 timeout: 30_000,
               });
      @@ -286,7 +285,7 @@ scenario(
             await step("The provider returns to the OAuth callback", async () => {
               const callbackUrl = await submitProviderLoginFromPage(page);
               callback = new URL(callbackUrl);
      -        const response = await page.goto(callbackUrl, { waitUntil: "networkidle" });
      +        const response = await visit(page, callbackUrl);
               expect(response?.status(), "the callback renders its popup result page").toBe(200);
             });
       
      diff --git a/e2e/cloud/onboarding-mcp-url.test.ts b/e2e/cloud/onboarding-mcp-url.test.ts
      index b1998668f2..84de5e0a70 100644
      --- a/e2e/cloud/onboarding-mcp-url.test.ts
      +++ b/e2e/cloud/onboarding-mcp-url.test.ts
      @@ -6,6 +6,7 @@ import { Effect } from "effect";
       
       import { scenario } from "../src/scenario";
       import { Browser, Target } from "../src/services";
      +import { visit } from "../src/surfaces/browser";
       
       scenario(
         "Onboarding · the MCP setup step hands the user their org-scoped MCP server URL",
      @@ -19,7 +20,7 @@ scenario(
             await step(
               "A fresh user without an org lands on the create-org onboarding page",
               async () => {
      -          await page.goto("/", { waitUntil: "networkidle" });
      +          await visit(page, "/");
                 // Step 1 of 2 — the org-name input is the landmark that proves we're on onboarding.
                 await page.getByPlaceholder("Northwind Labs").waitFor();
               },
      diff --git a/e2e/cloud/org-api-keys-console.test.ts b/e2e/cloud/org-api-keys-console.test.ts
      new file mode 100644
      index 0000000000..828dcb9afb
      --- /dev/null
      +++ b/e2e/cloud/org-api-keys-console.test.ts
      @@ -0,0 +1,142 @@
      +// Cloud-only: the Organization keys SECTION of the API keys page — the console
      +// surface over `/api/account/org-api-keys`, minting the machine credential for
      +// the tenant-wide admin plane.
      +//
      +// Two members are built through the real flows. The guarantees pinned here:
      +//
      +//   1. an ADMIN sees the section, mints an org key through the dialog, gets the
      +//      one-time reveal, and the minted value ACTUALLY authenticates the admin
      +//      API (the whole point of the credential);
      +//   2. the listing shows the key afterward and revoke asks for confirmation
      +//      before killing it — after which the value stops authenticating;
      +//   3. a PLAIN MEMBER never sees the section at all.
      +//
      +// Runs against emulate >= 0.13.9, whose WorkOS emulator serves the org-key
      +// routes (list/mint via /organizations/:id/api_keys, org-owner validation) —
      +// the gap that previously kept this scenario impossible.
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +
      +import { scenario } from "../src/scenario";
      +import { Browser, Target } from "../src/services";
      +import { forBrowser, joinOrg } from "./support/session";
      +import { visit } from "../src/surfaces/browser";
      +
      +declare global {
      +  interface Window {
      +    __e2eCopied?: Array;
      +  }
      +}
      +
      +scenario(
      +  "Admin · organization keys are minted in the console and authenticate the admin API",
      +  { timeout: 180_000 },
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const browser = yield* Browser;
      +
      +    const admin = yield* target.newIdentity();
      +    const invitee = yield* target.newIdentity({ org: false });
      +    const member = yield* joinOrg(target, admin, invitee);
      +
      +    let mintedValue = "";
      +
      +    // ── The admin's view: mint, verify, revoke ──────────────────────────────
      +    yield* browser.session(forBrowser(admin), async ({ page, step }) => {
      +      let slug = "";
      +
      +      await step("Land in the workspace and open the API keys page", async () => {
      +        await visit(page, "/");
      +        await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
      +          timeout: 30_000,
      +        });
      +        slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? "";
      +        await page.getByRole("link", { name: "API keys" }).click();
      +        await page.waitForURL((url) => url.pathname === `/${slug}/api-keys`, { timeout: 30_000 });
      +      });
      +
      +      await step("The admin is offered the Organization keys section", async () => {
      +        await page
      +          .getByRole("heading", { name: "Organization keys", exact: true })
      +          .waitFor({ state: "visible", timeout: 30_000 });
      +      });
      +
      +      await step("Mint an org key through the dialog and capture the reveal", async () => {
      +        await page.getByRole("button", { name: "New org key" }).click();
      +        const dialog = page.getByRole("dialog");
      +        await dialog.getByLabel("Name").fill("e2e backend reader");
      +        await dialog.getByRole("button", { name: "Create key" }).click();
      +
      +        // The one-time secret panel renders once the key exists; its first
      +        // input holds the plaintext value (same shape the personal-key
      +        // feedback scenario reads).
      +        await dialog.getByText("It is only shown once").waitFor({ timeout: 30_000 });
      +        mintedValue = await dialog.locator("input").first().inputValue();
      +        expect(mintedValue, "a real key value is revealed once").toMatch(/^sk_/);
      +        await dialog.getByRole("button", { name: "Close", exact: true }).first().click();
      +      });
      +
      +      await step("The minted key appears in the org listing", async () => {
      +        await page
      +          .getByText("e2e backend reader")
      +          .first()
      +          .waitFor({ state: "visible", timeout: 30_000 });
      +      });
      +
      +      await step("The minted value authenticates the tenant-wide admin API", async () => {
      +        // The credential's purpose, proven from outside the browser: a backend
      +        // holding ONLY this value can read the admin plane.
      +        const response = await fetch(new URL("/api/admin/users", target.baseUrl), {
      +          headers: { authorization: `Bearer ${mintedValue}` },
      +        });
      +        expect(response.status, "the org key reads the admin plane").toBe(200);
      +        const body = (await response.json()) as {
      +          users: ReadonlyArray<{ email: string | null }>;
      +        };
      +        expect(
      +          body.users.map((user) => user.email),
      +          "and sees the workspace's members",
      +        ).toContain(admin.credentials?.email);
      +      });
      +
      +      await step("Revoke asks for confirmation, then the key stops working", async () => {
      +        await page.getByRole("button", { name: "Revoke e2e backend reader" }).click();
      +        // The confirm dialog names the key and warns; nothing is revoked yet.
      +        await page
      +          .getByRole("heading", { name: "Revoke organization key" })
      +          .waitFor({ state: "visible", timeout: 30_000 });
      +        await page.getByRole("button", { name: "Revoke key" }).click();
      +        await page
      +          .getByRole("heading", { name: "Revoke organization key" })
      +          .waitFor({ state: "hidden", timeout: 30_000 });
      +
      +        // The revoked value no longer authenticates.
      +        const after = await fetch(new URL("/api/admin/users", target.baseUrl), {
      +          headers: { authorization: `Bearer ${mintedValue}` },
      +        });
      +        expect(after.status, "the revoked key is refused").toBe(401);
      +      });
      +    });
      +
      +    // ── The plain member's view ───────────────────────────────────────────
      +    yield* browser.session(forBrowser(member), async ({ page, step }) => {
      +      await step("A plain member is not shown the Organization keys section", async () => {
      +        await visit(page, "/");
      +        await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
      +          timeout: 30_000,
      +        });
      +        const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? "";
      +        await visit(page, `/${slug}/api-keys`);
      +        // The personal half renders for everyone…
      +        await page
      +          .getByRole("heading", { name: "Personal keys" })
      +          .waitFor({ state: "visible", timeout: 30_000 });
      +        // …the org section does not exist for a non-admin.
      +        expect(
      +          await page.getByRole("heading", { name: "Organization keys", exact: true }).count(),
      +          "a plain member is not shown a section that would only refuse them",
      +        ).toBe(0);
      +      });
      +    });
      +  }),
      +);
      diff --git a/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts
      index f18a6fc8c2..bb3f9f187c 100644
      --- a/e2e/cloud/org-delete.test.ts
      +++ b/e2e/cloud/org-delete.test.ts
      @@ -9,6 +9,7 @@ import { Effect } from "effect";
       
       import { scenario } from "../src/scenario";
       import { Browser, Target } from "../src/services";
      +import { visit, settle } from "../src/surfaces/browser";
       
       scenario(
         "Organizations · an admin deletes the organization from settings",
      @@ -22,7 +23,7 @@ scenario(
             const ORG = "Doomed Org";
       
             await step("Fresh user creates an org via onboarding", async () => {
      -        await page.goto("/", { waitUntil: "networkidle" });
      +        await visit(page, "/");
               await page.getByPlaceholder("Northwind Labs").fill(ORG);
               await page.getByRole("button", { name: "Create organization" }).click();
               await page.getByText("Connect your MCP client").waitFor();
      @@ -32,13 +33,13 @@ scenario(
               await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
                 timeout: 30_000,
               });
      -        await page.waitForLoadState("networkidle");
      +        await settle(page);
             });
       
             const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]!;
       
             await step("Open Organization settings and find the danger zone", async () => {
      -        await page.goto(`/${slug}/org`, { waitUntil: "networkidle" });
      +        await visit(page, `/${slug}/org`);
               // The admin-only danger zone renders (a member would not see it).
               await page.getByText("Permanently delete this organization").waitFor();
             });
      diff --git a/e2e/cloud/org-last-visited.test.ts b/e2e/cloud/org-last-visited.test.ts
      new file mode 100644
      index 0000000000..6912fce1cc
      --- /dev/null
      +++ b/e2e/cloud/org-last-visited.test.ts
      @@ -0,0 +1,96 @@
      +// Cloud-only (browser): a bare entry (`/`) lands in the org this browser LAST
      +// worked in, not the org pinned into the session cookie at login time.
      +//
      +// Org switching is stateless (the URL slug scopes every request; nothing
      +// rewrites the session cookie), so without extra memory a bare entry would
      +// always canonicalize onto the session's login-time org — for a multi-org user
      +// that reads as "executor.sh forgot which org I was on". The last-org cookie
      +// (`executor-last-org`) is that memory: the client records the slug of the org
      +// it is verifiably viewing, and the SSR gate redirects bare document requests
      +// onto it after re-checking live membership.
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +
      +import { scenario } from "../src/scenario";
      +import { Browser, Target } from "../src/services";
      +import { visit } from "../src/surfaces/browser";
      +
      +const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
      +
      +scenario(
      +  "Org URLs · a bare entry lands in the last-visited org, not the session's login org",
      +  {},
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const browser = yield* Browser;
      +
      +    // Identity starts in org A. Create org B through the real endpoint, which
      +    // returns the refreshed cookie — the SESSION is now pinned to org B, so
      +    // without the last-org cookie every bare entry would land in B.
      +    const identity = yield* target.newIdentity();
      +    const cookie = identity.headers?.cookie ?? "";
      +
      +    const createB = yield* Effect.promise(() =>
      +      fetch(new URL("/api/auth/create-organization", target.baseUrl), {
      +        method: "POST",
      +        headers: {
      +          "content-type": "application/json",
      +          cookie,
      +          ...CLOUD_ORIGIN_HEADERS(target.baseUrl),
      +        },
      +        body: JSON.stringify({ name: "Last Visited Org B" }),
      +      }),
      +    );
      +    expect(createB.ok, "org B was created").toBe(true);
      +    const orgB = (yield* Effect.promise(() => createB.json())) as { slug: string };
      +    const setCookie = createB.headers.get("set-cookie") ?? "";
      +    const sessionB = /wos-session=([^;]+)/.exec(setCookie)?.[1];
      +    expect(sessionB, "creating org B pinned the session into it").toBeTruthy();
      +
      +    const orgs = (yield* Effect.promise(() =>
      +      fetch(new URL("/api/auth/organizations", target.baseUrl), {
      +        headers: { cookie: `wos-session=${sessionB}` },
      +      }).then((r) => r.json()),
      +    )) as { organizations: ReadonlyArray<{ name: string; slug: string }> };
      +    const slugA = orgs.organizations.find((o) => o.name.startsWith("Org user-"))?.slug;
      +    expect(slugA, "org A has a slug").toBeTruthy();
      +    expect(slugA, "the two orgs have distinct slugs").not.toBe(orgB.slug);
      +
      +    // Drive the browser as the session pinned to B.
      +    const inB = {
      +      ...identity,
      +      headers: { cookie: `wos-session=${sessionB}` },
      +      cookies: [{ name: "wos-session", value: sessionB! }],
      +    };
      +
      +    yield* browser.session(inB, async ({ page, step }) => {
      +      await step("Work in org A by its slug URL (the session still pins org B)", async () => {
      +        await visit(page, `/${slugA}`);
      +        await page.getByText("Integrations").first().waitFor({ timeout: 30_000 });
      +        // The client records the viewed org once /account/me confirms it.
      +        await page.waitForFunction(
      +          (slug) => document.cookie.includes(`executor-last-org=${slug}`),
      +          slugA,
      +          { timeout: 30_000 },
      +        );
      +      });
      +
      +      await step("A bare entry (`/`) returns to org A, not the session's org B", async () => {
      +        await visit(page, "/");
      +        await page.waitForURL(
      +          (url) => url.pathname === `/${slugA}` || url.pathname === `/${slugA}/`,
      +          {
      +            timeout: 30_000,
      +          },
      +        );
      +        await page.getByText("Integrations").first().waitFor({ timeout: 30_000 });
      +      });
      +
      +      await step("A bare deep link keeps its path while landing in org A", async () => {
      +        await visit(page, "/policies");
      +        await page.waitForURL((url) => url.pathname === `/${slugA}/policies`, { timeout: 30_000 });
      +        await page.getByText("Policies").first().waitFor({ timeout: 30_000 });
      +      });
      +    });
      +  }),
      +);
      diff --git a/e2e/cloud/org-limit.test.ts b/e2e/cloud/org-limit.test.ts
      index 3bd8b2b256..dcdb570936 100644
      --- a/e2e/cloud/org-limit.test.ts
      +++ b/e2e/cloud/org-limit.test.ts
      @@ -8,6 +8,7 @@ import { Effect } from "effect";
       
       import { scenario } from "../src/scenario";
       import { Billing, Browser, Target } from "../src/services";
      +import { visit, settle } from "../src/surfaces/browser";
       
       const FREE_LIMIT = 3;
       
      @@ -23,7 +24,7 @@ scenario(
       
           yield* browser.session(identity, async ({ page, step }) => {
             await step("A fresh user lands on onboarding (no organization yet)", async () => {
      -        await page.goto("/", { waitUntil: "networkidle" });
      +        await visit(page, "/");
               await page.getByPlaceholder("Northwind Labs").waitFor();
             });
       
      @@ -43,7 +44,7 @@ scenario(
               await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
                 timeout: 30_000,
               });
      -        await page.waitForLoadState("networkidle");
      +        await settle(page);
             });
       
             const openCreateOrgModal = async (currentOrg: string) => {
      diff --git a/e2e/cloud/org-multitab-cookie.test.ts b/e2e/cloud/org-multitab-cookie.test.ts
      index 27c8a967a5..75125828ba 100644
      --- a/e2e/cloud/org-multitab-cookie.test.ts
      +++ b/e2e/cloud/org-multitab-cookie.test.ts
      @@ -21,6 +21,7 @@ import { Effect } from "effect";
       
       import { scenario } from "../src/scenario";
       import { Browser, Target } from "../src/services";
      +import { visit } from "../src/surfaces/browser";
       
       scenario(
         "Org tabs · two tabs on different orgs stay independent (URL-scoped, no cookie steal)",
      @@ -53,7 +54,7 @@ scenario(
             let slugB = "";
       
             await step("Onboard org A in tab 1", async () => {
      -        await tab1.goto("/", { waitUntil: "networkidle" });
      +        await visit(tab1, "/");
               await tab1.getByPlaceholder("Northwind Labs").fill("Multitab A");
               await tab1.getByRole("button", { name: "Create organization" }).click();
               await tab1.getByText("Connect your MCP client").waitFor({ timeout: 30_000 });
      @@ -83,7 +84,7 @@ scenario(
             const tab2 = await tab1.context().newPage();
       
             await step("Tab 2 opens org A's URL and stays in A — no switch, no reload loop", async () => {
      -        await tab2.goto(`/${slugA}/policies`, { waitUntil: "networkidle" });
      +        await visit(tab2, `/${slugA}/policies`);
               await tab2.getByText("Policies").first().waitFor({ timeout: 30_000 });
               expect(new URL(tab2.url()).pathname, "tab 2 stays on org A's URL").toBe(
                 `/${slugA}/policies`,
      @@ -134,7 +135,7 @@ scenario(
       
           yield* browser.session(identity, async ({ page, step }) => {
             await step("Open the OpenAPI add form", async () => {
      -        await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" });
      +        await visit(page, "/integrations/add/openapi");
               await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor();
             });
       
      diff --git a/e2e/cloud/org-slug-foreign.test.ts b/e2e/cloud/org-slug-foreign.test.ts
      index 04aee48d35..c50e024d2b 100644
      --- a/e2e/cloud/org-slug-foreign.test.ts
      +++ b/e2e/cloud/org-slug-foreign.test.ts
      @@ -13,6 +13,7 @@ import { Effect } from "effect";
       
       import { scenario } from "../src/scenario";
       import { Browser, Target } from "../src/services";
      +import { visit } from "../src/surfaces/browser";
       
       const CLOUD_ORIGIN_HEADERS = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
       
      @@ -67,10 +68,10 @@ scenario(
       
           yield* browser.session(inB, async ({ page, step }) => {
             await step("Land in org B, then open org A's slug URL directly", async () => {
      -        await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" });
      +        await visit(page, `/${orgB.slug}`);
               await page.getByText("Integrations").first().waitFor({ timeout: 30_000 });
               // Open org A by its slug while the cookie is still pinned to B.
      -        await page.goto(`/${slugA}/policies`, { waitUntil: "networkidle" });
      +        await visit(page, `/${slugA}/policies`);
             });
       
             await step("Org A's page renders at its slugged URL — the URL is the scope", async () => {
      diff --git a/e2e/cloud/org-switcher.test.ts b/e2e/cloud/org-switcher.test.ts
      index eaf1aa9980..62f77bd4ed 100644
      --- a/e2e/cloud/org-switcher.test.ts
      +++ b/e2e/cloud/org-switcher.test.ts
      @@ -8,6 +8,7 @@ import { Effect } from "effect";
       
       import { scenario } from "../src/scenario";
       import { Browser, Target } from "../src/services";
      +import { visit, settle } from "../src/surfaces/browser";
       
       scenario(
         "Organizations · switching organizations switches the workspace",
      @@ -20,7 +21,7 @@ scenario(
           yield* browser.session(identity, async ({ page, step }) => {
             // ── Step 1: onboarding, create the first org ─────────────────────
             await step("Fresh user lands on onboarding (no organization yet)", async () => {
      -        await page.goto("/", { waitUntil: "networkidle" });
      +        await visit(page, "/");
               await page.getByPlaceholder("Northwind Labs").waitFor();
             });
       
      @@ -43,7 +44,7 @@ scenario(
               await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
                 timeout: 30_000,
               });
      -        await page.waitForLoadState("networkidle");
      +        await settle(page);
             });
       
             // ── Step 2: create the second org via the account-menu switcher ──
      @@ -89,7 +90,7 @@ scenario(
             // rendered with data-disabled="" (Radix convention). The only item without
             // data-disabled that isn't "Create organization" is ORG_1.
             await step(`Open the org switcher and switch back to "${ORG_1}"`, async () => {
      -        await page.waitForLoadState("networkidle");
      +        await settle(page);
               await page.getByRole("button", { name: /Test User/ }).click();
               // Click the SubTrigger (shows current org name = ORG_2) to expand the list.
               await page.getByRole("menuitem", { name: ORG_2 }).click();
      diff --git a/e2e/cloud/repro-listener-supersede.test.ts b/e2e/cloud/repro-listener-supersede.test.ts
      new file mode 100644
      index 0000000000..b225b8b596
      --- /dev/null
      +++ b/e2e/cloud/repro-listener-supersede.test.ts
      @@ -0,0 +1,130 @@
      +// Cloud: regression test for the standalone-listener eviction storm.
      +//
      +// The MCP spec allows a client to hold a standalone SSE listener (a bare GET)
      +// for server-initiated messages. The patched agents SDK applies
      +// latest-listener-wins to that stream:
      +//
      +//   packages/... -> agents/dist/mcp/index.js:880
      +//   supersedePriorStreamConnections(agent, connection.id, STANDALONE_STREAM_ID)
      +//     -> for every OTHER connection on the same streamId: close(1000, ...)
      +//
      +// STANDALONE_STREAM_ID is shared by every listener, so a session with two
      +// listeners has each arrival kill the other. Any SSE client reconnects when
      +// its stream closes, so two listeners evict each other indefinitely: the
      +// session can never hold a listener open and the client sees a permanent
      +// "disconnected / reconnecting" churn.
      +//
      +// Observed in production: one session produced ~55,600 listener GETs over
      +// 6.75 hours against only 106 POSTs, arriving 3-4 within milliseconds every
      +// ~1.2s, each answered 200 in ~130ms.
      +//
      +// This test drives N listeners that reconnect when closed, exactly like a real
      +// client, and asserts the session does NOT churn.
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +import { Client } from "@modelcontextprotocol/sdk/client/index.js";
      +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
      +
      +import { scenario } from "../src/scenario";
      +import { Mcp, Target } from "../src/services";
      +import type { Identity } from "../src/target";
      +
      +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
      +
      +const RUN_MS = 15_000;
      +const SCENARIO_TIMEOUT_MS = RUN_MS + 120_000;
      +
      +interface Trial {
      +  readonly opens: number;
      +  readonly medianLifeMs: number;
      +}
      +
      +/** Hold `count` standalone listeners for RUN_MS, reconnecting whenever the
      + *  server closes one — the behaviour of any real SSE client. */
      +const driveListeners = async (
      +  mcpUrl: string,
      +  bearer: string,
      +  sessionId: string,
      +  count: number,
      +): Promise => {
      +  const deadline = Date.now() + RUN_MS;
      +  const lifetimes: number[] = [];
      +  let opens = 0;
      +
      +  const listener = async (): Promise => {
      +    while (Date.now() < deadline) {
      +      const started = Date.now();
      +      const controller = new AbortController();
      +      const guard = setTimeout(() => controller.abort(), Math.max(500, deadline - Date.now()));
      +      try {
      +        const res = await fetch(mcpUrl, {
      +          method: "GET",
      +          headers: {
      +            authorization: `Bearer ${bearer}`,
      +            accept: "text/event-stream",
      +            "mcp-session-id": sessionId,
      +            "mcp-protocol-version": "2025-06-18",
      +          },
      +          signal: controller.signal,
      +        });
      +        opens += 1;
      +        const reader = res.body?.getReader();
      +        // Read until the SERVER closes the stream.
      +        if (reader) {
      +          for (;;) {
      +            const { done } = await reader.read();
      +            if (done) break;
      +          }
      +        }
      +      } catch {
      +        // aborted at the deadline, or the socket was torn down
      +      }
      +      clearTimeout(guard);
      +      lifetimes.push(Date.now() - started);
      +    }
      +  };
      +
      +  await Promise.all(Array.from({ length: count }, () => listener()));
      +  const sorted = [...lifetimes].sort((a, b) => a - b);
      +  return { opens, medianLifeMs: sorted[Math.floor(sorted.length / 2)] ?? 0 };
      +};
      +
      +scenario(
      +  "REGRESSION · a second standalone listener must not evict the first",
      +  { timeout: SCENARIO_TIMEOUT_MS },
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const mcp = yield* Mcp;
      +    const identity = yield* target.newIdentity();
      +    const bearer = yield* mcp.mintBearer(emailOf(identity));
      +
      +    const client = new Client({ name: "e2e-supersede", version: "0.0.1" }, { capabilities: {} });
      +    const transport = new StreamableHTTPClientTransport(new URL(target.mcpUrl), {
      +      requestInit: { headers: { authorization: `Bearer ${bearer}` } },
      +    });
      +    yield* Effect.promise(() => client.connect(transport));
      +    const sessionId = transport.sessionId;
      +    expect(sessionId, "the client got a session id").toEqual(expect.any(String));
      +    if (sessionId === undefined) return yield* Effect.die("missing session id");
      +    yield* Effect.promise(() => client.close().catch(() => undefined));
      +
      +    // Control: a single listener should simply stay open for the whole window.
      +    const single = yield* Effect.promise(() => driveListeners(target.mcpUrl, bearer, sessionId, 1));
      +    console.log(JSON.stringify({ event: "supersede_single", ...single }));
      +
      +    // Two listeners, each reconnecting when closed.
      +    const double = yield* Effect.promise(() => driveListeners(target.mcpUrl, bearer, sessionId, 2));
      +    console.log(JSON.stringify({ event: "supersede_double", ...double }));
      +
      +    // One listener over a 15s window opens roughly once (a max-age rotation
      +    // could add one more). Anything beyond a handful is the eviction storm.
      +    expect(single.opens, "a single listener does not churn").toBeLessThanOrEqual(3);
      +
      +    // The regression: two listeners must not evict each other. Without the fix
      +    // this is in the hundreds, with a median stream life of ~200ms.
      +    expect(double.opens, "two listeners do not evict each other").toBeLessThanOrEqual(6);
      +    expect(double.medianLifeMs, "streams stay open rather than dying instantly").toBeGreaterThan(
      +      2_000,
      +    );
      +  }),
      +);
      diff --git a/e2e/cloud/spec-update-convergence.test.ts b/e2e/cloud/spec-update-convergence.test.ts
      index 2a20630dfb..0a9ca49647 100644
      --- a/e2e/cloud/spec-update-convergence.test.ts
      +++ b/e2e/cloud/spec-update-convergence.test.ts
      @@ -106,16 +106,19 @@ const serveMutableSpec = (initial: string) =>
         );
       
       // ── Session plumbing over the real auth endpoints (mirrors the web app) ──────
      -const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
      +const ORG_SELECTOR_HEADER = "x-executor-organization";
       
       const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) =>
         Effect.promise(async () => {
           const response = await fetch(new URL(path, target.baseUrl), {
             method: "POST",
             headers: {
      +        // The identity's own headers (session cookie + org selector) exactly
      +        // as its API client would send them — org-scoped endpoints fail
      +        // closed without the selector.
      +        ...(identity.headers ?? {}),
               "content-type": "application/json",
               origin: new URL(target.baseUrl).origin,
      -        cookie: cookieOf(identity),
             },
             body: JSON.stringify(body),
           });
      @@ -125,17 +128,28 @@ const postJson = (target: TargetShape, path: string, identity: Identity, body: u
           return response;
         });
       
      -const withRefreshedSession = (identity: Identity, response: Response): Identity => {
      +const withRefreshedSession = (
      +  identity: Identity,
      +  response: Response,
      +  orgSelector: string,
      +): Identity => {
         const refreshed = (response.headers.getSetCookie?.() ?? [])
           .find((header) => header.startsWith("wos-session="))
           ?.split(";")[0];
         if (!refreshed) throw new Error("response did not refresh the session cookie");
      -  return { ...identity, headers: { cookie: refreshed } };
      +  return {
      +    ...identity,
      +    headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector },
      +  };
       };
       
      -/** Invite `member` into `admin`'s org and accept — the real invite flow. */
      +/** Invite `member` into `admin`'s org and accept — the real invite flow.
      + *  The member's requests inherit the admin's org selector (org-scoped reads
      + *  fail closed without the header). */
       const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>
         Effect.gen(function* () {
      +    const adminSelector = admin.headers?.[ORG_SELECTOR_HEADER];
      +    if (!adminSelector) throw new Error("admin identity carries no org selector header");
           const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {
             email: member.credentials?.email,
           });
      @@ -143,7 +157,7 @@ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>
           const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
             invitationId: invitation.id,
           });
      -    return withRefreshedSession(member, acceptResponse);
      +    return withRefreshedSession(member, acceptResponse, adminSelector);
         });
       
       const apiKeyTemplate = [
      diff --git a/e2e/cloud/storage-error-report-shape.test.ts b/e2e/cloud/storage-error-report-shape.test.ts
      new file mode 100644
      index 0000000000..ab8a848f4c
      --- /dev/null
      +++ b/e2e/cloud/storage-error-report-shape.test.ts
      @@ -0,0 +1,280 @@
      +// Cloud-only: what an operator SEES when a write is rejected by the database.
      +//
      +// The product guarantee: a storage failure is reported under a stable headline
      +// built from the operation and the database's error code — never the statement
      +// text, never the values that were bound into it. Two consequences, both of
      +// them things production got wrong:
      +//
      +//   - The values bound into a rejected statement are customer data (the
      +//     organization id, the connection name, whatever the user typed into the
      +//     description). They must not appear in the report's headline.
      +//   - The headline is the grouping key of the error reporter, so one defect that
      +//     hits several tables — or the same table through several WHERE shapes —
      +//     must arrive as ONE report, not one per statement.
      +//
      +// The failure is induced through the public typed API only: PostgreSQL cannot
      +// store a NUL byte in a text column, so a connection whose description carries
      +// one is rejected by the driver with SQLSTATE 22021 while the statement and its
      +// bound parameters are already assembled. That is the same class of failure the
      +// production reports came from, reachable without touching the database.
      +//
      +// Two surfaces are asserted, both public:
      +//   1. What the CALLER gets — an opaque `InternalError` carrying only a trace
      +//      id, with no driver text anywhere in the payload.
      +//   2. What the OPERATOR gets — the server's own error log, where the trace id
      +//      the caller received joins to the report the server filed. Its headline —
      +//      the captured exception's type and message — is what the error reporter
      +//      files the report under, and groups by.
      +import { randomBytes } from "node:crypto";
      +import { readFileSync } from "node:fs";
      +import { resolve } from "node:path";
      +
      +import { expect } from "@effect/vitest";
      +import { Cause, Effect, Exit, Schedule } from "effect";
      +import type { HttpApiClient } from "effect/unstable/httpapi";
      +import { composePluginApi } from "@executor-js/api/server";
      +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
      +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
      +
      +import { RUNS_DIR, scenario } from "../src/scenario";
      +import { Api, Target } from "../src/services";
      +
      +const api = composePluginApi([openApiHttpPlugin()] as const);
      +type Client = HttpApiClient.ForApi;
      +
      +/** A text value PostgreSQL cannot store — the driver rejects it as 22021. */
      +const NUL = String.fromCharCode(0);
      +
      +const SLUG = "storage-error-report-shape";
      +
      +/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
      +const pingSpec = JSON.stringify({
      +  openapi: "3.0.3",
      +  info: { title: "Ping API", version: "1.0.0" },
      +  paths: {
      +    "/ping": {
      +      get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
      +    },
      +  },
      +});
      +
      +/** Registers a fresh apiKey-authenticated integration for connections to bind to. */
      +const registerIntegration = (client: Client) =>
      +  Effect.gen(function* () {
      +    const slug = IntegrationSlug.make(`${SLUG}-${randomBytes(4).toString("hex")}`);
      +    yield* client.openapi.addSpec({
      +      payload: {
      +        spec: { kind: "blob", value: pingSpec },
      +        slug,
      +        baseUrl: "http://127.0.0.1:59999", // never contacted during registration
      +        authenticationTemplate: [
      +          {
      +            slug: "apiKey",
      +            type: "apiKey",
      +            headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
      +          },
      +        ],
      +      },
      +    });
      +    return slug;
      +  });
      +
      +interface RejectedWrite {
      +  /** The trace id the caller was handed; joins to the server's report. */
      +  readonly traceId: string;
      +  /** The name the caller chose for the connection — customer data. */
      +  readonly name: string;
      +  /** The free text the caller typed — customer data, and the NUL carrier. */
      +  readonly description: string;
      +  /** The whole client-visible failure, serialized. */
      +  readonly payload: string;
      +}
      +
      +/**
      + * Create a connection whose description PostgreSQL will refuse, and return what
      + * the caller can see about the failure.
      + */
      +const rejectedConnectionWrite = (
      +  client: Client,
      +  integration: IntegrationSlug,
      +): Effect.Effect =>
      +  Effect.gen(function* () {
      +    const name = ConnectionName.make(
      +      `${SLUG.replaceAll("-", "")}${randomBytes(4).toString("hex")}`,
      +    );
      +    const description = `desc-${randomBytes(6).toString("hex")}${NUL}tail`;
      +
      +    const exit = yield* Effect.exit(
      +      client.connections.create({
      +        payload: {
      +          owner: "org",
      +          name,
      +          integration,
      +          template: AuthTemplateSlug.make("apiKey"),
      +          description,
      +          value: `sk-${randomBytes(4).toString("hex")}`,
      +        },
      +      }),
      +    );
      +
      +    const failure = Exit.isFailure(exit)
      +      ? exit.cause.reasons.find(Cause.isFailReason)?.error
      +      : exit.value;
      +    const error = failure as { readonly _tag?: string; readonly traceId?: string } | undefined;
      +
      +    // PostgreSQL refuses the NUL byte, so the write cannot succeed, and what
      +    // comes back is the opaque internal failure — never the storage error.
      +    expect(error?._tag, "the caller sees an opaque internal failure").toBe("InternalError");
      +    expect(error?.traceId ?? "", "the caller is handed a trace id to quote").toMatch(
      +      /^[0-9a-f]{32}$/,
      +    );
      +
      +    return {
      +      traceId: error?.traceId ?? "",
      +      name,
      +      description,
      +      payload: JSON.stringify(failure),
      +    };
      +  });
      +
      +/**
      + * The dev stack's stdout. The suite's globalsetup funnels it into the run
      + * artifacts; a scenario run against an already-booted instance (`cli up cloud`)
      + * reads that instance's log instead.
      + */
      +const serverLogCandidates = [
      +  resolve(RUNS_DIR, "cloud", "server-logs", "boot.log"),
      +  resolve(RUNS_DIR, "..", ".dev", "cloud.log"),
      +];
      +
      +const readServerLog = (): string => {
      +  const texts = serverLogCandidates.flatMap((path) => {
      +    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing which of the two stdout sinks this run uses
      +    try {
      +      return [readFileSync(path, "utf8")];
      +    } catch {
      +      return [];
      +    }
      +  });
      +  return texts.join("\n");
      +};
      +
      +const REPORT_PREFIX = "[api] unhandled cause: ";
      +
      +/** A stack frame in the logged cause — where the report's headline stops. */
      +const STACK_FRAME = /^\s+at /;
      +
      +interface FiledReport {
      +  /** Type + message: what the reporter names and groups the report by. */
      +  readonly headline: string;
      +  /** The whole record, headline and chained cause — what a diagnosis reads. */
      +  readonly full: string;
      +}
      +
      +/**
      + * The report the server filed for one request.
      + *
      + * The headline is the captured cause's type and message up to the first stack
      + * frame — exactly what `Cause.prettyErrors` hands the reporter as the
      + * exception. The message is multi-line whenever the driver's text is
      + * (`Failed query: …\nparams: …`), so the whole headline has to be read, not
      + * just its first line.
      + *
      + * Found by walking back from the correlation record carrying the caller's trace
      + * id, so it is THIS request's report and not a neighbour's.
      + */
      +const reportFor = (traceId: string): Effect.Effect =>
      +  Effect.sync(() => {
      +    const lines = readServerLog().split("\n");
      +    const correlated = lines.findLastIndex(
      +      (line) =>
      +        line.includes('"event":"sentry_before_send_otel_correlation"') &&
      +        line.includes(`"sentry_event_id":"${traceId}"`),
      +    );
      +    if (correlated === -1) return undefined;
      +    const reported = lines
      +      .slice(0, correlated)
      +      .findLastIndex((line) => line.startsWith(REPORT_PREFIX));
      +    if (reported === -1) return undefined;
      +    const block = [
      +      lines[reported]!.slice(REPORT_PREFIX.length),
      +      ...lines.slice(reported + 1, correlated),
      +    ];
      +    const end = block.slice(1).findIndex((line) => STACK_FRAME.test(line));
      +    return {
      +      headline: block
      +        .slice(0, end === -1 ? 1 : end + 1)
      +        .join("\n")
      +        .trimEnd(),
      +      full: block.join("\n"),
      +    };
      +  }).pipe(
      +    Effect.filterOrFail(
      +      (report): report is FiledReport => report !== undefined,
      +      () => `no error report joined to trace id ${traceId} in the server log`,
      +    ),
      +    // The log is a file the dev stack appends to; the write lands moments after
      +    // the response. Poll rather than sleep (~20s ceiling).
      +    Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(40))),
      +  );
      +
      +scenario(
      +  "Storage · a rejected write is reported without its SQL or the caller's data",
      +  { timeout: 120_000 },
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const { client: apiClient } = yield* Api;
      +    const identity = yield* target.newIdentity();
      +    const client = yield* apiClient(api, identity);
      +    const integration = yield* registerIntegration(client);
      +
      +    const first = yield* rejectedConnectionWrite(client, integration);
      +    const second = yield* rejectedConnectionWrite(client, integration);
      +
      +    for (const write of [first, second]) {
      +      expect(write.payload, "the client payload carries no driver text").not.toContain(
      +        "Failed query",
      +      );
      +      expect(write.payload, "the client payload carries no bound parameter").not.toContain(
      +        write.description,
      +      );
      +    }
      +
      +    const report = yield* reportFor(first.traceId);
      +    const headline = report.headline;
      +
      +    // The symptom: the statement and everything bound into it used to BE the
      +    // headline, so the report was named after the customer's data.
      +    expect(headline, "the report headline carries no statement text").not.toContain("Failed query");
      +    expect(headline, "the report headline carries no statement text").not.toContain("insert into");
      +    expect(headline, "the report headline carries no bound parameters").not.toContain("params:");
      +    expect(headline, "the report headline carries no user-typed description").not.toContain(
      +      first.description,
      +    );
      +    expect(headline, "the report headline carries no user-chosen connection name").not.toContain(
      +      first.name,
      +    );
      +    expect(headline, "the report headline carries no organization id").not.toContain("org_");
      +
      +    // What it says instead: which operation failed, and how the database
      +    // refused it — enough to act on, stable across calls.
      +    expect(headline, "the report still names the failing operation").toContain("connection.create");
      +    expect(headline, "the report still names the database's error code").toContain("22021");
      +
      +    // Shaping the headline must not mean throwing the diagnosis away: the
      +    // driver's own text is still filed with the report, one level down, where
      +    // it informs a fix instead of naming the report.
      +    expect(report.full, "the driver's statement is still filed under the report").toContain(
      +      "Failed query",
      +    );
      +
      +    // The fan-out: the two writes bound different names, descriptions and
      +    // secrets, so their statements differ in every parameter. One defect, one
      +    // report — not one report per set of values.
      +    const secondReport = yield* reportFor(second.traceId);
      +    expect(
      +      secondReport.headline,
      +      "a second rejected write with different values files the same report",
      +    ).toBe(headline);
      +  }),
      +);
      diff --git a/e2e/cloud/support/session.ts b/e2e/cloud/support/session.ts
      new file mode 100644
      index 0000000000..cb8b0d6cf7
      --- /dev/null
      +++ b/e2e/cloud/support/session.ts
      @@ -0,0 +1,205 @@
      +// Cloud session + membership helpers shared by the scenarios that need MORE
      +// than one identity in an org.
      +//
      +// Every one of these is a thin wrapper over a REAL product endpoint — there is
      +// no back door here. They were copy-pasted verbatim into four cloud scenarios
      +// before living here, and the copies drifted in the two ways that matter, both
      +// now folded in:
      +//
      +//   - only one carried `forBrowser`, the fix for the fact that `joinOrg`
      +//     refreshes `headers.cookie` (the sealed session is re-issued on the new
      +//     org) but leaves `cookies` on the PRE-join value — and the browser context
      +//     is seeded from `cookies`. A scenario on a stale copy browses as the
      +//     person's old org while its API calls use the new one.
      +//   - only one forwarded the ORG SELECTOR header. Org-scoped endpoints fail
      +//     closed without it (`AccountNoOrganization`), so a cookie-only copy cannot
      +//     even build the fixture: `/api/account/members/invite` 403s.
      +//
      +// `cloud/*.test.ts` is a vitest `include` of `*.test.ts` only, so this module
      +// is never collected as a suite.
      +import { Effect } from "effect";
      +
      +import type { Identity, Target as TargetShape } from "../../src/target";
      +
      +/**
      + * The per-request org selector. `/api/auth/switch-organization` was removed
      + * (#1000): the URL prefix or this header is the scope authority now, and the
      + * session's own org is only a default.
      + *
      + * This is the RIGHT way to read one account's data in a specific org: a session
      + * cookie captured before the account joined another org is stale the moment the
      + * later `accept-invitation` re-seals it, so holding onto the old cookie reads as
      + * an unauthenticated caller. Take the CURRENT identity and point it at the org
      + * you mean.
      + *
      + * Mirrors `ORG_SELECTOR_HEADER` in `apps/cloud/src/auth/organization.ts`
      + * (`EXECUTOR_ORG_SELECTOR_HEADER` in the SDK's shared exports); duplicated as a
      + * literal so the e2e suite stays a black-box client of the deployed product.
      + */
      +const ORG_SELECTOR_HEADER = "x-executor-organization";
      +
      +/** The session cookie header this identity authenticates API calls with. */
      +export const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
      +
      +/**
      + * The identity re-bound so the BROWSER carries the same session the API calls
      + * do. `joinOrg` / `createOrg` refresh `headers.cookie` (the org is baked into
      + * the sealed session) but leave `cookies` on the pre-change value, and the
      + * browser context is seeded from `cookies`.
      + */
      +export const forBrowser = (identity: Identity): Identity => {
      +  const cookie = cookieOf(identity);
      +  const separator = cookie.indexOf("=");
      +  if (separator < 0) throw new Error("identity carries no session cookie");
      +  return {
      +    ...identity,
      +    cookies: [{ name: cookie.slice(0, separator), value: cookie.slice(separator + 1) }],
      +  };
      +};
      +
      +/** POST as `identity`, failing loudly on a non-2xx (a silent skip here would
      + *  leave the scenario asserting against a half-built fixture).
      + *
      + *  Sends the identity's OWN headers, not just its cookie: org-scoped endpoints
      + *  (`/api/account/members/invite` among them) fail closed with
      + *  `AccountNoOrganization` when the org selector header is absent, because the
      + *  session's own org is only a default and is never fallen back to. */
      +export const postJson = (
      +  target: TargetShape,
      +  path: string,
      +  identity: Identity,
      +  body: unknown,
      +): Effect.Effect =>
      +  Effect.promise(async () => {
      +    const response = await fetch(new URL(path, target.baseUrl), {
      +      method: "POST",
      +      headers: {
      +        ...(identity.headers ?? {}),
      +        "content-type": "application/json",
      +        origin: new URL(target.baseUrl).origin,
      +      },
      +      body: JSON.stringify(body),
      +    });
      +    if (!response.ok) {
      +      throw new Error(`${path} failed (${response.status}): ${await response.text()}`);
      +    }
      +    return response;
      +  });
      +
      +/** The identity re-bound to the refreshed session cookie a response set, and
      + *  scoped to `orgSelector` — carrying the cookie alone would leave every
      + *  org-scoped read the identity goes on to make failing closed. */
      +export const withRefreshedSession = (
      +  identity: Identity,
      +  response: Response,
      +  orgSelector: string,
      +): Identity => {
      +  const refreshed = (response.headers.getSetCookie?.() ?? [])
      +    .find((header) => header.startsWith("wos-session="))
      +    ?.split(";")[0];
      +  if (!refreshed) throw new Error("response did not refresh the session cookie");
      +  return { ...identity, headers: { cookie: refreshed, [ORG_SELECTOR_HEADER]: orgSelector } };
      +};
      +
      +/** The org selector this identity's requests carry — the same header the web
      + *  client derives from the console URL's slug. */
      +export const orgSelectorOf = (identity: Identity): string => {
      +  const selector = identity.headers?.[ORG_SELECTOR_HEADER];
      +  if (!selector) throw new Error(`identity ${identity.label} carries no org selector header`);
      +  return selector;
      +};
      +
      +/**
      + * Invite `member` into `admin`'s org and accept — the real invite flow. The
      + * invite carries no roleSlug, so the joiner lands as a plain MEMBER (the actor
      + * an access-denial assertion needs); pass `roleSlug` for anything else.
      + *
      + * The returned identity is bound to the newly-joined org — the re-sealed
      + * session from accept-invitation, scoped by the selector to the org they were
      + * invited INTO (`admin`'s). Calling this twice for one person is how a scenario
      + * builds a multi-org member: the WorkOS membership in the earlier org persists,
      + * and both the session and the selector point at the later one.
      + */
      +export const joinOrg = (
      +  target: TargetShape,
      +  admin: Identity,
      +  member: Identity,
      +  options: { readonly roleSlug?: string } = {},
      +): Effect.Effect =>
      +  Effect.gen(function* () {
      +    const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {
      +      email: member.credentials?.email,
      +      ...(options.roleSlug === undefined ? {} : { roleSlug: options.roleSlug }),
      +    });
      +    const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string };
      +    const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
      +      invitationId: invitation.id,
      +    });
      +    return withRefreshedSession(member, acceptResponse, orgSelectorOf(admin));
      +  });
      +
      +/** Create another org for this account; returns the identity bound to it. */
      +export const createOrg = (
      +  target: TargetShape,
      +  identity: Identity,
      +  name: string,
      +): Effect.Effect =>
      +  Effect.gen(function* () {
      +    const response = yield* postJson(target, "/api/auth/create-organization", identity, { name });
      +    const created = (yield* Effect.promise(() => response.clone().json())) as { id: string };
      +    return withRefreshedSession(identity, response, created.id);
      +  });
      +
      +/** This identity, scoped to `organizationId` for the requests it makes. */
      +export const inOrg = (identity: Identity, organizationId: string): Identity => ({
      +  ...identity,
      +  headers: { ...identity.headers, [ORG_SELECTOR_HEADER]: organizationId },
      +});
      +
      +/** The caller's own account id — the `externalId` the admin plane reports and
      + *  the users page renders in mono. */
      +export const accountIdOf = (target: TargetShape, identity: Identity): Effect.Effect =>
      +  Effect.promise(async () => {
      +    const response = await fetch(new URL("/api/account/me", target.baseUrl), {
      +      headers: { cookie: cookieOf(identity) },
      +    });
      +    if (!response.ok) throw new Error(`/api/account/me failed (${response.status})`);
      +    const body = (await response.json()) as { user: { id: string } };
      +    return body.user.id;
      +  });
      +
      +/** The org this identity's session is currently bound to: id and URL slug. */
      +export const activeOrg = (
      +  target: TargetShape,
      +  identity: Identity,
      +): Effect.Effect<{ readonly id: string; readonly slug: string }> =>
      +  Effect.promise(async () => {
      +    const response = await fetch(new URL("/api/auth/me", target.baseUrl), {
      +      headers: { cookie: cookieOf(identity) },
      +    });
      +    if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`);
      +    const body = (await response.json()) as {
      +      organization: { id: string; slug: string } | null;
      +    };
      +    if (!body.organization) throw new Error("identity has no active organization");
      +    return { id: body.organization.id, slug: body.organization.slug };
      +  });
      +
      +/** Every org this account is a member of, plus the one the session is on. */
      +export const organizationsOf = (
      +  target: TargetShape,
      +  identity: Identity,
      +): Effect.Effect<{
      +  readonly organizations: ReadonlyArray<{ readonly id: string; readonly slug: string }>;
      +  readonly activeOrganizationId: string | null;
      +}> =>
      +  Effect.promise(async () => {
      +    const response = await fetch(new URL("/api/auth/organizations", target.baseUrl), {
      +      headers: { cookie: cookieOf(identity) },
      +    });
      +    if (!response.ok) throw new Error(`/api/auth/organizations failed (${response.status})`);
      +    return (await response.json()) as {
      +      organizations: ReadonlyArray<{ id: string; slug: string }>;
      +      activeOrganizationId: string | null;
      +    };
      +  });
      diff --git a/e2e/cloud/surface-reachability.test.ts b/e2e/cloud/surface-reachability.test.ts
      index 35bec01d30..17ab2d541e 100644
      --- a/e2e/cloud/surface-reachability.test.ts
      +++ b/e2e/cloud/surface-reachability.test.ts
      @@ -35,6 +35,27 @@ scenario(
           expect(paths, "path-param routes are documented").toContain(
             "/api/executions/{executionId}/resume",
           );
      +
      +    // The account and admin-users groups. These are composed into the spec by
      +    // hand (`CloudOpenApi` in `extensions/routes.ts`), and the composition is
      +    // the ONLY thing that documents them — the routes serve either way, so a
      +    // group dropped from the `.add(...)` chain leaves a mounted, undocumented
      +    // plane and nothing else fails. Asserted against the SERVED spec rather
      +    // than the module: cloud builds the same composition twice (here and in
      +    // `extensions/docs.ts`) and only this one reaches the runtime, so importing
      +    // either module could pass while the wire is wrong.
      +    expect(paths, "the account plane is documented").toContain("/api/account/me");
      +    expect(paths, "including the org-key surface the console reads").toContain(
      +      "/api/account/org-api-keys",
      +    );
      +    expect(paths, "the tenant-wide admin users plane is documented").toContain("/api/admin/users");
      +    expect(paths, "including the joined view the console list consumes").toContain(
      +      "/api/admin/users/with-connections",
      +    );
      +    expect(paths, "and the per-user connections read").toContain(
      +      "/api/admin/users/{externalId}/connections",
      +    );
      +    expect(paths, "and the single-user read").toContain("/api/admin/users/{identifier}");
         }),
       );
       
      diff --git a/e2e/cloud/unauthenticated-skeleton.test.ts b/e2e/cloud/unauthenticated-skeleton.test.ts
      index 5898bb7e70..277729797e 100644
      --- a/e2e/cloud/unauthenticated-skeleton.test.ts
      +++ b/e2e/cloud/unauthenticated-skeleton.test.ts
      @@ -4,8 +4,8 @@
       // AuthGate used to SSR the AUTHENTICATED app-shell skeleton (sidebar + card
       // grid) for every visitor and only swap to a login page after a client-side
       // `/account/me` 401 — signed-out users were shown an app they'd never reach.
      -// Now the SSR auth gate (apps/cloud/src/auth/ssr-gate.ts) verifies the sealed
      -// session cookie in the worker and 302s signed-out document requests to
      +// Now the document auth gate (apps/cloud/src/auth/doc-gate.ts) verifies the
      +// sealed session cookie in the worker and 302s signed-out document requests to
       // /login (carrying ?returnTo=), so the app shell never exists for them.
       import { expect } from "@effect/vitest";
       import { Effect } from "effect";
      @@ -136,7 +136,14 @@ scenario(
               await page.goto("/", { waitUntil: "commit" });
               await page.locator("aside").first().waitFor({ state: "visible" });
             });
      -      expect(new URL(page.url()).pathname, "no login detour for a valid session").toBe("/");
      +      // The contract is "no login detour": under SPA serving the shell becomes
      +      // visible only after auth resolves, by which point OrgSlugGate may have
      +      // already canonicalized the bare root onto the org's slug (that landing
      +      // is pinned by auth-routing-flow). Either resting place is signed-in
      +      // routing working; /login is the only wrong answer.
      +      expect(new URL(page.url()).pathname, "no login detour for a valid session").not.toBe(
      +        "/login",
      +      );
           });
       
           // A signed-in visitor landing on /login is bounced back into the app.
      diff --git a/e2e/cloud/workspace-write-permissions.test.ts b/e2e/cloud/workspace-write-permissions.test.ts
      new file mode 100644
      index 0000000000..955e175f3a
      --- /dev/null
      +++ b/e2e/cloud/workspace-write-permissions.test.ts
      @@ -0,0 +1,18 @@
      +import { Effect } from "effect";
      +
      +import { scenario } from "../src/scenario";
      +import { Target } from "../src/services";
      +import { workspaceWritePermissions } from "../src/workspace-write-permissions";
      +import { joinOrg } from "./support/session";
      +
      +scenario(
      +  "Workspace writes · cloud members are denied while Personal writes and admin writes succeed",
      +  { timeout: 180_000 },
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const admin = yield* target.newIdentity();
      +    const invitee = yield* target.newIdentity({ org: false });
      +    const member = yield* joinOrg(target, admin, invitee);
      +    yield* workspaceWritePermissions(target, admin, member);
      +  }),
      +);
      diff --git a/e2e/cloudflare/mcp-native-elicitation.test.ts b/e2e/cloudflare/mcp-native-elicitation.test.ts
      new file mode 100644
      index 0000000000..1262e7ed11
      --- /dev/null
      +++ b/e2e/cloudflare/mcp-native-elicitation.test.ts
      @@ -0,0 +1,108 @@
      +// Cloudflare self-host native elicitation through the real Streamable HTTP
      +// transport. A policy gates a built-in read tool, so the test observes both
      +// directions on the same tools/call stream: elicitation/create reaches the
      +// client, and accept/decline decisions return to the execution engine.
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +import { Client } from "@modelcontextprotocol/sdk/client/index.js";
      +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
      +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
      +import { composePluginApi } from "@executor-js/api/server";
      +
      +import { scenario } from "../src/scenario";
      +import { Api, Mcp, Target } from "../src/services";
      +
      +const coreApi = composePluginApi([] as const);
      +const GATED_TOOL = "executor.coreTools.policies.list";
      +const GATED_CODE = `
      +const result = await tools.executor.coreTools.policies.list({});
      +return JSON.stringify(result);
      +`;
      +
      +scenario(
      +  "Cloudflare · native MCP elicitation carries approval decisions on the tool call stream",
      +  { timeout: 120_000 },
      +  Effect.gen(function* () {
      +    const target = yield* Target;
      +    const api = yield* Api;
      +    const mcp = yield* Mcp;
      +    const identity = yield* target.newIdentity();
      +    const apiClient = yield* api.client(coreApi, identity);
      +    const policy = yield* apiClient.policies.create({
      +      payload: { owner: "org", pattern: GATED_TOOL, action: "require_approval" },
      +    });
      +
      +    yield* Effect.gen(function* () {
      +      let decision: "accept" | "decline" | "cancel" = "accept";
      +      let elicitationCount = 0;
      +      const client = yield* Effect.acquireRelease(
      +        Effect.promise(async () => {
      +          const connectedClient = new Client(
      +            { name: "executor-cloudflare-native-elicitation-e2e", version: "1.0.0" },
      +            { capabilities: { elicitation: { form: {}, url: {} } } },
      +          );
      +          connectedClient.setRequestHandler(ElicitRequestSchema, async () => {
      +            elicitationCount += 1;
      +            return decision === "accept"
      +              ? { action: "accept" as const, content: {} }
      +              : { action: decision };
      +          });
      +          const url = new URL(mcp.url);
      +          url.searchParams.set("elicitation_mode", "native");
      +          await connectedClient.connect(new StreamableHTTPClientTransport(url));
      +          return connectedClient;
      +        }),
      +        (connectedClient) => Effect.promise(() => connectedClient.close()),
      +      );
      +
      +      const accepted = yield* Effect.promise(() =>
      +        client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, {
      +          timeout: 10_000,
      +        }),
      +      );
      +      expect(elicitationCount, "the native elicitation reached the client").toBe(1);
      +      expect(accepted.isError, "accepting lets the gated tool complete").toBeFalsy();
      +      expect(
      +        JSON.stringify(accepted.content),
      +        "the gated tool returned its policy listing",
      +      ).toContain(policy.id);
      +
      +      decision = "decline";
      +      const declined = yield* Effect.promise(() =>
      +        client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, {
      +          timeout: 10_000,
      +        }),
      +      );
      +      expect(elicitationCount, "the second native elicitation also reached the client").toBe(2);
      +      expect(declined.isError, "declining blocks the gated tool").toBe(true);
      +      expect(
      +        JSON.stringify(declined.content),
      +        "the engine reports the client's decline decision",
      +      ).toContain("declined by the user");
      +      expect(
      +        JSON.stringify(declined.content),
      +        "the declined tool did not return its output",
      +      ).not.toContain(policy.id);
      +
      +      decision = "cancel";
      +      const cancelled = yield* Effect.promise(() =>
      +        client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, {
      +          timeout: 10_000,
      +        }),
      +      );
      +      expect(elicitationCount, "the cancel elicitation also reached the client").toBe(3);
      +      expect(cancelled.isError, "cancelling blocks the gated tool").toBe(true);
      +      expect(
      +        JSON.stringify(cancelled.content),
      +        "the engine reports the client's cancel decision",
      +      ).toContain("cancelled by the user");
      +    }).pipe(
      +      Effect.scoped,
      +      Effect.ensuring(
      +        apiClient.policies
      +          .remove({ params: { policyId: policy.id }, payload: { owner: "org" } })
      +          .pipe(Effect.ignore),
      +      ),
      +    );
      +  }),
      +);
      diff --git a/e2e/desktop-vm/custom-tools-sidecar.test.ts b/e2e/desktop-vm/custom-tools-sidecar.test.ts
      deleted file mode 100644
      index 244a017b9b..0000000000
      --- a/e2e/desktop-vm/custom-tools-sidecar.test.ts
      +++ /dev/null
      @@ -1,228 +0,0 @@
      -/* oxlint-disable executor/no-try-catch-or-throw -- boundary: e2e SSH and raw HTTP assertions */
      -// Proves the packaged desktop bundle's sidecar daemon can package and invoke a
      -// local-directory custom tool from inside the GUI guest. The API calls run over
      -// guest loopback so the daemon reads the same guest filesystem as the fixture.
      -import { execFile } from "node:child_process";
      -import { promisify } from "node:util";
      -
      -import { expect, it } from "@effect/vitest";
      -import { Effect } from "effect";
      -
      -import { scenario } from "../src/scenario";
      -
      -const execFileAsync = promisify(execFile);
      -
      -const SSH_OPTS = [
      -  "-o",
      -  "StrictHostKeyChecking=no",
      -  "-o",
      -  "UserKnownHostsFile=/dev/null",
      -  "-o",
      -  "ConnectTimeout=15",
      -  "-o",
      -  "ServerAliveInterval=5",
      -  "-o",
      -  "LogLevel=ERROR",
      -] as const;
      -
      -type DesktopOs = "macos" | "linux" | "windows";
      -
      -interface CurlResult {
      -  readonly status: number;
      -  readonly body: T;
      -  readonly text: string;
      -}
      -
      -interface SyncResponse {
      -  readonly status: "published" | "up-to-date" | "failed";
      -  readonly tools: readonly string[];
      -}
      -
      -interface ExecuteResponse {
      -  readonly structured: unknown;
      -  readonly text: string;
      -}
      -
      -const NAME = "Desktop sidecar · local-directory custom tool publishes and executes";
      -const guestIp = process.env.E2E_DESKTOP_VM_IP;
      -const os: DesktopOs =
      -  process.env.E2E_TARGET === "desktop-windows"
      -    ? "windows"
      -    : process.env.E2E_TARGET === "desktop-linux"
      -      ? "linux"
      -      : "macos";
      -
      -const token = (): string => (os === "linux" ? "desktop-linux-e2e" : "desktop-macos-e2e");
      -
      -const ssh = async (command: string): Promise<{ stdout: string; stderr: string; code: number }> => {
      -  if (!guestIp) throw new Error("E2E_DESKTOP_VM_IP is not set");
      -  const wrapped =
      -    os === "linux" ? `export XDG_RUNTIME_DIR=/run/user/$(id -u); ${command}` : command;
      -  try {
      -    const { stdout, stderr } = await execFileAsync(
      -      process.env.E2E_SSHPASS_BIN ?? "/opt/homebrew/bin/sshpass",
      -      ["-p", "admin", "ssh", ...SSH_OPTS, `admin@${guestIp}`, wrapped],
      -      { maxBuffer: 64 * 1024 * 1024 },
      -    );
      -    return { stdout, stderr, code: 0 };
      -  } catch (error) {
      -    const err = error as { stdout?: string; stderr?: string; code?: number };
      -    return {
      -      stdout: err.stdout ?? "",
      -      stderr: err.stderr ?? "",
      -      code: typeof err.code === "number" ? err.code : 1,
      -    };
      -  }
      -};
      -
      -const fixturePath = (slug: string): string =>
      -  os === "linux"
      -    ? `/home/admin/apps-sidecar-fixture-${slug}`
      -    : `/Users/admin/apps-sidecar-fixture-${slug}`;
      -
      -const writeFixtureCommand = (path: string): string => `
      -set -eu
      -rm -rf '${path}'
      -mkdir -p '${path}/tools'
      -cat >'${path}/tools/greet.ts' <<'EOF'
      -${"import"} { z } from "zod";
      -${"import"} { defineTool } from "executor:app";
      -
      -const Input = z.object({ name: z.string() });
      -
      -export default defineTool({
      -  description: "Greet",
      -  input: Input,
      -  async handler(input) {
      -    return { greeting: "hello " + input.name };
      -  },
      -});
      -EOF
      -printf '%s' '{"name":"packed-apps-smoke","dependencies":{"zod":"4.3.6"}}' >'${path}/package.json'
      -: >'${path}/bun.lock'
      -`;
      -
      -const writeFixture = async (path: string): Promise => {
      -  const result = await ssh(writeFixtureCommand(path));
      -  expect(
      -    result.code,
      -    `fixture is written into the desktop guest\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
      -  ).toBe(0);
      -};
      -
      -const removeFixture = (path: string): Effect.Effect =>
      -  Effect.promise(() => ssh(`rm -rf '${path}'`).then(() => undefined)).pipe(Effect.ignore);
      -
      -const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
      -
      -const guestRequest = async (
      -  path: string,
      -  init: { readonly method: "POST" | "DELETE"; readonly body?: unknown },
      -  timeoutSeconds = 30,
      -): Promise> => {
      -  const body = init.body === undefined ? undefined : JSON.stringify(init.body);
      -  const bodyFlag = body === undefined ? "" : ` --data-binary ${shellQuote(body)}`;
      -  const command = [
      -    "curl",
      -    "-sS",
      -    "--max-time",
      -    String(timeoutSeconds),
      -    "-w",
      -    shellQuote("\\n__HTTP_STATUS__:%{http_code}"),
      -    "-X",
      -    init.method,
      -    "-H",
      -    shellQuote(`Authorization: Bearer ${token()}`),
      -    "-H",
      -    shellQuote("content-type: application/json"),
      -    bodyFlag,
      -    shellQuote(`http://127.0.0.1:4789${path}`),
      -  ].join(" ");
      -  const result = await ssh(command);
      -  expect(
      -    result.code,
      -    `${init.method} ${path} exits cleanly\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
      -  ).toBe(0);
      -  const marker = "\n__HTTP_STATUS__:";
      -  const markerIndex = result.stdout.lastIndexOf(marker);
      -  expect(markerIndex, `${init.method} ${path} printed an HTTP status`).toBeGreaterThanOrEqual(0);
      -  const text = result.stdout.slice(0, markerIndex);
      -  const status = Number(result.stdout.slice(markerIndex + marker.length).trim());
      -  return { status, body: (text.length > 0 ? JSON.parse(text) : null) as T, text };
      -};
      -
      -const expectGuestRequest = async (
      -  path: string,
      -  init: { readonly method: "POST" | "DELETE"; readonly body?: unknown },
      -  timeoutSeconds?: number,
      -): Promise => {
      -  const result = await guestRequest(path, init, timeoutSeconds);
      -  expect(result.status, `${init.method} ${path}: ${result.text}`).toBe(200);
      -  return result.body;
      -};
      -
      -const deleteSource = (slug: string): Effect.Effect =>
      -  Effect.promise(() =>
      -    expectGuestRequest(`/api/apps/sources/${slug}`, { method: "DELETE" }).then(() => undefined),
      -  ).pipe(Effect.ignore);
      -
      -const syncAndInvoke = (input: {
      -  readonly slug: string;
      -  readonly path: string;
      -}): Effect.Effect =>
      -  Effect.promise(async () => {
      -    await expectGuestRequest("/api/apps/sources", {
      -      method: "POST",
      -      body: {
      -        kind: "local-directory",
      -        slug: input.slug,
      -        app: input.slug,
      -        path: input.path,
      -      },
      -    });
      -
      -    const sync = await expectGuestRequest(
      -      `/api/apps/sources/${input.slug}/sync`,
      -      { method: "POST" },
      -      180,
      -    );
      -    expect(sync.status, "the desktop sidecar bundled and published the tool").toBe("published");
      -    expect(sync.tools, "the desktop sidecar bundled and published the tool").toContain("greet");
      -
      -    const execution = await expectGuestRequest("/api/executions", {
      -      method: "POST",
      -      body: {
      -        code: `export default await tools["${input.slug}.org.published.greet"]({ name: "Ada" })`,
      -        autoApprove: true,
      -      },
      -    });
      -    expect(
      -      JSON.stringify(execution.structured ?? execution.text),
      -      "the published tool executes and returns the greeting",
      -    ).toContain('"greeting":"hello Ada"');
      -  });
      -
      -if (!guestIp) {
      -  it.skip(`${NAME} (needs a desktop guest with E2E_DESKTOP_VM_IP)`, () => {});
      -} else if (os === "windows") {
      -  it.skip(`${NAME} (desktop-windows does not boot a sidecar daemon with a known token; use cli-windows)`, () => {});
      -} else {
      -  scenario(
      -    "Desktop sidecar · local-directory custom tool publishes and executes",
      -    { timeout: 300_000 },
      -    Effect.gen(function* () {
      -      const slug = `sidecar-smoke-${crypto.randomUUID().slice(0, 8)}`;
      -      const path = fixturePath(slug);
      -
      -      yield* Effect.promise(() => writeFixture(path)).pipe(
      -        Effect.andThen(syncAndInvoke({ slug, path })),
      -        Effect.ensuring(
      -          Effect.all([deleteSource(slug), removeFixture(path)], {
      -            concurrency: "unbounded",
      -            discard: true,
      -          }),
      -        ),
      -      );
      -    }),
      -  );
      -}
      diff --git a/e2e/fixtures/custom-tools-git/custom-tools-shas.txt b/e2e/fixtures/custom-tools-git/custom-tools-shas.txt
      deleted file mode 100644
      index ca359a139f..0000000000
      --- a/e2e/fixtures/custom-tools-git/custom-tools-shas.txt
      +++ /dev/null
      @@ -1,3 +0,0 @@
      -1f4b8f61f800b8164678f38c1c8c325e38baaf36
      -252ca58f9bea3e7dacaae04b828c57a497a6233c
      -f8b60f186b4a637bb5e5527da2a29da0f13b8683
      diff --git a/e2e/fixtures/custom-tools-git/custom-tools-v1.pack b/e2e/fixtures/custom-tools-git/custom-tools-v1.pack
      deleted file mode 100644
      index 086e39354c..0000000000
      Binary files a/e2e/fixtures/custom-tools-git/custom-tools-v1.pack and /dev/null differ
      diff --git a/e2e/fixtures/custom-tools-git/custom-tools-v2.pack b/e2e/fixtures/custom-tools-git/custom-tools-v2.pack
      deleted file mode 100644
      index 830215f202..0000000000
      Binary files a/e2e/fixtures/custom-tools-git/custom-tools-v2.pack and /dev/null differ
      diff --git a/e2e/fixtures/custom-tools-git/custom-tools-v3.pack b/e2e/fixtures/custom-tools-git/custom-tools-v3.pack
      deleted file mode 100644
      index fff2e2477a..0000000000
      Binary files a/e2e/fixtures/custom-tools-git/custom-tools-v3.pack and /dev/null differ
      diff --git a/e2e/fixtures/custom-tools-git/generate.ts b/e2e/fixtures/custom-tools-git/generate.ts
      deleted file mode 100644
      index 94667b96c4..0000000000
      --- a/e2e/fixtures/custom-tools-git/generate.ts
      +++ /dev/null
      @@ -1,158 +0,0 @@
      -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
      -import { tmpdir } from "node:os";
      -import { dirname, join } from "node:path";
      -import { fileURLToPath } from "node:url";
      -
      -const fixtureDir = dirname(fileURLToPath(import.meta.url));
      -
      -const run = (cwd: string, args: readonly string[], stdin?: string): string => {
      -  const proc = Bun.spawnSync({
      -    cmd: ["git", ...args],
      -    cwd,
      -    stdin: stdin ? new TextEncoder().encode(stdin) : undefined,
      -  });
      -  if (!proc.success) {
      -    throw new Error(
      -      `git ${args.join(" ")} failed\n${proc.stderr.toString()}\n${proc.stdout.toString()}`,
      -    );
      -  }
      -  return proc.stdout.toString().trim();
      -};
      -
      -const write = async (root: string, path: string, body: string) => {
      -  await mkdir(dirname(join(root, path)), { recursive: true });
      -  await writeFile(join(root, path), body);
      -};
      -
      -const packageJson = JSON.stringify({ dependencies: { effect: "4.0.0-beta.59", zod: "4.3.6" } });
      -const executorJson = JSON.stringify({ description: "Custom tools e2e fixture" });
      -
      -const echoTool = `import { z } from "zod";
      -import { defineTool, integration } from "executor:app";
      -
      -export default defineTool({
      -  description: "Echo a message through a projected app connection.",
      -  integrations: { apps: integration("repo") },
      -  input: z.object({ message: z.string() }),
      -  output: {
      -    type: "object",
      -    properties: { message: { type: "string" }, version: { type: "string" } },
      -    required: ["message", "version"],
      -  },
      -  annotations: { readOnly: true },
      -  async handler(input) {
      -    return { message: input.message, version: "v1" };
      -  },
      -});
      -`;
      -
      -const staticTool = `import { defineTool } from "executor:app";
      -
      -export default defineTool({
      -  description: "Return a static fixture marker.",
      -  input: { type: "object", properties: {} },
      -  output: {
      -    type: "object",
      -    properties: { ok: { type: "boolean" } },
      -    required: ["ok"],
      -  },
      -  annotations: { readOnly: true },
      -  async handler() {
      -    return { ok: true };
      -  },
      -});
      -`;
      -
      -const effectTool = `import { Effect } from "effect";
      -import { defineTool } from "executor:app";
      -
      -export default defineTool({
      -  description: "Return a marker produced through Effect.",
      -  input: { type: "object", properties: {} },
      -  output: {
      -    type: "object",
      -    properties: { ok: { type: "boolean" }, dependency: { type: "string" } },
      -    required: ["ok", "dependency"],
      -  },
      -  annotations: { readOnly: true },
      -  async handler() {
      -    return Effect.runSync(Effect.succeed({ ok: true, dependency: "effect" }));
      -  },
      -});
      -`;
      -
      -const extraTool = `import { defineTool } from "executor:app";
      -
      -export default defineTool({
      -  description: "Return a second static fixture marker.",
      -  input: { type: "object", properties: {} },
      -  output: {
      -    type: "object",
      -    properties: { added: { type: "boolean" } },
      -    required: ["added"],
      -  },
      -  annotations: { readOnly: true },
      -  async handler() {
      -    return { added: true };
      -  },
      -});
      -`;
      -
      -const badCollectTool = `import { defineTool } from "executor:app";
      -
      -const input = { type: "object", properties: {} };
      -
      -export default {
      -  "bad-collect": defineTool({
      -    description: "Trigger a collect-stage fixture failure.",
      -    input,
      -    async handler() {
      -      return { ok: true };
      -    },
      -  }),
      -};
      -`;
      -
      -const commit = (root: string, message: string): string => {
      -  run(root, ["add", "."]);
      -  run(root, ["commit", "-q", "-m", message]);
      -  return run(root, ["rev-parse", "HEAD"]);
      -};
      -
      -const pack = async (root: string, sha: string, name: string) => {
      -  const proc = Bun.spawnSync({
      -    cmd: ["git", "pack-objects", "--stdout", "--revs"],
      -    cwd: root,
      -    stdin: new TextEncoder().encode(`${sha}\n`),
      -  });
      -  if (!proc.success) throw new Error(`git pack-objects failed: ${proc.stderr.toString()}`);
      -  await writeFile(join(fixtureDir, name), proc.stdout);
      -};
      -
      -const root = await mkdtemp(join(tmpdir(), "custom-tools-git-"));
      -try {
      -  run(root, ["init", "-q"]);
      -  run(root, ["config", "user.name", "Fixture Generator"]);
      -  run(root, ["config", "user.email", "fixture@example.test"]);
      -
      -  await write(root, "executor.json", executorJson);
      -  await write(root, "package.json", packageJson);
      -  await write(root, "bun.lock", "");
      -  await write(root, "tools/echo-tool.ts", echoTool);
      -  await write(root, "tools/static-tool.ts", staticTool);
      -  await write(root, "tools/effect-tool.ts", effectTool);
      -  const sha1 = commit(root, "custom tools v1");
      -
      -  await write(root, "tools/extra-tool.ts", extraTool);
      -  const sha2 = commit(root, "custom tools v2");
      -
      -  await write(root, "tools/bad-collect.ts", badCollectTool);
      -  const sha3 = commit(root, "custom tools v3");
      -
      -  await writeFile(join(fixtureDir, "custom-tools-shas.txt"), `${sha1}\n${sha2}\n${sha3}\n`);
      -  await pack(root, sha1, "custom-tools-v1.pack");
      -  await pack(root, sha2, "custom-tools-v2.pack");
      -  await pack(root, sha3, "custom-tools-v3.pack");
      -} finally {
      -  await rm(root, { recursive: true, force: true });
      -}
      diff --git a/e2e/local/auth.test.ts b/e2e/local/auth.test.ts
      index 68201a6843..225205b388 100644
      --- a/e2e/local/auth.test.ts
      +++ b/e2e/local/auth.test.ts
      @@ -36,8 +36,10 @@ scenario(
                 await page.goto(url, { waitUntil: "domcontentloaded" });
                 await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 });
                 // Integrations actually LOAD (the built-in Executor integration) — proves
      -          // auth + data, not just the static shell.
      -          await page.getByText("built-in").first().waitFor({ timeout: 30_000 });
      +          // auth + data, not just the static shell. Matched on the row's stable
      +          // testid: the list renders each integration's name + slug, never the
      +          // literal "built-in" (that string is only an internal `kind`).
      +          await page.getByTestId("integration-entry-executor").first().waitFor({ timeout: 30_000 });
                 // The token is moved out of the URL and persisted to localStorage.
                 expect(new URL(page.url()).searchParams.has("_token")).toBe(false);
                 const stored = await page.evaluate(() => localStorage.getItem("executor.authToken"));
      @@ -70,7 +72,7 @@ scenario(
                 await page.getByRole("button", { name: "Connect" }).click();
                 await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 });
                 // The reconnect fully restores — integrations LOAD, not a stale 401.
      -          await page.getByText("built-in").first().waitFor({ timeout: 30_000 });
      +          await page.getByTestId("integration-entry-executor").first().waitFor({ timeout: 30_000 });
               });
             }),
           );
      diff --git a/e2e/local/boot-process.test.ts b/e2e/local/boot-process.test.ts
      new file mode 100644
      index 0000000000..f5c43cca20
      --- /dev/null
      +++ b/e2e/local/boot-process.test.ts
      @@ -0,0 +1,101 @@
      +import { describe, expect, it } from "@effect/vitest";
      +import { mkdtempSync, rmSync } from "node:fs";
      +import { tmpdir } from "node:os";
      +import { join } from "node:path";
      +
      +import {
      +  BootProcessExitError,
      +  BootReadinessTimeoutError,
      +  bootProcesses,
      +  isBootReadinessTimeout,
      +  waitForBoot,
      +} from "../setup/boot";
      +import { claimAndBoot, isAddrInUse } from "../src/ports";
      +
      +describe("e2e boot process lifecycle", () => {
      +  it("fails immediately with the boot log when a child exits", async () => {
      +    const tempDir = mkdtempSync(join(tmpdir(), "executor-e2e-boot-"));
      +    const logFile = join(tempDir, "boot.log");
      +    let readinessProbeAborted = false;
      +
      +    try {
      +      const processes = bootProcesses(
      +        [
      +          {
      +            cmd: process.execPath,
      +            args: [
      +              "-e",
      +              'console.error("Error: Port 44550 is already in use (EADDRINUSE)"); process.exit(17)',
      +            ],
      +            cwd: tempDir,
      +            logFile,
      +          },
      +        ],
      +        { label: "lifecycle-test" },
      +      );
      +
      +      const startedAt = Date.now();
      +      let failure: unknown;
      +      try {
      +        await waitForBoot(
      +          processes,
      +          (signal) =>
      +            new Promise((_resolve, reject) => {
      +              signal.addEventListener(
      +                "abort",
      +                () => {
      +                  readinessProbeAborted = true;
      +                  // oxlint-disable-next-line executor/no-promise-reject -- boundary: the fixture models an abort-aware readiness promise
      +                  reject(signal.reason);
      +                },
      +                { once: true },
      +              );
      +            }),
      +        );
      +      } catch (error) {
      +        failure = error;
      +      }
      +
      +      expect(failure).toBeInstanceOf(BootProcessExitError);
      +      expect(Date.now() - startedAt, "child exit beats the readiness timeout").toBeLessThan(5_000);
      +      expect(readinessProbeAborted, "the losing readiness probe is cancelled").toBe(true);
      +      expect(isAddrInUse(failure), "the port claimer can retry this boot failure").toBe(true);
      +      const bootFailure = failure as BootProcessExitError;
      +      expect(bootFailure.exitCode).toBe(17);
      +      expect(bootFailure.logTail).toContain("Port 44550 is already in use");
      +
      +      await processes.teardown();
      +    } finally {
      +      rmSync(tempDir, { recursive: true, force: true });
      +    }
      +  });
      +
      +  it("releases a failed claim before retrying a readiness timeout", async () => {
      +    const envVar = "E2E_BOOT_LIFECYCLE_TEST_PORT";
      +    let attempts = 0;
      +    const claimedPorts: number[] = [];
      +
      +    try {
      +      const booted = await claimAndBoot(
      +        [{ envVar, offset: 8, label: "boot lifecycle test" }],
      +        async (ports) => {
      +          attempts += 1;
      +          claimedPorts.push(ports[envVar]!);
      +          if (attempts === 1) {
      +            throw new BootReadinessTimeoutError("http://127.0.0.1:1", 10, "fixture timeout");
      +          }
      +          return { teardown: async () => {}, value: "ready" };
      +        },
      +        { maxAttempts: 2, label: "lifecycle-test", retryWhen: isBootReadinessTimeout },
      +      );
      +
      +      expect(booted.value).toBe("ready");
      +      expect(attempts).toBe(2);
      +      expect(claimedPorts).toHaveLength(2);
      +      await booted.teardown();
      +      expect(process.env[envVar], "the successful claim is cleared at teardown").toBeUndefined();
      +    } finally {
      +      delete process.env[envVar];
      +    }
      +  });
      +});
      diff --git a/e2e/local/cli-mcp-daemon-attach-stress.test.ts b/e2e/local/cli-mcp-daemon-attach-stress.test.ts
      index 95daad7cfe..488c7e13fa 100644
      --- a/e2e/local/cli-mcp-daemon-attach-stress.test.ts
      +++ b/e2e/local/cli-mcp-daemon-attach-stress.test.ts
      @@ -21,20 +21,24 @@ import { expect } from "@effect/vitest";
       import { Client } from "@modelcontextprotocol/sdk/client/index.js";
       import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
       import { Effect } from "effect";
      +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
       import { mkdtempSync, readdirSync, rmSync } from "node:fs";
       import { tmpdir } from "node:os";
       import { join } from "node:path";
       import { fileURLToPath } from "node:url";
      -import type { Subprocess } from "bun";
       
       import { scenario } from "../src/scenario";
      +import { stopAutoSpawnedDaemon } from "./daemon-process";
       
       const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
       const testScope = join(repoRoot, "apps/local");
       // Generous: a dev-mode daemon boots a Vite dev server, slow under machine load.
       const readyTimeoutMs = 150_000;
       
      -type DaemonProc = Subprocess<"ignore", "pipe", "pipe">;
      +// vitest runs this suite under NODE, not bun, so the daemon is spawned with
      +// node:child_process (the rest of the e2e harness does the same). `Bun.spawn`
      +// here threw `ReferenceError: Bun is not defined` on every run.
      +type DaemonProc = ChildProcessWithoutNullStreams;
       
       const waitForDaemonReady = (
         proc: DaemonProc,
      @@ -44,50 +48,38 @@ const waitForDaemonReady = (
           let stdoutBuffer = "";
           let stderrBuffer = "";
           let settled = false;
      -    const decoder = new TextDecoder();
      -    const stdout = proc.stdout.getReader();
      -    const stderr = proc.stderr.getReader();
           const deadline = setTimeout(() => {
             if (settled) return;
             settled = true;
             // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr
             rejectReady(new Error(`daemon did not announce ready: ${stderrBuffer}`));
           }, readyTimeoutMs);
      -    void (async () => {
      -      while (true) {
      -        const { value, done } = await stderr.read();
      -        if (done) return;
      -        stderrBuffer += decoder.decode(value);
      -      }
      -    })();
      -    void (async () => {
      -      while (true) {
      -        const { value, done } = await stdout.read();
      -        if (done) {
      -          if (!settled) {
      -            settled = true;
      -            clearTimeout(deadline);
      -            // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr
      -            rejectReady(new Error(`daemon stdout closed before ready: ${stderrBuffer}`));
      -          }
      -          return;
      -        }
      -        stdoutBuffer += decoder.decode(value);
      -        const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuffer);
      -        if (match) {
      -          settled = true;
      -          clearTimeout(deadline);
      -          resolveReady({ port: Number(match[1]), stderr: () => stderrBuffer });
      -          return;
      -        }
      +    proc.stderr.on("data", (chunk: Buffer) => {
      +      stderrBuffer += chunk.toString();
      +    });
      +    proc.stdout.on("data", (chunk: Buffer) => {
      +      if (settled) return;
      +      stdoutBuffer += chunk.toString();
      +      const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuffer);
      +      if (match) {
      +        settled = true;
      +        clearTimeout(deadline);
      +        resolveReady({ port: Number(match[1]), stderr: () => stderrBuffer });
             }
      -    })();
      +    });
      +    proc.stdout.on("close", () => {
      +      if (settled) return;
      +      settled = true;
      +      clearTimeout(deadline);
      +      // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr
      +      rejectReady(new Error(`daemon stdout closed before ready: ${stderrBuffer}`));
      +    });
         });
       
       const spawnDaemon = (dataDir: string): DaemonProc =>
      -  Bun.spawn(
      +  spawn(
      +    "bun",
           [
      -      "bun",
             "run",
             "dev:cli",
             "daemon",
      @@ -103,17 +95,24 @@ const spawnDaemon = (dataDir: string): DaemonProc =>
           {
             cwd: repoRoot,
             env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },
      -      stdin: "ignore",
      -      stdout: "pipe",
      -      stderr: "pipe",
      +      stdio: ["ignore", "pipe", "pipe"],
      +      detached: true,
           },
      -  );
      +  ) as DaemonProc;
      +
      +const exited = (proc: DaemonProc): Promise =>
      +  proc.exitCode !== null || proc.signalCode !== null
      +    ? Promise.resolve()
      +    : new Promise((resolve) => proc.once("exit", () => resolve()));
       
       const stopProc = async (proc: DaemonProc): Promise => {
      -  if (proc.exitCode !== null) return;
      -  proc.kill("SIGTERM");
      -  await Promise.race([proc.exited, Bun.sleep(3000)]);
      -  if (proc.exitCode === null) proc.kill("SIGKILL");
      +  if (proc.exitCode !== null || proc.signalCode !== null) return;
      +  if (proc.pid) process.kill(-proc.pid, "SIGTERM");
      +  await Promise.race([exited(proc), new Promise((resolve) => setTimeout(resolve, 3000))]);
      +  if (proc.exitCode === null && proc.signalCode === null && proc.pid) {
      +    process.kill(-proc.pid, "SIGKILL");
      +    await exited(proc);
      +  }
       };
       
       const startForegroundDaemon = (dataDir: string) =>
      @@ -187,28 +186,14 @@ const runOneClient = async (
         }
       };
       
      -/** `executor mcp` now ensures a DURABLE (detached) daemon and bridges to it, so a
      - * cold-start scenario leaves that daemon running. Stop it before removing the
      - * data dir so the test never leaks an orphan daemon. */
      -const stopAutoSpawnedDaemon = (dataDir: string): void => {
      -  try {
      -    const manifest = JSON.parse(
      -      readFileSync(join(dataDir, "server-control", "server.json"), "utf8"),
      -    ) as { pid?: number };
      -    if (manifest.pid) process.kill(manifest.pid, "SIGTERM");
      -  } catch {
      -    // no manifest (no daemon spawned) — nothing to stop.
      -  }
      -};
      -
       const withTempData = Effect.acquireRelease(
         Effect.sync(() => {
           const root = mkdtempSync(join(tmpdir(), "executor-mcp-stress-"));
           return join(root, "data");
         }),
         (dataDir) =>
      -    Effect.sync(() => {
      -      stopAutoSpawnedDaemon(dataDir);
      +    Effect.promise(async () => {
      +      await stopAutoSpawnedDaemon(dataDir);
             rmSync(join(dataDir, ".."), { recursive: true, force: true });
           }),
       );
      @@ -321,8 +306,13 @@ scenario(
             "2",
           );
       
      -    daemon.proc.kill("SIGKILL");
      -    yield* Effect.promise(() => Promise.race([daemon.proc.exited, Bun.sleep(3000)]));
      +    if (daemon.proc.pid) process.kill(-daemon.proc.pid, "SIGKILL");
      +    yield* Effect.promise(() =>
      +      Promise.race([
      +        exited(daemon.proc),
      +        new Promise((resolve) => setTimeout(resolve, 3000)),
      +      ]),
      +    );
       
           // The next call must settle (reject) quickly — a 10s bound well under the
           // scenario timeout catches a hang.
      @@ -332,7 +322,7 @@ scenario(
                 .callTool({ name: "execute", arguments: { code: "return 3" } })
                 .then(() => "resolved" as const)
                 .catch(() => "rejected" as const),
      -        Bun.sleep(10_000).then(() => "timeout" as const),
      +        new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 10_000)),
             ]),
           );
           // eslint-disable-next-line no-console
      diff --git a/e2e/local/cli-telemetry-optout.test.ts b/e2e/local/cli-telemetry-optout.test.ts
      new file mode 100644
      index 0000000000..441d735f53
      --- /dev/null
      +++ b/e2e/local/cli-telemetry-optout.test.ts
      @@ -0,0 +1,27 @@
      +import { expect, it } from "@effect/vitest";
      +import { Effect } from "effect";
      +
      +import { makeCliSurface } from "../src/surfaces/cli";
      +
      +it.live("terminal E2E sessions force product telemetry off", () =>
      +  makeCliSurface()
      +    .session(
      +      [
      +        process.execPath,
      +        "-e",
      +        "console.log([process.env.DO_NOT_TRACK, process.env.EXECUTOR_DISABLE_ANALYTICS, process.env.EXECUTOR_DISABLE_INTEGRATIONS_FETCH].join('|'))",
      +      ],
      +      (terminal) =>
      +        terminal.screen
      +          .waitUntil((screen) => screen.text.includes("1|1|1"), { timeoutMs: 5_000 })
      +          .then((screen) => expect(screen.text).toContain("1|1|1")),
      +      {
      +        env: {
      +          DO_NOT_TRACK: "0",
      +          EXECUTOR_DISABLE_ANALYTICS: "0",
      +          EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "0",
      +        },
      +      },
      +    )
      +    .pipe(Effect.timeout("10 seconds")),
      +);
      diff --git a/e2e/local/codex-plugins.test.ts b/e2e/local/codex-plugins.test.ts
      new file mode 100644
      index 0000000000..423e9a13ed
      --- /dev/null
      +++ b/e2e/local/codex-plugins.test.ts
      @@ -0,0 +1,235 @@
      +// Codex plugins as one-click stdio presets.
      +//
      +// The server-side scanner reads `$CODEX_HOME` and reports locally installed
      +// OpenAI Codex plugins with stdio MCP servers: the three curated ones the
      +// shared "Codex Computer Use" client binary implements (Apple Messages,
      +// Computer Use, Computer History) plus anything in the plugin cache with a
      +// local-command `.mcp.json`. This scenario boots the real local server with
      +// `CODEX_HOME` pointed at a fixture layout whose "client binary" is a wrapper
      +// around the e2e stdio MCP fixture, and drives the same API the add-form's
      +// Codex-plugins section uses:
      +//
      +//   1. `mcp.listCodexPlugins` reports the curated entries and the scanned
      +//      cache entry, all available.
      +//   2. Adding an entry with its reported recipe (the one-click card path)
      +//      registers the integration, auto-connects, and detects its tools —
      +//      including `saw_codex_home`, which the fixture advertises only when
      +//      CODEX_HOME actually reached the spawned subprocess.
      +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
      +import { tmpdir } from "node:os";
      +import { join } from "node:path";
      +import { fileURLToPath } from "node:url";
      +
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +import { HttpApiClient } from "effect/unstable/httpapi";
      +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
      +import { composePluginApi } from "@executor-js/api/server";
      +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
      +
      +import { scenario } from "../src/scenario";
      +import { Cli, RunDir } from "../src/services";
      +import { withLocalServer } from "./local-server";
      +
      +const api = composePluginApi([mcpHttpPlugin()] as const);
      +
      +const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));
      +const CHROME_CLIENT_RELATIVE = join(
      +  "plugins",
      +  "cache",
      +  "openai-bundled",
      +  "chrome",
      +  "latest",
      +  "scripts",
      +  "browser-client.mjs",
      +);
      +const APP_SERVER_FIXTURE = fileURLToPath(
      +  new URL("./fixtures/codex-app-server.mjs", import.meta.url),
      +);
      +
      +/** A fixture CODEX_HOME: the curated install markers (the Computer Use app
      + *  and a `codex` CLI whose `app-server` is the fake app-server fixture) and
      + *  one cached plugin wrapping the self-contained stdio MCP fixture. */
      +const makeCodexHome = (): string => {
      +  const home = mkdtempSync(join(tmpdir(), "codex-home-e2e-"));
      +  const wrapper = `#!/bin/sh\nexec node "${FIXTURE}" "$@"\n`;
      +
      +  // The Computer Use app is the plugin-installed marker; the bridge never
      +  // spawns it, so an empty executable is enough.
      +  const clientDir = join(
      +    home,
      +    "computer-use",
      +    "Codex Computer Use.app",
      +    "Contents",
      +    "SharedSupport",
      +    "SkyComputerUseClient.app",
      +    "Contents",
      +    "MacOS",
      +  );
      +  mkdirSync(clientDir, { recursive: true });
      +  writeFileSync(join(clientDir, "SkyComputerUseClient"), wrapper, { mode: 0o755 });
      +
      +  // The `codex` CLI the curated recipes spawn — resolved through PATH, so
      +  // the scenario prepends this bin dir to the server's PATH.
      +  mkdirSync(join(home, "bin"), { recursive: true });
      +  writeFileSync(join(home, "bin", "codex"), `#!/bin/sh\nexec node "${APP_SERVER_FIXTURE}" "$@"\n`, {
      +    mode: 0o755,
      +  });
      +
      +  // Chrome's bundled browser client, behind the `latest` symlink Codex keeps.
      +  const chromeClient = join(home, CHROME_CLIENT_RELATIVE);
      +  mkdirSync(join(chromeClient, ".."), { recursive: true });
      +  writeFileSync(chromeClient, "export const setupBrowserRuntime = async () => ({});\n");
      +
      +  const versionDir = join(home, "plugins", "cache", "personal", "echo-suite", "1.0.2");
      +  mkdirSync(join(versionDir, ".codex-plugin"), { recursive: true });
      +  mkdirSync(join(versionDir, "bin"), { recursive: true });
      +  writeFileSync(
      +    join(versionDir, ".codex-plugin", "plugin.json"),
      +    JSON.stringify({
      +      name: "echo-suite",
      +      mcpServers: "./.mcp.json",
      +      interface: { displayName: "Echo Suite", shortDescription: "Echo tools for e2e" },
      +    }),
      +  );
      +  writeFileSync(
      +    join(versionDir, ".mcp.json"),
      +    JSON.stringify({ mcpServers: { "echo-suite": { command: "./bin/run", cwd: "." } } }),
      +  );
      +  writeFileSync(join(versionDir, "bin", "run"), wrapper, { mode: 0o755 });
      +
      +  return home;
      +};
      +
      +scenario(
      +  "Local · Codex plugins are discovered from CODEX_HOME and add as one-click stdio presets",
      +  // Above the 240s boot-URL wait in `withLocalServer` for the same reason as
      +  // stdio-mcp.test.ts: a cold vite boot must fail with the harness's
      +  // diagnostic, not vitest's generic timeout.
      +  { timeout: 300_000 },
      +  Effect.gen(function* () {
      +    const cli = yield* Cli;
      +    const runDir = yield* RunDir;
      +    const codexHome = makeCodexHome();
      +
      +    yield* withLocalServer(
      +      cli,
      +      runDir,
      +      (server) =>
      +        Effect.gen(function* () {
      +          const client = yield* HttpApiClient.make(api, {
      +            baseUrl: new URL("/api", server.origin).toString(),
      +            transformClient: HttpClient.mapRequest((request) =>
      +              HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
      +            ),
      +          }).pipe(Effect.provide(FetchHttpClient.layer));
      +
      +          // The scanner reports the curated plugins and the cached one, all
      +          // available (the fixture home has every binary in place).
      +          const { plugins } = yield* client.mcp.listCodexPlugins();
      +          const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
      +          expect([...byId.keys()].sort(), "curated + scanned entries are reported").toEqual([
      +            "codex-chrome",
      +            "codex-computer-history",
      +            "codex-computer-use",
      +            "codex-echo-suite",
      +            "codex-messages",
      +            "codex-openai-docs",
      +          ]);
      +          for (const plugin of plugins) {
      +            expect(plugin.available, `${plugin.id} is available`).toBe(true);
      +            expect(plugin.env, `${plugin.id} declares CODEX_HOME`).toEqual({
      +              CODEX_HOME: codexHome,
      +            });
      +          }
      +          // Curated entries carry the app-server bridge recipe: `codex
      +          // app-server`, the server name the bridge calls tools on, and the
      +          // preset it came from — that last one is what lets a macOS refusal
      +          // name the exact grant to enable.
      +          const messages = byId.get("codex-messages");
      +          expect(messages?.command.endsWith("codex"), "curated entries spawn the codex CLI").toBe(
      +            true,
      +          );
      +          expect(messages?.args, "curated entries run the app-server").toEqual(["app-server"]);
      +          expect(messages?.appServer, "curated entries name their Codex server").toEqual({
      +            presetId: "codex-messages",
      +            server: "messages",
      +          });
      +          // Computer Use and Chrome have no server of their own: both are
      +          // projected onto `node_repl`, and Chrome carries the client module
      +          // its surface imports, resolved through the `latest` symlink.
      +          expect(byId.get("codex-computer-use")?.appServer).toEqual({
      +            presetId: "codex-computer-use",
      +            server: "node_repl",
      +            surface: "sky",
      +          });
      +          expect(byId.get("codex-chrome")?.appServer).toEqual({
      +            presetId: "codex-chrome",
      +            server: "node_repl",
      +            surface: "browser",
      +            modulePath: join(codexHome, CHROME_CLIENT_RELATIVE),
      +          });
      +
      +          // Add two entries exactly as the add-form's Codex-plugins card does:
      +          // the reported recipe, verbatim.
      +          for (const id of ["codex-messages", "codex-echo-suite"] as const) {
      +            const plugin = byId.get(id)!;
      +            yield* client.mcp.addServer({
      +              payload: {
      +                transport: "stdio",
      +                name: plugin.name,
      +                slug: plugin.slug,
      +                description: plugin.summary,
      +                command: plugin.command,
      +                args: [...plugin.args],
      +                ...(plugin.cwd === undefined ? {} : { cwd: plugin.cwd }),
      +                // Static, not `env`: CODEX_HOME is a machine path the scanner
      +                // resolved, so it must not become a credential to type.
      +                ...(plugin.env === undefined ? {} : { staticEnv: { ...plugin.env } }),
      +                ...(plugin.appServer === undefined
      +                  ? {}
      +                  : { appServer: { server: plugin.appServer.server } }),
      +              },
      +            });
      +          }
      +
      +          const integrations = yield* client.integrations.list();
      +          const slugs = integrations.map((integration) => String(integration.slug));
      +          expect(slugs, "both plugins registered").toEqual(
      +            expect.arrayContaining(["codex_messages", "codex_echo_suite"]),
      +          );
      +
      +          // One-click means connected: the env values auto-create the default
      +          // connection, so tools are discovered with no further step.
      +          for (const slug of ["codex_messages", "codex_echo_suite"]) {
      +            const connections = yield* client.connections.list({ query: { integration: slug } });
      +            expect(
      +              connections.map((connection) => String(connection.name)),
      +              `${slug} auto-connected`,
      +            ).toContain("default");
      +            // Nothing to configure: the connection carries no credential, so
      +            // the person is never shown a field for a path we already know.
      +            expect(
      +              connections.map((connection) => String(connection.template)),
      +              `${slug} needs no credential`,
      +            ).toEqual(["none"]);
      +
      +            const tools = yield* client.tools.list({ query: { integration: slug } });
      +            const names = tools.map((tool) => tool.name);
      +            expect(names, `${slug} tools are detected`).toContain("echo_tool");
      +            expect(
      +              names,
      +              `CODEX_HOME reached ${slug}'s spawned subprocess (saw_codex_home is gated on it)`,
      +            ).toContain("saw_codex_home");
      +          }
      +        }),
      +      {
      +        env: {
      +          CODEX_HOME: codexHome,
      +          // The scanner resolves the `codex` CLI through the server's PATH.
      +          PATH: `${join(codexHome, "bin")}:${process.env["PATH"] ?? ""}`,
      +        },
      +      },
      +    );
      +  }),
      +);
      diff --git a/e2e/local/custom-tools-directory-picker.test.ts b/e2e/local/custom-tools-directory-picker.test.ts
      deleted file mode 100644
      index 463c62ebb4..0000000000
      --- a/e2e/local/custom-tools-directory-picker.test.ts
      +++ /dev/null
      @@ -1,115 +0,0 @@
      -// Local-only: custom tool local-directory sources can browse the user's own
      -// filesystem through the local app. The shared self-host target intentionally
      -// does not expose this source kind.
      -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
      -import { homedir } from "node:os";
      -import { join } from "node:path";
      -
      -import { expect } from "@effect/vitest";
      -import { Effect } from "effect";
      -
      -import { scenario } from "../src/scenario";
      -import { Browser, Cli, RunDir, Target } from "../src/services";
      -import { withLocalServer } from "./local-server";
      -
      -scenario(
      -  "Local custom tools · the directory picker selects a local source path",
      -  { timeout: 180_000 },
      -  Effect.gen(function* () {
      -    const cli = yield* Cli;
      -    const browser = yield* Browser;
      -    const target = yield* Target;
      -    const runDir = yield* RunDir;
      -    const identity = yield* target.newIdentity();
      -    const fixtureRoot = mkdtempSync(join(homedir(), ".executor-picker-e2e-"));
      -    const fixtureDir = join(fixtureRoot, "picked-tools");
      -    mkdirSync(join(fixtureDir, "tools"), { recursive: true });
      -    writeFileSync(
      -      join(fixtureDir, "package.json"),
      -      JSON.stringify({
      -        name: "picked-tools",
      -        version: "0.0.0",
      -        private: true,
      -        dependencies: { zod: "^4.1.13" },
      -      }),
      -    );
      -    writeFileSync(
      -      join(fixtureDir, "tools", "echo.ts"),
      -      `
      -import { z } from "zod";
      -import { defineTool } from "executor:app";
      -
      -export default defineTool({
      -  description: "Echo a picker message.",
      -  input: z.object({ message: z.string() }),
      -  handler(input) {
      -    return { echoed: \`local-picker:\${input.message}\` };
      -  },
      -});
      -`,
      -    );
      -
      -    yield* withLocalServer(cli, runDir, ({ url }) =>
      -      browser.session(identity, async ({ page, step }) => {
      -        await step("Open custom tools from the local console", async () => {
      -          const addSourceUrl = new URL(url);
      -          addSourceUrl.pathname = "/integrations/add/apps";
      -          await page.goto(addSourceUrl.toString(), {
      -            waitUntil: "domcontentloaded",
      -          });
      -          await page.getByRole("heading", { name: "Add custom tools" }).waitFor({
      -            timeout: 60_000,
      -          });
      -        });
      -
      -        await step("Browse to a local directory and select it", async () => {
      -          await page.getByRole("button", { name: "Directory path" }).click();
      -          const pathInput = page.getByPlaceholder("/Users/me/tools");
      -          await pathInput.fill(fixtureRoot);
      -          await page.getByRole("button", { name: "Browse" }).click();
      -          const dialog = page.getByRole("dialog", { name: "Choose directory" });
      -          await dialog
      -            .getByRole("button", { name: /picked-tools/ })
      -            .getByText("tools", { exact: true })
      -            .waitFor({ timeout: 30_000 });
      -          await dialog.getByRole("button", { name: "picked-tools" }).click();
      -          await dialog.getByText("1 tool found:").waitFor({ timeout: 30_000 });
      -          await dialog.getByText("echo", { exact: true }).waitFor({ timeout: 30_000 });
      -          await dialog.getByRole("button", { name: "Select" }).click();
      -          expect(await pathInput.inputValue()).toBe(fixtureDir);
      -        });
      -
      -        await step("Sync the picked directory as a custom tools source", async () => {
      -          await page.getByRole("button", { name: "Sync source" }).click();
      -          await page.waitForURL(/\/integrations\/picked-tools(?:\?|$)/, { timeout: 90_000 });
      -          await page.getByLabel("Source").getByText("1 tool").waitFor({ timeout: 90_000 });
      -        });
      -
      -        await step("Run the published local tool from the console", async () => {
      -          await page.getByRole("tab", { name: "Tools" }).click();
      -          await page.getByRole("button", { name: /picked-tools\s+1/ }).click();
      -          await page.getByRole("button", { name: "echo", exact: true }).click();
      -          await page.getByRole("tab", { name: "Run" }).click();
      -          await page.getByLabel("message").fill("from directory picker");
      -          await page.getByRole("button", { name: "Run", exact: true }).click();
      -          await page
      -            .locator("pre")
      -            .filter({ hasText: "local-picker:from directory picker" })
      -            .last()
      -            .waitFor({ timeout: 90_000 });
      -        });
      -
      -        await step("Remove the local directory source", async () => {
      -          await page.goto(new URL("/integrations/picked-tools?tab=source", url).toString(), {
      -            waitUntil: "networkidle",
      -          });
      -          await page.getByRole("button", { name: "Remove" }).click();
      -          await page.getByRole("button", { name: "Remove source" }).click();
      -          await page.waitForURL(/\/integrations(?:\?|$)/, { timeout: 90_000 });
      -        });
      -      }),
      -    ).pipe(
      -      Effect.ensuring(Effect.sync(() => rmSync(fixtureRoot, { recursive: true, force: true }))),
      -    );
      -  }),
      -);
      diff --git a/e2e/local/daemon-process.ts b/e2e/local/daemon-process.ts
      new file mode 100644
      index 0000000000..d7eddb09b4
      --- /dev/null
      +++ b/e2e/local/daemon-process.ts
      @@ -0,0 +1,55 @@
      +import { readFile } from "node:fs/promises";
      +import { join } from "node:path";
      +
      +const isAlive = (pid: number): boolean => {
      +  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: process liveness probing reports false for an already-reaped test daemon
      +  try {
      +    process.kill(process.platform === "win32" ? pid : -pid, 0);
      +    return true;
      +  } catch {
      +    return false;
      +  }
      +};
      +
      +const signal = (pid: number, name: NodeJS.Signals): void => {
      +  // Auto-started daemons are detached process-group leaders. Signal the whole
      +  // private group so their Vite child cannot survive a failed test.
      +  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Windows and pre-detach failures require a direct-pid fallback
      +  try {
      +    process.kill(process.platform === "win32" ? pid : -pid, name);
      +  } catch {
      +    // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a process that exited between the liveness check and signal
      +    try {
      +      process.kill(pid, name);
      +    } catch {}
      +  }
      +};
      +
      +const waitUntilStopped = async (pid: number, timeoutMs: number): Promise => {
      +  const deadline = performance.now() + timeoutMs;
      +  while (performance.now() < deadline) {
      +    if (!isAlive(pid)) return true;
      +    await new Promise((resolve) => setTimeout(resolve, 50));
      +  }
      +  return !isAlive(pid);
      +};
      +
      +/** Stop the detached daemon elected by an `executor mcp` cold start. */
      +export const stopAutoSpawnedDaemon = async (dataDir: string): Promise => {
      +  // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a bridge that failed before writing its manifest
      +  try {
      +    const manifest = JSON.parse(
      +      await readFile(join(dataDir, "server-control", "server.json"), "utf8"),
      +    ) as { readonly pid?: unknown };
      +    if (!Number.isSafeInteger(manifest.pid) || (manifest.pid as number) <= 0) return;
      +
      +    const pid = manifest.pid as number;
      +    signal(pid, "SIGTERM");
      +    if (await waitUntilStopped(pid, 10_000)) return;
      +
      +    signal(pid, "SIGKILL");
      +    await waitUntilStopped(pid, 2_000);
      +  } catch {
      +    // No manifest means there is no auto-started daemon to stop.
      +  }
      +};
      diff --git a/e2e/local/fixtures/codex-app-server.mjs b/e2e/local/fixtures/codex-app-server.mjs
      new file mode 100644
      index 0000000000..8ed82528cd
      --- /dev/null
      +++ b/e2e/local/fixtures/codex-app-server.mjs
      @@ -0,0 +1,117 @@
      +// A zero-dependency fake `codex app-server`, for the `local` e2e project.
      +//
      +// The curated Codex plugin presets no longer spawn a plugin's MCP client
      +// directly — their service refuses tool calls from non-Codex hosts — so the
      +// scanner emits `codex app-server` recipes and the connector bridges MCP to
      +// the app-server protocol in process. This fixture stands in for the real
      +// binary with the same wire shapes (newline-delimited JSON-RPC, v2 protocol):
      +// `initialize`, the `initialized` notification, `thread/start`,
      +// `mcpServerStatus/list`, and `mcpServer/tool/call` against one MCP server
      +// named `messages`.
      +//
      +// Like stdio-mcp-server.mjs it gates a `saw_codex_home` tool on CODEX_HOME
      +// being present in this process's env, so a scenario can prove the declared
      +// env reached the spawned child through the bridge.
      +
      +import { createInterface } from "node:readline";
      +
      +const send = (message) => {
      +  process.stdout.write(`${JSON.stringify(message)}\n`);
      +};
      +
      +const THREAD_ID = "thread-e2e-1";
      +
      +const TOOLS = {
      +  echo_tool: {
      +    name: "echo_tool",
      +    description: "Echoes the provided text back",
      +    inputSchema: {
      +      type: "object",
      +      properties: { text: { type: "string" } },
      +      required: ["text"],
      +    },
      +  },
      +};
      +
      +if (process.env.CODEX_HOME !== undefined) {
      +  TOOLS.saw_codex_home = {
      +    name: "saw_codex_home",
      +    description: "Present only because CODEX_HOME is set in this server's environment",
      +    inputSchema: { type: "object", properties: {} },
      +  };
      +}
      +
      +const handle = (msg) => {
      +  // Notifications (`initialized`) carry no id and expect no response.
      +  if (msg.id === undefined || msg.id === null) return;
      +
      +  if (msg.method === "initialize") {
      +    send({ jsonrpc: "2.0", id: msg.id, result: { userAgent: "codex-e2e-fixture/0.0.0" } });
      +    return;
      +  }
      +
      +  if (msg.method === "thread/start") {
      +    send({ jsonrpc: "2.0", id: msg.id, result: { thread: { id: THREAD_ID } } });
      +    return;
      +  }
      +
      +  if (msg.method === "mcpServerStatus/list") {
      +    send({
      +      jsonrpc: "2.0",
      +      id: msg.id,
      +      result: {
      +        data: [
      +          {
      +            name: "messages",
      +            runtimeStatus: "connected",
      +            pluginId: "messages",
      +            serverInfo: null,
      +            tools: TOOLS,
      +            resources: [],
      +            resourceTemplates: [],
      +            authStatus: "unsupported",
      +          },
      +        ],
      +        nextCursor: null,
      +      },
      +    });
      +    return;
      +  }
      +
      +  if (msg.method === "mcpServer/tool/call") {
      +    const { server, tool } = msg.params ?? {};
      +    if (server !== "messages" || TOOLS[tool] === undefined) {
      +      send({
      +        jsonrpc: "2.0",
      +        id: msg.id,
      +        error: { code: -32602, message: `unknown server or tool: ${server}/${tool}` },
      +      });
      +      return;
      +    }
      +    const text =
      +      tool === "echo_tool" ? String(msg.params?.arguments?.text ?? "") : "codex-home-present";
      +    send({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text }] } });
      +    return;
      +  }
      +
      +  send({
      +    jsonrpc: "2.0",
      +    id: msg.id,
      +    error: { code: -32601, message: `Method not found: ${msg.method}` },
      +  });
      +};
      +
      +const rl = createInterface({ input: process.stdin });
      +rl.on("line", (line) => {
      +  const trimmed = line.trim();
      +  if (!trimmed) return;
      +  let msg;
      +  // oxlint-disable-next-line executor/no-try-catch-or-throw -- standalone zero-dep fixture: hand-rolled JSON-RPC framing, not product code
      +  try {
      +    // oxlint-disable-next-line executor/no-json-parse -- standalone zero-dep fixture: hand-rolled JSON-RPC framing, not product code
      +    msg = JSON.parse(trimmed);
      +  } catch {
      +    return;
      +  }
      +  handle(msg);
      +});
      diff --git a/e2e/local/fixtures/stdio-mcp-server.mjs b/e2e/local/fixtures/stdio-mcp-server.mjs
      index 9f57c79a28..cf3f88573d 100644
      --- a/e2e/local/fixtures/stdio-mcp-server.mjs
      +++ b/e2e/local/fixtures/stdio-mcp-server.mjs
      @@ -11,6 +11,12 @@
       // It exposes one tool, `echo_tool`, and (when EXECUTOR_E2E_SECRET is set in the
       // child env) a second `whoami` tool that returns that env value — so a scenario
       // can prove a per-connection secret env var actually reached the subprocess.
      +//
      +// It also reports its own environment the same way: for each variable in
      +// ENV_PROBES below it advertises a tool ONLY when that variable is present in
      +// the child's env. Gating the tool NAME rather than returning a value means the
      +// answer survives the whole discovery path unaltered — a scenario reads it off
      +// the tools API, and "absent" cannot be confused with "empty string".
       
       import { createInterface } from "node:readline";
       
      @@ -38,6 +44,28 @@ if (process.env.EXECUTOR_E2E_SECRET) {
         });
       }
       
      +// Each entry advertises `saw_` when its variable reached this process.
      +// The first three cover the three ways a variable can be handed to a stdio
      +// server: declared on the source, allowlisted infrastructure inherited from
      +// the host, and — the leak — an unrelated host variable that must never
      +// travel. CODEX_HOME is the variable the Codex-plugin presets declare, so the
      +// codex-plugins scenario can prove it reached the spawn.
      +const ENV_PROBES = [
      +  { tool: "saw_declared_env", key: "EXECUTOR_E2E_SECRET" },
      +  { tool: "saw_proxy_env", key: "NO_PROXY" },
      +  { tool: "saw_host_secret", key: "EXECUTOR_E2E_HOST_ONLY_SECRET" },
      +  { tool: "saw_codex_home", key: "CODEX_HOME" },
      +];
      +
      +for (const probe of ENV_PROBES) {
      +  if (process.env[probe.key] === undefined) continue;
      +  TOOLS.push({
      +    name: probe.tool,
      +    description: `Present only because ${probe.key} is set in this server's environment`,
      +    inputSchema: { type: "object", properties: {} },
      +  });
      +}
      +
       const handle = (msg) => {
         if (msg.method === "initialize") {
           send({
      diff --git a/e2e/local/local-server.ts b/e2e/local/local-server.ts
      index 6991dd8922..798a62cc9f 100644
      --- a/e2e/local/local-server.ts
      +++ b/e2e/local/local-server.ts
      @@ -43,10 +43,18 @@ export const withLocalServer = (
           /** Extra env merged into the `executor web` process, e.g. to force a
            *  published-version signal for the update check. */
           readonly env?: Record;
      +    /** Reuse an existing data dir instead of a throwaway one (and keep it on
      +     *  exit) — for durability scenarios that boot the server twice against the
      +     *  same persisted state. The caller owns cleanup. */
      +    readonly dataDir?: string;
      +    /** Terminal cast filename (default "terminal.cast") so multi-boot
      +     *  scenarios keep one recording per boot. */
      +    readonly castName?: string;
         },
       ): Effect.Effect =>
         Effect.gen(function* () {
      -    const dataDir = mkdtempSync(join(tmpdir(), "executor-local-e2e-"));
      +    const ownsDataDir = options?.dataDir === undefined;
      +    const dataDir = options?.dataDir ?? mkdtempSync(join(tmpdir(), "executor-local-e2e-"));
       
           let publishUrl!: (url: string) => void;
           const urlReady = new Promise((res) => {
      @@ -60,7 +68,7 @@ export const withLocalServer = (
           yield* Effect.all(
             [
               cli.session(
      -          ["bun", "run", "dev:cli", "web", "--foreground", "--port", "0"],
      +          ["bun", "run", "apps/cli/src/main.ts", "web", "--foreground", "--port", "0"],
                 async (term) => {
                   markRecordingStart(runDir, "terminal");
                   markFocus(runDir, "terminal");
      @@ -99,11 +107,17 @@ export const withLocalServer = (
                   // otherwise the orphaned child wedges the terminal teardown.
                   markFocus(runDir, "terminal");
                   await term.keyboard.press("Control+C");
      +            await term.waitForExit({ timeoutMs: 15_000 });
                 },
                 {
                   cwd: repoRoot,
      -            env: { EXECUTOR_DATA_DIR: dataDir, EXECUTOR_SCOPE_DIR: dataDir, ...options?.env },
      -            record: join(runDir, "terminal.cast"),
      +            env: {
      +              EXECUTOR_DEV: "1",
      +              EXECUTOR_DATA_DIR: dataDir,
      +              EXECUTOR_SCOPE_DIR: dataDir,
      +              ...options?.env,
      +            },
      +            record: join(runDir, options?.castName ?? "terminal.cast"),
                   viewport: { cols: 120, rows: 40 },
                 },
               ),
      @@ -119,5 +133,11 @@ export const withLocalServer = (
               }),
             ],
             { concurrency: "unbounded" },
      -    ).pipe(Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))));
      +    ).pipe(
      +      Effect.ensuring(
      +        ownsDataDir
      +          ? Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))
      +          : Effect.void,
      +      ),
      +    );
         });
      diff --git a/e2e/local/mcp-native-elicitation.test.ts b/e2e/local/mcp-native-elicitation.test.ts
      new file mode 100644
      index 0000000000..8a977829a3
      --- /dev/null
      +++ b/e2e/local/mcp-native-elicitation.test.ts
      @@ -0,0 +1,128 @@
      +// Repro for #1555: "Local HTTP MCP native elicitation times out before reaching
      +// client". The Cloudflare counterpart (cloudflare/mcp-native-elicitation.test.ts,
      +// green since #1522) proves the reverse request routes on the originating
      +// tools/call stream there. This is the same scenario against the LOCAL daemon's
      +// HTTP MCP endpoint, which the report says never delivers `elicitation/create`
      +// and fails with McpNativeElicitationTransportError / -32001 request timed out.
      +//
      +// Same shape as the local browser-approval scenario (a require_approval policy
      +// on a built-in read tool), but with `elicitation_mode=native` and a client that
      +// advertises `capabilities.elicitation.form` and answers the request in-band —
      +// so no browser is needed, only the raw SDK transport with the bearer.
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +import { HttpApiClient } from "effect/unstable/httpapi";
      +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
      +import { Client } from "@modelcontextprotocol/sdk/client/index.js";
      +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
      +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
      +import { composePluginApi } from "@executor-js/api/server";
      +
      +import { scenario } from "../src/scenario";
      +import { Cli, RunDir } from "../src/services";
      +import { withLocalServer } from "./local-server";
      +
      +const coreApi = composePluginApi([] as const);
      +
      +const GATED_TOOL = "executor.coreTools.policies.list";
      +const GATED_CODE = `
      +const result = await tools.executor.coreTools.policies.list({});
      +return JSON.stringify(result);
      +`;
      +
      +scenario(
      +  "Local · native MCP elicitation carries approval decisions on the tool call stream",
      +  { timeout: 300_000 },
      +  Effect.gen(function* () {
      +    const cli = yield* Cli;
      +    const runDir = yield* RunDir;
      +
      +    yield* withLocalServer(cli, runDir, (server) =>
      +      Effect.gen(function* () {
      +        // Bearer-authed typed API client (local's credential is the printed
      +        // token) — used only to plant the gating policy.
      +        const api = yield* HttpApiClient.make(coreApi, {
      +          baseUrl: new URL("/api", server.origin).toString(),
      +          transformClient: HttpClient.mapRequest((request) =>
      +            HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
      +          ),
      +        }).pipe(Effect.provide(FetchHttpClient.layer));
      +
      +        const policy = yield* api.policies.create({
      +          payload: { owner: "org", pattern: GATED_TOOL, action: "require_approval" },
      +        });
      +
      +        yield* Effect.gen(function* () {
      +          let decision: "accept" | "decline" | "cancel" = "accept";
      +          let elicitationCount = 0;
      +          const client = yield* Effect.acquireRelease(
      +            Effect.promise(async () => {
      +              const connectedClient = new Client(
      +                { name: "e2e-local-native-elicitation", version: "1.0.0" },
      +                { capabilities: { elicitation: { form: {}, url: {} } } },
      +              );
      +              connectedClient.setRequestHandler(ElicitRequestSchema, async () => {
      +                elicitationCount += 1;
      +                return decision === "accept"
      +                  ? { action: "accept" as const, content: {} }
      +                  : { action: decision };
      +              });
      +              const url = new URL(`${server.origin}/mcp`);
      +              url.searchParams.set("elicitation_mode", "native");
      +              url.searchParams.set("artifacts", "false");
      +              await connectedClient.connect(
      +                new StreamableHTTPClientTransport(url, {
      +                  requestInit: { headers: { authorization: `Bearer ${server.token}` } },
      +                }),
      +              );
      +              return connectedClient;
      +            }),
      +            (connectedClient) => Effect.promise(() => connectedClient.close()),
      +          );
      +
      +          const accepted = yield* Effect.promise(() =>
      +            client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, {
      +              timeout: 30_000,
      +            }),
      +          );
      +          expect(elicitationCount, "the native elicitation reached the client").toBe(1);
      +          expect(accepted.isError, "accepting lets the gated tool complete").toBeFalsy();
      +          expect(
      +            JSON.stringify(accepted.content),
      +            "the gated tool returned its policy listing",
      +          ).toContain(policy.id);
      +
      +          decision = "decline";
      +          const declined = yield* Effect.promise(() =>
      +            client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, {
      +              timeout: 30_000,
      +            }),
      +          );
      +          expect(elicitationCount, "the second native elicitation also reached the client").toBe(2);
      +          expect(declined.isError, "declining blocks the gated tool").toBe(true);
      +          expect(
      +            JSON.stringify(declined.content),
      +            "the engine reports the client's decline decision",
      +          ).toContain("declined by the user");
      +          expect(
      +            JSON.stringify(declined.content),
      +            "the declined tool did not return its output",
      +          ).not.toContain(policy.id);
      +
      +          decision = "cancel";
      +          const cancelled = yield* Effect.promise(() =>
      +            client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, {
      +              timeout: 30_000,
      +            }),
      +          );
      +          expect(elicitationCount, "the cancel elicitation also reached the client").toBe(3);
      +          expect(cancelled.isError, "cancelling blocks the gated tool").toBe(true);
      +          expect(
      +            JSON.stringify(cancelled.content),
      +            "the engine reports the client's cancel decision",
      +          ).toContain("cancelled by the user");
      +        }).pipe(Effect.scoped);
      +      }),
      +    );
      +  }),
      +);
      diff --git a/e2e/local/oauth-await-longpoll.test.ts b/e2e/local/oauth-await-longpoll.test.ts
      new file mode 100644
      index 0000000000..55ae28ba6e
      --- /dev/null
      +++ b/e2e/local/oauth-await-longpoll.test.ts
      @@ -0,0 +1,265 @@
      +// The desktop system-browser OAuth flow: the renderer cannot receive a
      +// postMessage from the user's external browser, so it asks the local server
      +// for the result at `/api/oauth/await/:sessionId`. Guarantee under test: the
      +// server LONG-POLLS that route — a request issued while the flow is still in
      +// flight is held open and answered the instant the provider callback
      +// completes, instead of answered `null` (which made the renderer wait up to a
      +// full 1s poll tick with the result already sitting in memory).
      +//
      +// The journey, against a real `executor web` boot:
      +//
      +//   1. `oauth.start` a connect → the await sessionId (= `started.state`).
      +//   2. Issue an await BEFORE the provider callback. It must be held open — a
      +//      pre-long-poll server answers `null` immediately, which this scenario
      +//      rejects explicitly. A SECOND await for the same session must answer
      +//      `null` instantly instead of holding: held waiters are capped at one
      +//      per session, so an unpatched 1s-interval client degrades to exactly
      +//      the old poll behavior instead of stacking held connections.
      +//   3. Complete the provider consent and land the REAL `/api/oauth/callback`
      +//      redirect (the path that publishes the result and wakes waiters).
      +//   4. The held request resolves promptly (well under the old 1s poll tick)
      +//      and carries the ok result — single-consumer delivery of the one-shot
      +//      result.
      +import { randomBytes } from "node:crypto";
      +import { writeFileSync } from "node:fs";
      +import { join } from "node:path";
      +
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +import { HttpApiClient } from "effect/unstable/httpapi";
      +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
      +import { composePluginApi } from "@executor-js/api/server";
      +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
      +import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
      +import {
      +  AuthTemplateSlug,
      +  ConnectionName,
      +  IntegrationSlug,
      +  OAuthClientSlug,
      +} from "@executor-js/sdk/shared";
      +import { serveOAuthTestServer } from "@executor-js/sdk/testing";
      +
      +import { scenario } from "../src/scenario";
      +import { Cli, RunDir } from "../src/services";
      +import { withLocalServer, type ServerHandle } from "./local-server";
      +
      +const api = composePluginApi([mcpHttpPlugin()] as const);
      +
      +const name = ConnectionName.make("default");
      +const template = AuthTemplateSlug.make("oauth2");
      +
      +// The old server answered a pending await with `null` immediately; the new
      +// one holds it (up to 25s per hold). Observing "still unresolved" after this
      +// window separates the two deterministically: a holding server cannot answer
      +// within it, and an answer within it IS the regression.
      +const HELD_OBSERVATION_MS = 600;
      +
      +// The renderer used to learn about completion up to a full poll tick (1s)
      +// after the callback. The held request must beat that with margin for CI
      +// jitter.
      +const PROMPT_RESOLUTION_BUDGET_MS = 900;
      +
      +const apiClient = (server: ServerHandle) =>
      +  HttpApiClient.make(api, {
      +    baseUrl: new URL("/api", server.origin).toString(),
      +    transformClient: HttpClient.mapRequest((request) =>
      +      HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
      +    ),
      +  }).pipe(Effect.provide(FetchHttpClient.layer));
      +
      +interface AwaitAnswer {
      +  /** When the server answered (response headers received). */
      +  readonly settledAt: number;
      +  readonly status: number;
      +  readonly body: unknown;
      +}
      +
      +/** One `/api/oauth/await/:sessionId` request, exactly as the desktop renderer
      + *  issues it (bearer-gated GET). The promise settles only when the server
      + *  answers — which is the behavior under test. */
      +const issueAwait = (server: ServerHandle, sessionId: string): Promise =>
      +  fetch(`${server.origin}/api/oauth/await/${encodeURIComponent(sessionId)}`, {
      +    headers: { authorization: `Bearer ${server.token}` },
      +  }).then(async (response) => {
      +    const settledAt = Date.now();
      +    return { settledAt, status: response.status, body: (await response.json()) as unknown };
      +  });
      +
      +/** Drive the test AS's consent (authorize → login) and return the app
      + *  callback URL the provider redirects to — carrying code + state. Unlike the
      + *  durability scenario this does NOT redeem the code by hand: landing the real
      + *  callback is the exact path that publishes the result and wakes waiters. */
      +const resolveProviderConsent = (authorizationUrl: string) =>
      +  Effect.promise(async () => {
      +    const authorize = await fetch(authorizationUrl, { redirect: "manual" });
      +    const loginUrl = authorize.headers.get("location");
      +    if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`);
      +    const login = await fetch(loginUrl, {
      +      method: "POST",
      +      headers: { authorization: `Basic ${Buffer.from("alice:password").toString("base64")}` },
      +      redirect: "manual",
      +    });
      +    const callbackUrl = login.headers.get("location");
      +    if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`);
      +    return callbackUrl;
      +  });
      +
      +scenario(
      +  "Local · a held OAuth await answers the moment the provider callback lands",
      +  {},
      +  Effect.scoped(
      +    Effect.gen(function* () {
      +      const cli = yield* Cli;
      +      const runDir = yield* RunDir;
      +
      +      const oauth = yield* serveOAuthTestServer({ scopes: ["mcp.read"] });
      +      // A real MCP upstream that accepts the AS's bearers, so the connect is a
      +      // genuine end-to-end flow, not a stub handshake.
      +      const mcp = yield* serveMcpServer(() => makeEchoMcpServer({ name: "longpoll-mcp" }), {
      +        auth: {
      +          validateAuthorization: (authorization) => oauth.acceptsAuthorizationHeader(authorization),
      +        },
      +      });
      +
      +      const suffix = randomBytes(4).toString("hex");
      +      const slug = IntegrationSlug.make(`oauth-longpoll-${suffix}`);
      +      const clientSlug = OAuthClientSlug.make(`oauth-longpoll-client-${suffix}`);
      +
      +      yield* withLocalServer(cli, runDir, (server) =>
      +        Effect.gen(function* () {
      +          const client = yield* apiClient(server);
      +
      +          yield* client.mcp.addServer({
      +            payload: {
      +              transport: "remote",
      +              name: "Longpoll MCP",
      +              endpoint: mcp.url,
      +              slug: String(slug),
      +              authenticationTemplate: [{ kind: "oauth2" }],
      +            },
      +          });
      +          yield* client.oauth.createClient({
      +            payload: {
      +              owner: "org",
      +              slug: clientSlug,
      +              grant: "authorization_code",
      +              authorizationUrl: oauth.authorizationEndpoint,
      +              tokenUrl: oauth.tokenEndpoint,
      +              clientId: "test-client",
      +              clientSecret: "test-secret",
      +              resource: oauth.mcpResourceUrl,
      +              originIntegration: slug,
      +            },
      +          });
      +
      +          const started = yield* client.oauth.start({
      +            payload: {
      +              client: clientSlug,
      +              clientOwner: "org",
      +              owner: "org",
      +              name,
      +              integration: slug,
      +              template,
      +            },
      +          });
      +          expect(started.status, "oauth.start redirects to the authorization server").toBe(
      +            "redirect",
      +          );
      +          if (started.status !== "redirect") return yield* Effect.die("no redirect");
      +          // The await sessionId is the start state — exactly what the desktop
      +          // renderer passes to openOAuthSystemBrowser.
      +          const sessionId = started.state;
      +
      +          // -- 2. Await BEFORE the callback: the request must be held. -------
      +          const heldAwait = issueAwait(server, sessionId);
      +          const observed = yield* Effect.promise(() =>
      +            Promise.race([
      +              heldAwait.then(() => "answered" as const),
      +              new Promise<"held">((resolve) =>
      +                setTimeout(() => resolve("held"), HELD_OBSERVATION_MS),
      +              ),
      +            ]),
      +          );
      +          expect(
      +            observed,
      +            "an await issued mid-flow is held open, not answered null immediately (the pre-long-poll behavior)",
      +          ).toBe("held");
      +
      +          // A second await while one is already held: over the one-per-session
      +          // cap, so it answers null instantly — the pre-long-poll behavior an
      +          // unpatched 1s-interval client relies on — instead of stacking a
      +          // held connection.
      +          const overCap = yield* Effect.promise(() =>
      +            Promise.race([
      +              issueAwait(server, sessionId),
      +              new Promise<"held">((resolve) =>
      +                setTimeout(() => resolve("held"), HELD_OBSERVATION_MS),
      +              ),
      +            ]),
      +          );
      +          expect(overCap, "an over-cap await answers immediately instead of holding").not.toBe(
      +            "held",
      +          );
      +          if (overCap === "held") return yield* Effect.die("over-cap await was held");
      +          expect(overCap.status, "an over-cap await answers 200 like an immediate poll").toBe(200);
      +          expect(
      +            overCap.body,
      +            "an over-cap await answers null — still pending — exactly like the old server",
      +          ).toBeNull();
      +
      +          // -- 3. Complete consent and land the real callback redirect. ------
      +          const redirected = yield* resolveProviderConsent(started.authorizationUrl);
      +          // The harness boots `executor web --port 0` (OS-assigned), and the
      +          // server builds the redirect URI from the CONFIGURED port — so the
      +          // redirect reads `127.0.0.1:0`. Land the same callback path + query
      +          // (code + state, the cryptographic gate) on the server's real bound
      +          // origin; it is the same server and the same handler.
      +          const callbackUrl = new URL(redirected);
      +          expect(
      +            callbackUrl.pathname,
      +            "the provider redirects back to this server's OAuth callback",
      +          ).toBe("/api/oauth/callback");
      +          const callbackStartedAt = Date.now();
      +          const callback = yield* Effect.promise(() =>
      +            fetch(`${server.origin}${callbackUrl.pathname}${callbackUrl.search}`),
      +          );
      +          const callbackHtml = yield* Effect.promise(() => callback.text());
      +          const callbackCompletedAt = Date.now();
      +          expect(callback.status, "the OAuth callback succeeds").toBe(200);
      +          expect(callbackHtml, "the callback page reports the connect succeeded").toContain(
      +            "Connected",
      +          );
      +
      +          // -- 4. The held request answers promptly with the one-shot result.
      +          const answer = yield* Effect.promise(() => heldAwait);
      +          expect(answer.status, "a held await answers 200 like an immediate poll").toBe(200);
      +          expect(
      +            answer.body,
      +            "the held await receives the one-shot result the callback published",
      +          ).toMatchObject({ ok: true, sessionId });
      +
      +          const sinceCallback = answer.settledAt - callbackCompletedAt;
      +          // Console output is swallowed by the runner, so park the measured
      +          // timings in the run dir as reviewable evidence.
      +          writeFileSync(
      +            join(runDir, "await-timing.json"),
      +            JSON.stringify(
      +              {
      +                heldObservationMs: HELD_OBSERVATION_MS,
      +                callbackHandlingMs: callbackCompletedAt - callbackStartedAt,
      +                awaitsSettledAfterCallbackMs: sinceCallback,
      +                budgetMs: PROMPT_RESOLUTION_BUDGET_MS,
      +              },
      +              null,
      +              2,
      +            ),
      +          );
      +          expect(
      +            sinceCallback,
      +            "held awaits resolve promptly after the callback — well under the old 1s poll tick",
      +          ).toBeLessThan(PROMPT_RESOLUTION_BUDGET_MS);
      +        }),
      +      );
      +    }),
      +  ),
      +);
      diff --git a/e2e/local/oauth-token-durability.test.ts b/e2e/local/oauth-token-durability.test.ts
      new file mode 100644
      index 0000000000..0949d4c012
      --- /dev/null
      +++ b/e2e/local/oauth-token-durability.test.ts
      @@ -0,0 +1,252 @@
      +// Repro for the user report: "OAuth custom-MCP tokens are stored in a
      +// non-durable keychain on headless/sandbox daemons → connections silently die
      +// on every restart/recreate."
      +//
      +// The local app's plugin order (apps/local/executor.config.ts) registers
      +// keychainPlugin() BEFORE fileSecretsPlugin(), and defaultWritableProvider()
      +// picks the first writable provider — so wherever the keyring write-probe
      +// passes, every minted OAuth access/refresh token lands in the keychain
      +// (connection.provider = "keychain"). On sandbox hosts that keychain is an
      +// in-memory store: only EXECUTOR_DATA_DIR (data.db + auth.json) survives a
      +// sandbox stop/recreate, and data.db holds just the item REFERENCE
      +// (oauth:org::default:refresh), not the secret. After recreate the
      +// connection is a zombie: health says expired — "Stored refresh token could
      +// not be resolved." — and the user must re-auth. Repeatedly.
      +//
      +// The scenario models the sandbox lifecycle with two real `executor web` boots
      +// on ONE kept data dir (what the sandbox persists), wiping this run's keychain
      +// service between them (what the sandbox loses):
      +//
      +//   boot 1: OAuth-connect a custom MCP server (real authorization-code flow
      +//           against a live test AS) → connection is healthy.
      +//   stop/recreate: keychain wiped, EXECUTOR_DATA_DIR kept.
      +//   boot 2: the SAME connection must still be healthy — durable-by-default is
      +//           the product guarantee. With the bug, this is where the zombie
      +//           appears (expired: "Stored refresh token could not be resolved.").
      +//
      +// Note: the repro only bites where the keychain probe passes (macOS dev
      +// machines, sandbox hosts with a kernel keyring). On headless CI without a
      +// reachable keyring the keychain provider never registers and the file
      +// provider is already the default — the scenario then passes vacuously, which
      +// mirrors production reality.
      +import { execFileSync } from "node:child_process";
      +import { randomBytes } from "node:crypto";
      +import { mkdtempSync, rmSync } from "node:fs";
      +import { tmpdir } from "node:os";
      +import { join } from "node:path";
      +
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +import { HttpApiClient } from "effect/unstable/httpapi";
      +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
      +import { composePluginApi } from "@executor-js/api/server";
      +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
      +import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
      +import {
      +  AuthTemplateSlug,
      +  ConnectionName,
      +  IntegrationSlug,
      +  OAuthClientSlug,
      +} from "@executor-js/sdk/shared";
      +import { serveOAuthTestServer } from "@executor-js/sdk/testing";
      +
      +import { scenario } from "../src/scenario";
      +import { Cli, RunDir } from "../src/services";
      +import { withLocalServer, type ServerHandle } from "./local-server";
      +
      +const api = composePluginApi([mcpHttpPlugin()] as const);
      +
      +const name = ConnectionName.make("default");
      +const template = AuthTemplateSlug.make("oauth2");
      +
      +/** Drive the test AS's consent by hand (authorize → login → code), without
      + *  visiting the app's callback: `oauth.complete` takes the code directly. */
      +const completeAuthorization = (authorizationUrl: string) =>
      +  Effect.promise(async () => {
      +    const authorize = await fetch(authorizationUrl, { redirect: "manual" });
      +    const loginUrl = authorize.headers.get("location");
      +    if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`);
      +    const login = await fetch(loginUrl, {
      +      method: "POST",
      +      headers: { authorization: `Basic ${Buffer.from("alice:password").toString("base64")}` },
      +      redirect: "manual",
      +    });
      +    const callbackUrl = login.headers.get("location");
      +    if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`);
      +    const code = new URL(callbackUrl).searchParams.get("code");
      +    if (!code) throw new Error("callback carried no authorization code");
      +    return code;
      +  });
      +
      +/** Simulate the non-durable secret store being lost on sandbox recreate: drop
      + *  every entry under this run's keychain service. On macOS `security` deletes
      + *  one matching item per call, so loop until none remain. When the fix routes
      + *  tokens to the file provider this is a no-op — nothing lives there. */
      +const wipeKeychainService = (serviceName: string): void => {
      +  if (process.platform !== "darwin") return;
      +  for (let i = 0; i < 32; i++) {
      +    try {
      +      execFileSync("security", ["delete-generic-password", "-s", serviceName], {
      +        stdio: "ignore",
      +      });
      +    } catch {
      +      return; // no more entries under this service
      +    }
      +  }
      +};
      +
      +const apiClient = (server: ServerHandle) =>
      +  HttpApiClient.make(api, {
      +    baseUrl: new URL("/api", server.origin).toString(),
      +    transformClient: HttpClient.mapRequest((request) =>
      +      HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
      +    ),
      +  }).pipe(Effect.provide(FetchHttpClient.layer));
      +
      +scenario(
      +  "Local · an OAuth MCP connection survives a sandbox stop/recreate (data dir kept, keychain lost)",
      +  // Two full `executor web` boots; each allows up to 240s for a cold vite
      +  // start, so the test deadline must clear both plus the OAuth journey.
      +  { timeout: 600_000 },
      +  Effect.scoped(
      +    Effect.gen(function* () {
      +      const cli = yield* Cli;
      +      const runDir = yield* RunDir;
      +
      +      // The AS mints instantly-expiring access tokens so EVERY health check
      +      // exercises the refresh path — the exact path that dies when the stored
      +      // refresh token evaporates with the keychain.
      +      const oauth = yield* serveOAuthTestServer({
      +        scopes: ["mcp.read"],
      +        tokenExpiresInSeconds: 0,
      +      });
      +      // A real MCP upstream that accepts exactly the AS's issued bearers, so
      +      // "healthy" is a genuine dial-and-list verdict, not a stub's opinion.
      +      const mcp = yield* serveMcpServer(() => makeEchoMcpServer({ name: "durability-mcp" }), {
      +        auth: {
      +          validateAuthorization: (authorization) => oauth.acceptsAuthorizationHeader(authorization),
      +        },
      +      });
      +
      +      const suffix = randomBytes(4).toString("hex");
      +      const slug = IntegrationSlug.make(`oauth-durability-${suffix}`);
      +      const clientSlug = OAuthClientSlug.make(`oauth-durability-client-${suffix}`);
      +      // A unique keychain service per run: the wipe below can only ever touch
      +      // this scenario's entries, and leftovers never collide across runs.
      +      const keychainService = `executor-e2e-durability-${suffix}`;
      +      // The data dir is the durable surface (what a sandbox backs up); it is
      +      // deliberately KEPT across the two boots and cleaned only at the end.
      +      const dataDir = mkdtempSync(join(tmpdir(), "executor-durability-e2e-"));
      +      const serverEnv = { EXECUTOR_KEYCHAIN_SERVICE_NAME: keychainService };
      +
      +      yield* Effect.ensuring(
      +        Effect.gen(function* () {
      +          // ---- Boot 1: connect the OAuth MCP integration. -------------------
      +          yield* withLocalServer(
      +            cli,
      +            runDir,
      +            (server) =>
      +              Effect.gen(function* () {
      +                const client = yield* apiClient(server);
      +
      +                yield* client.mcp.addServer({
      +                  payload: {
      +                    transport: "remote",
      +                    name: "Durability MCP",
      +                    endpoint: mcp.url,
      +                    slug: String(slug),
      +                    authenticationTemplate: [{ kind: "oauth2" }],
      +                  },
      +                });
      +                yield* client.oauth.createClient({
      +                  payload: {
      +                    owner: "org",
      +                    slug: clientSlug,
      +                    grant: "authorization_code",
      +                    authorizationUrl: oauth.authorizationEndpoint,
      +                    tokenUrl: oauth.tokenEndpoint,
      +                    clientId: "test-client",
      +                    clientSecret: "test-secret",
      +                    // MCP oauth2 templates discover scopes via the protected
      +                    // resource's metadata; the client must name that resource.
      +                    resource: oauth.mcpResourceUrl,
      +                    originIntegration: slug,
      +                  },
      +                });
      +
      +                const started = yield* client.oauth.start({
      +                  payload: {
      +                    client: clientSlug,
      +                    clientOwner: "org",
      +                    owner: "org",
      +                    name,
      +                    integration: slug,
      +                    template,
      +                  },
      +                });
      +                expect(started.status, "oauth.start redirects to the authorization server").toBe(
      +                  "redirect",
      +                );
      +                if (started.status !== "redirect") return yield* Effect.die("no redirect");
      +                const code = yield* completeAuthorization(started.authorizationUrl);
      +                yield* client.oauth.complete({ payload: { state: started.state, code } });
      +
      +                const connection = yield* client.connections.get({
      +                  params: { owner: "org", integration: slug, name },
      +                });
      +                // Where did the minted tokens actually land? This is the root
      +                // cause made visible: "keychain" = non-durable on sandbox hosts.
      +                console.info(
      +                  `[durability repro] minted connection provider: ${String(connection.provider)}`,
      +                );
      +
      +                const healthy = yield* client.connections.checkHealth({
      +                  params: { owner: "org", integration: slug, name },
      +                  query: {},
      +                });
      +                expect(
      +                  healthy.status,
      +                  `the fresh OAuth connection is healthy (detail: ${healthy.detail ?? "-"})`,
      +                ).toBe("healthy");
      +              }),
      +            { dataDir, env: serverEnv, castName: "terminal-boot1.cast" },
      +          );
      +
      +          // ---- Sandbox stop/recreate: keychain gone, data dir kept. ---------
      +          wipeKeychainService(keychainService);
      +
      +          // ---- Boot 2: the connection must still work. ----------------------
      +          yield* withLocalServer(
      +            cli,
      +            runDir,
      +            (server) =>
      +              Effect.gen(function* () {
      +                const client = yield* apiClient(server);
      +
      +                const health = yield* client.connections.checkHealth({
      +                  params: { owner: "org", integration: slug, name },
      +                  query: {},
      +                });
      +                console.info(
      +                  `[durability repro] post-recreate health: ${health.status} — ${health.detail ?? "-"}`,
      +                );
      +                // THE guarantee: OAuth credentials live in the durable store
      +                // (EXECUTOR_DATA_DIR), so losing the host's ephemeral keychain
      +                // must not kill the connection. With the bug this reads
      +                // expired: "Stored refresh token could not be resolved."
      +                expect(
      +                  health.status,
      +                  `the OAuth connection survives a sandbox recreate (detail: ${health.detail ?? "-"})`,
      +                ).toBe("healthy");
      +              }),
      +            { dataDir, env: serverEnv, castName: "terminal-boot2.cast" },
      +          );
      +        }),
      +        Effect.sync(() => {
      +          wipeKeychainService(keychainService);
      +          rmSync(dataDir, { recursive: true, force: true });
      +        }),
      +      );
      +    }),
      +  ),
      +);
      diff --git a/e2e/local/server-profile-header-env.test.ts b/e2e/local/server-profile-header-env.test.ts
      new file mode 100644
      index 0000000000..3bc768c8c5
      --- /dev/null
      +++ b/e2e/local/server-profile-header-env.test.ts
      @@ -0,0 +1,109 @@
      +// Local-only: server profiles can carry HTTP headers backed by environment
      +// variables. This is the CLI surface needed for Cloudflare Access service
      +// tokens on self-hosted Executor deployments.
      +import { execFile } from "node:child_process";
      +import { mkdtempSync, readFileSync, rmSync } from "node:fs";
      +import { tmpdir } from "node:os";
      +import { join } from "node:path";
      +import { fileURLToPath } from "node:url";
      +import { promisify } from "node:util";
      +
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +
      +import { scenario } from "../src/scenario";
      +
      +const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
      +const CLI_ENTRY = "apps/cli/src/main.ts";
      +const execFileAsync = promisify(execFile);
      +
      +const runCli = async (
      +  dataDir: string,
      +  args: ReadonlyArray,
      +  env: Record = {},
      +): Promise => {
      +  try {
      +    const { stdout } = await execFileAsync("bun", ["run", CLI_ENTRY, ...args], {
      +      cwd: repoRoot,
      +      env: {
      +        ...process.env,
      +        EXECUTOR_DATA_DIR: dataDir,
      +        EXECUTOR_DISABLE_UPDATE_CHECK: "1",
      +        ...env,
      +      },
      +      maxBuffer: 1024 * 1024,
      +      timeout: 60_000,
      +    });
      +    return stdout;
      +  } catch (cause) {
      +    const error = cause as {
      +      readonly code?: number | string;
      +      readonly stdout?: string;
      +      readonly stderr?: string;
      +    };
      +    throw new Error(
      +      `executor ${args.join(" ")} failed with ${error.code ?? "unknown"}\n${error.stdout ?? ""}\n${error.stderr ?? ""}`,
      +    );
      +  }
      +};
      +
      +scenario(
      +  "CLI server profiles · header env mappings are persisted without secret values",
      +  {},
      +  Effect.promise(async () => {
      +    const dataDir = mkdtempSync(join(tmpdir(), "executor-profile-headers-"));
      +    try {
      +      await runCli(dataDir, [
      +        "server",
      +        "add",
      +        "cloudflare",
      +        "https://executor.example",
      +        "--header-env",
      +        "CF-Access-Client-Id=EXECUTOR_CF_ACCESS_CLIENT_ID",
      +        "--header-env",
      +        "CF-Access-Client-Secret=EXECUTOR_CF_ACCESS_CLIENT_SECRET",
      +        "--default",
      +      ]);
      +
      +      const listing = await runCli(dataDir, ["server", "list"], {
      +        EXECUTOR_CF_ACCESS_CLIENT_ID: "dummy-client-id-secret-value",
      +        EXECUTOR_CF_ACCESS_CLIENT_SECRET: "dummy-client-secret-value",
      +      });
      +      expect(listing, "the profile lists its env-backed headers").toContain("2 header-env");
      +      expect(listing, "listing does not print the client id secret").not.toContain(
      +        "dummy-client-id-secret-value",
      +      );
      +      expect(listing, "listing does not print the client secret").not.toContain(
      +        "dummy-client-secret-value",
      +      );
      +
      +      const raw = readFileSync(join(dataDir, "server-connections.json"), "utf8");
      +      const store = JSON.parse(raw) as {
      +        readonly profiles: ReadonlyArray<{
      +          readonly connection: {
      +            readonly headers?: Record;
      +          };
      +        }>;
      +      };
      +      expect(store.profiles[0]?.connection.headers).toEqual({
      +        "CF-Access-Client-Id": {
      +          kind: "env",
      +          name: "EXECUTOR_CF_ACCESS_CLIENT_ID",
      +        },
      +        "CF-Access-Client-Secret": {
      +          kind: "env",
      +          name: "EXECUTOR_CF_ACCESS_CLIENT_SECRET",
      +        },
      +      });
      +      expect(raw, "profile storage keeps env names").toContain("EXECUTOR_CF_ACCESS_CLIENT_ID");
      +      expect(raw, "profile storage does not keep resolved client id").not.toContain(
      +        "dummy-client-id-secret-value",
      +      );
      +      expect(raw, "profile storage does not keep resolved client secret").not.toContain(
      +        "dummy-client-secret-value",
      +      );
      +    } finally {
      +      rmSync(dataDir, { recursive: true, force: true });
      +    }
      +  }),
      +);
      diff --git a/e2e/local/stdio-mcp-edit-ui.test.ts b/e2e/local/stdio-mcp-edit-ui.test.ts
      new file mode 100644
      index 0000000000..6c86bb9e30
      --- /dev/null
      +++ b/e2e/local/stdio-mcp-edit-ui.test.ts
      @@ -0,0 +1,138 @@
      +// Local-only — the integration Edit sheet for a STDIO MCP server, driven in a
      +// real browser. `local` is the only surface that enables stdio MCP
      +// (`dangerouslyAllowStdioMCP: true`), so it is the only place this sheet can be
      +// exercised end to end.
      +//
      +// The sheet used to say "Stdio MCP integrations cannot be edited. Remove and
      +// recreate the integration with the updated command." (#812) — changing a
      +// command meant editing `executor.jsonc` by hand, or losing the integration's
      +// connections and policies to a delete-and-re-add.
      +//
      +// The assertion is deliberately not "the field accepted my text". The scenario
      +// edits the DECLARED env map through the form and then reads the tool catalog:
      +// the fixture advertises `saw_declared_env` only when that variable is present
      +// in the spawned child's environment, so the tool appearing proves the edited
      +// config travelled the whole way — form, config write, respawn, rediscovery.
      +import { fileURLToPath } from "node:url";
      +
      +import { expect } from "@effect/vitest";
      +import { Effect } from "effect";
      +import { HttpApiClient } from "effect/unstable/httpapi";
      +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
      +import { composePluginApi } from "@executor-js/api/server";
      +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
      +
      +import { scenario } from "../src/scenario";
      +import { Browser, Cli, RunDir, Target } from "../src/services";
      +import { withLocalServer } from "./local-server";
      +
      +const api = composePluginApi([mcpHttpPlugin()] as const);
      +
      +const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));
      +
      +const SECRET = "s3cr3t-typed-into-the-edit-sheet";
      +
      +scenario(
      +  "Local · a stdio MCP server's command and environment are editable from the integration Edit sheet",
      +  { timeout: 300_000 },
      +  Effect.gen(function* () {
      +    const cli = yield* Cli;
      +    const browser = yield* Browser;
      +    const target = yield* Target;
      +    const runDir = yield* RunDir;
      +    const identity = yield* target.newIdentity();
      +
      +    yield* withLocalServer(cli, runDir, (server) =>
      +      Effect.gen(function* () {
      +        const client = yield* HttpApiClient.make(api, {
      +          baseUrl: new URL("/api", server.origin).toString(),
      +          transformClient: HttpClient.mapRequest((request) =>
      +            HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
      +          ),
      +        }).pipe(Effect.provide(FetchHttpClient.layer));
      +
      +        const slug = "e2e-stdio-editable";
      +
      +        // A plain stdio server: no declared env, so the env-gated tool is absent
      +        // until the edit adds the variable.
      +        yield* client.mcp.addServer({
      +          payload: {
      +            transport: "stdio",
      +            name: "E2E Stdio Editable",
      +            command: "node",
      +            args: [FIXTURE],
      +            slug,
      +          },
      +        });
      +
      +        const before = yield* client.tools.list({ query: { integration: slug } });
      +        expect(
      +          before.map((t) => t.name),
      +          "the server starts with its base tool and no declared env",
      +        ).toContain("echo_tool");
      +        expect(
      +          before.map((t) => t.name),
      +          "the env-gated tool is absent before the edit",
      +        ).not.toContain("saw_declared_env");
      +
      +        yield* browser.session(identity, async ({ page, step }) => {
      +          await step("Open the stdio integration from the console", async () => {
      +            await page.goto(server.url, { waitUntil: "domcontentloaded" });
      +            await page.getByTestId(`integration-entry-${slug}`).first().click();
      +            // Wait for the detail page's own data, not just its shell: clicking
      +            // Edit while the accounts panel is still a skeleton races the layout
      +            // shift that lands when it resolves.
      +            await page.getByText("default").first().waitFor({ timeout: 30_000 });
      +            await page.getByRole("button", { name: "Edit" }).waitFor({ timeout: 30_000 });
      +          });
      +
      +          await step("Open the Edit sheet — the stdio command is editable", async () => {
      +            await page.getByRole("button", { name: "Edit" }).click();
      +            await page.getByText("Edit integration").waitFor({ timeout: 30_000 });
      +            await page.getByText("Server command").waitFor({ timeout: 30_000 });
      +            // The read-only dead end this replaces.
      +            expect(
      +              await page.getByText("Stdio MCP integrations cannot be edited").count(),
      +              "the read-only message is gone",
      +            ).toBe(0);
      +            expect(
      +              await page.getByRole("textbox", { name: "Command" }).inputValue(),
      +              "the stored command is loaded into the form",
      +            ).toBe("node");
      +          });
      +
      +          await step("Declare an environment variable and save", async () => {
      +            await page
      +              .getByRole("textbox", { name: "Environment variables" })
      +              .fill(`EXECUTOR_E2E_SECRET=${SECRET}`);
      +            await page.getByRole("button", { name: "Save" }).click();
      +            await page.getByText("Server command").waitFor({ state: "hidden", timeout: 30_000 });
      +          });
      +        });
      +
      +        // The edit persisted as the DECLARED static env map on the config.
      +        const stored = yield* client.mcp.getServer({ params: { slug } });
      +        expect(
      +          stored?.config.transport === "stdio" ? stored.config.env : undefined,
      +          "the sheet wrote the declared env map",
      +        ).toEqual({ EXECUTOR_E2E_SECRET: SECRET });
      +        expect(
      +          stored?.config.transport === "stdio" ? stored.config.command : undefined,
      +          "the command it did not touch is unchanged",
      +        ).toBe("node");
      +
      +        // And it reached the spawned server: the fixture gates this tool's very
      +        // existence on that variable being in its own environment.
      +        const after = yield* client.tools.list({ query: { integration: slug } });
      +        expect(
      +          after.map((t) => t.name),
      +          "the edited environment reached the respawned server and the catalog was rebuilt",
      +        ).toContain("saw_declared_env");
      +        expect(
      +          after.map((t) => t.name),
      +          "the rebuild kept the tools that still exist",
      +        ).toContain("echo_tool");
      +      }),
      +    );
      +  }),
      +);
      diff --git a/e2e/local/stdio-mcp.test.ts b/e2e/local/stdio-mcp.test.ts
      index f890122592..ad1e8ef101 100644
      --- a/e2e/local/stdio-mcp.test.ts
      +++ b/e2e/local/stdio-mcp.test.ts
      @@ -14,6 +14,14 @@
       // stdio add never created one, so the integration landed with zero connections
       // and zero tools. The fix auto-creates the default connection on add and routes
       // the env values into the connection's secret store.
      +//
      +// The scenario also covers what ELSE reaches that subprocess. A stdio server
      +// that declared any `env` used to be spawned with `{ ...process.env, ...env }`,
      +// so it received every variable the daemon holds — including the key that
      +// decrypts the secret store. `withLocalServer`'s `env` option plants two
      +// variables on the real daemon process (see DAEMON_ENV) and the fixture reports
      +// which of them survived the spawn, so the boundary is measured on the product
      +// path rather than at the call site of the transport.
       import { fileURLToPath } from "node:url";
       
       import { expect } from "@effect/vitest";
      @@ -37,8 +45,26 @@ const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.
       // the connection's secret env reached the spawned subprocess.
       const SECRET = "s3cr3t-from-the-vault";
       
      +// Planted on the `executor web` process itself, so the only way either value
      +// can reach a stdio subprocess is by being inherited across the spawn.
      +//
      +// NO_PROXY stands in for the infrastructure allowlist: it is one of the
      +// variables a stdio server must keep inheriting, and it is the inert member of
      +// that list — with no proxy configured alongside it, it changes nothing about
      +// the daemon's own egress, so planting it cannot perturb the rest of the run.
      +//
      +// The sentinel stands in for a real host secret such as EXECUTOR_SECRET_KEY.
      +// A variable only this scenario sets keeps the assertion honest on a machine
      +// where the real one happens not to be exported.
      +const PROXY_VALUE = "stdio-env-e2e.invalid";
      +const HOST_ONLY_SENTINEL = "host-secret-that-must-not-reach-a-stdio-server";
      +const DAEMON_ENV = {
      +  NO_PROXY: PROXY_VALUE,
      +  EXECUTOR_E2E_HOST_ONLY_SECRET: HOST_ONLY_SENTINEL,
      +};
      +
       scenario(
      -  "Local · a stdio MCP server's tools are detected on a fresh install, with env stored as a secret",
      +  "Local · a stdio MCP server's tools are detected on a fresh install, with env stored as a secret and the daemon's own environment withheld",
         // Must stay STRICTLY greater than the boot-URL wait in `withLocalServer`
         // (currently 240s). When this CI job runs `stdio-mcp.test.ts` alone it always
         // pays a cold `vite optimizeDeps` boot (no prior file to warm the cache), the
      @@ -51,102 +77,195 @@ scenario(
           const cli = yield* Cli;
           const runDir = yield* RunDir;
       
      -    yield* withLocalServer(cli, runDir, (server) =>
      -      Effect.gen(function* () {
      -        const client = yield* HttpApiClient.make(api, {
      -          baseUrl: new URL("/api", server.origin).toString(),
      -          transformClient: HttpClient.mapRequest((request) =>
      -            HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
      -          ),
      -        }).pipe(Effect.provide(FetchHttpClient.layer));
      -
      -        const slug = "e2e-stdio";
      -
      -        // Add the stdio server exactly as the desktop/local "Add MCP" flow does,
      -        // including a secret env var the server needs.
      -        yield* client.mcp.addServer({
      -          payload: {
      -            transport: "stdio",
      -            name: "E2E Stdio",
      -            command: "node",
      -            args: [FIXTURE],
      +    yield* withLocalServer(
      +      cli,
      +      runDir,
      +      (server) =>
      +        Effect.gen(function* () {
      +          const client = yield* HttpApiClient.make(api, {
      +            baseUrl: new URL("/api", server.origin).toString(),
      +            transformClient: HttpClient.mapRequest((request) =>
      +              HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
      +            ),
      +          }).pipe(Effect.provide(FetchHttpClient.layer));
      +
      +          const slug = "e2e-stdio";
      +
      +          // Add the stdio server exactly as the desktop/local "Add MCP" flow does,
      +          // including a secret env var the server needs.
      +          yield* client.mcp.addServer({
      +            payload: {
      +              transport: "stdio",
      +              name: "E2E Stdio",
      +              command: "node",
      +              args: [FIXTURE],
      +              env: { EXECUTOR_E2E_SECRET: SECRET },
      +              slug,
      +            },
      +          });
      +
      +          // The integration lands in the catalog — the add itself works.
      +          const integrations = yield* client.integrations.list();
      +          expect(
      +            integrations.map((i) => String(i.slug)),
      +            "the stdio MCP integration is registered",
      +          ).toContain(slug);
      +
      +          // The add auto-creates the default connection (the v1.5 split makes this
      +          // the thing that drives tool discovery). Pre-fix there were zero.
      +          const connections = yield* client.connections.list({ query: { integration: slug } });
      +          expect(
      +            connections.map((c) => String(c.name)),
      +            "a default connection was auto-created for the stdio server",
      +          ).toContain("default");
      +
      +          // THE SYMPTOM, fixed: the stdio server's tools are detected. `whoami`
      +          // appearing proves the connection's secret env reached the subprocess.
      +          const tools = yield* client.tools.list({ query: { integration: slug } });
      +          const names = tools.map((t) => t.name);
      +          expect(names, "the stdio server's base tool is detected").toContain("echo_tool");
      +          expect(
      +            names,
      +            "the secret env var reached the spawned subprocess (whoami is gated on it)",
      +          ).toContain("whoami");
      +
      +          // --- What the daemon's own environment does, and does not, hand to a
      +          // stdio server. This is the branch that leaked: `e2e-stdio` declares an
      +          // `env`, and the declared-env branch used to spawn with the whole of
      +          // `process.env` merged underneath. Each `saw_*` tool exists only because
      +          // the matching variable was present in the CHILD's environment. ---
      +          expect(names, "the declared env var is visible to the server").toContain(
      +            "saw_declared_env",
      +          );
      +          expect(
      +            names,
      +            "the host's proxy configuration still reaches the server, or every stdio server behind a corporate proxy breaks",
      +          ).toContain("saw_proxy_env");
      +          expect(
      +            names,
      +            "a host variable the source never declared (EXECUTOR_E2E_HOST_ONLY_SECRET) must not reach the server",
      +          ).not.toContain("saw_host_secret");
      +
      +          // "Properly store auth": the secret value is NOT in the integration's
      +          // config blob — only the var NAME is declared there; the value lives on
      +          // the connection (the secret store).
      +          const stored = yield* client.mcp.getServer({ params: { slug } });
      +          expect(
      +            JSON.stringify(stored?.config ?? {}),
      +            "the secret value is not persisted in the integration config",
      +          ).not.toContain(SECRET);
      +
      +          // --- The UI path: DECLARE env var names, then provide the secret value
      +          // as a connection credential (what the add form now does). ---
      +          const declSlug = "e2e-stdio-decl";
      +          yield* client.mcp.addServer({
      +            payload: {
      +              transport: "stdio",
      +              name: "E2E Stdio Declared",
      +              command: "node",
      +              args: [FIXTURE],
      +              envVars: ["EXECUTOR_E2E_SECRET"],
      +              slug: declSlug,
      +            },
      +          });
      +
      +          // Declaring a secret env var (no value) does NOT auto-connect: the
      +          // secret is still missing, so there are no tools until you connect.
      +          const beforeConns = yield* client.connections.list({ query: { integration: declSlug } });
      +          expect(beforeConns, "no connection until the secret is provided").toHaveLength(0);
      +          const beforeTools = yield* client.tools.list({ query: { integration: declSlug } });
      +          expect(beforeTools, "no tools until the secret is provided").toHaveLength(0);
      +
      +          // Provide the secret as the connection credential (the connect step).
      +          yield* client.connections.create({
      +            payload: {
      +              owner: "org",
      +              name: ConnectionName.make("default"),
      +              integration: IntegrationSlug.make(declSlug),
      +              template: AuthTemplateSlug.make("env"),
      +              values: { EXECUTOR_E2E_SECRET: SECRET },
      +            },
      +          });
      +
      +          const declTools = yield* client.tools.list({ query: { integration: declSlug } });
      +          expect(
      +            declTools.map((t) => t.name),
      +            "connecting with the secret discovers the env-gated tool",
      +          ).toContain("whoami");
      +
      +          // --- versionNegotiation "auto" survives the API → config → connector
      +          // path and still reaches a legacy server: the probe gets the fixture's
      +          // method-not-found for `server/discover` (a definitive legacy verdict)
      +          // and falls back to `initialize`. Modern-era acceptance against a real
      +          // legacy-disabled SDK v2 server lives in the plugin's
      +          // stdio-negotiation.test.ts. ---
      +          const autoSlug = "e2e-stdio-auto";
      +          yield* client.mcp.addServer({
      +            payload: {
      +              transport: "stdio",
      +              name: "E2E Stdio Auto",
      +              command: "node",
      +              args: [FIXTURE],
      +              versionNegotiation: "auto",
      +              slug: autoSlug,
      +            },
      +          });
      +
      +          const autoStored = yield* client.mcp.getServer({ params: { slug: autoSlug } });
      +          expect(
      +            JSON.stringify(autoStored?.config ?? {}),
      +            "the negotiation mode is persisted on the integration config",
      +          ).toContain('"versionNegotiation":"auto"');
      +
      +          const autoTools = yield* client.tools.list({ query: { integration: autoSlug } });
      +          expect(
      +            autoTools.map((t) => t.name),
      +            "auto negotiation falls back to legacy and still discovers tools",
      +          ).toContain("echo_tool");
      +          expect(
      +            autoTools.map((t) => t.name),
      +            "this server declared no env, so the env-gated tool is absent to begin with",
      +          ).not.toContain("saw_declared_env");
      +
      +          // --- Editing a stdio server's config (what the integration Edit sheet
      +          // now does instead of telling you to remove and recreate). The tool
      +          // catalog is persisted per connection, so a plain config replace is
      +          // only enough because core stamps `config_revised_at` on a config
      +          // write and every connection whose catalog predates that stamp is
      +          // rebuilt on the next read. The edit path therefore needs NO explicit
      +          // refresh of its own — this asserts that, so nobody adds one back.
      +          //
      +          // Adding a declared env var is the lever: the fixture advertises
      +          // `saw_declared_env` only when that variable reached the child, so the
      +          // tool appearing with no further action proves both halves — the new
      +          // config reached the spawn, and the catalog was rebuilt from it. ---
      +          const editedConfig = {
      +            ...autoStored!.config,
                   env: { EXECUTOR_E2E_SECRET: SECRET },
      -            slug,
      -          },
      -        });
      -
      -        // The integration lands in the catalog — the add itself works.
      -        const integrations = yield* client.integrations.list();
      -        expect(
      -          integrations.map((i) => String(i.slug)),
      -          "the stdio MCP integration is registered",
      -        ).toContain(slug);
      -
      -        // The add auto-creates the default connection (the v1.5 split makes this
      -        // the thing that drives tool discovery). Pre-fix there were zero.
      -        const connections = yield* client.connections.list({ query: { integration: slug } });
      -        expect(
      -          connections.map((c) => String(c.name)),
      -          "a default connection was auto-created for the stdio server",
      -        ).toContain("default");
      -
      -        // THE SYMPTOM, fixed: the stdio server's tools are detected. `whoami`
      -        // appearing proves the connection's secret env reached the subprocess.
      -        const tools = yield* client.tools.list({ query: { integration: slug } });
      -        const names = tools.map((t) => t.name);
      -        expect(names, "the stdio server's base tool is detected").toContain("echo_tool");
      -        expect(
      -          names,
      -          "the secret env var reached the spawned subprocess (whoami is gated on it)",
      -        ).toContain("whoami");
      -
      -        // "Properly store auth": the secret value is NOT in the integration's
      -        // config blob — only the var NAME is declared there; the value lives on
      -        // the connection (the secret store).
      -        const stored = yield* client.mcp.getServer({ params: { slug } });
      -        expect(
      -          JSON.stringify(stored?.config ?? {}),
      -          "the secret value is not persisted in the integration config",
      -        ).not.toContain(SECRET);
      -
      -        // --- The UI path: DECLARE env var names, then provide the secret value
      -        // as a connection credential (what the add form now does). ---
      -        const declSlug = "e2e-stdio-decl";
      -        yield* client.mcp.addServer({
      -          payload: {
      -            transport: "stdio",
      -            name: "E2E Stdio Declared",
      -            command: "node",
      -            args: [FIXTURE],
      -            envVars: ["EXECUTOR_E2E_SECRET"],
      -            slug: declSlug,
      -          },
      -        });
      -
      -        // Declaring a secret env var (no value) does NOT auto-connect: the
      -        // secret is still missing, so there are no tools until you connect.
      -        const beforeConns = yield* client.connections.list({ query: { integration: declSlug } });
      -        expect(beforeConns, "no connection until the secret is provided").toHaveLength(0);
      -        const beforeTools = yield* client.tools.list({ query: { integration: declSlug } });
      -        expect(beforeTools, "no tools until the secret is provided").toHaveLength(0);
      -
      -        // Provide the secret as the connection credential (the connect step).
      -        yield* client.connections.create({
      -          payload: {
      -            owner: "org",
      -            name: ConnectionName.make("default"),
      -            integration: IntegrationSlug.make(declSlug),
      -            template: AuthTemplateSlug.make("env"),
      -            values: { EXECUTOR_E2E_SECRET: SECRET },
      -          },
      -        });
      -
      -        const declTools = yield* client.tools.list({ query: { integration: declSlug } });
      -        expect(
      -          declTools.map((t) => t.name),
      -          "connecting with the secret discovers the env-gated tool",
      -        ).toContain("whoami");
      -      }),
      +          } as typeof autoStored.config;
      +
      +          yield* client.mcp.configureServer({
      +            params: { slug: autoSlug },
      +            payload: { config: editedConfig },
      +          });
      +
      +          const editedStored = yield* client.mcp.getServer({ params: { slug: autoSlug } });
      +          expect(
      +            JSON.stringify(editedStored?.config ?? {}),
      +            "the edit persisted, and left the untouched negotiation mode alone",
      +          ).toContain('"versionNegotiation":"auto"');
      +
      +          const editedTools = yield* client.tools.list({ query: { integration: autoSlug } });
      +          expect(
      +            editedTools.map((t) => t.name),
      +            "the edited config reached the spawn and the catalog was rediscovered",
      +          ).toContain("saw_declared_env");
      +          expect(
      +            editedTools.map((t) => t.name),
      +            "rediscovery replaced the catalog rather than dropping what still exists",
      +          ).toContain("echo_tool");
      +        }),
      +      { env: DAEMON_ENV },
           );
         }),
       );
      diff --git a/e2e/local/vite-dev-routing.test.ts b/e2e/local/vite-dev-routing.test.ts
      index 287eb3f8c6..3e0c490311 100644
      --- a/e2e/local/vite-dev-routing.test.ts
      +++ b/e2e/local/vite-dev-routing.test.ts
      @@ -80,10 +80,11 @@ const startPlainViteDev = async (): Promise => {
         const dataDir = mkdtempSync(join(tmpdir(), "executor-local-vite-e2e-"));
         const port = await freePort();
         const origin = `http://127.0.0.1:${port}`;
      +  const viteEntrypoint = join(localAppDir, "node_modules", "vite", "bin", "vite.js");
         let logs = "";
         const child = spawn(
      -    "bunx",
      -    ["--bun", "vite", "dev", "--host", "127.0.0.1", "--port", String(port), "--strictPort"],
      +    "bun",
      +    [viteEntrypoint, "dev", "--host", "127.0.0.1", "--port", String(port), "--strictPort"],
           {
             cwd: localAppDir,
             env: { ...process.env, EXECUTOR_DATA_DIR: dataDir, PORT: String(port) },
      diff --git a/e2e/mcp-apps/.gitignore b/e2e/mcp-apps/.gitignore
      new file mode 100644
      index 0000000000..cb5522839d
      --- /dev/null
      +++ b/e2e/mcp-apps/.gitignore
      @@ -0,0 +1,5 @@
      +node_modules/
      +package-lock.json
      +test-results/
      +playwright-report/
      +.exdata/
      diff --git a/e2e/mcp-apps/README.md b/e2e/mcp-apps/README.md
      new file mode 100644
      index 0000000000..b93940d34f
      --- /dev/null
      +++ b/e2e/mcp-apps/README.md
      @@ -0,0 +1,67 @@
      +# MCP Apps tests (sunpeak)
      +
      +Render and test executor's generative-UI MCP Apps (the `create-artifact` tool from the
      +`mcp-apps-shell` plugin) in a **real MCP-Apps host**, headlessly, in CI, with **no
      +VM and no Claude/ChatGPT account**.
      +
      +[sunpeak](https://github.com/Sunpeak-AI/sunpeak) locally replicates the Claude
      +and ChatGPT app runtimes: it connects to an MCP server, calls a UI tool, mounts
      +the returned `ui://` resource in a sandboxed iframe with the host bridge, and
      +hands the test a frame-scoped handle to the rendered component. Every test runs
      +against both host simulations. This is the lightweight successor to the earlier
      +MCPJam Playwright harness (which we drove by hand via fragile UI selectors).
      +
      +## Run
      +
      +```bash
      +cd e2e/mcp-apps
      +npm install        # also patches sunpeak (see below) and is isolated from the bun workspace
      +npm test           # builds the shell, starts sunpeak + `executor mcp`, runs the specs
      +```
      +
      +`playwright.config.ts` starts `executor mcp` over **stdio** (from source, so the
      +shell resource is served), so there is no daemon or HTTP token to manage. Specs
      +live in `tests/`; `npm run test:headed` and `npm run report` help when debugging.
      +
      +A spec is tiny:
      +
      +```ts
      +import { test, expect } from "sunpeak/test";
      +test("widget mounts", async ({ inspector }) => {
      +  const result = await inspector.renderTool("create-artifact", { code: APP_SRC });
      +  const app = result.app().frameLocator("iframe"); // see "extra iframe" below
      +  await expect(app.locator('button:has-text("Increment")')).toBeVisible();
      +});
      +```
      +
      +## Two interop notes (both handled here)
      +
      +1. **sunpeak doesn't advertise the MCP-Apps UI _client_ capability.** Its
      +   inspector connects with `new Client({ name, version })` and never declares
      +   `capabilities.extensions["io.modelcontextprotocol/ui"]`. executor's
      +   `create-artifact` (correctly, per the MCP-Apps spec) only mounts the widget inline
      +   when the host advertises it can render `text/html;profile=mcp-app`; otherwise
      +   it returns its browser **fallback URL** and nothing renders. `scripts/patch-sunpeak.mjs`
      +   (a postinstall) adds that capability to sunpeak's client. This is
      +   upstream-worthy: sunpeak should advertise it itself.
      +
      +2. **executor's shell nests one extra iframe.** The shell mounts the generated
      +   component in a nested `srcdoc` iframe (`shell-app` -> `inner-renderer`, the
      +   double-iframe sandbox), one level below sunpeak's `result.app()`. So tests use
      +   `result.app().frameLocator("iframe")` to reach the component.
      +
      +## Why stdio + from source
      +
      +`create-artifact`'s shell resource (`ui://executor/shell-tanstack-query.html`) is
      +`packages/hosts/mcp-apps-shell/dist/mcp-app.html`. `pretest` builds it. Running
      +`executor mcp` from source serves it directly. (The compiled binary now ships
      +the shell too — see `apps/cli/src/build.ts` — but source is the cheaper path
      +for a test.)
      +
      +## Scope
      +
      +sunpeak is a host _simulation_: it covers the protocol contract, the rendered
      +UI, and tool/theme/display-mode behavior. It does not catch real-host quirks
      +(OAuth, production CSP). For the shell component in isolation there is also the
      +faster in-repo unit test at
      +`packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts`.
      diff --git a/e2e/mcp-apps/package.json b/e2e/mcp-apps/package.json
      new file mode 100644
      index 0000000000..d382017772
      --- /dev/null
      +++ b/e2e/mcp-apps/package.json
      @@ -0,0 +1,17 @@
      +{
      +  "name": "@executor-js/e2e-mcp-apps",
      +  "private": true,
      +  "description": "Render + test executor's generative-UI MCP Apps in a real MCP-Apps host (sunpeak), headless, no VM.",
      +  "type": "module",
      +  "scripts": {
      +    "postinstall": "node scripts/check-sunpeak.mjs",
      +    "pretest": "bun run --cwd ../../packages/hosts/mcp-apps-shell build:shell",
      +    "test": "playwright test",
      +    "test:headed": "playwright test --headed",
      +    "report": "playwright show-report"
      +  },
      +  "devDependencies": {
      +    "@playwright/test": "^1.56.0",
      +    "sunpeak": "^0.20.56"
      +  }
      +}
      diff --git a/e2e/mcp-apps/playwright.config.ts b/e2e/mcp-apps/playwright.config.ts
      new file mode 100644
      index 0000000000..49f264c2f8
      --- /dev/null
      +++ b/e2e/mcp-apps/playwright.config.ts
      @@ -0,0 +1,31 @@
      +import { fileURLToPath } from "node:url";
      +import { dirname, resolve } from "node:path";
      +import { defineConfig } from "sunpeak/test/config";
      +
      +// sunpeak runs `sunpeak inspect` (a real MCP-Apps host that mounts ui://
      +// resources in a sandboxed iframe) as the Playwright web server, connects it to
      +// the MCP server below, and gives tests a frame-scoped handle to the rendered
      +// app. We point it at executor over stdio (`executor mcp`) so the whole thing is
      +// self-contained: no daemon to manage, no HTTP token.
      +//
      +// Run the daemon FROM SOURCE (bun apps/cli/bin/executor.ts) so the shell
      +// resource is served from packages/hosts/mcp-apps-shell/dist/mcp-app.html
      +// (`pretest` builds it). The compiled binary also ships the shell, but source
      +// is the cheapest path for a test.
      +const here = dirname(fileURLToPath(import.meta.url));
      +const repo = resolve(here, "../..");
      +
      +export default defineConfig({
      +  testDir: "tests",
      +  server: {
      +    command: "bun",
      +    // `--artifacts` because artifacts are opt-in per MCP connection: a bare
      +    // `executor mcp` serves no artifact tools and no ui:// shell resource, so
      +    // there would be nothing for sunpeak to mount.
      +    args: [resolve(repo, "apps/cli/bin/executor.ts"), "mcp", "--artifacts"],
      +    env: {
      +      EXECUTOR_DATA_DIR: resolve(here, ".exdata"),
      +    },
      +    cwd: repo,
      +  },
      +});
      diff --git a/e2e/mcp-apps/scripts/check-sunpeak.mjs b/e2e/mcp-apps/scripts/check-sunpeak.mjs
      new file mode 100644
      index 0000000000..5b4283e1dc
      --- /dev/null
      +++ b/e2e/mcp-apps/scripts/check-sunpeak.mjs
      @@ -0,0 +1,45 @@
      +// Verify sunpeak's inspector advertises the MCP-Apps UI *client* capability to
      +// the upstream MCP server.
      +//
      +// executor gates inline UI rendering on that advertisement (per the MCP-Apps
      +// spec: a server mounts inline only when the host says it can render
      +// `text/html;profile=mcp-app`). A host that doesn't advertise it gets the
      +// deep-link fallback instead of the widget — which would make this whole suite
      +// pass while testing the wrong delivery path.
      +//
      +// sunpeak used to construct its Client with no capabilities at all and we
      +// patched the shipped file. As of 0.20.69 it declares the capability itself, so
      +// this is now a CHECK rather than a patch. It stays fatal: if a future sunpeak
      +// drops the capability, the harness must fail loudly at install time rather
      +// than silently degrade.
      +import { readFileSync, existsSync } from "node:fs";
      +import { fileURLToPath } from "node:url";
      +import { dirname, resolve } from "node:path";
      +
      +const here = dirname(fileURLToPath(import.meta.url));
      +const target = resolve(here, "../node_modules/sunpeak/bin/commands/inspect.mjs");
      +
      +if (!existsSync(target)) {
      +  console.error(`[check-sunpeak] ${target} not found. Is sunpeak installed?`);
      +  process.exit(1);
      +}
      +
      +const src = readFileSync(target, "utf8");
      +const EXTENSION_ID = "io.modelcontextprotocol/ui";
      +const MIME_TYPE = "text/html;profile=mcp-app";
      +
      +if (!src.includes(EXTENSION_ID) || !src.includes(MIME_TYPE)) {
      +  console.error(
      +    [
      +      "[check-sunpeak] the inspector no longer advertises MCP-Apps client support.",
      +      `  expected both ${EXTENSION_ID} and ${MIME_TYPE}`,
      +      `  in: ${target}`,
      +      "Without it executor returns its deep-link fallback and these tests would pass",
      +      "while testing nothing. Pin a sunpeak version that advertises the capability,",
      +      "or restore a patch here that injects it into the Client constructor.",
      +    ].join("\n"),
      +  );
      +  process.exit(1);
      +}
      +
      +console.log("[check-sunpeak] inspector advertises MCP-Apps UI client capability");
      diff --git a/e2e/mcp-apps/tests/create-artifact.spec.ts b/e2e/mcp-apps/tests/create-artifact.spec.ts
      new file mode 100644
      index 0000000000..340a7d3c3f
      --- /dev/null
      +++ b/e2e/mcp-apps/tests/create-artifact.spec.ts
      @@ -0,0 +1,87 @@
      +import { test, expect } from "sunpeak/test";
      +
      +// executor's create-artifact shell mounts the generated component in a *nested* srcdoc
      +// iframe (shell-app -> inner-renderer, the MCP-Apps double-iframe sandbox), one
      +// level below sunpeak's own `result.app()`. Descend that extra level.
      +const appBody = (result: { app: () => ReturnType["app"]> } | any) =>
      +  result.app().frameLocator("iframe");
      +
      +const COUNTER = `function App() {
      +  const [count, setCount] = useState(0);
      +  return (
      +    
      +

      MCP App counter

      + {count} + +
      + ); +}`; + +// sunpeak runs every test against both the Claude and ChatGPT host simulations. + +test("create-artifact mounts an interactive React widget", async ({ inspector }) => { + const result = await inspector.renderTool("create-artifact", { + code: COUNTER, + title: "MCP App counter", + }); + const app = appBody(result); + + await expect(app.locator("text=MCP App counter")).toBeVisible({ timeout: 15000 }); + const inc = app.locator('button:has-text("Increment")'); + await expect(inc).toBeVisible(); + + // state is live: clicking updates the rendered output + await inc.click(); + await expect(app.locator("text=1")).toBeVisible(); +}); + +test("create-artifact renders in dark theme", async ({ inspector }) => { + const result = await inspector.renderTool( + "create-artifact", + { code: COUNTER, title: "MCP App counter" }, + { theme: "dark" }, + ); + await expect(appBody(result).locator("text=MCP App counter")).toBeVisible({ timeout: 15000 }); +}); + +// The whole point of patching sunpeak to advertise MCP-Apps support is that +// executor returns the inline widget rather than its deep-link fallback. Assert +// that directly, so a future sunpeak change that drops the capability fails +// here instead of quietly testing the wrong delivery path. +// Protocol-level assertions go through the `mcp` fixture: `inspector` models +// what a *host* shows the user and does not surface the raw tool result. +const structuredOf = (result: { structuredContent?: unknown }): Record => + (result.structuredContent ?? {}) as Record; + +test("create-artifact returns an inline widget, not the deep-link fallback", async ({ mcp }) => { + const result = await mcp.callTool("create-artifact", { + code: COUNTER, + title: "MCP App counter", + description: "A counter used by the MCP Apps e2e harness", + }); + const structured = structuredOf(result); + // The whole point of the inspector advertising MCP-Apps support: executor + // must hand back the component, not a URL to go look at it elsewhere. + expect(structured.status).not.toBe("fallback_url"); + expect(structured.code).toContain("MCP App counter"); + // Every successful render is persisted, so the artifact can be reopened. + expect(structured.artifactId).toBeTruthy(); +}); + +test("a rendered artifact is listed and can be shown again", async ({ inspector, mcp }) => { + const rendered = await mcp.callTool("create-artifact", { + code: COUNTER, + title: "Retrievable counter", + description: "Saved so list-artifacts can find it", + }); + const artifactId = structuredOf(rendered).artifactId as string; + expect(artifactId).toBeTruthy(); + + const listed = await mcp.callTool("list-artifacts", {}); + const artifacts = structuredOf(listed).artifacts as Array<{ id: string }> | undefined; + expect(artifacts?.some((artifact) => artifact.id === artifactId)).toBe(true); + + // …and the stored source really does render, through the same shell. + const shown = await inspector.renderTool("show-artifact", { id: artifactId }); + await expect(appBody(shown).locator("text=MCP App counter")).toBeVisible({ timeout: 15000 }); +}); diff --git a/e2e/package.json b/e2e/package.json index ca937fc190..5a8666f5e2 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/e2e", - "version": "0.0.32", + "version": "0.0.47", "private": true, "type": "module", "scripts": { @@ -11,6 +11,7 @@ "test:selfhost-docker": "vitest run --project selfhost-docker", "test:cloudflare": "vitest run --project cloudflare", "test:local": "vitest run --project local", + "test:ci-shard": "vitest run --config scripts/ci-shard.vitest.config.ts", "test:watch": "vitest", "ports": "bun scripts/ports.ts", "summary": "bun scripts/summary.ts", @@ -22,7 +23,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.6", + "@executor-js/emulate": "^0.14.1", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", diff --git a/e2e/scenarios/artifact-approval.test.ts b/e2e/scenarios/artifact-approval.test.ts new file mode 100644 index 0000000000..ea1f52a34b --- /dev/null +++ b/e2e/scenarios/artifact-approval.test.ts @@ -0,0 +1,228 @@ +// Cross-target: approving a destructive action triggered from inside an +// artifact. +// +// This is the console's own path, not MCP. The artifact page has no MCP client, +// so the shell's host adapter posts `POST /executions` (with the artifact id) +// and then `POST /executions/:id/resume` when the human answers the modal. The +// existing approval coverage all runs over MCP, where the engine lives for the +// lifetime of a session Durable Object — so none of it could see that on a host +// building an executor per request the pause and the resume land on DIFFERENT +// engine instances, and the approval failed with `ExecutionNotFoundError`. +// +// Two things are asserted that the older scenarios could not: +// +// 1. The approval is honoured at all on this path, and the side effect lands. +// 2. It is still honoured after a human-scale delay. The reported symptom was +// an approval that took "tens of seconds", so a scenario approving +// instantly would stay green against the broken code. It is worth being +// precise about why: the bug is NOT time-based (a resume 7ms after the +// pause failed identically), so the delay is not what makes this catch the +// regression — the artifact-scoped path is. The delay guards the separate +// promise that an approval window is human-scale. +import { randomUUID } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const coreApi = composePluginApi([] as const); + +/** + * Long enough to cover the observed human pause while staying well inside the + * 15-minute approval lease. The shard planner keeps this file isolated so the + * real elapsed-time assertion overlaps the rest of CI instead of blocking it. + */ +const APPROVAL_DELAY_MS = 65_000; + +/** + * The one call the shell's proxy is allowed to emit: a single awaited tool call, + * no statements around it. `policies.create` carries a `requiresApproval` + * annotation, so with no matching policy in play the annotation is the only + * thing that can pause it — and the pattern is unique-per-run and matches no + * real tool, so the created rule is inert. + * + * `executor` is a reserved tool root (`RESERVED_TOOL_ROOTS`), so it resolves + * without a connection binding — which lets the scenario exercise the + * artifact-scoped approval path without first connecting an integration. + */ +const artifactActionCode = (pattern: string) => + `return await tools.executor.coreTools.policies.create(${JSON.stringify({ + owner: "user", + pattern, + action: "block", + })})`; + +/** A minimal artifact. Its source is irrelevant to the approval path — what + * matters is that a real artifact id scopes the call, exactly as the rendered + * page supplies it. */ +const artifactSource = ` +function App() { + return
      Approval fixture
      ; +} +`; + +const pausedExecutionId = (structured: unknown): string | undefined => + (structured as { readonly executionId?: string } | null)?.executionId; + +scenario( + "Artifacts · a destructive action approved from an artifact runs after a human-scale delay", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const apiSurface = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* apiSurface.client(coreApi, identity); + + const pattern = `artifact-approval-${randomUUID().slice(0, 8)}.*`; + const code = artifactActionCode(pattern); + + const artifact = yield* client.artifacts.save({ + payload: { + title: `Approval fixture ${randomUUID().slice(0, 8)}`, + code: artifactSource, + }, + }); + + const cleanup = Effect.all( + [ + client.artifacts + .remove({ params: { artifactId: artifact.id as ArtifactId } }) + .pipe(Effect.ignore), + client.policies.list().pipe( + Effect.flatMap((list) => + Effect.forEach( + list.filter((p) => p.pattern === pattern), + (p) => + client.policies + .remove({ params: { policyId: p.id }, payload: { owner: "user" } }) + .pipe(Effect.ignore), + ), + ), + Effect.ignore, + ), + ], + { discard: true }, + ); + + yield* Effect.gen(function* () { + // The artifact fires its mutation. The tool gates itself, so the shell + // gets a pause back and renders the approval modal. + const paused = yield* client.executions.execute({ + payload: { code, artifactId: artifact.id }, + }); + expect(paused.status, "a destructive artifact action pauses for approval").toBe("paused"); + if (paused.status !== "paused") return; // narrowing only + + const executionId = pausedExecutionId(paused.structured); + expect(executionId, "the pause carries an execution id to approve").toBeTruthy(); + if (!executionId) return; // narrowing only + + // Nothing may happen while the human is still deciding. + const beforeApproval = yield* client.policies.list(); + expect( + beforeApproval.some((p) => p.pattern === pattern), + "the action does not run while it is waiting on approval", + ).toBe(false); + + // Exercise the real API and persistence path across an actual delay. A + // private fake clock cannot prove that request-scoped engines and durable + // storage still agree after the originating request has been gone. + yield* Effect.sleep(APPROVAL_DELAY_MS); + + const approved = yield* client.executions.resume({ + params: { executionId }, + payload: { action: "accept", content: {} }, + }); + + expect(approved.status, "approving the action runs it to completion").toBe("completed"); + if (approved.status !== "completed") return; // narrowing only + expect(approved.isError, "the approved action is not an error").toBe(false); + + const afterApproval = yield* client.policies.list(); + expect( + afterApproval.some((p) => p.pattern === pattern), + "the approved action's side effect actually landed", + ).toBe(true); + }).pipe(Effect.ensuring(cleanup)); + }), +); + +scenario( + "Artifacts · declining an artifact action leaves it unrun", + {}, + Effect.gen(function* () { + const target = yield* Target; + const apiSurface = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* apiSurface.client(coreApi, identity); + + const pattern = `artifact-decline-${randomUUID().slice(0, 8)}.*`; + const code = artifactActionCode(pattern); + + const artifact = yield* client.artifacts.save({ + payload: { + title: `Decline fixture ${randomUUID().slice(0, 8)}`, + code: artifactSource, + }, + }); + + const cleanup = Effect.all( + [ + client.artifacts + .remove({ params: { artifactId: artifact.id as ArtifactId } }) + .pipe(Effect.ignore), + client.policies.list().pipe( + Effect.flatMap((list) => + Effect.forEach( + list.filter((p) => p.pattern === pattern), + (p) => + client.policies + .remove({ params: { policyId: p.id }, payload: { owner: "user" } }) + .pipe(Effect.ignore), + ), + ), + Effect.ignore, + ), + ], + { discard: true }, + ); + + yield* Effect.gen(function* () { + const paused = yield* client.executions.execute({ + payload: { code, artifactId: artifact.id }, + }); + expect(paused.status, "the action pauses for approval").toBe("paused"); + if (paused.status !== "paused") return; // narrowing only + + const executionId = pausedExecutionId(paused.structured); + if (!executionId) return; // narrowing only + + yield* client.executions.resume({ + params: { executionId }, + payload: { action: "decline" }, + }); + + const afterDecline = yield* client.policies.list(); + expect( + afterDecline.some((p) => p.pattern === pattern), + "a declined action never runs", + ).toBe(false); + + // A declined approval is spent: the same id cannot be re-approved into a + // run afterwards. + yield* client.executions + .resume({ params: { executionId }, payload: { action: "accept", content: {} } }) + .pipe(Effect.ignore); + + const afterRetry = yield* client.policies.list(); + expect( + afterRetry.some((p) => p.pattern === pattern), + "approving after a decline does not resurrect the action", + ).toBe(false); + }).pipe(Effect.ensuring(cleanup)); + }), +); diff --git a/e2e/scenarios/artifact-loading-surface.test.ts b/e2e/scenarios/artifact-loading-surface.test.ts new file mode 100644 index 0000000000..141d61dda1 --- /dev/null +++ b/e2e/scenarios/artifact-loading-surface.test.ts @@ -0,0 +1,331 @@ +// Opening an artifact shows ONE loading surface, not three. +// +// ## What went wrong +// +// Opening an artifact used to walk through three visually distinct placeholders +// in quick succession, each in its own layout and each visible for a few hundred +// milliseconds: +// +// 1. "Loading artifact…" — the row being fetched, small corner text +// 2. "Preparing the renderer…" — the lazy chunk arriving, a different corner +// 3. "Preparing interactive UI" — the shell booting, a card in the middle +// +// Those are executor's own internal boundaries — an HTTP request, a code-split +// point, an iframe handshake — and none of them is a fact a person opening a +// saved dashboard has any use for. What they produced was churn: three +// different things flashing in three different places. Three quick loading +// states are worse than one longer one, because every transition is a fresh +// demand on the eye. +// +// ## What is asserted, and why it is asserted this way +// +// The honest test of "the user never saw a placeholder" is not a screenshot at +// one moment — the states this is about were only ever visible for a few +// hundred milliseconds, so any single sample would miss them and pass against +// the old code too. So the page is SAMPLED CONTINUOUSLY, from before navigation +// until the artifact is live, and the assertion is over everything that was ever +// on screen: +// +// - none of the three placeholder texts ever appeared; +// - the stage's bounding box never moved, so nothing was ever laid out twice. +// +// Both directions are covered, because they fail differently: warm (from the +// gallery, renderer chunk already fetched) and cold (a direct URL in a fresh +// context, nothing cached). +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; +import type { Page } from "playwright"; +import { composePluginApi } from "@executor-js/api/server"; +import { AccountHttpApi } from "@executor-js/api"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([] as const); + +const uniqueSuffix = () => randomBytes(4).toString("hex"); + +/** + * The placeholder texts that must never reach a user again. + * + * Named literally rather than by testid: the point is that the WORDS are gone + * from the experience, and a rename that kept the churn under a different + * caption should not pass. Each string is the exact one the corresponding + * placeholder rendered before this change. + */ +const FORBIDDEN_LOADING_TEXT = [ + "Loading artifact", + "Preparing the renderer", + "Preparing interactive UI", +] as const; + +/** A tall, distinctive artifact — the same shape the main artifacts scenario + * uses, so the fill-mode sizing under test here is the real one. */ +const artifactSource = (marker: string) => ` +function App() { + return ( +
      +
      +

      Loading Surface

      +

      ${marker}

      +
      +
      + {Array.from({ length: 30 }, (_, i) => ( +

      + Row {i + 1}: ${marker} +

      + ))} +
      +
      + ); +} +`; + +const structuredOf = (result: { readonly raw: unknown }): Record => + ((result.raw as { structuredContent?: Record }).structuredContent ?? + {}) as Record; + +const artifactContent = (page: Page) => + page.frameLocator('[data-testid="artifact-shell-frame"]').frameLocator("iframe"); + +type Sample = { + readonly text: string; + readonly stage: { + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; + } | null; +}; + +declare global { + // eslint-disable-next-line no-var + var __loadingSamples: Array | undefined; + // eslint-disable-next-line no-var + var __loadingSampler: number | undefined; +} + +/** + * Watch the CONSOLE document continuously for the whole open. + * + * Installed as an init script so it is running before the first byte of the + * page, and polls on an animation frame — fast enough that a placeholder + * visible for even one paint is recorded. `innerText` of the console document + * only (never the frames): the shell's own inner states live one document down + * and are covered separately, and reaching into a frame from here would sample + * the artifact's own text as if it were the console's. + * + * The stage's box is sampled alongside, because a placeholder that came and + * went without changing any TEXT would still have moved the layout, and a + * layout that jumps is the same defect wearing a different hat. + */ +const startSampling = async (page: Page): Promise => { + await page.addInitScript(() => { + globalThis.__loadingSamples = []; + const sample = () => { + const stage = document.querySelector( + '[data-testid="artifact-shell-container"], [data-slot="artifact-stage-skeleton"]', + ); + const box = stage?.getBoundingClientRect(); + globalThis.__loadingSamples?.push({ + text: document.body?.innerText ?? "", + stage: box + ? { + x: Math.round(box.x), + y: Math.round(box.y), + w: Math.round(box.width), + h: Math.round(box.height), + } + : null, + }); + globalThis.__loadingSampler = requestAnimationFrame(sample); + }; + sample(); + }); +}; + +const readSamples = (page: Page): Promise> => + page.evaluate(() => globalThis.__loadingSamples ?? []); + +/** + * Assert the whole open was one surface. + * + * Two independent properties over the same recording — no placeholder words + * ever rendered, and the stage never changed size or position — because either + * one alone would let the churn back in through the other door. + */ +const expectSingleLoadingSurface = (samples: ReadonlyArray, label: string): void => { + expect(samples.length, `${label}: the sampler actually ran`).toBeGreaterThan(3); + + for (const forbidden of FORBIDDEN_LOADING_TEXT) { + const hit = samples.findIndex((entry) => entry.text.includes(forbidden)); + expect( + hit, + `${label}: "${forbidden}" was on screen at sample ${hit} of ${samples.length} — the open still walks through more than one loading state`, + ).toBe(-1); + } + + // The stage's geometry, over every frame in which a stage existed at all. + // The skeleton and the artifact frame share one box by construction (the + // skeleton is an absolutely-positioned cover over the frame's own container), + // so a change here means one of them was laid out differently from the other. + const boxes = samples.map((entry) => entry.stage).filter(Predicate.isNotNull); + expect(boxes.length, `${label}: the stage was on screen at some point`).toBeGreaterThan(0); + + const first = boxes[0]; + if (!first) return; + for (const [index, box] of boxes.entries()) { + // A pixel of tolerance for sub-pixel rounding as the scrollbar settles. + expect( + Math.abs(box.w - first.w) <= 1 && Math.abs(box.h - first.h) <= 1, + `${label}: the stage changed size mid-load at sample ${index} (${JSON.stringify(box)} vs ${JSON.stringify(first)}) — the artifact appeared in a different box than the skeleton held`, + ).toBe(true); + } +}; + +scenario( + "Artifacts · opening an artifact shows one loading surface, not three", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + const { client: apiClient } = yield* Api; + + const identity = yield* target.newIdentity(); + const client = yield* apiClient(api, identity); + // Artifacts are on by default; the plain session carries the surface + // this scenario exercises. + const session = mcp.session(identity); + + const suffix = uniqueSuffix(); + const title = `Loading Surface ${suffix}`; + const marker = `surface-ok-${suffix}`; + + let artifactId: ArtifactId | undefined; + + yield* Effect.gen(function* () { + const created = yield* session.call("create-artifact", { + code: artifactSource(marker), + title, + description: "An artifact opened to watch how it loads", + }); + expect(created.ok, `create-artifact succeeded: ${created.text}`).toBe(true); + artifactId = structuredOf(created).artifactId as ArtifactId; + expect(artifactId, "the artifact was persisted").toBeTruthy(); + + const accountClient = yield* apiClient(AccountHttpApi, identity); + const me = yield* accountClient.account.me(); + const orgSlug = me.organization?.slug; + const detailPath = orgSlug + ? `/${orgSlug}/artifacts/${artifactId}` + : `/artifacts/${artifactId}`; + + // ------------------------------------------------------------------ + // WARM: the journey a user actually takes — gallery, then a click. + // ------------------------------------------------------------------ + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Artifacts gallery", async () => { + await startSampling(page); + await visit(page, `${target.baseUrl}/artifacts`); + await page + .getByRole("link", { name: `Open artifact ${title}` }) + .waitFor({ timeout: 30_000 }); + // Discard everything from the gallery's own load: this scenario is + // about the OPEN, and the list has a loading state of its own that is + // out of scope here. + await page.evaluate(() => { + globalThis.__loadingSamples = []; + }); + }); + + await step("Click through to the artifact", async () => { + await page.getByRole("link", { name: `Open artifact ${title}` }).click(); + await artifactContent(page).getByTestId("artifact-marker").waitFor({ timeout: 60_000 }); + }); + + await step("The whole open was one surface", async () => { + expectSingleLoadingSurface(await readSamples(page), "warm open"); + }); + + await step("The skeleton hands over and gets out of the way", async () => { + // The cover is removed once the artifact has painted — not left over + // it at zero opacity, which would swallow every click. + await expect + .poll(async () => await page.locator('[data-testid="artifact-stage-cover"]').count(), { + timeout: 20_000, + message: "the loading cover was removed after the artifact rendered", + }) + .toBe(0); + }); + + await step("Fill-mode sizing survived the handoff", async () => { + // The frame is the viewport below the title bar, exactly as it is in + // the steady state — so nothing about holding a skeleton over it + // changed the height chain it measures against. + const frameBox = await page.locator('[data-testid="artifact-shell-frame"]').boundingBox(); + const viewport = page.viewportSize(); + expect(frameBox?.height ?? 0, "the frame fills the room it was given").toBeGreaterThan( + 200, + ); + expect( + frameBox?.height ?? 0, + "the frame is bounded by the window, not by its content", + ).toBeLessThanOrEqual(viewport?.height ?? 0); + + const pageScrolls = await page.evaluate( + () => document.documentElement.scrollHeight > document.documentElement.clientHeight + 1, + ); + expect(pageScrolls, "the console page still does not scroll").toBe(false); + }); + }); + + // ------------------------------------------------------------------ + // COLD: the deep link, in a context that has never loaded the console. + // + // The harder case, and the one the preload exists for: nothing is cached, + // so the row fetch and the renderer chunk both start from zero. This is + // where the three states used to be most visible, and where a regression + // in the preload would show up first. + // ------------------------------------------------------------------ + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the artifact by URL, cold", async () => { + await startSampling(page); + await page.goto(`${target.baseUrl}${detailPath}`, { waitUntil: "commit" }); + await artifactContent(page).getByTestId("artifact-marker").waitFor({ timeout: 60_000 }); + }); + + await step("The cold open was one surface too", async () => { + expectSingleLoadingSurface(await readSamples(page), "cold open"); + }); + + await step("The artifact is live and interactive", async () => { + const rendered = artifactContent(page).getByTestId("artifact-marker"); + expect(await rendered.textContent()).toContain(marker); + + // Interactive, not merely painted: the cover is gone, so a scroll + // inside the artifact actually reaches it. + const scrolled = await artifactContent(page) + .getByTestId("artifact-scroll") + .evaluate((el) => { + el.scrollTop = 200; + return el.scrollTop; + }); + expect(scrolled, "the artifact takes input after the handoff").toBeGreaterThan(0); + }); + }); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + artifactId === undefined + ? Effect.void + : client.artifacts.remove({ params: { artifactId } }), + ).pipe(Effect.ignore), + ), + ); + }), +); diff --git a/e2e/scenarios/artifact-preview-gallery.test.ts b/e2e/scenarios/artifact-preview-gallery.test.ts new file mode 100644 index 0000000000..a468f5897b --- /dev/null +++ b/e2e/scenarios/artifact-preview-gallery.test.ts @@ -0,0 +1,282 @@ +// A visual check of the artifact gallery's previews, in light and dark. +// +// The other artifacts scenarios prove the mechanism: the layout preview is +// stored at create time and upgraded to a snapshot once the artifact is opened. +// This one exists to show what that actually LOOKS like across the shapes a +// model really produces — a stat grid, a chart, and a component whose data is +// still loading — because "the preview is real" is a claim about appearance and +// the only honest way to check appearance is to look at it. +// +// It seeds through `create-artifact` rather than the storage API, so every +// preview here was produced by the same smoke render a model's create runs +// through. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Mcp, Target } from "../src/services"; +import { revisit, visit } from "../src/surfaces/browser"; + +const uniqueSuffix = () => randomBytes(3).toString("hex"); + +/** A stat grid over a table: the most common dashboard shape. */ +const dashboardSource = ` +function App() { + const metrics = [ + { label: "Active users", value: "12,480", delta: "+8.2% vs last week" }, + { label: "Sessions", value: "48,201", delta: "+3.1% vs last week" }, + { label: "Error rate", value: "0.42%", delta: "-0.3% vs last week" }, + { label: "p95 latency", value: "284ms", delta: "+12ms vs last week" }, + ]; + const rows = Array.from({ length: 6 }, (_, i) => ({ + name: "checkout-service-" + (i + 1), + status: i % 3 === 0 ? "degraded" : "healthy", + })); + return ( +
      +
      +

      Release Readiness

      +

      Rolling 24h across all services

      +
      +
      + {metrics.map((m) => ( + + + {m.label} + {m.value} + + + {m.delta} + + + ))} +
      + + Services + + + + + Service + Status + + + + {rows.map((r) => ( + + {r.name} + + + {r.status} + + + + ))} + +
      +
      +
      +
      + ); +}`; + +/** A chart: the shape whose preview is almost entirely SVG geometry. */ +const chartSource = ` +function App() { + const data = [ + { month: "Jan", revenue: 4200 }, { month: "Feb", revenue: 5100 }, + { month: "Mar", revenue: 4800 }, { month: "Apr", revenue: 6300 }, + { month: "May", revenue: 7100 }, { month: "Jun", revenue: 8250 }, + ]; + return ( +
      +
      +

      Revenue by month

      +

      Current fiscal year

      +
      + + + + + + + + + + + + +
      + ); +}`; + +/** + * A component showing its loading state: the shape whose preview is a skeleton. + * + * Deliberately does NOT call a tool. A real `tools.*` query would need a bound + * connection, and binding one here would test the binding path rather than the + * preview — while the SKELETON, which is what this case is about, is identical + * either way: the smoke render's transport never settles, so every querying + * artifact previews exactly this. + */ +const loadingSource = ` +function App() { + return ( +
      +
      +

      Open Issues

      +

      Loading from the tracker

      +
      + +
      + ); +}`; + +scenario( + "Artifacts · the gallery shows each artifact's real layout, in light and dark", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + + const identity = yield* target.newIdentity(); + // Artifacts are on by default; the plain session carries the surface + // this scenario exercises. + const session = mcp.session(identity); + const suffix = uniqueSuffix(); + + const seeded = [ + ["Release Readiness", dashboardSource, "Service health across the fleet"], + ["Revenue by Month", chartSource, "Monthly revenue for the fiscal year"], + ["Open Issues", loadingSource, "Open GitHub issues, live"], + ] as const; + + for (const [name, code, description] of seeded) { + const result = yield* session.call("create-artifact", { + code, + title: `${name} ${suffix}`, + description, + }); + expect(result.ok, `${name} saved: ${result.text}`).toBe(true); + } + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Artifacts gallery", async () => { + await visit(page, `${target.baseUrl}/artifacts`); + await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); + // Every seeded artifact is present before anything is measured. + for (const [name] of seeded) { + await page + .getByRole("link", { name: `Open artifact ${name} ${suffix}` }) + .waitFor({ timeout: 30_000 }); + } + }); + + await step("Every card draws the artifact's own layout", async () => { + const previews = page.locator('[data-slot="artifact-preview"]'); + await expect + .poll(async () => await previews.count(), { timeout: 20_000 }) + .toBe(seeded.length); + + // Not one schematic among them: all three previews came from a real + // render of the artifact's own source. + for (let index = 0; index < seeded.length; index += 1) { + expect( + await previews.nth(index).getAttribute("data-preview-kind"), + `card ${index} shows a captured preview rather than the fallback`, + ).toBe("layout"); + } + + // And the content is the artifact's, not a generic frame: each card + // carries text or geometry its own source produced. + const gallery = await page.locator('[data-slot="artifact-grid"]').innerText(); + expect(gallery, "the dashboard's own heading is on its card").toContain( + "Release Readiness", + ); + expect(gallery, "the dashboard's real stat labels are on its card").toContain( + "Active users", + ); + expect(gallery, "the chart artifact's heading is on its card").toContain( + "Revenue by month", + ); + + // A chart's LAYOUT preview is its frame and heading, not its bars. + // + // That is a property of Recharts, not of the sanitizer: its + // `ResponsiveContainer` measures the element it is inside, and on a + // server there is nothing to measure, so it renders a sized wrapper and + // no SVG at all. Asserting bars here would be asserting something no + // server render can produce. What the card can promise is the artifact's + // identity and its frame — and the second layer, the snapshot taken once + // the artifact has actually been opened in a browser, is what fills in + // the geometry. See the artifacts scenario for that half. + const chartCard = page + .locator('[data-slot="artifact-card"]') + .filter({ hasText: `Revenue by Month ${suffix}` }); + const chartPreview = chartCard.locator('[data-slot="artifact-preview"]'); + expect( + await chartPreview.getAttribute("data-preview-kind"), + "the chart artifact still gets a real layout preview", + ).toBe("layout"); + expect( + await chartPreview.locator(".recharts-responsive-container").count(), + "the chart's frame is drawn even though its geometry needs a viewport", + ).toBeGreaterThan(0); + + // The preview's STRUCTURE has to survive, not just its text. The card + // renders the markup in the console document, so a layout class the + // model used that this stylesheet never compiled would silently + // collapse a four-up stat row into one column — the preview would still + // "work" and would be showing the wrong shape. `grid-cols-4` is the + // case: the console itself never needs one, so only the safelist in + // `globals.css` puts it in the stylesheet. + const statColumns = await page + .locator('[data-slot="artifact-card"]') + .filter({ hasText: `Release Readiness ${suffix}` }) + .locator('[data-slot="artifact-preview"] .grid-cols-4') + .first() + .evaluate( + (node) => + globalThis + .getComputedStyle(node) + .gridTemplateColumns.split(" ") + .filter((track) => track.length > 0).length, + ); + expect( + statColumns, + "the dashboard's four-column stat row is still four columns in the preview", + ).toBe(4); + }); + + await step("Gallery in dark mode", async () => { + // The stored markup is token-based, so the SAME markup has to read + // correctly against the console's dark surface without being restyled. + await page.emulateMedia({ colorScheme: "dark" }); + await revisit(page); + await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); + await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 }); + + // The card's own background follows the theme, and the preview inside + // it inherits the console's tokens rather than carrying its own. + const card = page.locator('[data-slot="artifact-card"]').first(); + const surface = await card.evaluate( + (node) => globalThis.getComputedStyle(node).backgroundColor, + ); + expect(surface, "the card is drawn on a dark surface").not.toBe("rgb(255, 255, 255)"); + }); + + await step("Gallery in light mode", async () => { + await page.emulateMedia({ colorScheme: "light" }); + await revisit(page); + await page.getByRole("heading", { name: "Artifacts", level: 1 }).waitFor(); + await page.locator('[data-slot="artifact-preview"]').first().waitFor({ timeout: 20_000 }); + }); + }); + }), +); diff --git a/e2e/scenarios/artifacts.test.ts b/e2e/scenarios/artifacts.test.ts new file mode 100644 index 0000000000..fa9a82d03b --- /dev/null +++ b/e2e/scenarios/artifacts.test.ts @@ -0,0 +1,854 @@ +// Cross-target: the artifacts journey, end to end. +// +// An agent on a client that cannot display MCP Apps calls `create-artifact`. The +// product promise under test is vision.md's delivery negotiation: the model +// behaves identically either way, and only DELIVERY changes — a client without +// MCP Apps gets a deep link into the web app instead of an embedded widget. +// Persistence is what makes that possible, so the same artifact must then be +// reachable three ways: the deep link, the Artifacts tab, and back through MCP +// by title. +// +// Two scenarios, split by what they prove: +// 1. create-artifact saves + delivers a working deep link, and the page renders +// the component live (the fallback path a non-Apps client actually walks). +// 2. Rename and delete in the console are what MCP reads back afterwards +// (the console and the agent share one store, not two caches). +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { Page } from "playwright"; +import { composePluginApi } from "@executor-js/api/server"; +import { AccountHttpApi } from "@executor-js/api"; +import type { ArtifactId } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([] as const); + +/** + * The component `create-artifact` persists. + * + * It must declare none of the ~280 globals the shell puts in scope (`Card`, + * `useState`, …) or the server's guard rejects it before it is ever saved, and + * its rendered text must be distinctive enough to assert on inside the shell's + * nested sandbox iframe. + * + * It is purely declarative, which is now the whole contract rather than a + * stylistic choice: there is no `run()` in scope, and code that calls one is + * rejected before it is saved. Rows come from `Array.from`, a display constant — + * an artifact that needed live data would reach for + * `useQuery(tools...queryOptions(...))`. + * + * It is also deliberately TALL — 40 rows at 28px, far more than fits on the + * detail page — and laid out the way the `artifact-style` skill teaches: a + * bounded flex column whose header stays put while one region scrolls. That is + * what the detail page's fill mode exists to support, and what the steps below + * assert: the frame is the viewport, the page itself does not scroll, and the + * heading is still on screen after the rows have been scrolled. + */ +const ARTIFACT_ROW_COUNT = 40; + +const artifactSource = (marker: string) => ` +function App() { + return ( +
      +
      +

      Release Readiness

      +

      ${marker}

      +
      +
      + {Array.from({ length: ${ARTIFACT_ROW_COUNT} }, (_, i) => ( +

      + Check {i + 1}: ${marker} +

      + ))} +
      +
      + ); +} +`; + +/** Selfhost shares one workspace across scenarios, so every title is unique to + * this run and assertions look for "mine", never "the only one". */ +const uniqueSuffix = () => randomBytes(4).toString("hex"); + +const structuredOf = (result: { readonly raw: unknown }): Record => + ((result.raw as { structuredContent?: Record }).structuredContent ?? + {}) as Record; + +/** + * The generated component, two frames down. + * + * The artifact page hosts the shell DOCUMENT in a sandboxed iframe and speaks + * the MCP-Apps protocol to it — the same way any MCP-Apps client loads + * `ui://executor/shell.html`. The shell then compiles the stored JSX into its + * own nested sandbox. Both hops are load-bearing, so tests descend through both. + */ +const artifactContent = (page: Page) => + page.frameLocator('[data-testid="artifact-shell-frame"]').frameLocator("iframe"); + +/** + * A fingerprint of the CONSOLE document's styling. + * + * The shell ships its own Tailwind build and its own palette; the console's is + * strictly grayscale. Sampling a token, a real rendered control, and the + * stylesheet count catches the leak whether it arrives as a `ok
", + ); + expect(result).toBe("
ok
"); + expect(result).not.toContain("evil.test"); + }); + + it("does not let a nested tag close the skip early", () => { + const result = sanitizeArtifactPreviewMarkup( + "
after
", + ); + expect(result).toBe("
after
"); + expect(result).not.toContain("still inside"); + expect(result).not.toContain("payload"); + }); + + it("removes noscript, template and object with their contents", () => { + for (const tag of ["noscript", "template", "object"]) { + const result = sanitizeArtifactPreviewMarkup(`
<${tag}>hiddenkept
`); + expect(result).toBe("
kept
"); + } + }); + }); + + describe("event handlers", () => { + it("strips every on* attribute", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("steal"); + }); + + it("strips handlers whatever their casing", () => { + const result = sanitizeArtifactPreviewMarkup('
x
'); + expect(result).not.toContain("steal"); + }); + + it("strips a handler whose value contains a closing angle bracket", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("steal"); + }); + }); + + describe("external references", () => { + it("drops img entirely, so no request is ever made", () => { + const result = sanitizeArtifactPreviewMarkup( + '
ok
', + ); + expect(result).toBe("
ok
"); + expect(result).not.toContain("evil.test"); + }); + + it("drops iframe and its content", () => { + const result = sanitizeArtifactPreviewMarkup( + '
ok
', + ); + expect(result).toBe("
ok
"); + }); + + it("unwraps an anchor but keeps its text, dropping the href", () => { + const result = sanitizeArtifactPreviewMarkup( + '', + ); + expect(result).toBe("
Dashboard
"); + expect(result).not.toContain("evil.test"); + }); + + it("drops a style declaration that can fetch", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("evil.test"); + }); + + it("drops a url() smuggled into an allowed style property", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("evil.test"); + }); + + it("drops position and transform, which would escape the card", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + }); + + it("drops an svg url() paint that points anywhere but a local id", () => { + const result = sanitizeArtifactPreviewMarkup( + '', + ); + expect(result).toContain(" { + const result = sanitizeArtifactPreviewMarkup( + '
ok
', + ); + expect(result).toBe("
ok
"); + }); + + it("neutralises a javascript: scheme wherever it lands", () => { + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).not.toContain("javascript:"); + }); + }); + + describe("interactive elements", () => { + it("unwraps a button, keeping its label", () => { + const result = sanitizeArtifactPreviewMarkup("
"); + expect(result).toBe("
Refresh
"); + }); + + it("drops form controls", () => { + const result = sanitizeArtifactPreviewMarkup( + '
ok
', + ); + expect(result).toBe("
ok
"); + expect(result).not.toContain("evil.test"); + }); + }); + + describe("ids", () => { + it("namespaces ids so a preview cannot collide with the host document", () => { + const result = sanitizeArtifactPreviewMarkup('
x
'); + expect(result).toBe('
x
'); + }); + + it("rewrites local paint references in step with the ids", () => { + const result = sanitizeArtifactPreviewMarkup( + '' + + '', + ); + expect(result).toContain('id="artifact-preview-grad"'); + expect(result).toContain('fill="url(#artifact-preview-grad)"'); + }); + }); + + describe("text and entities", () => { + it("keeps escaped markup escaped, and does not let it re-enter as an element", () => { + const result = sanitizeArtifactPreviewMarkup( + "
<script>alert(1)</script>
", + ); + expect(result).toBe("
<script>alert(1)</script>
"); + expect(result).not.toContain(" { + // The input is already-escaped HTML. Escaping `&` a second time would + // turn `&` into `&amp;` and corrupt every Tailwind arbitrary + // variant in the markup. + const once = sanitizeArtifactPreviewMarkup("
a < b && c
"); + expect(once).toBe("
a < b && c
"); + expect(sanitizeArtifactPreviewMarkup(once ?? "")).toBe(once); + }); + + it("preserves a Tailwind arbitrary variant containing an ampersand", () => { + // What React writes for `[&_.recharts-layer]:outline-hidden`. + const result = sanitizeArtifactPreviewMarkup( + '
x
', + ); + expect(result).toBe('
x
'); + expect(result).not.toContain("&amp;"); + }); + }); + + describe("size", () => { + it("returns null past the cap", () => { + const huge = `
${"x".repeat(ARTIFACT_PREVIEW_MARKUP_LIMIT + 1)}
`; + expect(sanitizeArtifactPreviewMarkup(huge)).toBeNull(); + }); + + it("keeps markup just under the cap", () => { + const body = "x".repeat(1000); + expect(sanitizeArtifactPreviewMarkup(`
${body}
`)).toBe(`
${body}
`); + }); + }); + + describe("nothing usable", () => { + it("returns null for empty input", () => { + expect(sanitizeArtifactPreviewMarkup("")).toBeNull(); + }); + + it("returns null when everything sanitized away", () => { + expect(sanitizeArtifactPreviewMarkup("")).toBeNull(); + }); + + it("returns null for whitespace-only structure", () => { + expect(sanitizeArtifactPreviewMarkup("
")).toBeNull(); + }); + + it("keeps a purely geometric render that has no text at all", () => { + // A chart-only artifact is a real preview even with zero text nodes. + const result = sanitizeArtifactPreviewMarkup( + '
', + ); + expect(result).not.toBeNull(); + expect(result).toContain(" { + it("does not hang or throw on an unterminated tag", () => { + expect(() => sanitizeArtifactPreviewMarkup('
{ + expect(() => sanitizeArtifactPreviewMarkup("
", next + 4); + index = end === -1 ? markup.length : end + 3; + continue; + } + if (markup.startsWith("", next); + index = end === -1 ? markup.length : end + 1; + continue; + } + + // Walk to this tag's `>`, skipping any inside a quoted attribute value. + let cursor = next + 1; + let quote = ""; + while (cursor < markup.length) { + const char = markup[cursor]; + if (quote !== "") { + if (char === quote) quote = ""; + } else if (char === '"' || char === "'") { + quote = char; + } else if (char === ">") { + break; + } + cursor += 1; + } + if (cursor >= markup.length) break; + + const inner = markup.slice(next + 1, cursor); + index = cursor + 1; + + const closing = inner.startsWith("/"); + const body = closing ? inner.slice(1) : inner; + const selfClosing = body.endsWith("/"); + const normalized = selfClosing ? body.slice(0, -1) : body; + const nameEnd = normalized.search(/[\s/]/); + const tag = (nameEnd === -1 ? normalized : normalized.slice(0, nameEnd)).toLowerCase(); + if (tag === "") continue; + + if (skipDepth > 0) { + // Inside a cut subtree: track only the tag that opened it, so a `
` + // nested in a dropped `", + ); + const externalRefs = [...documentOnly.matchAll(/<(?:script|link)[^>]*(?:src|href)="([^"]+)"/g)] + .map((match) => match[1]) + .filter((url) => !url.startsWith("data:")); + expect(externalRefs).toEqual([]); + + await clientTransport.close(); + await serverTransport.close(); + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx new file mode 100644 index 0000000000..9a585dea48 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx @@ -0,0 +1,432 @@ +// `virtual:executor-mcp-apps-shell-html` is supplied by `mcpAppsShellAsset` +// (`@executor-js/mcp-apps-shell/vite`). Referenced explicitly — and first, as +// triple-slash directives are only honored above the imports — so the ambient +// declaration travels with this file into consumers that bundle it; a +// consumer's tsconfig `include` covers only its own sources. +/// +import { useCallback, useEffect, useRef, useState } from "react"; +import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { ArtifactRendererProps } from "@executor-js/react/api/artifact-renderer"; + +import shellHtmlUrl from "virtual:executor-mcp-apps-shell-html"; +// From `./preview-capture`, NOT from `./shell-app`: that module imports the +// shell build's virtual modules (`virtual:executor-tailwind-browser` and +// friends), which do not exist in the console's bundle — importing a constant +// from it drags them in and the artifact renderer fails to load entirely. +import { PREVIEW_CAPTURE_HOST_CONTEXT_KEY, SAVE_ARTIFACT_PREVIEW_TOOL } from "./preview-capture"; +// Same import-free-module reasoning as `./preview-capture`: the vocabulary for +// "the artifact is on screen now", shared by the shell document that sends it +// and this host, which is the one host that holds a loading surface until it +// arrives. +import { ARTIFACT_RENDERED_TOOL, RENDER_SIGNAL_HOST_CONTEXT_KEY } from "./render-signal"; +// Same reasoning as `./preview-capture`: a module with no build-graph imports of +// its own, so both the host half (here) and the app half (`shell-app.tsx`) can +// agree on the vocabulary without either dragging in the other's virtual +// modules. +import { FILL_DISPLAY_MODE, INLINE_DISPLAY_MODE } from "./display-mode"; + +/** + * Hosts the MCP-Apps shell on the console's artifact page. + * + * The shell is NOT a React component this page mounts. It is a self-contained + * DOCUMENT — the very bytes served as the `ui://executor/shell.html` resource — + * loaded into a sandboxed iframe, with this component implementing the HOST half + * of the MCP Apps protocol against it. The artifact page is therefore an MCP + * Apps client like any other, and the shell runs in one configuration + * everywhere. + * + * That is not architectural purity for its own sake; mounting `McpAppsShell` + * inline was actively broken: + * + * - It imports `@tailwindcss/browser` and its own `globals.css` at IMPORT + * scope, and it themes by toggling `document.documentElement`. Inline, all + * of that landed on the CONSOLE document — the shell's palette (a teal + * `--primary`) overwrote the console's strictly-grayscale tokens for the + * rest of the session, and its runtime Tailwind JIT kept mutating the page. + * - Its inner renderer reports content height over the MCP-Apps resize + * protocol, which expects a host frame to consume it. With no host, nothing + * did, so tall artifacts clipped mid-viewport. + * + * Both symptoms are one root cause — a document-scoped program mounted without + * its document — and both are fixed by giving it one. + * + * This module is still BROWSER-ONLY (it builds a postMessage bridge against a + * live iframe), so the `ArtifactRendererProvider` loader seam is unchanged: apps + * register it through a dynamic `import()` that only ever resolves in the + * browser, and the page holds it behind `ClientOnly` + `Suspense`. It stays the + * DEFAULT export because the seam hands the loader straight to `React.lazy`. + */ + +/** What the page passes as `host` — see `createHttpShellHost` in + * `@executor-js/react/api/shell-host`. Declared structurally because that + * package depends on THIS one for its component barrel, so importing its type + * here would close a package cycle turbo rejects outright. */ +type HttpShellHost = { + readonly callServerTool: (params: { + name: string; + arguments?: Record; + }) => Promise; + readonly getHostContext: () => { readonly theme: "light" | "dark" } | undefined; + readonly openLink: (params: { url: string }) => Promise; + /** Store a settled-render snapshot as this artifact's gallery preview. */ + readonly savePreview: (artifactId: string, preview: string) => void; +}; + +/** The frame height before the app has reported its content size, in the inline + * fallback path. Tall enough that the shell's own loading state is not itself + * clipped. */ +const INITIAL_FRAME_HEIGHT = 320; + +/** + * A floor under the measured viewport. + * + * The container is measured, not guessed, so this only guards the degenerate + * readings — a container measured while the page is still laying out, or a + * window dragged to a sliver. Below this there is no room for a header and a + * scroll region both, and a frame of a few pixels reads as a broken page. + */ +const MIN_FRAME_HEIGHT = 160; + +/** + * The console's active theme, resolved the way the console's own CSS resolves + * it: an explicit `.dark` class wins, otherwise the OS preference. + */ +const readDocumentTheme = (): "light" | "dark" => { + if (document.documentElement.classList.contains("dark")) return "dark"; + return globalThis.window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +}; + +/** + * What this host tells the app about the surface it has. + * + * The detail page is the one place an artifact is not embedded in a flow of + * other content — it IS the page — so it advertises the spec's `fullscreen` + * mode and hands over a FIXED `containerDimensions.height`. Together those two + * fields are the whole negotiation: the mode says "you own this surface", the + * fixed height says how much of it there is. See `./display-mode` for why + * `fullscreen` rather than a mode of our own, and why a fixed `height` rather + * than a `maxHeight`. + * + * `availableDisplayModes` lists both, truthfully: this host can render an + * artifact either way, and an app that asks to go back to `inline` is asking + * for something the page could honor. + * + * Before the container has been measured (`viewport === null`) the page has not + * yet laid out, and claiming a surface whose size is unknown would have the app + * resolve `height: 100%` against nothing. So until then this is an ordinary + * inline host, which is also what makes the mode switch a no-op for a viewport + * that never resolves. + */ +const hostContextFor = (theme: "light" | "dark", viewport: number | null): McpUiHostContext => ({ + theme, + displayMode: viewport === null ? INLINE_DISPLAY_MODE : FILL_DISPLAY_MODE, + availableDisplayModes: [INLINE_DISPLAY_MODE, FILL_DISPLAY_MODE], + platform: "web", + ...(viewport === null ? {} : { containerDimensions: { height: viewport } }), + // The console is the one host with somewhere to put a preview, so it is the + // one host that asks for one. Carried on the spec's open index signature, + // which is what it exists for; every other host omits it and the shell + // skips the capture entirely. + [PREVIEW_CAPTURE_HOST_CONTEXT_KEY]: true, + // The console holds ONE skeleton from navigation until the artifact paints, + // so it is the one host that needs telling when that is. See + // `./render-signal` for why the shell cannot infer it and why no size report + // is the same fact. + [RENDER_SIGNAL_HOST_CONTEXT_KEY]: true, +}); + +export default function ArtifactShell(props: ArtifactRendererProps) { + const frameRef = useRef(null); + const containerRef = useRef(null); + /** + * The measured height of the page's content area, or `null` before the first + * measurement. + * + * This — not the app's reported content height — is the frame's height. That + * inversion is the fix: the artifact page gives the artifact a viewport and + * holds it there, so the artifact's own `flex-1 min-h-0 overflow-auto` has + * something to resolve against and its header can stay put while its table + * scrolls underneath. + */ + const [viewport, setViewport] = useState(null); + /** + * The app's own reported content height — the inline story, kept for the case + * where the container never resolves to a usable size (a host embedding this + * component somewhere with no bounded height of its own). On the detail page + * the observer resolves immediately and this is never read. + */ + const [contentHeight, setContentHeight] = useState(INITIAL_FRAME_HEIGHT); + const [theme, setTheme] = useState<"light" | "dark">(() => readDocumentTheme()); + + // Read through refs inside the bridge: the bridge is built once per artifact, + // and rebuilding it when the host identity changes would reload the iframe and + // discard the rendered component. + const hostRef = useRef(props.host as HttpShellHost); + hostRef.current = props.host as HttpShellHost; + const codeRef = useRef(props.code); + codeRef.current = props.code; + const artifactIdRef = useRef(props.artifactId); + artifactIdRef.current = props.artifactId; + // The page's "you can drop the skeleton now" callback, read through a ref for + // the same reason as the host: the bridge is built once per artifact, and + // capturing the prop would pin whichever function existed then. + const onRenderedRef = useRef(props.onRendered); + onRenderedRef.current = props.onRendered; + // The bridge is built once per artifact and must see the CURRENT viewport, + // both when it opens (so its initial host context is already the fill one) + // and inside `onsizechange` (so it knows to ignore the report). + const viewportRef = useRef(viewport); + viewportRef.current = viewport; + + const bridgeRef = useRef(null); + + // Track the console's theme and forward it as host context, which is what + // drives the shell's own `applyTheme` — inside the iframe's document, where it + // belongs, rather than on the console's `documentElement`. + useEffect(() => { + const media = globalThis.window.matchMedia?.("(prefers-color-scheme: dark)"); + const sync = () => setTheme(readDocumentTheme()); + media?.addEventListener("change", sync); + const observer = new MutationObserver(sync); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); + return () => { + media?.removeEventListener("change", sync); + observer.disconnect(); + }; + }, []); + + /** + * Measure the surface the page has given this artifact, and keep measuring. + * + * A `ResizeObserver` on the container rather than a `window` resize listener: + * the content area is a flex child of the console's own layout, so it changes + * height for reasons that never touch the window — the sidebar collapsing, a + * banner appearing above it. Observing the box that actually holds the frame + * catches all of those and the window resize alike. + * + * `borderBoxSize` is read in preference to `contentRect` so a container that + * ever gains padding or a border still reports the space the frame occupies. + */ + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const measure = (next: number) => { + const bounded = Math.max(MIN_FRAME_HEIGHT, Math.floor(next)); + // Guard the state write, not just the render: an observer that fires on + // every sub-pixel reflow would otherwise push a new host context — and + // therefore a `ui/notifications/host-context-changed` — into the frame + // continuously while a user drags a window edge. + setViewport((prev) => (prev === bounded ? prev : bounded)); + }; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const borderBox = entry.borderBoxSize?.[0]?.blockSize; + measure(typeof borderBox === "number" ? borderBox : entry.contentRect.height); + }); + observer.observe(container); + // Seed from the current layout rather than waiting for the observer's first + // callback, so the very first host context the bridge is built with already + // carries a viewport and the app never renders a frame in the wrong mode. + measure(container.getBoundingClientRect().height); + + return () => observer.disconnect(); + }, []); + + // Both the theme and the viewport travel on the same context, and + // `setHostContext` diffs for us — it notifies the app only about fields that + // actually changed, so a resize does not re-announce the theme. + useEffect(() => { + bridgeRef.current?.setHostContext(hostContextFor(theme, viewport)); + }, [theme, viewport]); + + /** + * Build and connect the bridge the moment the iframe ELEMENT mounts — as the + * frame's `ref`, not its `onLoad`. + * + * This ordering is the whole correctness argument, so it is worth stating + * exactly. `ui/initialize` is a fire-and-forget `postMessage`: the app's + * `connect()` sends it once, ext-apps has no retry, no backoff and no + * readiness handshake, and `AppBridge` buffers nothing. The host's transport + * only begins listening inside `bridge.connect()`, whose last synchronous step + * is `window.addEventListener("message", …)`. So a `ui/initialize` posted + * while the host has no listener is delivered to a window that ignores it and + * is gone for good — both sides then wait forever, which is the shell stuck on + * "Connecting" (the app's request does eventually time out after the SDK's 60s + * default, but the host has long since stopped being able to answer). + * + * Connecting in `onLoad` lost that race. The frame's `load` event fires AFTER + * its document has been parsed and its module scripts have run, so the app + * could — and on a cold, dev-served, 4.5MB document did — post `ui/initialize` + * before the host was listening. It "worked" locally only because the two + * landed within a few milliseconds of each other. + * + * A ref callback runs during the commit phase, synchronously after React has + * inserted the element into the document and therefore before the browser has + * fetched a single byte of `src`. `contentWindow` is already non-null there + * (it is the initial `about:blank` window), and a same-origin navigation keeps + * the browsing context's WindowProxy identity — so both the transport's + * `postMessage` target and its `event.source` filter still refer to the shell + * document once it replaces the placeholder. The listener is thus provably up + * before any script in the frame can run, whatever the machine, cache state or + * document size. This is also how sunpeak's host does it, which is why real + * MCP-Apps hosts never saw this bug. + * + * The returned cleanup makes this callback the SOLE owner of the bridge's + * lifecycle. Previously an effect keyed on `props.code` closed + * `bridgeRef.current`, which is a second, unsynchronized owner: under a + * StrictMode double-invoke or any re-render that reorders effects against + * refs, that cleanup could close a bridge a later mount had just connected, + * stranding the shell on "Connecting" for an entirely different reason. Now + * each bridge is created and closed by the same attach/detach pair and can + * only ever close itself. + */ + const attachFrame = useCallback((frame: HTMLIFrameElement | null): (() => void) | undefined => { + frameRef.current = frame; + const contentWindow = frame?.contentWindow; + if (!contentWindow) return undefined; + + const bridge = new AppBridge( + // No MCP client: the artifact page reaches the server over the ordinary + // executions HTTP API, so every app-originated call is answered by the + // handlers below rather than proxied onto an MCP connection. + null, + { name: "Executor Console", version: "1.0.0" }, + { openLinks: {}, serverTools: {} }, + { hostContext: hostContextFor(readDocumentTheme(), viewportRef.current) }, + ); + + // Deliver the stored source the way a real host does after `create-artifact`: the + // tool input, then the tool result carrying `structuredContent.code` — the + // exact shape `renderedInAppResult` builds in the MCP host. The shell + // already handles both, so it takes the same path here as it does under any + // other MCP-Apps client, with no artifact-page-only branch. + bridge.oninitialized = () => { + const code = codeRef.current; + const artifactId = artifactIdRef.current; + void bridge + .sendToolInput({ arguments: { code, artifactId } }) + .then(() => + bridge.sendToolResult({ + content: [{ type: "text", text: "Rendered saved artifact." }], + structuredContent: { code, artifactId }, + }), + ) + .catch((error: unknown) => { + console.error("[executor-console] Failed to deliver the artifact to the shell:", error); + }); + }; + + /** + * The app keeps reporting its content height. In fill mode this host + * deliberately does nothing with it. + * + * That is the clean division the mode buys: the HOST owns the mode, so the + * app does not need a branch for "am I being measured" — it reports its size + * truthfully in every mode, exactly as the protocol says, and a host that + * has already decided the frame's height simply does not consume the report. + * Acting on it here would undo the whole fix, since the app's content is now + * as tall as the viewport it was given and growing the frame to match would + * chase itself. + * + * Inline hosts — every chat client — are untouched: the frame grows, the + * page scrolls, nothing clips. + */ + bridge.onsizechange = (params) => { + if (viewportRef.current !== null) return; + if (typeof params.height !== "number") return; + setContentHeight(Math.max(MIN_FRAME_HEIGHT, Math.ceil(params.height))); + }; + + bridge.oncalltool = (params) => { + // The shell's preview upgrade is not a server tool; it is this host's own + // call, answered here. Intercepted before the tool transport so it never + // reaches `/executions` — a snapshot is not an execution and must not be + // metered, approved or logged as one. + if (params.name === SAVE_ARTIFACT_PREVIEW_TOOL) { + const artifactId = params.arguments?.["artifactId"]; + const preview = params.arguments?.["preview"]; + if (typeof artifactId === "string" && typeof preview === "string") { + hostRef.current.savePreview(artifactId, preview); + } + return Promise.resolve({ content: [] } satisfies CallToolResult); + } + + // "The artifact is on screen." Answered here for the same reason the + // preview is: it is this host's own call, not a server tool, and it must + // never reach `/executions`. The page uses it to take its skeleton down + // — see `ArtifactStage`. + if (params.name === ARTIFACT_RENDERED_TOOL) { + onRenderedRef.current?.(); + return Promise.resolve({ content: [] } satisfies CallToolResult); + } + return hostRef.current.callServerTool({ + name: params.name, + ...(params.arguments ? { arguments: params.arguments } : {}), + }); + }; + + bridge.onopenlink = async (params) => { + await hostRef.current.openLink({ url: params.url }); + return {}; + }; + + void bridge + .connect(new PostMessageTransport(contentWindow, contentWindow)) + .catch((error: unknown) => { + console.error("[executor-console] Artifact shell failed to connect:", error); + }); + + bridgeRef.current = bridge; + + // Detach: close THIS bridge, and only clear the shared ref if it is still + // the current one, so a bridge that has already been replaced can never + // null out its successor. + return () => { + void bridge.close(); + if (bridgeRef.current === bridge) bridgeRef.current = null; + if (frameRef.current === frame) frameRef.current = null; + }; + }, []); + + return ( + // The measured box. `h-full` against the page's content area is what makes + // the measurement meaningful: the container is exactly the room the page has + // given the artifact, so the number the observer reads is the number the + // frame should be. `min-h-0` lets it shrink inside the page's flex column + // rather than being floored at its content's height. +
+ + + +`; + +const startShellServer = async (): Promise => { + const server = await createViteServer({ + configFile: resolve(packageRoot, "vite.config.shell.ts"), + clearScreen: false, + logLevel: "error", + server: { + host: "127.0.0.1", + port: 0, + }, + }); + + await server.listen(); + const url = server.resolvedUrls?.local[0]; + if (!url) { + throw new Error("Vite did not report a local shell URL."); + } + + return { + url: url.replace(/\/$/, ""), + close: () => server.close(), + }; +}; + +const readBody = (request: IncomingMessage): Promise => + new Promise((resolveBody, rejectBody) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.from(chunk)); + }); + request.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8"))); + request.on("error", rejectBody); + }); + +const startOpenApiServer = (): Promise => + new Promise((resolveServer, rejectServer) => { + let baseUrl = ""; + let domainAutoRenew = false; + let nextDomainGetDelayMs = 0; + const postRequests: string[] = []; + + const server: Server = createServer(async (request, response) => { + if (request.method === "GET" && request.url === "/openapi.json") { + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + openapi: "3.0.0", + info: { title: "Inventory", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/items": { + get: { + operationId: "listItems", + responses: { + "200": { + description: "Inventory items", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Item" }, + }, + }, + }, + }, + }, + }, + post: { + operationId: "createItem", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreateItem" }, + }, + }, + }, + responses: { + "200": { + description: "Created item", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreatedItem" }, + }, + }, + }, + }, + }, + }, + "/domains/{domain}": { + get: { + operationId: "getDomain", + parameters: [ + { + name: "domain", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Domain", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Domain" }, + }, + }, + }, + }, + }, + }, + "/domains/{domain}/auto-renew": { + post: { + operationId: "updateDomainAutoRenew", + parameters: [ + { + name: "domain", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AutoRenewInput" }, + }, + }, + }, + responses: { + "200": { + description: "Updated domain", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Domain" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + AutoRenewInput: { + type: "object", + required: ["autoRenew"], + properties: { + autoRenew: { type: "boolean" }, + }, + }, + Domain: { + type: "object", + required: ["domain", "renew"], + properties: { + domain: { type: "string" }, + renew: { type: "boolean" }, + }, + }, + Item: { + type: "object", + required: ["id", "name"], + properties: { + id: { type: "integer" }, + name: { type: "string" }, + }, + }, + CreateItem: { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }, + CreatedItem: { + type: "object", + required: ["id", "name", "created"], + properties: { + id: { type: "integer" }, + name: { type: "string" }, + created: { type: "boolean" }, + }, + }, + }, + }, + }), + ); + return; + } + + if (request.method === "GET" && request.url === "/items") { + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify([{ id: 1, name: "Seed Widget" }])); + return; + } + + if (request.method === "GET" && request.url?.startsWith("/domains/")) { + const domain = decodeURIComponent(request.url.slice("/domains/".length)); + if (!domain.includes("/")) { + const delayMs = nextDomainGetDelayMs; + nextDomainGetDelayMs = 0; + if (delayMs > 0) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs)); + } + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ domain, renew: domainAutoRenew })); + return; + } + } + + if (request.method === "POST" && request.url === "/items") { + const body = await readBody(request); + postRequests.push(body); + const parsed = JSON.parse(body) as { name?: unknown }; + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + id: 2, + name: typeof parsed.name === "string" ? parsed.name : "Unnamed", + created: true, + }), + ); + return; + } + + if ( + request.method === "POST" && + request.url?.startsWith("/domains/") && + request.url.endsWith("/auto-renew") + ) { + const body = await readBody(request); + postRequests.push(body); + const parsed = JSON.parse(body) as { autoRenew?: unknown }; + domainAutoRenew = parsed.autoRenew === true; + nextDomainGetDelayMs = 300; + const domainPath = request.url.slice( + "/domains/".length, + request.url.length - "/auto-renew".length, + ); + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + domain: decodeURIComponent(domainPath), + renew: domainAutoRenew, + }), + ); + return; + } + + response.statusCode = 404; + response.end("not found"); + }); + + server.once("error", rejectServer); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectServer); + const address = server.address(); + if (!address || typeof address === "string") { + rejectServer(new Error("Failed to resolve OpenAPI server address.")); + return; + } + + const { port } = address as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; + resolveServer({ + specUrl: `${baseUrl}/openapi.json`, + postRequests, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((err) => (err ? rejectClose(err) : resolveClose())); + }), + }); + }); + }); + +const makePausedResult = ( + id: string, + request: ReturnType, +): ExecutionResult => ({ + status: "paused", + execution: { id, elicitationContext: { address: formToolId, args: {}, request } }, +}); + +/** + * These tests drive the shell directly over postMessage rather than through + * `create-artifact`, so persistence is out of scope here — but the ui tools only + * register when an artifacts port is present, and `execute-action` is what the + * shell actually calls. A trivial in-memory port is enough to bring them up. + * + * Every artifact this port mints is pre-bound to the harness's one `inventory` + * connection, under both the implicit role (`inventory`) and an explicit `alt` + * role. That is what lets a generated component here address the short form + * `tools.inventory.listItems` and the role form `tools.inventory("alt").…`, and + * still reach `tools.inventory.*` on the executor — the same rewrite a + * real artifact gets from its stored bindings. + */ +/** The artifact every harness component renders as. Pre-seeded so a test can + * hand its id to `execute-action` exactly as the host does. */ +const HARNESS_ARTIFACT_ID = "art_1"; + +const HARNESS_BINDINGS: ArtifactBindings = { + inventory: { + integration: IntegrationSlug.make(INVENTORY_SLUG), + owner: "org", + connection: ConnectionName.make(INVENTORY_CONNECTION), + }, + alt: { + integration: IntegrationSlug.make(INVENTORY_SLUG), + owner: "org", + connection: ConnectionName.make(INVENTORY_CONNECTION), + }, + // Used by the elicitation fixtures, which run against a stub engine that + // answers any address rather than the real inventory executor. + profile: { + integration: IntegrationSlug.make("profile"), + owner: "org", + connection: ConnectionName.make("main"), + }, +}; + +const makeInMemoryArtifacts = () => { + const rows = new Map(); + let seq = 0; + // Pre-seeded, because these tests push code into the shell directly rather + // than through `create-artifact`. The row exists so `execute-action` has + // bindings to resolve against, the way it would for any real artifact. + rows.set(HARNESS_ARTIFACT_ID, { + id: ArtifactId.make(HARNESS_ARTIFACT_ID), + owner: "user", + title: "Harness", + description: null, + code: "", + bindings: HARNESS_BINDINGS, + preview: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }); + return { + list: (): Effect.Effect => Effect.sync(() => [...rows.values()]), + get: (id: string): Effect.Effect => + Effect.suspend(() => { + const row = rows.get(id); + return row ? Effect.succeed(row) : Effect.fail(new Error(`no artifact ${id}`)); + }), + save: (input: { + readonly title: string; + readonly description?: string | null; + readonly code: string; + readonly bindings?: ArtifactBindings | null; + readonly preview?: string | null; + }): Effect.Effect => + Effect.sync(() => { + seq += 1; + const artifact = { + id: ArtifactId.make(`art_${seq}`), + owner: "user" as const, + title: input.title, + description: input.description ?? null, + code: input.code, + bindings: input.bindings ?? HARNESS_BINDINGS, + preview: + input.preview === undefined || input.preview === null + ? null + : ({ kind: "layout", markup: input.preview } as const), + createdAt: new Date(seq * 1000), + updatedAt: new Date(seq * 1000), + }; + rows.set(artifact.id, artifact); + return artifact; + }), + }; +}; + +const startMcpHarnessForEngine = async ( + engine: ExecutionEngine, +): Promise => { + const mcpServer = await Effect.runPromise( + createExecutorMcpServer({ + engine, + loadAppShellHtml: loadMcpAppsShellHtml, + artifacts: makeInMemoryArtifacts(), + // Artifacts are opt-in per connection; this harness drives the artifact + // tools, the connection default. + artifactsEnabled: true, + }), + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client( + { name: "browser-harness", version: "1.0.0" }, + { capabilities: appsWithoutElicitationCapabilities }, + ); + + await mcpServer.connect(serverTransport); + await client.connect(clientTransport); + + return { + callTool: async (params) => { + if (!params.name) { + throw new Error("Missing MCP tool name."); + } + return client.callTool({ + name: params.name, + arguments: params.arguments ?? {}, + }); + }, + close: async () => { + await clientTransport.close(); + await serverTransport.close(); + }, + }; +}; + +const startMcpHarness = async (openApi: OpenApiServer): Promise => { + const executor = await Effect.runPromise( + Effect.gen(function* () { + const built = yield* createExecutor( + makeTestConfig({ plugins: [inventoryPlugin(openApi.postRequests)] }), + ); + // Tools only exist per connection, so register the integration and open + // one org `main` connection — that is what makes + // `tools.inventory.*` resolvable from generated code. + yield* built["inventory-test"].seed(); + yield* built.connections.create({ + owner: "org", + name: INVENTORY_CONNECTION, + integration: INVENTORY_SLUG, + template: INVENTORY_TEMPLATE, + value: "token", + }); + return built; + }), + ); + + const engine = createExecutionEngine({ + executor, + codeExecutor: makeQuickJsExecutor({ + timeoutMs: 5_000, + memoryLimitBytes: 32 * 1024 * 1024, + }), + }); + + return startMcpHarnessForEngine(engine); +}; + +const startSchemaElicitationMcpHarness = (): Promise => + startMcpHarnessForEngine({ + getDescription: Effect.succeed("schema elicitation test executor"), + // The fake forks nothing, so there is no sandbox fiber to end. + shutdown: Effect.void, + execute: () => Effect.succeed({ result: null }), + executeWithPause: () => + Effect.succeed( + makePausedResult( + "schema_exec", + FormElicitation.make({ + message: "Provide approval details", + requestedSchema: { + type: "object", + required: ["name", "count", "priority", "tags"], + properties: { + name: { + type: "string", + title: "Display name", + minLength: 2, + description: "Human-readable name to submit.", + }, + count: { + type: "integer", + title: "Count", + minimum: 1, + default: 2, + }, + priority: { + type: "string", + title: "Priority", + enum: ["low", "high"], + enumNames: ["Low", "High"], + }, + notify: { + type: "boolean", + title: "Notify", + default: false, + }, + tags: { + type: "array", + title: "Tags", + minItems: 1, + items: { + enum: ["alpha", "beta"], + enumNames: ["Alpha", "Beta"], + }, + }, + }, + }, + }), + ), + ), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + resume: (_executionId, response) => + Effect.succeed({ + status: "completed", + result: { result: response.content ?? {} }, + }), + }); + +const startHostServer = (shellUrl: string, mcp: McpHarness): Promise => + new Promise((resolveServer, rejectServer) => { + const html = createHostHtml(shellUrl); + const server: Server = createServer(async (request, response) => { + if (request.method === "POST" && request.url === "/tools/call") { + try { + const body = await readBody(request); + const params = JSON.parse(body) as HostToolCall; + const result = await mcp.callTool(params); + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(result)); + } catch (err) { + response.statusCode = 500; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + content: [ + { + type: "text", + text: err instanceof Error ? err.message : String(err), + }, + ], + isError: true, + }), + ); + } + return; + } + + if (request.method !== "GET" || request.url !== "/") { + response.statusCode = 404; + response.end("not found"); + return; + } + + response.statusCode = 200; + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(html); + }); + + server.once("error", rejectServer); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectServer); + const address = server.address(); + if (!address || typeof address === "string") { + rejectServer(new Error("Failed to resolve host server address.")); + return; + } + + const { port } = address as AddressInfo; + resolveServer({ + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((err) => (err ? rejectClose(err) : resolveClose())); + }), + }); + }); + }); + +const waitForValue = async ( + page: Page, + read: () => T | undefined, + label: string, +): Promise => { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const value = read(); + if (value !== undefined) return value; + await page.waitForTimeout(50); + } + throw new Error(`Timed out waiting for ${label}.`); +}; + +const waitForShellFrame = (page: Page): Promise => + waitForValue( + page, + () => page.frames().find((frame) => frame.url().includes("/mcp-app.html")), + "MCP app shell iframe", + ); + +const waitForInnerFrame = async (page: Page, shellFrame: Frame): Promise => { + const locator = shellFrame.locator('iframe[title="Generated UI"]'); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const handle = await locator.elementHandle(); + const frame = await handle?.contentFrame(); + await handle?.dispose(); + if (frame) return frame; + await page.waitForTimeout(50); + } + throw new Error("Timed out waiting for generated UI iframe."); +}; + +const waitForHostInitialized = (page: Page) => + page.waitForFunction(() => + Boolean((window as unknown as BrowserHostWindow).__mcpHostState.initialized), + ); + +const getHostState = (page: Page): Promise => + page.evaluate(() => (window as unknown as BrowserHostWindow).__mcpHostState); + +const openHarness = async (browser: Browser, hostUrl: string) => { + const page = await browser.newPage(); + await page.goto(hostUrl, { waitUntil: "domcontentloaded" }); + const shellFrame = await waitForShellFrame(page); + await waitForHostInitialized(page); + await shellFrame.locator('[data-testid="shell-loading-state"]').waitFor({ timeout: 10_000 }); + return { page, shellFrame }; +}; + +const renderGeneratedUi = async (page: Page, shellFrame: Frame, code: string): Promise => { + await page.evaluate( + ([value, artifactId]) => + (window as unknown as BrowserHostWindow).__sendGeneratedUi(value!, artifactId!), + [code, HARNESS_ARTIFACT_ID] as const, + ); + await shellFrame.locator('iframe[title="Generated UI"]').waitFor({ timeout: 10_000 }); + return waitForInnerFrame(page, shellFrame); +}; + +describe("MCP app generated UI browser isolation", () => { + let openApiServer: OpenApiServer | undefined; + let mcpHarness: McpHarness | undefined; + let shellServer: ShellServer | undefined; + let hostServer: HostServer | undefined; + let browser: Browser | undefined; + + beforeAll(async () => { + openApiServer = await startOpenApiServer(); + mcpHarness = await startMcpHarness(openApiServer); + shellServer = await startShellServer(); + hostServer = await startHostServer(shellServer.url, mcpHarness); + browser = await chromium.launch({ + executablePath: chromeExecutablePath, + headless: process.env.PLAYWRIGHT_HEADLESS !== "0", + slowMo: process.env.PLAYWRIGHT_SLOWMO ? Number(process.env.PLAYWRIGHT_SLOWMO) : undefined, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + }, 60_000); + + afterAll(async () => { + await browser?.close(); + await hostServer?.close(); + await shellServer?.close(); + await mcpHarness?.close(); + await openApiServer?.close(); + }, 30_000); + + it("runs generated UI in a sandboxed browser iframe and proxies live tool calls", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedDataCode); + await innerFrame.waitForFunction( + () => document.querySelector("#status")?.textContent === "Widget", + undefined, + { timeout: 10_000 }, + ); + + const rendererAttributes = await shellFrame + .locator('iframe[title="Generated UI"]') + .evaluate((element) => ({ + sandbox: element.getAttribute("sandbox"), + srcDoc: element.getAttribute("srcdoc") ?? "", + })); + + expect(rendererAttributes.sandbox).toBe("allow-scripts"); + expect(rendererAttributes.srcDoc).toContain('meta name="executor-render-token"'); + expect(rendererAttributes.srcDoc).toContain("default-src 'none'"); + expect(rendererAttributes.srcDoc).toContain("connect-src 'none'"); + expect(rendererAttributes.srcDoc).toContain("form-action 'none'"); + expect(rendererAttributes.srcDoc).toContain("frame-src 'none'"); + expect(rendererAttributes.srcDoc).toContain("worker-src 'none'"); + + const parentAccess = await innerFrame.evaluate(() => { + try { + void window.parent.document.body; + return "allowed"; + } catch (err) { + return err instanceof DOMException ? err.name : String(err); + } + }); + expect(parentAccess).toBe("SecurityError"); + + const blockedText = (await innerFrame.locator("#blocked").textContent()) ?? ""; + for (const name of networkPrimitives) { + expect(blockedText).toContain( + `${name} is disabled in generated UI. Use tools.* via useQuery/useMutation.`, + ); + } + + const hostState = await getHostState(page); + expect(hostState.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "execute-action", + arguments: { + code: "return await tools.inventory.listItems({})", + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + // An artifact that uses two accounts of one integration tags each call site + // with a role. The role has to survive four hops — the inner proxy's `apply` + // trap, the TanStack cache key, the postMessage bridge, and the + // `execute-action` grammar — and be what the server resolves the connection + // from. If any hop dropped it, both reads would collapse onto one binding and + // the two calls would be indistinguishable on the wire. + it("carries an integration role from the proxy through to execute-action", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedRoleTaggedCode); + + // Both roles resolved to a real connection and returned real rows. + await innerFrame.waitForFunction( + () => + document.querySelector("#primary")?.textContent !== "loading" && + document.querySelector("#secondary")?.textContent !== "loading", + undefined, + { timeout: 10_000 }, + ); + const primary = await innerFrame.locator("#primary").textContent(); + expect(primary).not.toBe(""); + expect(await innerFrame.locator("#secondary").textContent()).toBe(primary); + + // Two separate calls, each naming its own role — not one shared query. + const roleCalls = (await getHostState(page)).toolCalls + .map((call) => (call.arguments as { code?: string } | undefined)?.code) + .filter((code): code is string => Boolean(code?.includes("listItems"))); + expect(roleCalls).toEqual( + expect.arrayContaining([ + 'return await tools.inventory("inventory").listItems({})', + 'return await tools.inventory("alt").listItems({})', + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + // The design system only exists for the model if the classes it writes turn + // into CSS. They did not: the frame received the shell's COMPILED stylesheet, + // which by construction contains only utilities executor's own components use, + // so anything else silently no-opped — `text-2xl` computed to 16px and + // `md:grid-cols-4` stayed one column. The frame now carries the theme as + // source plus a compiler. This renders classes that appear nowhere in the + // shell's own sources and asserts the COMPUTED result, because "the class is + // in the DOM" was always true even when it did nothing. + it("compiles utility classes the model invents, against executor's tokens", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi( + page, + shellFrame, + `function App() { + return ( +
+
24
+
+ abcd +
+
label
+
muted
+
+ ); + }`, + ); + await innerFrame.locator("#probe").waitFor({ timeout: 10_000 }); + + const computed = await innerFrame.evaluate(() => { + const styleOf = (id: string) => getComputedStyle(document.getElementById(id)!); + return { + fontSize: styleOf("size").fontSize, + gridColumns: styleOf("grid").gridTemplateColumns.split(" ").length, + letterSpacing: styleOf("arbitrary").letterSpacing, + labelSize: styleOf("arbitrary").fontSize, + mutedColor: styleOf("token").color, + bodyFont: getComputedStyle(document.body).fontFamily, + bodyNumeric: getComputedStyle(document.body).fontVariantNumeric, + }; + }); + + // A built-in scale step, a responsive variant, and an arbitrary value — + // three different paths through the compiler, none of them in the shell. + expect(computed.fontSize, "text-2xl resolves to its real size").toBe("24px"); + expect(computed.gridColumns, "md:grid-cols-4 produces four columns").toBe(4); + expect(computed.labelSize, "an arbitrary text size compiles").toBe("11px"); + expect(computed.letterSpacing, "an arbitrary tracking compiles").not.toBe("normal"); + + // Compiled against EXECUTOR's theme, not Tailwind's defaults: the muted + // token is the design system's gray, and the type is Geist. + expect(computed.mutedColor, "muted-foreground is executor's gray").toBe("rgb(102, 102, 102)"); + expect(computed.bodyFont, "the frame is set in Geist").toContain("Geist"); + expect(computed.bodyNumeric, "numbers are tabular by default").toContain("tabular-nums"); + } finally { + await page.close(); + } + }, 30_000); + + it("blocks popup, form, and nested-frame escape attempts from generated UI", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedEscapeAttemptCode); + await innerFrame.locator("#escape-results").waitFor({ timeout: 10_000 }); + + const escapeText = (await innerFrame.locator("#escape-results").textContent()) ?? ""; + expect(escapeText).toContain("popup:true"); + expect(escapeText).toContain("form:"); + expect(escapeText).toContain("iframe:"); + + const pages = browser.contexts().flatMap((context) => context.pages()); + expect(pages).toHaveLength(1); + + const hostState = await getHostState(page); + expect(hostState.toolCalls).toHaveLength(0); + } finally { + await page.close(); + } + }, 30_000); + + it("rejects spoofed renderer messages unless the iframe window and token match", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + // A tool-call request from the wrong window, and one from the right + // window with the wrong token. Neither may reach the bridge. + await shellFrame.evaluate(() => { + const iframe = document.querySelector('iframe[title="Generated UI"]'); + const srcDoc = iframe?.getAttribute("srcdoc") ?? ""; + const token = //.exec(srcDoc)?.[1]; + if (!iframe?.contentWindow || !token) { + throw new Error("Generated UI iframe is missing a token."); + } + + const payload = { + type: "executor.toolCall", + path: ["inventory", "listItems"], + args: [{}], + }; + window.dispatchEvent( + new MessageEvent("message", { + source: window, + data: { ...payload, requestId: 1, token }, + }), + ); + window.dispatchEvent( + new MessageEvent("message", { + source: iframe.contentWindow, + data: { ...payload, requestId: 2, token: "wrong" }, + }), + ); + }); + + await page.waitForTimeout(100); + expect((await getHostState(page)).toolCalls).toHaveLength(0); + + await shellFrame.evaluate(() => { + const iframe = document.querySelector('iframe[title="Generated UI"]'); + const srcDoc = iframe?.getAttribute("srcdoc") ?? ""; + const token = //.exec(srcDoc)?.[1]; + if (!iframe?.contentWindow || !token) { + throw new Error("Generated UI iframe is missing a token."); + } + + window.dispatchEvent( + new MessageEvent("message", { + source: iframe.contentWindow, + data: { + type: "executor.toolCall", + requestId: 3, + token, + path: ["inventory", "listItems"], + args: [{}], + }, + }), + ); + }); + + await page.waitForFunction( + () => (window as unknown as BrowserHostWindow).__mcpHostState.toolCalls.length === 1, + ); + // Even the accepted message becomes the one proxy grammar, never raw code + // — the outer frame builds the source, the inner frame only names a tool. + expect((await getHostState(page)).toolCalls[0]).toEqual( + expect.objectContaining({ + name: "execute-action", + arguments: { + code: "return await tools.inventory.listItems({})", + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + ); + } finally { + await page.close(); + } + }, 30_000); + + it("handles elicitations in the trusted shell instead of the generated iframe", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").waitFor({ timeout: 10_000 }); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + expect(await innerFrame.locator("text=Approve action").count()).toBe(0); + expect(await shellFrame.locator('[data-testid="trusted-interaction-fields"]').count()).toBe( + 0, + ); + expect(await shellFrame.locator("text=Response content").count()).toBe(0); + expect(openApiServer.postRequests).toHaveLength(0); + + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#approval-status")?.textContent === "Approved Widget:true", + undefined, + { timeout: 10_000 }, + ); + expect(openApiServer.postRequests).toEqual([JSON.stringify({ name: "Approved Widget" })]); + + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toEqual([ + expect.objectContaining({ + name: "execute-action-resume", + arguments: { + executionId: expect.any(String), + action: "accept", + content: "{}", + }, + }), + ]); + } finally { + await page.close(); + } + }, 30_000); + + // One refusal control, not two. `buildElicit` and `enforceApproval` in + // `@executor-js/sdk` raise `ElicitationDeclinedError` for ANY non-accept + // action, so "decline" and "cancel" are the same outcome at the gate and the + // modal offers only Cancel. + it("resumes a canceled approval without performing the mutation", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + + const { page, shellFrame } = await openHarness(browser, hostServer.url); + const initialPostCount = openApiServer.postRequests.length; + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + + expect(await shellFrame.getByRole("button", { name: "Decline" }).count()).toBe(0); + await shellFrame + .locator('[data-testid="trusted-interaction-cancel"]') + .click({ timeout: 10_000 }); + await page.waitForFunction( + () => (window as unknown as BrowserHostWindow).__mcpHostState.resumeCalls.length === 1, + undefined, + { timeout: 10_000 }, + ); + + expect(openApiServer.postRequests).toHaveLength(initialPostCount); + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toEqual([ + expect.objectContaining({ + name: "execute-action-resume", + arguments: { + executionId: expect.any(String), + action: "cancel", + content: "{}", + }, + }), + ]); + } finally { + await page.close(); + } + }, 30_000); + + it("tells the user what they are confirming rather than where the modal came from", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + + const card = shellFrame.locator('[data-testid="trusted-interaction-card"]'); + await card.waitFor({ timeout: 10_000 }); + const cardText = await card.innerText(); + + expect(cardText).toContain("Approve action"); + expect(cardText).toContain("You've triggered a destructive action in the UI, please confirm"); + expect(cardText).not.toContain("This approval is handled by the Executor shell."); + // The concrete request preview stays — copy changed, evidence did not. + expect(cardText).toContain("createItem"); + } finally { + await page.close(); + } + }, 30_000); + + it("stops asking for a tool the user approved for good, and only that tool", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + const modal = shellFrame.locator('[data-testid="trusted-interaction-modal"]'); + + try { + // Remembered grants persist per console origin, so start from a known + // empty state rather than inheriting one from an earlier run. + await shellFrame.evaluate(() => globalThis.localStorage.clear()); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedTwoToolApprovalCode); + const initialPostCount = openApiServer.postRequests.length; + + await innerFrame.locator("#create-first").click({ timeout: 10_000 }); + await modal.waitFor({ timeout: 10_000 }); + await shellFrame + .locator('[data-testid="trusted-interaction-approve-menu"]') + .click({ timeout: 10_000 }); + await shellFrame + .locator('[data-testid="trusted-interaction-approve-always"]') + .click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#create-status")?.textContent === "First Widget", + undefined, + { timeout: 10_000 }, + ); + + // Same tool again: answered from the grant, so no modal ever mounts. + await innerFrame.locator("#create-second").click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#create-status")?.textContent === "Second Widget", + undefined, + { timeout: 10_000 }, + ); + expect(await modal.count()).toBe(0); + expect(openApiServer.postRequests.slice(initialPostCount)).toEqual([ + JSON.stringify({ name: "First Widget" }), + JSON.stringify({ name: "Second Widget" }), + ]); + + // A different address on the same artifact is a different action. + await innerFrame.locator("#renew").click({ timeout: 10_000 }); + await modal.waitFor({ timeout: 10_000 }); + await shellFrame + .locator('[data-testid="trusted-interaction-approve"]') + .click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#renew-status")?.textContent === "false", + undefined, + { timeout: 10_000 }, + ); + + const hostState = await getHostState(page); + expect( + hostState.resumeCalls.map((call) => (call.arguments as { action?: string }).action), + ).toEqual(["accept", "accept", "accept"]); + } finally { + await shellFrame.evaluate(() => globalThis.localStorage.clear()).catch(() => undefined); + await page.close(); + } + }, 60_000); + + it("blocks generated UI mutations that run on mount until trusted approval", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const initialPostCount = openApiServer.postRequests.length; + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedAutoMutationCode); + await innerFrame.locator("#auto-mutation-status").waitFor({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + + expect(openApiServer.postRequests).toHaveLength(initialPostCount); + const hostStateBeforeApproval = await getHostState(page); + expect(hostStateBeforeApproval.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.createItem({"body":{"name":"Mount Widget"}})', + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + ]), + ); + + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#auto-mutation-status")?.textContent === "Mount Widget:true", + undefined, + { timeout: 10_000 }, + ); + expect(openApiServer.postRequests).toHaveLength(initialPostCount + 1); + expect(openApiServer.postRequests.at(-1)).toBe(JSON.stringify({ name: "Mount Widget" })); + } finally { + await page.close(); + } + }, 30_000); + + it("updates live query state after an approved TanStack Query mutation", async () => { + if (!browser || !hostServer || !openApiServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const initialPostCount = openApiServer.postRequests.length; + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedTanstackQueryCode); + await innerFrame.waitForFunction( + () => document.querySelector("#auto-renew-state")?.textContent === "false", + undefined, + { timeout: 10_000 }, + ); + + await innerFrame.locator("#auto-renew-toggle").click({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + expect(openApiServer.postRequests).toHaveLength(initialPostCount); + + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + + await innerFrame.waitForFunction( + () => document.querySelector("#auto-renew-state")?.textContent === "true", + undefined, + { timeout: 10_000 }, + ); + await innerFrame.waitForFunction( + () => + document.querySelector("#auto-renew-success")?.textContent === + "Auto-renew enabled successfully", + undefined, + { timeout: 10_000 }, + ); + + expect(openApiServer.postRequests).toHaveLength(initialPostCount + 1); + expect(openApiServer.postRequests.at(-1)).toBe(JSON.stringify({ autoRenew: true })); + + const hostState = await getHostState(page); + expect(hostState.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.getDomain({"domain":"openexecutor.com"})', + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + expect.objectContaining({ + name: "execute-action", + arguments: { + code: 'return await tools.inventory.updateDomainAutoRenew({"domain":"openexecutor.com","body":{"autoRenew":true}})', + artifactId: HARNESS_ARTIFACT_ID, + }, + }), + ]), + ); + } finally { + await page.close(); + } + }, 30_000); + + it("pages through a multi-page upstream declaratively with useInfiniteQuery", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedInfiniteQueryCode); + + // Page 1 only — an infinite query fetches exactly one page up front. The + // whole point is that "get everything" is the user's/component's choice, + // not an opaque 40-iteration loop hidden inside a queryFn. + await innerFrame.waitForFunction( + () => document.querySelector("#paged-items")?.textContent === "alpha,beta", + undefined, + { timeout: 10_000 }, + ); + expect(await innerFrame.locator("#paged-has-next").textContent()).toBe("true"); + + await innerFrame.locator("#paged-more").click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#paged-items")?.textContent === "alpha,beta,gamma,delta", + undefined, + { timeout: 10_000 }, + ); + + await innerFrame.locator("#paged-more").click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => + document.querySelector("#paged-items")?.textContent === "alpha,beta,gamma,delta,epsilon", + undefined, + { timeout: 10_000 }, + ); + // `getNextPageParam` returned undefined for the last page, so paging stops. + await innerFrame.waitForFunction( + () => document.querySelector("#paged-has-next")?.textContent === "false", + undefined, + { timeout: 10_000 }, + ); + expect(await innerFrame.locator("#paged-count").textContent()).toBe("3"); + + // Every page went over the wire as an ordinary single tool call — the + // cursor merged into the tool INPUT, not spliced into code. The first + // request carries no cursor at all (`initialPageParam: null`). + const hostState = await getHostState(page); + const pagedCalls = hostState.toolCalls + .map((call) => (call.arguments as { code?: string } | undefined)?.code) + .filter((code): code is string => Boolean(code?.includes("listPaged("))); + expect(pagedCalls).toEqual([ + 'return await tools.inventory.listPaged({"limit":2})', + 'return await tools.inventory.listPaged({"limit":2,"cursor":"2"})', + 'return await tools.inventory.listPaged({"limit":2,"cursor":"4"})', + ]); + } finally { + await page.close(); + } + }, 30_000); + + it("places an infinite-query cursor down a dotted path into a nested input", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi( + page, + shellFrame, + generatedNestedInfiniteQueryCode, + ); + await innerFrame.waitForFunction( + () => document.querySelector("#nested-items")?.textContent === "alpha,beta", + undefined, + { timeout: 10_000 }, + ); + + await innerFrame.locator("#nested-more").click({ timeout: 10_000 }); + await innerFrame.waitForFunction( + () => document.querySelector("#nested-items")?.textContent === "alpha,beta,gamma,delta", + undefined, + { timeout: 10_000 }, + ); + + const hostState = await getHostState(page); + const nestedCalls = hostState.toolCalls + .map((call) => (call.arguments as { code?: string } | undefined)?.code) + .filter((code): code is string => Boolean(code?.includes("listPagedNested("))); + // The merge is a deep write that preserves the sibling `limit`, rather + // than replacing the nested object. + expect(nestedCalls).toEqual([ + 'return await tools.inventory.listPagedNested({"query":{"limit":2}})', + 'return await tools.inventory.listPagedNested({"query":{"limit":2,"since":"2"}})', + ]); + } finally { + await page.close(); + } + }, 30_000); + + it("refuses arbitrary code posted straight at execute-action", async () => { + if (!browser || !hostServer) throw new Error("Browser harness did not start."); + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // The shell offers no way to write this — but the app channel is reachable + // from anything that can speak the bridge, so the SERVER is what has to + // refuse it. Posted through the host's own tools/call path, exactly as a + // hostile frame would. + await renderGeneratedUi(page, shellFrame, generatedStaticCode); + + const rejected = await page.evaluate(async () => { + const response = await fetch("/tools/call", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "execute-action", + arguments: { + code: "let all = []; for (let i = 0; i < 40; i++) { all.push(await tools.inventory.listItems({})) } return all", + }, + }), + }); + return (await response.json()) as { + isError?: boolean; + content?: Array<{ text?: string }>; + }; + }); + + expect(rejected.isError).toBe(true); + expect(rejected.content?.[0]?.text).toContain("single tool call"); + expect(rejected.content?.[0]?.text).toContain("infiniteQueryOptions"); + } finally { + await page.close(); + } + }, 30_000); + + it("renders form elicitations as typed approval fields", async () => { + if (!browser || !shellServer) { + throw new Error("Browser harness did not start."); + } + + const schemaMcpHarness = await startSchemaElicitationMcpHarness(); + const schemaHostServer = await startHostServer(shellServer.url, schemaMcpHarness); + const { page, shellFrame } = await openHarness(browser, schemaHostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedSchemaApprovalCode); + await innerFrame.locator("#ask-schema").click({ timeout: 10_000 }); + await shellFrame.locator("text=Provide approval details").waitFor({ timeout: 10_000 }); + + await shellFrame.getByLabel("Display name").fill("Rhea"); + await shellFrame.getByLabel("Count").fill("3"); + await shellFrame.getByLabel("Priority").selectOption("high"); + await shellFrame.getByLabel("Notify").click(); + await shellFrame.getByLabel("Beta").click(); + await shellFrame.getByRole("button", { name: "Approve" }).click({ timeout: 10_000 }); + + await innerFrame.waitForFunction( + () => + document.querySelector("#schema-status")?.textContent === + JSON.stringify({ + name: "Rhea", + count: 3, + priority: "high", + notify: true, + tags: ["beta"], + }), + undefined, + { timeout: 10_000 }, + ); + + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toEqual([ + expect.objectContaining({ + name: "execute-action-resume", + arguments: { + executionId: "schema_exec", + action: "accept", + content: JSON.stringify({ + name: "Rhea", + count: 3, + priority: "high", + notify: true, + tags: ["beta"], + }), + }, + }), + ]); + } finally { + await page.close(); + await schemaHostServer.close(); + await schemaMcpHarness.close(); + } + }, 30_000); + + // "Don't ask again" is consent to skip the prompt, not consent to invent the + // answer. This schema's `name`, `priority` and `tags` are required with no + // defaults, so there is nothing to resume with and the modal must come back. + it("still asks when a remembered tool needs values the user never gave", async () => { + if (!browser || !shellServer) { + throw new Error("Browser harness did not start."); + } + + const schemaMcpHarness = await startSchemaElicitationMcpHarness(); + const schemaHostServer = await startHostServer(shellServer.url, schemaMcpHarness); + const { page, shellFrame } = await openHarness(browser, schemaHostServer.url); + const modal = shellFrame.locator('[data-testid="trusted-interaction-modal"]'); + + try { + await shellFrame.evaluate(() => globalThis.localStorage.clear()); + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedSchemaApprovalCode); + + await innerFrame.locator("#ask-schema").click({ timeout: 10_000 }); + await modal.waitFor({ timeout: 10_000 }); + await shellFrame.getByLabel("Display name").fill("Rhea"); + await shellFrame.getByLabel("Priority").selectOption("high"); + await shellFrame.getByLabel("Beta").click(); + await shellFrame + .locator('[data-testid="trusted-interaction-approve-menu"]') + .click({ timeout: 10_000 }); + await shellFrame + .locator('[data-testid="trusted-interaction-approve-always"]') + .click({ timeout: 10_000 }); + await modal.waitFor({ state: "detached", timeout: 10_000 }); + + await innerFrame.locator("#ask-schema").click({ timeout: 10_000 }); + await modal.waitFor({ timeout: 10_000 }); + // The remembered grant did not answer for the user, and nothing resumed + // behind their back. + const hostState = await getHostState(page); + expect(hostState.resumeCalls).toHaveLength(1); + } finally { + await shellFrame.evaluate(() => globalThis.localStorage.clear()).catch(() => undefined); + await page.close(); + await schemaHostServer.close(); + await schemaMcpHarness.close(); + } + }, 60_000); + + /** + * The regression guard for the whole point of fill mode. + * + * What broke before it existed: an artifact's `h-full` resolved against an + * unbounded chain, so `flex-1 min-h-0 overflow-auto` was just a div that grew. + * The table pushed its own header off the top of the screen and the user + * scrolled a document when they wanted an app. + * + * So this asserts the three things that have to be true together, since any + * one of them alone can hold while the layout is still broken: + * + * 1. the scroll region is genuinely bounded and overflowing + * (`scrollHeight > clientHeight`) — not merely tall; + * 2. it actually scrolls when scrolled; + * 3. the artifact's header does NOT move while it does. + */ + it("gives a bounded viewport to an artifact laid out as an app", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // The console's negotiation, verbatim: the spec's `fullscreen` mode plus a + // FIXED container height. Both are required — a mode with no height is not + // a viewport, and the shell deliberately stays inline for it. + await page.evaluate(() => { + (window as unknown as BrowserHostWindow).__setHostContext({ + displayMode: "fullscreen", + availableDisplayModes: ["inline", "fullscreen"], + containerDimensions: { height: 900 }, + }); + }); + await shellFrame.waitForFunction(() => + document.documentElement.classList.contains("artifact-fill"), + ); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedAppLayoutCode); + await innerFrame.locator("#layout-row-119").waitFor({ timeout: 10_000 }); + // The class has to reach the INNER document too — it is the last link in + // the height chain, and the artifact's own `h-full` resolves there. + await innerFrame.waitForFunction(() => + document.documentElement.classList.contains("artifact-fill"), + ); + + const before = await innerFrame.evaluate(() => { + const scroller = document.querySelector("#layout-scroll"); + const header = document.querySelector("#layout-header"); + const thead = document.querySelector("#layout-thead"); + const firstRow = document.querySelector("#layout-row-0"); + if (!scroller || !header || !thead || !firstRow) { + throw new Error("Missing layout elements."); + } + return { + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + headerTop: header.getBoundingClientRect().top, + theadTop: thead.getBoundingClientRect().top, + firstRowTop: firstRow.getBoundingClientRect().top, + bodyScrollHeight: document.body.scrollHeight, + bodyClientHeight: document.body.clientHeight, + }; + }); + + // Bounded: the region is shorter than its content, which is what makes it + // a scroll box rather than a tall div. + expect(before.clientHeight).toBeGreaterThan(0); + expect(before.scrollHeight).toBeGreaterThan(before.clientHeight); + // And the DOCUMENT is not the thing that overflows — the artifact absorbed + // its own content instead of pushing the frame out. + expect(before.bodyScrollHeight).toBeLessThanOrEqual(before.bodyClientHeight + 1); + + const after = await innerFrame.evaluate(() => { + const scroller = document.querySelector("#layout-scroll"); + const header = document.querySelector("#layout-header"); + const thead = document.querySelector("#layout-thead"); + const firstRow = document.querySelector("#layout-row-0"); + if (!scroller || !header || !thead || !firstRow) { + throw new Error("Missing layout elements."); + } + scroller.scrollTop = 400; + return { + scrollTop: scroller.scrollTop, + headerTop: header.getBoundingClientRect().top, + theadTop: thead.getBoundingClientRect().top, + firstRowTop: firstRow.getBoundingClientRect().top, + }; + }); + + // It really scrolled: the rows moved up by the amount scrolled. + expect(after.scrollTop).toBeGreaterThan(0); + expect(before.firstRowTop - after.firstRowTop).toBeCloseTo(after.scrollTop, 0); + // The artifact's own header stayed exactly where it was — this is the + // "Projects" title and its search box that used to scroll away. + expect(after.headerTop).toBeCloseTo(before.headerTop, 0); + // And the sticky table header stayed with it rather than travelling up + // with the rows. Compared against its own starting position rather than + // the container's edge, since `sticky` pins to the padding box (inside the + // 1px border), not the border box. + expect(after.theadTop).toBeCloseTo(before.theadTop, 0); + } finally { + await page.close(); + } + }, 60_000); + + /** + * The other half of the contract, and the one that protects every chat host: + * with no fill context the shell must behave exactly as it always has — report + * its content height and let the host grow the frame. A regression here would + * clip artifacts inside Claude, where there is no bounded viewport to fill. + */ + it("still grows to content for a host that offers no viewport", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedAppLayoutCode); + await innerFrame.locator("#layout-row-119").waitFor({ timeout: 10_000 }); + + const fillApplied = await shellFrame.evaluate(() => + document.documentElement.classList.contains("artifact-fill"), + ); + expect(fillApplied).toBe(false); + + // The inner document grows past the frame it was given and reports that + // height outward; the scroll region has no bound to scroll inside, which + // is correct — in a thread the transcript is what scrolls. + const metrics = await innerFrame.evaluate(() => ({ + bodyScrollHeight: document.body.scrollHeight, + rootHeight: document.getElementById("root")?.getBoundingClientRect().height ?? 0, + })); + expect(metrics.bodyScrollHeight).toBeGreaterThan(1000); + expect(metrics.rootHeight).toBeGreaterThan(1000); + } finally { + await page.close(); + } + }, 60_000); + + /** + * The render signal, and who is allowed to be affected by it. + * + * The console holds ONE loading surface across the whole open — data fetch, + * renderer chunk, shell boot, compile — and it can only take that surface down + * when the artifact has actually painted. Nothing on the console side can see + * that: the artifact is two sandboxed documents down. So the shell reports it, + * over the `callServerTool` seam, to a host that asked (`executorRenderSignal` + * on the host context). + * + * Two properties, and they matter in opposite directions: + * + * 1. A host that ASKS is told — after the artifact is on screen, not before. + * Firing early would reveal a blank frame, which is the exact defect the + * single surface exists to prevent. + * 2. A host that does NOT ask is completely unaffected: no call it has never + * heard of, and it keeps the shell's own loading card. That is every real + * MCP client, and this is the guard for them — asserted here because the + * default harness context omits the key, exactly like Claude's. + */ + it("tells a host that asked when the artifact painted, and no other host", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // `openHarness` already waited on it: a host that did not ask still gets + // the shell's own loading card, unchanged. + const before = await getHostState(page); + expect( + before.toolCalls.some((call) => call.name === "executor-artifact-rendered"), + "a host that never asked is not sent the signal", + ).toBe(false); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + // Still silent: the artifact has painted, but this host did not ask. + const afterRender = await getHostState(page); + expect( + afterRender.toolCalls.some((call) => call.name === "executor-artifact-rendered"), + "rendering alone does not make the call", + ).toBe(false); + } finally { + await page.close(); + } + }, 60_000); + + it("signals a console-style host once the artifact is on screen", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // Become the console: ask for the signal the way `ArtifactShell` does. + await page.evaluate(() => { + (window as unknown as BrowserHostWindow).__setHostContext({ + executorRenderSignal: true, + }); + }); + + // The shell drops its own loading card for a host that holds one, so + // there is no second placeholder under the console's skeleton. + await shellFrame + .locator('[data-testid="shell-loading-state"]') + .waitFor({ state: "detached", timeout: 10_000 }); + + const beforeRender = await getHostState(page); + expect( + beforeRender.toolCalls.some((call) => call.name === "executor-artifact-rendered"), + "nothing is signalled before there is an artifact to look at", + ).toBe(false); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + await page.waitForFunction(() => + (window as unknown as BrowserHostWindow).__mcpHostState.toolCalls.some( + (call: { name?: string }) => call.name === "executor-artifact-rendered", + ), + ); + + // Exactly once — including across a SECOND delivery, which is what the + // latch actually guards. A host replaces the artifact's code (the console + // does exactly this when the tab regains focus and the row has changed), + // the inner frame remounts and paints again, and the signal must not fire + // a second time: the host has long since taken its skeleton down, and a + // repeat would restart a transition that already finished. + const secondFrame = await renderGeneratedUi(page, shellFrame, generatedSecondRenderCode); + await secondFrame.locator("#second").waitFor({ timeout: 10_000 }); + + const after = await getHostState(page); + const signals = after.toolCalls.filter((call) => call.name === "executor-artifact-rendered"); + expect(signals.length, "the signal is latched, not repeated per render").toBe(1); + + // And it never reached the execution transport: a "you may stop waiting" + // is not an execution and must not be metered, approved or logged as one. + expect( + after.toolCalls.some((call) => call.name === "execute-action"), + "the signal is answered by the host, not proxied to execute-action", + ).toBe(false); + } finally { + await page.close(); + } + }, 60_000); + + /** + * The artifact can paint BEFORE the host says it wants to be told. + * + * "The artifact painted" travels up from the inner frame; "tell me when it + * does" travels down on the host context. They are independent channels and + * their order is not guaranteed — a host that advertises the capability in a + * later `host-context-changed` (rather than in its `ui/initialize` reply) + * arrives second. + * + * The paint never happens twice, so a shell that merely tested the capability + * at the moment of the paint would drop the only notification there was ever + * going to be, and the host would hold its loading surface over a fully + * rendered artifact forever. That is a stuck screen, not a cosmetic glitch, + * which is why the fact is remembered and re-checked rather than tested once. + */ + it("still signals a host that asks only after the artifact has painted", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + // Render FIRST, with the harness's default context, which does not ask. + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedStaticCode); + await innerFrame.locator("#ready").waitFor({ timeout: 10_000 }); + + const beforeAsking = await getHostState(page); + expect( + beforeAsking.toolCalls.some((call) => call.name === "executor-artifact-rendered"), + "nothing is sent to a host that has not asked", + ).toBe(false); + + // Only now does the host ask. The paint it is asking about is already in + // the past. + await page.evaluate(() => { + (window as unknown as BrowserHostWindow).__setHostContext({ + executorRenderSignal: true, + }); + }); + + await page.waitForFunction(() => + (window as unknown as BrowserHostWindow).__mcpHostState.toolCalls.some( + (call: { name?: string }) => call.name === "executor-artifact-rendered", + ), + ); + + const after = await getHostState(page); + expect( + after.toolCalls.filter((call) => call.name === "executor-artifact-rendered").length, + "the remembered paint is reported exactly once", + ).toBe(1); + } finally { + await page.close(); + } + }, 60_000); + + it("keeps trusted approval controls visible in a short host iframe", async () => { + if (!browser || !hostServer) { + throw new Error("Browser harness did not start."); + } + const { page, shellFrame } = await openHarness(browser, hostServer.url); + + try { + await page.evaluate(() => { + const appFrame = document.getElementById("app") as HTMLIFrameElement | null; + if (!appFrame) throw new Error("Missing app iframe."); + appFrame.style.height = "180px"; + }); + await shellFrame.waitForFunction(() => document.documentElement.clientHeight <= 180); + + const innerFrame = await renderGeneratedUi(page, shellFrame, generatedApprovalCode); + await innerFrame.locator("#ask").click({ timeout: 10_000 }); + await shellFrame.locator("text=Approve action").waitFor({ timeout: 10_000 }); + + const metrics = await shellFrame + .locator('[data-testid="trusted-interaction-modal"]') + .evaluate((modal) => { + const card = modal.querySelector('[data-testid="trusted-interaction-card"]'); + const body = modal.querySelector('[data-testid="trusted-interaction-body"]'); + const footer = modal.querySelector( + '[data-testid="trusted-interaction-footer"]', + ); + if (!card || !body || !footer) throw new Error("Missing trusted modal element."); + + const cardRect = card.getBoundingClientRect(); + const footerRect = footer.getBoundingClientRect(); + return { + bodyOverflowY: getComputedStyle(body).overflowY, + cardBottom: cardRect.bottom, + cardTop: cardRect.top, + footerBottom: footerRect.bottom, + footerTop: footerRect.top, + viewportHeight: document.documentElement.clientHeight, + }; + }); + + expect(metrics.bodyOverflowY).toBe("auto"); + expect(metrics.cardTop).toBeGreaterThanOrEqual(0); + expect(metrics.cardBottom).toBeLessThanOrEqual(metrics.viewportHeight + 1); + expect(metrics.footerTop).toBeGreaterThanOrEqual(0); + expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.viewportHeight + 1); + } finally { + await page.close(); + } + }, 30_000); +}); diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html new file mode 100644 index 0000000000..30e25767e1 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.html @@ -0,0 +1,17 @@ + + + + + + + + Executor Shell + + +
+ + + diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx new file mode 100644 index 0000000000..609e4b5e7e --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { App } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpAppsShell } from "./shell-app"; + +/** + * The shell's MCP-Apps entry point: connect, then render whatever the host + * sends. + * + * `tool-input` and `tool-result` are ONE-SHOT notifications. A host is free to + * send them the instant the handshake completes — `AppBridge.oninitialized` + * fires on `ui/notifications/initialized`, which `App.connect()` sends before it + * resolves. `McpAppsShell` registers its handlers in an effect, which cannot run + * until React has mounted it, which is at best a frame later. A prompt host + * therefore delivers the artifact into a void, and the shell sits on its loading + * state forever. + * + * That is not hypothetical: it is exactly what the console's artifact page does, + * and the ext-apps SDK warns about the race by name ("handler registered after + * connect() completed the ui/initialize handshake"). + * + * So the notifications are captured HERE, in `onAppCreated` — the callback the + * SDK provides for precisely this, running before `connect()` — and replayed + * into the shell once it has mounted and registered its own handlers. Buffering + * rather than racing means the shell's handlers stay where they belong (with the + * state they drive) and no host has to know how fast this app mounts. + */ + +type ToolInput = { arguments?: Record }; + +/** What arrived before the shell was ready to hear it. */ +type EarlyDelivery = { + toolInput?: ToolInput; + toolResult?: CallToolResult; + /** Set once the shell has taken over; further notifications go straight through. */ + drain?: (delivery: { toolInput?: ToolInput; toolResult?: CallToolResult }) => void; +}; + +const captureEarlyDelivery = (app: App, early: EarlyDelivery) => { + // `addEventListener`, not the `on*` setters: the shell replaces those with its + // own handlers, and this listener must survive that. + app.addEventListener("toolinput", (params) => { + if (early.drain) early.drain({ toolInput: params }); + else early.toolInput = params; + }); + app.addEventListener("toolresult", (params) => { + const result = params as CallToolResult; + if (early.drain) early.drain({ toolResult: result }); + else early.toolResult = result; + }); +}; + +function ConnectedShellApp() { + const earlyRef = React.useRef({}); + const { app, error: connectionError } = useApp({ + appInfo: { name: "Executor Shell", version: "1.0.0" }, + capabilities: {}, + onAppCreated: (created) => captureEarlyDelivery(created, earlyRef.current), + }); + + // Replay once, after the shell's own handlers are in place. The effect runs + // after the child's effects, which is exactly the ordering this needs. + React.useEffect(() => { + if (!app) return; + const early = earlyRef.current; + const deliver = (delivery: { toolInput?: ToolInput; toolResult?: CallToolResult }) => { + if (delivery.toolInput) app.ontoolinput?.(delivery.toolInput); + if (delivery.toolResult) app.ontoolresult?.(delivery.toolResult); + }; + deliver(early); + early.toolInput = undefined; + early.toolResult = undefined; + early.drain = deliver; + }, [app]); + + if (connectionError) { + return ( +
+
Connection error: {connectionError.message}
+
+ ); + } + + if (!app) { + return ( +
+
Connecting
+
+ ); + } + + return ; +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/packages/hosts/mcp-apps-shell/src/shell/preview-capture.ts b/packages/hosts/mcp-apps-shell/src/shell/preview-capture.ts new file mode 100644 index 0000000000..a5782a46e4 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/preview-capture.ts @@ -0,0 +1,36 @@ +/** + * The host call that stores a settled-render snapshot. + * + * ## Why this is its own module + * + * Both ends of the capture name it: the shell document sends it, and the + * console's `ArtifactShell` answers it. But `shell-app.tsx` imports + * `virtual:executor-tailwind-browser` and `virtual:executor-inner-renderer`, + * which only exist inside the standalone shell's own Vite build — so importing + * this constant FROM there drags those virtual modules into the console's + * bundle, where nothing supplies them, and the artifact renderer fails to load + * at all. + * + * Keeping the shared name in a module with no imports is what lets both sides + * agree on it without either pulling in the other's build graph. + * + * ## What it is + * + * Not an MCP tool the server advertises. It rides the `callServerTool` seam + * because that seam already exists in every host, and the console intercepts it + * before the tool transport — a snapshot is not an execution and must never be + * metered, approved or logged as one. A host that does not implement the name + * simply rejects the call, which is exactly the fail-quiet behaviour a preview + * upgrade should have. + */ +export const SAVE_ARTIFACT_PREVIEW_TOOL = "executor-save-artifact-preview"; + +/** + * The host-context key by which a host asks for a preview snapshot. + * + * Carried on `McpUiHostContext`'s open index signature, which is the spec's own + * forward-compatibility seam. Only the console sets it — it is the only host + * with somewhere to put a preview — so everywhere else the inner frame skips + * the capture entirely rather than doing the work and dropping it. + */ +export const PREVIEW_CAPTURE_HOST_CONTEXT_KEY = "executorPreviewCapture"; diff --git a/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts b/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts new file mode 100644 index 0000000000..fa3ee9a051 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/provided-globals.pin.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "@effect/vitest"; +import { PROVIDED_GLOBAL_NAMES } from "@executor-js/execution"; + +import { PROVIDED_SCOPE_NAMES } from "./component-runtime"; + +// The `create-artifact` tool rejects code that redeclares a name the shell already +// binds. That guard lives in the MCP host (which must not import React), so the +// name list is generated into the execution package by +// `scripts/gen-provided-globals.ts`. This test is the pin: add a component to +// `components.ts` without regenerating and it fails here rather than silently +// letting a model shadow a real binding at render time. +describe("provided globals", () => { + it("matches the scope the iframe actually binds", () => { + expect([...PROVIDED_GLOBAL_NAMES].sort()).toEqual([...PROVIDED_SCOPE_NAMES].sort()); + }); + + it("covers the names the shell is built around", () => { + for (const name of [ + "React", + "useState", + "useQuery", + "useInfiniteQuery", + "infiniteQueryOptions", + "tools", + "Card", + "cn", + ]) { + expect(PROVIDED_SCOPE_NAMES).toContain(name); + } + }); + + // The decision this branch encodes: artifact code is purely declarative + // `tools.*`. `run` was the escape hatch for arbitrary code and is gone from + // the model-facing surface entirely — re-adding it to the scope must fail + // here, not quietly reopen the hole. + it("binds no arbitrary-code escape hatch", () => { + expect(PROVIDED_SCOPE_NAMES).not.toContain("run"); + expect(PROVIDED_SCOPE_NAMES).not.toContain("eval"); + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/shell/proxy.ts b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts new file mode 100644 index 0000000000..6d7744d0c6 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/proxy.ts @@ -0,0 +1,181 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +export type ToolCallHost = { + readonly callServerTool: (params: { + name: string; + arguments?: Record; + }) => Promise; +}; + +export type TrustedInteraction = { + executionId: string; + interaction: { + kind?: unknown; + message?: unknown; + url?: unknown; + requestedSchema?: unknown; + /** + * The paused call's tool address (`executor.coreTools.policies.create`), + * put on the wire by `formatPausedExecution`. It is the only stable + * identity a pause carries — the execution id is per-call — so it is what + * the shell keys a remembered approval by. + */ + address?: unknown; + }; +}; + +export type TrustedInteractionResponse = { + action: "accept" | "decline" | "cancel"; + content?: Record; +}; + +export type RequestTrustedInteraction = ( + interaction: TrustedInteraction, +) => Promise; + +const TOOL_PATH_SEGMENT = /^[A-Za-z_$][\w$]*$/; + +/** + * The ONE grammar the shell ever puts on the `execute-action` wire: + * + * return await tools.("")?(.)*() + * + * A single proxy-shaped tool call, nothing else — no statements, no loops, no + * composition. The server parses `execute-action` against exactly this shape + * (`parseToolCallCode` in `@executor-js/host-mcp`), so an iframe cannot smuggle + * arbitrary code through the app channel even though the shell UI never offers + * a way to write any. `tool-call-grammar.pin.test.ts` pins the two together. + * + * The path's head is an INTEGRATION, and the optional role tags which of the + * artifact's connections for that integration to use. Neither the tier nor the + * connection name is ever on this wire: the server resolves them from the + * bindings stored on the artifact row. That is the whole point of the short + * form — an iframe that fabricated an address would be naming something the + * artifact was never bound to, and the server would refuse it. + * + * Role and args are both `JSON.stringify` output, so the emission stays + * parseable and a role containing a quote cannot break out of the call. + */ +export function toolCallCode( + path: readonly string[], + args: readonly unknown[], + role?: string, +): string { + if (path.length === 0) throw new Error("Invalid tool path."); + const parts = path.map((part) => { + if (typeof part !== "string" || !TOOL_PATH_SEGMENT.test(part)) { + throw new Error("Invalid tool path."); + } + return part; + }); + if (role !== undefined && (typeof role !== "string" || role.length === 0)) { + throw new Error("Invalid tool role."); + } + const [head, ...rest] = parts; + const tag = role === undefined ? "" : `(${JSON.stringify(role)})`; + const trailer = rest.length > 0 ? `.${rest.join(".")}` : ""; + return `return await tools.${head}${tag}${trailer}(${JSON.stringify(args[0] ?? {})})`; +} + +/** + * Calls one tool through the MCP Apps bridge, resolving any shell-owned + * approval the execution pauses for. + * + * `tools.github.issues.create({ title: "Bug" })` in the generated iframe arrives + * here as `(["github","issues","create"], [{ title: "Bug" }])` and becomes + * `execute-action` with + * `code: "return await tools.github.issues.create({\"title\":\"Bug\"})"`. + * + * `artifactId` rides alongside the code because the server cannot resolve the + * call without it: the path names an integration role, and the bindings that + * turn a role into a connection live on the artifact row. It is supplied by the + * host (from the tool input the artifact was delivered with), never by the + * generated component — and the server checks the caller owns it. + */ +export function createToolCaller( + app: ToolCallHost, + requestTrustedInteraction: RequestTrustedInteraction, + getArtifactId?: () => string | undefined, +): (path: readonly string[], args: readonly unknown[], role?: string) => Promise { + return (path, args, role) => { + const artifactId = getArtifactId?.(); + return app + .callServerTool({ + name: "execute-action", + arguments: { + code: toolCallCode(path, args, role), + ...(artifactId === undefined ? {} : { artifactId }), + }, + }) + .then((r) => resolveToolResult(app, r, requestTrustedInteraction)); + }; +} + +async function resolveToolResult( + app: ToolCallHost, + result: CallToolResult, + requestTrustedInteraction: RequestTrustedInteraction, +): Promise { + if (result.isError) { + const msg = result.content?.find((c) => c.type === "text")?.text ?? "Tool call failed"; + throw new Error(msg); + } + + const structured = result.structuredContent as Record | undefined; + const pending = parseTrustedInteraction(structured); + if (pending) { + const response = await requestTrustedInteraction(pending); + const resumed = await app.callServerTool({ + name: "execute-action-resume", + arguments: { + executionId: pending.executionId, + action: response.action, + content: JSON.stringify(response.content ?? {}), + }, + }); + return resolveToolResult(app, resumed, requestTrustedInteraction); + } + + return unwrapResult(structured) ?? parseTextContent(result); +} + +function parseTrustedInteraction( + structured: Record | undefined, +): TrustedInteraction | null { + if (!structured || structured.status !== "waiting_for_interaction") return null; + if (typeof structured.executionId !== "string") return null; + const interaction = + typeof structured.interaction === "object" && + structured.interaction !== null && + !Array.isArray(structured.interaction) + ? (structured.interaction as TrustedInteraction["interaction"]) + : {}; + return { executionId: structured.executionId, interaction }; +} + +/** + * Unwrap execution result. The kernel wraps results as + * `{ status: "completed", result: , logs: [...] }`. + * Return just the inner result value. + */ +function unwrapResult(structured: Record | undefined | null): unknown { + if ( + structured && + typeof structured === "object" && + "status" in structured && + "result" in structured + ) { + return structured.result; + } + return structured; +} + +function parseTextContent(r: { content?: Array<{ type: string; text?: string }> }): unknown { + const text = r.content?.find((c) => c.type === "text")?.text; + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/packages/hosts/mcp-apps-shell/src/shell/render-signal.ts b/packages/hosts/mcp-apps-shell/src/shell/render-signal.ts new file mode 100644 index 0000000000..5f493d53d1 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/render-signal.ts @@ -0,0 +1,60 @@ +/** + * The host call that says "the artifact is on screen now". + * + * ## Why a host needs to be told + * + * Opening an artifact is a three-document journey — the console fetches the + * row, loads the shell document into an iframe, and the shell compiles the JSX + * into a further nested frame. A host that wants to hold ONE loading surface + * across all of it (the console does; see `ArtifactStageSkeleton`) needs to know + * when the last of those has actually painted, and nothing in the MCP-Apps + * protocol says so. `ui/notifications/size-changed` is the closest thing and it + * is not the same fact: the shell document reports its own size while it is + * still booting, long before any artifact exists, and a fill-mode host ignores + * the report entirely. + * + * So the shell says it explicitly, once, when it has something to show. + * + * ## "Rendered" means presented, not successful + * + * The signal fires for the ERROR surfaces too — a compile failure, a component + * that threw, an artifact that rendered fine. All three are "the shell is done + * waiting and the user should be looking at the frame rather than a skeleton", + * which is the only question the host is asking. A signal that fired only on + * success would strand a failed artifact under a loading state forever, which is + * a worse bug than the one this exists to fix. + * + * ## Why this is its own module + * + * Same reason as `./preview-capture`, and it is a hard constraint rather than a + * preference: `shell-app.tsx` imports `virtual:executor-tailwind-browser` and + * `virtual:executor-inner-renderer`, which exist only inside the standalone + * shell's own Vite build. Importing a constant FROM there drags those virtual + * modules into the console's bundle, where nothing supplies them, and the + * artifact renderer then fails to load at all. A module with no imports of its + * own is what lets both halves agree on the vocabulary. + * + * ## Why it rides `callServerTool` + * + * Because that seam already exists in every host, exactly as the preview capture + * does. The console intercepts the name before the tool transport, so it never + * reaches `/executions` — a "you can stop waiting now" is not an execution and + * must not be metered, approved or logged as one. + */ +export const ARTIFACT_RENDERED_TOOL = "executor-artifact-rendered"; + +/** + * The host-context key by which a host asks to be told. + * + * Carried on `McpUiHostContext`'s open index signature, the spec's own + * forward-compatibility seam — the same door `PREVIEW_CAPTURE_HOST_CONTEXT_KEY` + * goes through. Only the console sets it, because only the console holds a + * loading surface it needs to take down. Every real MCP client omits it and the + * shell never makes the call, so no host is sent a tool name it has never heard + * of. + * + * A separate key from the preview capture rather than one "this is the console" + * flag: they are two independent capabilities, and a host that wants a seamless + * handoff without storing thumbnails should be able to say exactly that. + */ +export const RENDER_SIGNAL_HOST_CONTEXT_KEY = "executorRenderSignal"; diff --git a/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx new file mode 100644 index 0000000000..c9cfbe52bc --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/shell/shell-app.tsx @@ -0,0 +1,1098 @@ +// `virtual:executor-inner-renderer` is supplied by `innerRendererPlugin` +// (`@executor-js/mcp-apps-shell/vite`). Referenced explicitly — and first, as +// triple-slash directives are only honored above the imports — so the ambient +// declaration travels with this file into consumers that bundle the shell; +// a consumer's tsconfig `include` covers only its own sources. +/// +import "./globals.css"; +import "@tailwindcss/browser"; + +import React, { useState, useEffect, useRef, useCallback, type ReactNode } from "react"; +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + elicitationDefaultContent, + useElicitationApproval, +} from "@executor-js/react/components/elicitation-approval"; + +import { + createToolCaller, + type ToolCallHost, + type TrustedInteraction, + type TrustedInteractionResponse, +} from "./proxy"; +import * as Components from "./components"; +import { PREVIEW_CAPTURE_HOST_CONTEXT_KEY, SAVE_ARTIFACT_PREVIEW_TOOL } from "./preview-capture"; +import { ARTIFACT_RENDERED_TOOL, RENDER_SIGNAL_HOST_CONTEXT_KEY } from "./render-signal"; +import { isFillDisplayMode } from "./display-mode"; +import innerRendererScript from "virtual:executor-inner-renderer"; +// Raw source, not a compiled stylesheet: the inner frame compiles utilities on +// demand against these tokens. See `buildRendererSrcDoc`. +import themeSource from "./theme.css?raw"; +// The in-frame Tailwind compiler, as text to inline under a CSP that forbids +// fetching anything. Supplied by `tailwindBrowserSourcePlugin` — the package +// exports only its module entry, and this needs the bytes. +import tailwindBrowserScript from "virtual:executor-tailwind-browser"; + +type PendingInteraction = TrustedInteraction & { + resolve: (response: TrustedInteractionResponse) => void; + /** Where a "don't ask again" for this pause would be stored, or `null` when + * the pause cannot be scoped to a tool and artifact — see + * `approvalGrantKey`. `null` hides the option rather than storing a grant + * that would match the wrong thing. */ + grantKey: string | null; +}; + +export type McpAppsShellHost = ToolCallHost & { + readonly getHostContext: () => McpUiHostContext | undefined; + readonly openLink: (params: { url: string }) => Promise; + ontoolinput?: (params: { arguments?: Record }) => void; + ontoolresult?: (result: CallToolResult) => void; + onerror?: (err: Error) => void; + onhostcontextchanged?: (ctx: McpUiHostContext) => void; +}; + +type RendererState = { + token: string; + code: string; + srcDoc: string; + config: Record; + height: number; +}; + +type RendererRequest = + | { + type: "executor.toolCall"; + requestId: number; + token: string; + path: unknown; + args: unknown; + role?: unknown; + } + | { type: "executor.renderer.ready"; token: string } + | { type: "executor.renderer.config"; token: string; config: unknown } + | { type: "executor.renderer.size"; token: string; height: unknown } + | { type: "executor.renderer.error"; token: string; message: unknown } + | { type: "executor.renderer.preview"; token: string; preview: unknown } + /** The inner frame has committed and painted a tree — see `RenderSignal` in + * `./inner-renderer`. Relayed to the host as `ARTIFACT_RENDERED_TOOL`. */ + | { type: "executor.renderer.rendered"; token: string }; + +/** + * Whether this host wants a preview snapshot captured. + * + * Advertised by the console through `McpUiHostContext`'s open index signature, + * which is the spec's own forward-compatibility seam. Absent everywhere else — + * a real MCP client has nowhere to put a preview — and absent means the inner + * frame never captures anything, so the cost is not paid where it cannot pay + * off. + */ +const hostContextAllowsPreviewCapture = (ctx: McpUiHostContext | undefined): boolean => + ctx?.[PREVIEW_CAPTURE_HOST_CONTEXT_KEY] === true; + +/** + * Whether this host wants to be told when the artifact is on screen. + * + * Only a host that holds its own loading surface across the open does — the + * console — and it says so on the same open index signature the preview + * capability travels on. Absent everywhere else, and absent means the call is + * never made, so no MCP client is sent a tool name it has never heard of. + */ +const hostContextWantsRenderSignal = (ctx: McpUiHostContext | undefined): boolean => + ctx?.[RENDER_SIGNAL_HOST_CONTEXT_KEY] === true; + +// --------------------------------------------------------------------------- +// Theme application from MCP Apps host context +// --------------------------------------------------------------------------- + +function applyTheme(ctx: McpUiHostContext) { + if (ctx.theme) { + document.documentElement.classList.toggle("dark", ctx.theme === "dark"); + } +} + +/** + * The CSS hook for a bounded viewport. See the `html.artifact-fill` rule in + * `theme.css` for what it does and why it cannot be unconditional. + * + * Applied to the shell's own document here, and to the inner frame's document by + * the inner renderer when the shell tells it the mode — the chain has to hold at + * every link or the artifact's `h-full` still resolves against `auto`. + */ +const FILL_DOCUMENT_CLASS = "artifact-fill"; + +const applyFillMode = (ctx: McpUiHostContext | undefined) => { + document.documentElement.classList.toggle(FILL_DOCUMENT_CLASS, isFillDisplayMode(ctx)); +}; + +/** + * How tall the shell may grow before it scrolls internally, or `undefined` for + * "as tall as the content is". + * + * Precedence, tightest first: + * + * 1. The generated component's own `config.maxHeight` — the author asked for a + * scroll box, so give them one. + * 2. The host's `containerDimensions`, the MCP-Apps way of saying "this is the + * room you have". + * 3. Otherwise NO cap. An app cannot know how much room its host has; the + * protocol's answer is for the app to report its true content height via + * `size-changed` and let the host size the frame. Capping here would also be + * circular for a host that does exactly that — the shell's own viewport IS + * the frame the host is trying to size, so measuring it would pin the frame + * to whatever height it already had. + * + * The old unconditional 800px default was case 3 done wrong: it silently clipped + * anything taller, in every host, including ones with room to spare. + * + * ## This function is not consulted in fill mode + * + * A max height is a ceiling on GROWTH, which is a concept that only means + * something when the frame grows — that is, inline. In fill mode the shell is + * already exactly the host's viewport, so there is nothing left to cap: the + * layout is `height: 100%` all the way down and the artifact scrolls inside + * itself. + * + * That includes `config.maxHeight`. A component asking for a 600px scroll box is + * describing how it wants to sit inside a taller document; on a page where it IS + * the document, honoring it would letterbox the artifact into the top 600px of + * an otherwise empty screen. So in fill mode it is deliberately ignored, and it + * keeps its exact inline meaning everywhere else. + */ +const resolveMaxHeight = ( + config: Record, + hostContext: McpUiHostContext | undefined, +): number | undefined => { + if (isFillDisplayMode(hostContext)) return undefined; + + if (typeof config.maxHeight === "number") return config.maxHeight; + + const container = hostContext?.containerDimensions; + if (container) { + if ("height" in container && typeof container.height === "number") return container.height; + if ("maxHeight" in container && typeof container.maxHeight === "number") { + return container.maxHeight; + } + } + + return undefined; +}; + +const createRendererToken = (): string => { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `renderer_${Date.now()}_${Math.random().toString(36).slice(2)}`; +}; + +const escapeInlineHtml = (value: string): string => + value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +const escapeStyleContent = (value: string): string => value.replace(/<\/style/gi, "<\\/style"); + +const escapeScriptContent = (value: string): string => value.replace(/<\/script/gi, "<\\/script"); + +const collectShellCss = (): string => + Array.from(document.styleSheets) + .map((sheet) => { + try { + return Array.from(sheet.cssRules) + .map((rule) => rule.cssText) + .join("\n"); + } catch { + return ""; + } + }) + .filter((css) => css.length > 0) + .join("\n"); + +/** + * The srcDoc the model's component renders into. + * + * It carries TWO stylesheets, and the difference between them is the whole + * reason artifacts used to look half-styled: + * + * - `collectShellCss()` is the shell's own COMPILED stylesheet, scraped out of + * the live document. It has the `@font-face` rules with their `data:` URLs + * already resolved (the build did that), plus every utility executor's own + * components happen to use. It cannot contain anything else, because + * Tailwind only emits utilities it saw in a source file at build time — and + * the model's code did not exist then. + * + * - `themeSource` + the in-frame compiler is what makes the model's OWN + * classes real. `text-2xl`, `md:grid-cols-4`, `tracking-[0.08em]` — none of + * those appear in executor's components, so none of them were in the + * compiled sheet, and they silently did nothing: `text-2xl` computed to + * 16px, a responsive grid stayed one column. The frame therefore gets the + * theme as SOURCE, in the `text/tailwindcss` style tag `@tailwindcss/browser` + * looks for, and compiles utilities on demand against executor's tokens. + * + * The compiler is inlined rather than fetched because the CSP here is + * `default-src 'none'` with no `connect-src` — deliberately, since artifact code + * is untrusted. Nothing in this frame may open a connection, so everything it + * needs travels with it. + */ +const buildRendererSrcDoc = (token: string): string => { + const css = collectShellCss(); + return ` + + + + + + + + + + +
+ + +`; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +// --------------------------------------------------------------------------- +// Remembered approvals ("Approve and don't ask again") +// --------------------------------------------------------------------------- + +/** + * A grant is scoped to ONE tool address, on ONE artifact, for ONE viewer. + * + * - Tool address: `interaction.address`, the only stable identity a pause + * carries (the execution id is minted per call). Approving + * `github.issues.create` here says nothing about `github.repos.delete`. + * - Artifact: an artifact's bindings are what turn that address into a real + * connection, so the same address on a different artifact is a different + * action and asks again. + * - Viewer: v1 scoping is the storage itself. The shell frame is same-origin + * with the console (see the outer sandbox in `artifact-renderer.tsx`), so + * these grants live in the console origin's `localStorage`, which is already + * per browser profile and therefore per signed-in person at this machine. + * There is no viewer id inside the shell frame and inventing one would be a + * fiction; when grants need to follow a viewer across devices they belong on + * the server, keyed by the real viewer id. + * + * Nothing here is a security boundary. The SERVER still gates every call; this + * only decides whether the shell shows the human a modal it would otherwise + * answer the same way. + */ +const APPROVAL_GRANTS_STORAGE_KEY = "executor.shell.approval-grants.v1"; + +/** + * `localStorage`, or `null` when it is unusable. + * + * The shell document is same-origin with the console and normally has storage, + * but that is a property of how it is embedded, not a guarantee: an opaque + * origin throws on the property read itself, and a browser with site data + * blocked throws on write while still exposing the object. Both are probed + * here, once per call, so an approval can never be broken by storage — the + * worst case is that a grant is not remembered. + */ +const approvalGrantStorage = (): Storage | null => { + try { + const storage = globalThis.localStorage; + const probe = `${APPROVAL_GRANTS_STORAGE_KEY}.probe`; + storage.setItem(probe, "1"); + storage.removeItem(probe); + return storage; + } catch { + return null; + } +}; + +const readApprovalGrants = (): ReadonlySet => { + const storage = approvalGrantStorage(); + if (!storage) return new Set(); + try { + const raw = storage.getItem(APPROVAL_GRANTS_STORAGE_KEY); + if (!raw) return new Set(); + const parsed: unknown = JSON.parse(raw); + return new Set( + Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === "string") + : [], + ); + } catch { + return new Set(); + } +}; + +const rememberApprovalGrant = (key: string): void => { + const storage = approvalGrantStorage(); + if (!storage) return; + const next = new Set(readApprovalGrants()); + next.add(key); + try { + storage.setItem(APPROVAL_GRANTS_STORAGE_KEY, JSON.stringify([...next])); + } catch { + // Quota, or storage revoked between the probe and the write. The approval + // the user just gave still stands; only the memory of it is lost. + } +}; + +/** + * `null` when this pause cannot be scoped — no artifact id, or a pause with no + * address on it. Such an interaction is never auto-approved and never offers to + * be remembered, because a grant we cannot scope is a grant we cannot honor + * precisely. + */ +const approvalGrantKey = (artifactId: string | undefined, address: unknown): string | null => + artifactId !== undefined && + artifactId.length > 0 && + typeof address === "string" && + address.length > 0 + ? // A space separates the two halves: artifact ids are `art_`-prefixed + // base36 and tool addresses are dot-joined identifiers, so neither can + // contain one and no pair can collide. + `${artifactId} ${address}` + : null; + +// --------------------------------------------------------------------------- +// Shell App — connects to MCP host, receives code, renders components +// --------------------------------------------------------------------------- + +export function McpAppsShell({ + app, + initialCode, +}: { + app: McpAppsShellHost; + initialCode?: string | undefined; +}) { + const [component, setComponent] = useState(null); + const [renderer, setRenderer] = useState(null); + const [error, setError] = useState(null); + const [hostContext, setHostContext] = useState(); + const [pendingInteraction, setPendingInteraction] = useState(null); + const callToolRef = useRef< + (path: readonly string[], args: readonly unknown[], role?: string) => Promise + >(() => Promise.resolve(null)); + // Which artifact is rendering. Set from the tool input the host delivers + // alongside the code, so the server can look up this artifact's connection + // bindings when the component calls a tool. Held in a ref rather than state + // because only the tool caller reads it, and rebuilding the caller on every + // delivery would race the first call. + const artifactIdRef = useRef(undefined); + const pendingInteractionRef = useRef(null); + const rendererFrameRef = useRef(null); + const rendererRef = useRef(null); + // Whether the embedding host can store a preview snapshot. Held in a ref + // because the renderer message handler is built once and must not be rebuilt + // when the context changes. + const capturePreviewRef = useRef(false); + capturePreviewRef.current = hostContextAllowsPreviewCapture(hostContext); + // Whether the host has bounded this shell. Held in a ref for the same reason + // as the capture flag: the renderer message handler is built once, and reading + // this from the closure would pin whichever mode was current when it was made. + const fillRef = useRef(false); + fillRef.current = isFillDisplayMode(hostContext); + // Hand a captured snapshot to the host. Swallows every failure: a preview is + // a picture for a list, and the artifact the user is looking at does not + // depend on it in any way. + const savePreviewRef = useRef<(artifactId: string, preview: string) => void>(() => {}); + savePreviewRef.current = (artifactId, preview) => { + void Promise.resolve( + app.callServerTool({ + name: SAVE_ARTIFACT_PREVIEW_TOOL, + arguments: { artifactId, preview }, + }), + ).then( + () => undefined, + () => undefined, + ); + }; + // Whether the host asked to be told when the artifact paints. A ref for the + // same reason as the two above: the renderer message handler is built once, + // and reading this from the closure would pin whichever context was current + // when it was made. + const wantsRenderSignalRef = useRef(false); + wantsRenderSignalRef.current = hostContextWantsRenderSignal(hostContext); + /** + * Tell the host the artifact is on screen. At most once per shell. + * + * Once-only because the host's question is "may I take my loading surface + * down", which is answered exactly once; a re-render inside the artifact is + * not a second answer. The latch lives here rather than in the inner frame so + * it survives that frame remounting. + * + * ## The fact outlives the capability to report it + * + * "The artifact painted" and "the host wants to know" arrive over two + * INDEPENDENT channels, and their order is not guaranteed: the paint comes up + * from the inner frame, while the capability comes down on the host context — + * which on a fast host lands before the first render and on a slow one arrives + * after it. Dropping the signal because the context had not arrived yet leaves + * the host holding its loading surface over a fully rendered artifact, + * forever, since the paint never happens twice. + * + * So the paint is REMEMBERED rather than tested against the capability at the + * moment it occurs. Whichever of the two arrives second sends the call. + * + * Swallows every failure, like the preview relay: a host that does not + * implement the name rejects the call, and the correct response to that is + * nothing at all. + */ + const renderSignalSentRef = useRef(false); + /** The artifact has painted, whether or not anyone could be told yet. */ + const renderedRef = useRef(false); + const signalRenderedRef = useRef<() => void>(() => {}); + signalRenderedRef.current = () => { + renderedRef.current = true; + if (renderSignalSentRef.current || !wantsRenderSignalRef.current) return; + renderSignalSentRef.current = true; + void Promise.resolve( + app.callServerTool({ + name: ARTIFACT_RENDERED_TOOL, + ...(artifactIdRef.current === undefined + ? {} + : { arguments: { artifactId: artifactIdRef.current } }), + }), + ).then( + () => undefined, + () => undefined, + ); + }; + + // The other order: the artifact painted before the host said it wanted to + // know. Runs on every host-context change, and the latch inside makes all but + // the first a no-op. + useEffect(() => { + if (renderedRef.current) signalRenderedRef.current(); + }, [hostContext]); + + useEffect(() => { + rendererRef.current = renderer; + }, [renderer]); + + const requestTrustedInteraction = useCallback( + (interaction: TrustedInteraction): Promise => + new Promise((resolve) => { + // Remembered approvals are answered HERE, before any state is set, so a + // granted tool never mounts the modal — not even for a frame. + const grantKey = approvalGrantKey(artifactIdRef.current, interaction.interaction.address); + if (grantKey !== null && readApprovalGrants().has(grantKey)) { + // A schema-bearing elicitation is only auto-answerable when its own + // defaults satisfy it. A required field with no default has no + // answer the user ever gave, so fall through to the modal rather + // than resuming with fabricated values. + const content = elicitationDefaultContent(interaction.interaction.requestedSchema); + if (content !== null) { + resolve({ action: "accept", content }); + return; + } + } + + if (pendingInteractionRef.current) { + resolve({ action: "cancel" }); + return; + } + + const pending = { ...interaction, resolve, grantKey }; + pendingInteractionRef.current = pending; + setPendingInteraction(pending); + }), + [], + ); + + const completeTrustedInteraction = useCallback((response: TrustedInteractionResponse) => { + const pending = pendingInteractionRef.current; + pendingInteractionRef.current = null; + setPendingInteraction(null); + pending?.resolve(response); + }, []); + + const postToRenderer = useCallback((message: Record) => { + const current = rendererRef.current; + const target = rendererFrameRef.current?.contentWindow; + if (!current || !target) return; + target.postMessage({ ...message, token: current.token }, "*"); + }, []); + + useEffect(() => { + const handleRendererMessage = (event: MessageEvent) => { + const current = rendererRef.current; + if (!current || event.source !== rendererFrameRef.current?.contentWindow) return; + const data = event.data; + if (!isRecord(data) || data.token !== current.token) return; + const source = event.source; + if (!source || typeof source.postMessage !== "function") return; + const respond = (requestId: number, ok: boolean, value?: unknown, error?: string) => { + source.postMessage( + { + type: "executor.response", + requestId, + token: current.token, + ok, + value, + error, + }, + "*", + ); + }; + + if (data.type === "executor.renderer.ready") { + postToRenderer({ + type: "executor.render", + code: current.code, + theme: hostContext?.theme, + // The inner frame is the last link in the height chain, and it cannot + // see the host context — so the mode is told to it, the same way the + // theme and the preview capability already are. + fill: fillRef.current, + // Only the console advertises somewhere to put a preview. Passing the + // capability down rather than letting the frame guess keeps the inner + // renderer host-agnostic: under a real MCP client this is absent and + // no capture work happens at all. + capturePreview: capturePreviewRef.current, + }); + return; + } + + if (data.type === "executor.renderer.config") { + setRenderer((prev) => + prev && prev.token === current.token + ? { ...prev, config: isRecord(data.config) ? data.config : {} } + : prev, + ); + return; + } + + if (data.type === "executor.renderer.size") { + const height = typeof data.height === "number" ? Math.ceil(data.height) : current.height; + setRenderer((prev) => + prev && prev.token === current.token + ? { ...prev, height: Math.max(120, Math.min(4000, height)) } + : prev, + ); + return; + } + + if (data.type === "executor.renderer.rendered") { + signalRenderedRef.current(); + return; + } + + if (data.type === "executor.renderer.error") { + if (typeof data.message === "string") { + console.error("[executor-shell] Renderer error:", data.message); + } + // The frame rendered an error surface, which is still something for the + // user to look at — `RenderSignal` wraps the error path too, so the + // `rendered` message is already on its way. Nothing extra to do here. + return; + } + + if (data.type === "executor.renderer.preview") { + // Relay outward and forget. The picture is a nicety: nothing here waits + // on it, retries it, or reports its failure to the user. It travels + // over the same `callServerTool` seam as every other host call rather + // than a bespoke channel, and only to a host that asked for it. + // + // Through a ref for the same reason the tool caller is: this handler is + // built once, and reading `app` from the closure would pin whichever + // host object existed when it was created. + const artifactId = artifactIdRef.current; + if ( + capturePreviewRef.current && + artifactId !== undefined && + typeof data.preview === "string" && + data.preview !== "" + ) { + savePreviewRef.current(artifactId, data.preview); + } + return; + } + + // The ONLY request the generated iframe may make. There is no code + // channel: the inner renderer sends a tool path plus args, and the outer + // frame is what turns that into the one `execute-action` grammar. + if (data.type === "executor.toolCall") { + if (!Array.isArray(data.path)) { + respond(data.requestId, false, undefined, "Invalid tool path."); + return; + } + callToolRef + .current( + data.path as readonly string[], + Array.isArray(data.args) ? data.args : [], + typeof data.role === "string" && data.role.length > 0 ? data.role : undefined, + ) + .then((value) => respond(data.requestId, true, value)) + .catch((err: unknown) => + respond( + data.requestId, + false, + undefined, + err instanceof Error ? err.message : String(err), + ), + ); + } + }; + + window.addEventListener("message", handleRendererMessage); + return () => window.removeEventListener("message", handleRendererMessage); + }, [hostContext?.theme, postToRenderer]); + + useEffect(() => { + if (renderer) { + postToRenderer({ type: "executor.theme", theme: hostContext?.theme }); + } + }, [hostContext?.theme, postToRenderer, renderer]); + + /** + * Whether the host has given this shell a bounded viewport to fill. + * + * The shell does not decide this and measures nothing to find out — the host + * owns the mode and says so in its context. That is what lets the inner + * renderer keep reporting its size unconditionally in every mode: a fill-mode + * host simply does not consume the report. + */ + const fill = isFillDisplayMode(hostContext); + + // Relay a mode change to an already-rendered frame. `executor.render` carries + // the mode for a frame that has not booted yet; this covers the frame that is + // already up when the host changes its mind — which is not hypothetical, since + // the console's very first context arrives before it has measured its + // container and switches to fill once it has. + useEffect(() => { + if (renderer) { + postToRenderer({ type: "executor.fill", fill }); + } + }, [fill, postToRenderer, renderer]); + + /** Render a JSX code string in the sandboxed inner iframe. */ + const renderCode = useCallback((code: string) => { + try { + const token = createRendererToken(); + const nextRenderer = { + token, + code, + srcDoc: buildRendererSrcDoc(token), + config: {}, + height: 240, + }; + rendererRef.current = nextRenderer; + setRenderer(nextRenderer); + setComponent(null); + setError(null); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(`Compilation error: ${msg}`); + setComponent(null); + rendererRef.current = null; + setRenderer(null); + } + }, []); + + useEffect(() => { + callToolRef.current = createToolCaller( + app, + requestTrustedInteraction, + () => artifactIdRef.current, + ); + + // Handle tool input — fires on init (including page reload) with + // the tool arguments. For generative UI the arguments contain { code }. + app.ontoolinput = (params: { arguments?: Record }) => { + const artifactId = params.arguments?.artifactId; + if (typeof artifactId === "string") artifactIdRef.current = artifactId; + const code = params.arguments?.code; + if (code && typeof code === "string") { + renderCode(code); + } + }; + + app.ontoolresult = (result: CallToolResult) => { + const structured = result.structuredContent as Record | undefined; + const code = structured?.code; + // The result is where `create-artifact` / `show-artifact` report the id + // they saved under; the input only carries one on a reload replay. + const artifactId = structured?.artifactId; + if (typeof artifactId === "string") artifactIdRef.current = artifactId; + + if (code && typeof code === "string") { + renderCode(code); + return; + } + + // Not a generative UI result — render a data view + const DataView = () => { + const text = result.content?.find((c) => c.type === "text")?.text; + const isError = (result as { isError?: boolean }).isError; + const data = structured as Record | undefined; + + return ( + + + {isError ? ( + + + Error + + {text ?? "Unknown error"} + + + ) : ( +
+                  {data ? JSON.stringify(data, null, 2) : (text ?? "(no result)")}
+                
+ )} +
+
+ ); + }; + setComponent(() => DataView); + rendererRef.current = null; + setRenderer(null); + setError(null); + }; + + app.onerror = (err) => { + console.error("[executor-shell] App error:", err); + }; + + app.onhostcontextchanged = (ctx: McpUiHostContext) => { + // A host-context change notification carries only the CHANGED fields, so + // the merged context — not the delta — is what the mode must be read + // from: a resize sends `containerDimensions` alone, with no `displayMode` + // beside it, and judging the delta would read that as "no longer fill". + setHostContext((prev) => { + const next = { ...prev, ...ctx }; + applyFillMode(next); + return next; + }); + applyTheme(ctx); + }; + + (app as { onteardown?: () => Promise> }).onteardown = async () => { + return {}; + }; + }, [app, renderCode, requestTrustedInteraction]); + + // Apply initial host context + useEffect(() => { + const ctx = app.getHostContext(); + if (ctx) { + setHostContext(ctx); + applyTheme(ctx); + applyFillMode(ctx); + } + }, [app]); + + useEffect(() => { + if (initialCode) renderCode(initialCode); + }, [initialCode, renderCode]); + + /** + * The two surfaces that never reach the inner frame still end the wait. + * + * `error` is a shell-level compile failure and `component` is the data view a + * non-generative tool result renders — neither creates a renderer frame, so + * neither can emit `executor.renderer.rendered`. Without this, a host holding + * a loading surface would hold it forever on exactly the paths where there is + * nothing more coming. + * + * The signal itself is latched once, so this and the frame's own message + * cannot both take effect. + */ + useEffect(() => { + if (error !== null || component !== null) signalRenderedRef.current(); + }, [component, error]); + + if (error) { + return ( +
+ +
+ ); + } + + if (!component && !renderer) { + /* + A host that holds its own loading surface gets NOTHING here. + + The console does: it shows one skeleton from navigation until the + `rendered` signal, and this card would be a second loading state + underneath the first — briefly visible at every edge the cover does not + reach, and drawn for a host that explicitly said it is already handling + the wait. Asking for the signal is exactly the statement "I am showing the + user something; do not show them something else." + + Every other host — every real MCP client — still gets the card, because + there a bare empty frame while the shell boots is worse than a placeholder. + */ + if (wantsRenderSignalRef.current) return null; + + return ( +
+ +
+ ); + } + + const Component = component; + const config = renderer?.config ?? {}; + const maxHeight = resolveMaxHeight(config, hostContext); + const rendererHeight = renderer + ? maxHeight === undefined + ? renderer.height + : Math.min(renderer.height, maxHeight) + : undefined; + + return ( + +
+ {renderer ? ( +