History

Merge remote-tracking branch 'origin/main' into connection-create-conflict

Rhys Sullivan committed 2 months ago

Commit 14e82e52 parents 641aa73 ecb87de93 files · +3675 −178Browse files

Unified · Split

93 files
  1. .changeset/dialog-outside-click-keeps-form-state.md +11 −0
  2. .changeset/foreign-org-slug-not-found.md +13 −0
  3. .changeset/legacy-bigint-storage-class.md +11 −0
  4. .changeset/mcp-add-url-typing-probe.md +11 −0
  5. .changeset/mcp-integration-families.md +5 −0
  6. .changeset/mcp-remote-request-headers.md +15 −0
  7. .changeset/mcp-stdio-edit-ui.md +26 −0
  8. .changeset/mcp-tool-meta-passthrough.md +11 −0
  9. .changeset/selfhost-additional-trusted-origins.md +10 −0
  10. .changeset/sidebar-integration-connect.md +15 −0
  11. .changeset/tools-read-stale-sync-grace.md +11 −0
  12. .changeset/update-check-suppress-false-nags.md +14 −0
  13. .github/ISSUE_TEMPLATE/bug_report.yml +100 −0
  14. .github/ISSUE_TEMPLATE/feature_request.yml +51 −0
  15. .github/PULL_REQUEST_TEMPLATE.md +30 −0
  16. apps/cloud/src/engine/execution-stack.ts +5 −1
  17. apps/cloud/src/routes/__root.tsx +25 −7
  18. apps/cloud/src/routes/app/billing_.plans.tsx +4 −2
  19. apps/cloud/src/web/components/support-slot.tsx +2 −1
  20. apps/docs/hosted/docker.mdx +17 −0
  21. apps/host-selfhost/.env.example +5 −0
  22. apps/host-selfhost/src/auth/better-auth.test.ts +79 −0
  23. apps/host-selfhost/src/auth/better-auth.ts +33 −9
  24. apps/host-selfhost/src/auth/invalid-origin-help.test.ts +2 −0
  25. apps/host-selfhost/src/auth/invalid-origin-help.ts +4 −3
  26. apps/host-selfhost/src/auth/origin-resolution.test.ts +53 −1
  27. apps/host-selfhost/src/config.ts +56 −1
  28. apps/host-selfhost/src/db/data-migrations.ts +10 −1
  29. apps/local/src/db/data-migrations.ts +6 −0
  30. apps/local/src/db/legacy-bigint-boot.test.ts +120 −0
  31. apps/local/vite.config.ts +35 −1
  32. bun.lock +1 −0
  33. e2e/local/stdio-mcp-edit-ui.test.ts +138 −0
  34. e2e/local/stdio-mcp.test.ts +42 −0
  35. e2e/scenarios/graphql-introspection-health.test.ts +62 −0
  36. e2e/scenarios/integration-families.test.ts +69 −0
  37. e2e/scenarios/mcp-add-url-typing-probe.test.ts +48 −0
  38. e2e/scenarios/org-slug-routing.test.ts +59 −13
  39. e2e/scenarios/policy-remove-confirm.test.ts +85 −0
  40. e2e/scenarios/provider-plugins-ui.test.ts +5 −3
  41. e2e/selfhost/execute-emit-envelope.test.ts +94 −0
  42. e2e/selfhost/mcp-request-headers-add.test.ts +133 −0
  43. packages/core/api/src/index.ts +3 −0
  44. packages/core/api/src/server/scoped-executor.ts +10 −0
  45. packages/core/api/src/update-check.test.ts +103 −2
  46. packages/core/api/src/update-check.ts +58 −3
  47. packages/core/sdk/src/executor.ts +66 −1
  48. packages/core/sdk/src/index.ts +9 −0
  49. packages/core/sdk/src/sqlite-bigint-storage-class-migration.test.ts +202 −0
  50. packages/core/sdk/src/sqlite-bigint-storage-class-migration.ts +166 −0
  51. packages/plugins/graphql/src/sdk/plugin.test.ts +5 −1
  52. packages/plugins/graphql/src/sdk/plugin.ts +4 −4
  53. packages/plugins/mcp/package.json +1 −0
  54. packages/plugins/mcp/src/api/group.ts +2 −0
  55. packages/plugins/mcp/src/api/handlers.ts +4 −0
  56. packages/plugins/mcp/src/react/AddMcpIntegration.tsx +41 −19
  57. packages/plugins/mcp/src/react/EditMcpIntegration.tsx +158 −22
  58. packages/plugins/mcp/src/react/McpRequestHeadersEditor.tsx +118 −0
  59. packages/plugins/mcp/src/react/probe-url.test.ts +63 −0
  60. packages/plugins/mcp/src/react/probe-url.ts +25 −0
  61. packages/plugins/mcp/src/react/request-headers.test.ts +83 −0
  62. packages/plugins/mcp/src/react/request-headers.ts +58 −0
  63. packages/plugins/mcp/src/react/stdio-fields.test.ts +70 −0
  64. packages/plugins/mcp/src/react/stdio-fields.ts +51 −0
  65. packages/plugins/mcp/src/sdk/catalog-sync.test.ts +104 −1
  66. packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts +18 −0
  67. packages/plugins/mcp/src/sdk/index.ts +1 −0
  68. packages/plugins/mcp/src/sdk/manifest.ts +16 −1
  69. packages/plugins/mcp/src/sdk/plugin.test.ts +81 −0
  70. packages/plugins/mcp/src/sdk/plugin.ts +26 −8
  71. packages/plugins/mcp/src/sdk/probe-shape.test.ts +90 −0
  72. packages/plugins/mcp/src/sdk/probe-shape.ts +28 −17
  73. packages/plugins/mcp/src/sdk/types.ts +14 −0
  74. packages/plugins/mcp/src/testing/server.ts +12 −0
  75. packages/react/src/components/add-account-modal.tsx +3 −6
  76. packages/react/src/components/command.tsx +6 −0
  77. packages/react/src/components/dialog.tsx +15 −12
  78. packages/react/src/components/remove-oauth-app-dialog.tsx +2 −1
  79. packages/react/src/components/sheet.tsx +13 −10
  80. packages/react/src/components/sidebar.tsx +3 −0
  81. packages/react/src/components/update-card.test.ts +58 −0
  82. packages/react/src/components/update-card.tsx +33 −11
  83. packages/react/src/lib/integration-grouping.test.ts +14 −1
  84. packages/react/src/lib/integration-grouping.ts +1 −3
  85. packages/react/src/lib/outside-dismiss.test.ts +77 −0
  86. packages/react/src/lib/outside-dismiss.ts +53 −0
  87. packages/react/src/multiplayer/org-slug-gate.tsx +5 −1
  88. packages/react/src/multiplayer/shell.tsx +26 −2
  89. packages/react/src/pages/admin-users.tsx +2 −1
  90. packages/react/src/pages/api-keys.tsx +45 −4
  91. packages/react/src/pages/integrations.tsx +1 −1
  92. packages/react/src/pages/org.tsx +52 −2
  93. packages/react/src/pages/policies.tsx +50 −1
.changeset/dialog-outside-click-keeps-form-state.md+11−0
@@ -0,0 +1,11 @@
1---
2"@executor-js/react": patch
3---
4
5**A click outside a dialog or sheet no longer discards the form inside it**
6
7Radix dismisses an overlay surface on any un-prevented outside interaction, and `DialogContent` and `SheetContent` only prevented that for clicks landing in a portaled combobox or select popup. Every other outside click fell through to dismissal, so a stray click on the page behind a form — after switching windows to copy an ID, for example — closed the surface and destroyed what the user had typed. These surfaces unmount their state on close by design, so nothing was recoverable.
8
9The default is now the opposite: an outside interaction keeps the surface open. Escape and the close button are unchanged and still close it. `DialogContent` and `SheetContent` take a new `dismissOnOutsideClick` prop for surfaces with nothing to lose — confirmations, pickers, and read-only panels — and the portaled-popup guard still applies there, so choosing a combobox option never dismisses.
10
11`CommandDialog` sets `dismissOnOutsideClick` on by default, because a command palette holds only a search string and clicking away is the expected way to leave it.
.changeset/foreign-org-slug-not-found.md+13−0
@@ -0,0 +1,13 @@
1---
2"executor": patch
3---
4
5**A console URL naming an organization you cannot see is a not-found page, every time**
6
7Opening `/<some-other-slug>/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.
8
9The 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.
10
11The 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.
12
13The 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.
.changeset/legacy-bigint-storage-class.md+11−0
@@ -0,0 +1,11 @@
1---
2"executor": patch
3---
4
5**Saved integrations come back after an upgrade left an OAuth expiry in the old number format**
6
7Some 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.
8
9The `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.
10
11A 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.
.changeset/mcp-add-url-typing-probe.md+11−0
@@ -0,0 +1,11 @@
1---
2"@executor-js/plugin-mcp": patch
3---
4
5**The add-MCP form stops dialling the server URL while it is still being typed**
6
7The Server URL field auto-probes the endpoint after a 400ms pause. The only condition on that probe was that the trimmed value was non-empty, so every pause in typing dialled whatever was in the field: "h", "http://", the "a" in "http://a". Each of those probes failed, and the field dropped into a loading state and then an error with a retry button, for a value the user never meant to submit.
8
9The probe now runs only when the value looks like a finished endpoint: it parses as a URL, its scheme is http or https, and its hostname is either a local development host or has a dot with a label on each side. The debounce is unchanged, so a completed URL is still probed without the user having to submit.
10
11A probe that is superseded is also no longer allowed to answer. The field could previously report the outcome of a request for a URL that had since been edited, because each probe dispatched its result unconditionally. Editing the URL now invalidates any probe already in flight, and its reply is discarded rather than applied to the current value.
.changeset/mcp-integration-families.md+5−0
@@ -0,0 +1,5 @@
1---
2"@executor-js/plugin-mcp": patch
3---
4
5Allow MCP integrations to declare a catalog family and group any family with multiple services in the integrations UI.
.changeset/mcp-remote-request-headers.md+15−0
@@ -0,0 +1,15 @@
1---
2"executor": patch
3---
4
5**Fix: add remote MCP servers that sit behind an authenticating proxy**
6
7The add-MCP form now carries an optional request headers editor. The name/value
8pairs are sent on the connection check and on every later request, so an
9endpoint gated by an edge authenticator — a Cloudflare Access service token,
10for example — can be discovered and added.
11
12A `403` from such a gate is also no longer read as an unreachable server. It is
13classified the same way a `401` is: the endpoint needs credentials, so the add
14flow continues to the auth step instead of stopping on "Couldn't reach this
15URL".
.changeset/mcp-stdio-edit-ui.md+26−0
@@ -0,0 +1,26 @@
1---
2"executor": patch
3---
4
5**Stdio MCP integrations can be edited from the UI**
6
7The integration Edit sheet showed stdio servers as read-only text and told you
8to remove and recreate the integration to change its command. Fixing a typo in
9an argument, moving a server to a new path, or adding a static environment
10variable meant editing `executor.jsonc` by hand, or losing the integration's
11connections and tool policies to a delete-and-re-add.
12
13The sheet now edits the command, its arguments, the working directory, and the
14declared environment map, staged and applied by the sheet's own Save like the
15remote editor beside it. Arguments use the same quote-aware parsing as the add
16flow, so an argument containing spaces survives a round trip.
17
18The environment field edits the DECLARED static variables only. A stdio server
19receives those plus a small fixed base set — it does not inherit executor's
20environment — and secret values still belong to the connection, entered per
21account against the server's declared `stdio_env` method.
22
23Saving revises the integration config, which is already enough to rebuild the
24tool catalog: connections whose catalog predates the revision re-list on their
25next read, so an edited command's tools are correct without an explicit
26refresh.
.changeset/mcp-tool-meta-passthrough.md+11−0
@@ -0,0 +1,11 @@
1---
2"@executor-js/plugin-mcp": patch
3---
4
5**An MCP tool's reserved `_meta` map survives `tools/list` decoding and reaches the persisted catalog**
6
7The MCP spec reserves `_meta` on `Tool` for implementation-defined data, and servers use it for host-only routing and policy hints that do not belong in the closed `annotations` set. The plugin decoded each listed tool with a closed struct that did not declare the field, so `_meta` was discarded before the manifest entry was built. A host that embeds the plugin as its MCP client had no way to recover it: no hook exposes the raw `tools/list` result, and `connections.refresh()` answers with already-built tools.
8
9The listed-tool decode now declares `_meta`, and the manifest entry carries it through. Executor's own `Tool` has no `_meta` field, so `toToolDef` stamps the map into the `mcp` envelope the plugin already persists in each tool row's annotations, next to the real MCP tool name. The stamp schema declares it too, so it is not stripped a second time when a row is read back at invoke time. A host reads it from `annotations.mcp._meta`.
10
11The map stays opaque. Nothing in the plugin interprets its contents, and it is never merged into anything the model sees. Because it is entirely server-controlled, it is decoded permissively: a `_meta` that is not the spec's map shape is ignored for that tool rather than failing the whole-list decode, which would otherwise drop every tool the server advertises.
.changeset/selfhost-additional-trusted-origins.md+10−0
@@ -0,0 +1,10 @@
1---
2"executor": patch
3---
4
5Self-hosted instances can now allow additional browser origins without changing
6their canonical public URL. Set `EXECUTOR_TRUSTED_ORIGINS` to a comma-separated
7list of exact HTTP or HTTPS origins when one instance is intentionally reachable
8through multiple hostnames or addresses. OAuth callbacks, MCP metadata, approval
9links, and other generated URLs remain pinned to `EXECUTOR_WEB_BASE_URL`, and
10origins are never inferred from request headers.
.changeset/sidebar-integration-connect.md+15−0
@@ -0,0 +1,15 @@
1---
2"executor": patch
3---
4
5**Connect an integration from the sidebar**
6
7The sidebar lists your integrations on every console route, but connecting
8another one meant navigating back to the integrations page to reach its Connect
9action — the picker state was owned by that page, so the shared shell could
10render the list without being able to open the flow behind it.
11
12The connect dialog now belongs to the shell. A labelled plus button sits beside
13the sidebar's Integrations heading and opens the same picker, records the same
14event, and leaves the current route in place behind it. On mobile the navigation
15drawer closes first so the dialog gets the full viewport.
.changeset/tools-read-stale-sync-grace.md+11−0
@@ -0,0 +1,11 @@
1---
2"executor": patch
3---
4
5**Tools reads stop waiting on slow upstream servers**
6
7A 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.
8
9A 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.
10
11The 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.
.changeset/update-check-suppress-false-nags.md+14−0
@@ -0,0 +1,14 @@
1---
2"executor": patch
3---
4
5**Fix: stop the update check from claiming a newer version is available on builds it cannot compare**
6
7A build stamped with the placeholder 0.0.0 version always compared as older
8than the latest release, and a prerelease on a channel with no matching
9dist-tag (rc, alpha, and similar) always lost the comparison too. Both cases
10now short-circuit to "no update available" before the check reaches the
11registry.
12
13This applies wherever the update check runs, so the CLI check and the sidebar
14update card both stop showing an update prompt that a user could never act on.
.github/ISSUE_TEMPLATE/bug_report.yml+100−0
@@ -0,0 +1,100 @@
1name: Bug report
2description: Something in Executor does not work as expected.
3title: "[bug] "
4labels: ["bug"]
5body:
6 - type: markdown
7 attributes:
8 value: |
9 Please update to the latest version first — the bug may already be fixed.
10 If the desktop app crashes, will not start, or shows a blank window, use the
11 **[Desktop app crash](?template=desktop-crash.yml)** template instead.
12 - type: input
13 id: version
14 attributes:
15 label: Executor version
16 description: Run `executor --version`, or open the About menu in the desktop app.
17 placeholder: "1.5.4"
18 validations:
19 required: true
20 - type: dropdown
21 id: surface
22 attributes:
23 label: How do you run Executor?
24 options:
25 - Executor Cloud
26 - CLI
27 - Desktop app
28 - Self-host (Docker)
29 - Self-host (Cloudflare)
30 validations:
31 required: true
32 - type: dropdown
33 id: os
34 attributes:
35 label: Operating system
36 description: For Executor Cloud, select the system you browse from.
37 options:
38 - macOS (Apple Silicon)
39 - macOS (Intel)
40 - Windows
41 - Linux
42 validations:
43 required: true
44 - type: input
45 id: integration
46 attributes:
47 label: Integration involved
48 description: The integration or source type, if the bug is specific to one.
49 placeholder: "MCP server, OpenAPI spec, GraphQL API, …"
50 - type: textarea
51 id: what-happened
52 attributes:
53 label: What happened
54 description: Describe the behaviour you saw.
55 validations:
56 required: true
57 - type: textarea
58 id: expected
59 attributes:
60 label: What you expected
61 description: Describe the behaviour you wanted instead.
62 validations:
63 required: true
64 - type: textarea
65 id: repro
66 attributes:
67 label: Steps to reproduce
68 description: The smallest sequence that shows the bug. Include the commands you ran.
69 value: |
70 1.
71 2.
72 3.
73 validations:
74 required: true
75 - type: textarea
76 id: logs
77 attributes:
78 label: Diagnostics / logs
79 description: |
80 In the desktop app, open the **Executor** menu → **Export Diagnostics…** and attach
81 the zip — it has everything we need and no secrets.
82
83 Otherwise attach the relevant log:
84 - CLI / self-host: `~/.executor/logs/` (run `executor service status` for the exact path)
85 - macOS desktop: `~/Library/Logs/Executor/main.log`
86 - Windows desktop: `%APPDATA%\Executor\logs\main.log`
87 - Linux desktop: `~/.config/Executor/logs/main.log`
88
89 (Please don't attach anything else from `~/.executor` — that's where your data and
90 secrets live. Remove API keys and tokens from anything you paste.)
91 placeholder: Drag the diagnostics zip or log file here.
92 - type: checkboxes
93 id: checks
94 attributes:
95 label: Before you submit
96 options:
97 - label: I searched the open issues for a duplicate.
98 required: true
99 - label: I removed all keys, tokens, and credentials from this report.
100 required: true
.github/ISSUE_TEMPLATE/feature_request.yml+51−0
@@ -0,0 +1,51 @@
1name: Feature request
2description: Suggest a capability or an improvement for Executor.
3title: "[feature] "
4labels: ["enhancement"]
5body:
6 - type: markdown
7 attributes:
8 value: |
9 Tell us the task you want to do and why Executor cannot do it today.
10 A concrete example helps more than a general proposal.
11 - type: textarea
12 id: problem
13 attributes:
14 label: The problem
15 description: What are you trying to do, and where does Executor stop you?
16 validations:
17 required: true
18 - type: textarea
19 id: proposal
20 attributes:
21 label: Proposed shape
22 description: |
23 The change you want, as concretely as you can describe it. Name the command,
24 the setting, or the screen where it would live.
25 validations:
26 required: true
27 - type: textarea
28 id: alternatives
29 attributes:
30 label: Alternatives
31 description: Workarounds you tried, and why they are not sufficient.
32 - type: dropdown
33 id: surface
34 attributes:
35 label: Where it belongs
36 description: Select every form of Executor this applies to.
37 options:
38 - Executor Cloud
39 - CLI
40 - Desktop app
41 - Self-host (Docker)
42 - Self-host (Cloudflare)
43 - Not sure
44 multiple: true
45 - type: checkboxes
46 id: checks
47 attributes:
48 label: Before you submit
49 options:
50 - label: I searched the open issues for a duplicate.
51 required: true
.github/PULL_REQUEST_TEMPLATE.md+30−0
@@ -0,0 +1,30 @@
1<!-- Keep this short and plain. Delete the sections that do not apply. -->
2
3## Summary
4
5<!-- What does this change do, and why? One or two sentences. -->
6
7## Linked issue
8
9Fixes #
10
11## Verification
12
13<!--
14Say what you ran and what it showed. File paths alone are not evidence.
15User-visible work needs a specific e2e run or a browsable dev instance, plus
16the recording or trace that shows what to inspect. Add an e2e scenario when
17none covers the changed behaviour.
18-->
19
20- [ ] `bun run format:check`
21- [ ] `bun run lint`
22- [ ] `bun run typecheck`
23- [ ] `bun run test`
24- [ ] e2e — name the scenario, and link the recording or trace:
25
26## Checklist
27
28- [ ] Added a changeset (`bun run changeset`), or this change needs none.
29- [ ] Added or updated tests for the new behaviour.
30- [ ] No secrets, credentials, or private data in the diff.
apps/cloud/src/engine/execution-stack.ts+5−1
@@ -32,7 +32,7 @@ · 31 unmodified lines
32// seams module; the decorator is composed on top.32// seams module; the decorator is composed on top.
33// ---------------------------------------------------------------------------33// ---------------------------------------------------------------------------
34 34
35import { env } from "cloudflare:workers";35import { env, waitUntil } from "cloudflare:workers";
36import { Layer } from "effect";36import { Layer } from "effect";
37 37
38import {38import {
@@ -199,6 +199,10 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, ( · 160 unmodified lines
199 // user-selectable provider surface.199 // user-selectable provider surface.
200 exposeCredentialProviders: false,200 exposeCredentialProviders: false,
201 firstPartyOAuthClients: cloudFirstPartyOAuthClients(),201 firstPartyOAuthClients: cloudFirstPartyOAuthClients(),
202 // Workers cancel request-scoped I/O once the response settles; the ambient
203 // `waitUntil` binds to the in-flight invocation (HTTP request or DO call),
204 // so stale tool-catalog rebuilds that outlive a read still converge.
205 waitUntil,
202 // Enterprise-managed authorization ships behind a PostHog flag. Cloud is the206 // Enterprise-managed authorization ships behind a PostHog flag. Cloud is the
203 // one host with a flag service, so cloud is the one host that installs a207 // one host with a flag service, so cloud is the one host that installs a
204 // gate; everywhere else the seam stays empty and the profile is attempted as208 // gate; everywhere else the seam stays empty and the profile is attempted as
apps/cloud/src/routes/__root.tsx+25−7
@@ -262,15 +262,33 @@ function AuthGate() { · 261 unmodified lines
262 return urlOrgSlug ? <NotFoundPage /> : <BlankScreen />;262 return urlOrgSlug ? <NotFoundPage /> : <BlankScreen />;
263 }263 }
264 264
265 // The authenticated answer must NAME the org the URL names before any shell
266 // is built from it. `auth.organization` is the auth-hint cookie until
267 // `/account/me` lands, and the hint always names the session's OWN org — so
268 // on a foreign slug it is an answer about a different organization, and
269 // rendering the shell from it puts the user in a workspace the URL never
270 // named. `/account/me` is scoped by the URL's slug (getActiveOrgSlug), so
271 // once it resolves this can only agree or be null; a disagreement is
272 // therefore always an unresolved answer, never a verdict. Blank, not
273 // not-found: the 404 above is the only thing entitled to declare a wrong
274 // address, and it waits for the server.
275 //
276 // The legitimate cold load is untouched: the hint names the slug in the URL,
277 // so this matches on the very first paint and the shell renders with no
278 // round trip. Only a slug the hint does not name pays the wait — a foreign
279 // slug (which then 404s) and the frame after an org switch (which then
280 // renders the org the URL asked for, instead of flashing the previous one).
281 if (pathnameOrgSlug != null && auth.organization.slug !== pathnameOrgSlug) {
282 return <BlankScreen />;
283 }
284
265 const activeSlug = auth.organization.slug;285 const activeSlug = auth.organization.slug;
266 // The org context's slug feeds the connect card's `/<slug>/mcp` install URL.286 // The org context's slug feeds the connect card's `/<slug>/mcp` install URL.
267 // Prefer the URL's slug over the session's: on first paint `auth.organization`287 // Source it from the URL, which is the actual request scope and is correct on
268 // comes from the SSR auth-hint (the COOKIE's org), so a multi-org user viewing288 // the very first paint. The gate above has already made the two agree
269 // /<orgB> while their cookie still points at orgA would briefly render orgA's289 // whenever the URL names a slug at all, so this is the same value stated in
270 // slug in the copyable URL before /account/me (URL-scoped) corrects it. The290 // the terms the rest of the tree is keyed on. VALIDATED (pathnameOrgSlug, not
271 // URL slug is the actual request scope and is correct on the very first paint,291 // the raw route param): the `{-$orgSlug}` param also captures reserved
272 // so sourcing it from there removes that flash. VALIDATED (pathnameOrgSlug,
273 // not the raw route param): the `{-$orgSlug}` param also captures reserved
274 // console roots ("/integrations" → orgSlug "integrations"), which are not292 // console roots ("/integrations" → orgSlug "integrations"), which are not
275 // org scopes. Falls back to the auth org on a bare/reserved URL (which293 // org scopes. Falls back to the auth org on a bare/reserved URL (which
276 // OrgSlugGate canonicalizes onto it below).294 // OrgSlugGate canonicalizes onto it below).
apps/cloud/src/routes/app/billing_.plans.tsx+4−2
@@ -401,7 +401,8 @@ function EnterpriseContactDialog() { · 400 unmodified lines
401 Contact us401 Contact us
402 </Button>402 </Button>
403 </DialogTrigger>403 </DialogTrigger>
404 <DialogContent>404 {/* Static contact details only: clicking away dismisses it. */}
405 <DialogContent dismissOnOutsideClick>
405 <DialogHeader>406 <DialogHeader>
406 <DialogTitle>Talk to us about Enterprise</DialogTitle>407 <DialogTitle>Talk to us about Enterprise</DialogTitle>
407 <DialogDescription>408 <DialogDescription>
@@ -449,7 +450,8 @@ function SlackContactCta() { · 41 unmodified lines
449 <span aria-hidden>→</span>450 <span aria-hidden>→</span>
450 </Button>451 </Button>
451 </DialogTrigger>452 </DialogTrigger>
452 <DialogContent>453 {/* Static contact details only: clicking away dismisses it. */}
454 <DialogContent dismissOnOutsideClick>
453 <DialogHeader>455 <DialogHeader>
454 <DialogTitle>Get in touch on Slack</DialogTitle>456 <DialogTitle>Get in touch on Slack</DialogTitle>
455 <DialogDescription>457 <DialogDescription>
apps/cloud/src/web/components/support-slot.tsx+2−1
@@ -44,7 +44,8 @@ export function SupportSlot() { · 43 unmodified lines
44 <HelpIcon className="size-3.5 text-muted-foreground" />44 <HelpIcon className="size-3.5 text-muted-foreground" />
45 Get support45 Get support
46 </Button>46 </Button>
47 <DialogContent className="sm:max-w-[440px]">47 {/* Static links only: clicking away dismisses it. */}
48 <DialogContent dismissOnOutsideClick className="sm:max-w-[440px]">
48 <DialogHeader>49 <DialogHeader>
49 <DialogTitle className="font-display text-xl">Get support</DialogTitle>50 <DialogTitle className="font-display text-xl">Get support</DialogTitle>
50 <DialogDescription className="text-sm leading-relaxed">51 <DialogDescription className="text-sm leading-relaxed">
apps/docs/hosted/docker.mdx+17−0
@@ -60,6 +60,7 @@ the container defaults. · 59 unmodified lines
60| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |60| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. |
61| `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. |61| `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. |
62| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). |62| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). |
63| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. |
63| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. |64| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. |
64| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. |65| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. |
65| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. |66| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. |
@@ -91,6 +92,22 @@ For a headless deploy (CI or infra-as-code), set `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL · 25 unmodified lines
91and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser92and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser
92setup screen.93setup screen.
93 94
95### Additional browser aliases
96
97Keep `EXECUTOR_WEB_BASE_URL` pinned to the canonical public HTTPS origin used for
98OAuth callbacks and generated links. If the same instance is intentionally
99available through additional browser addresses, allow those exact origins
100separately:
101
102```bash
103-e EXECUTOR_WEB_BASE_URL=https://executor.example.com \
104-e EXECUTOR_TRUSTED_ORIGINS=http://executor.home.arpa:4788,http://192.0.2.10:4788
105```
106
107Only cookie-authenticated browser requests use this allowlist. OAuth callbacks,
108MCP metadata, approval links, and other absolute URLs remain pinned to
109`EXECUTOR_WEB_BASE_URL`. Origins are never inferred from request headers.
110
94## Connect an agent111## Connect an agent
95 112
96The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at113The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at
apps/host-selfhost/.env.example+5−0
@@ -8,6 +8,11 @@ · 7 unmodified lines
8# rejected. Behind a reverse proxy / TLS, set this to your public https URL.8# rejected. Behind a reverse proxy / TLS, set this to your public https URL.
9# EXECUTOR_WEB_BASE_URL=https://executor.example.com9# EXECUTOR_WEB_BASE_URL=https://executor.example.com
10 10
11# Additional browser origins allowed to authenticate with this instance, as a
12# comma-separated list. These aliases do not change OAuth callbacks or generated
13# links, which remain pinned to EXECUTOR_WEB_BASE_URL. Use exact origins only.
14# EXECUTOR_TRUSTED_ORIGINS=http://executor.home.arpa:4788,http://192.0.2.10:4788
15
11# --- Session secret -----------------------------------------------------------16# --- Session secret -----------------------------------------------------------
12# Generated and persisted under the data volume on first boot if unset. Set this17# Generated and persisted under the data volume on first boot if unset. Set this
13# to manage it yourself (must be at least 32 characters). Rotating it signs every18# to manage it yourself (must be at least 32 characters). Rotating it signs every
apps/host-selfhost/src/auth/better-auth.test.ts+79−0
@@ -7,10 +7,16 @@ import { afterAll, expect, test } from "@effect/vitest"; · 6 unmodified lines
7import { mintInviteCode } from "../testing/mint-invite";7import { mintInviteCode } from "../testing/mint-invite";
8 8
9// Real Better Auth path: set a secret + bootstrap admin before importing.9// Real Better Auth path: set a secret + bootstrap admin before importing.
10// Better Auth skips origin checks in test mode by default; this suite exercises
11// the production check so the trusted-origin cases below cover the real path.
12process.env.NODE_ENV = "production";
13process.env.TEST = "false";
10process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-"));14process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-"));
11process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv";15process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv";
12process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local";16process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local";
13process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123";17process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123";
18process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
19process.env.EXECUTOR_TRUSTED_ORIGINS = "http://executor.home.arpa:4788";
14 20
15const { makeSelfHostApiHandler } = await import("../app");21const { makeSelfHostApiHandler } = await import("../app");
16 22
@@ -19,6 +25,79 @@ afterAll(() => dispose()); · 2 unmodified lines
19 25
20const BASE = "http://localhost:4788";26const BASE = "http://localhost:4788";
21 27
28test("an HTTP trusted alias receives a usable session cookie with an HTTPS canonical URL", async () => {
29 const alias = "http://executor.home.arpa:4788";
30 const signIn = await handler(
31 new Request(`${alias}/api/auth/sign-in/email`, {
32 method: "POST",
33 headers: {
34 "content-type": "application/json",
35 origin: alias,
36 "sec-fetch-dest": "empty",
37 "sec-fetch-mode": "cors",
38 "sec-fetch-site": "same-origin",
39 },
40 body: JSON.stringify({
41 email: "admin@test.local",
42 password: "admin-password-123",
43 }),
44 }),
45 );
46 expect(signIn.status).toBe(200);
47 const sessionCookie = signIn.headers.get("set-cookie");
48 expect(sessionCookie).toContain("better-auth.session_token=");
49 expect(sessionCookie).not.toMatch(/(?:^|;\s*)Secure(?:;|$)/i);
50 expect(sessionCookie).not.toContain("__Secure-");
51});
52
53test("an explicitly trusted browser alias can sign up without changing the canonical base URL", async () => {
54 const alias = "http://executor.home.arpa:4788";
55 const inviteCode = await mintInviteCode(handler);
56 const signUp = await handler(
57 new Request(`${alias}/api/auth/sign-up/email`, {
58 method: "POST",
59 headers: {
60 "content-type": "application/json",
61 origin: alias,
62 "sec-fetch-dest": "empty",
63 "sec-fetch-mode": "cors",
64 "sec-fetch-site": "same-origin",
65 },
66 body: JSON.stringify({
67 email: "trusted-alias@test.local",
68 password: "member-password-123",
69 name: "Trusted Alias",
70 inviteCode,
71 }),
72 }),
73 );
74 expect(signUp.status).toBe(200);
75});
76
77test("an unlisted browser alias remains blocked", async () => {
78 const alias = "http://untrusted.home.arpa:4788";
79 const inviteCode = await mintInviteCode(handler);
80 const signUp = await handler(
81 new Request(`${alias}/api/auth/sign-up/email`, {
82 method: "POST",
83 headers: {
84 "content-type": "application/json",
85 origin: alias,
86 "sec-fetch-dest": "empty",
87 "sec-fetch-mode": "cors",
88 "sec-fetch-site": "same-origin",
89 },
90 body: JSON.stringify({
91 email: "untrusted-alias@test.local",
92 password: "member-password-123",
93 name: "Untrusted Alias",
94 inviteCode,
95 }),
96 }),
97 );
98 expect(signUp.status).toBe(403);
99});
100
22test("migrations create both the Better Auth and FumaDB executor schema regions", async () => {101test("migrations create both the Better Auth and FumaDB executor schema regions", async () => {
23 // Open a SEPARATE libSQL connection to the same file Better Auth (via its own102 // Open a SEPARATE libSQL connection to the same file Better Auth (via its own
24 // LibsqlDialect connection) and the FumaDB drizzle client wrote to. That this103 // LibsqlDialect connection) and the FumaDB drizzle client wrote to. That this
apps/host-selfhost/src/auth/better-auth.ts+33−9
@@ -25,6 +25,8 @@ interface SignupGate { · 24 unmodified lines
25// creation (the seed, or a future admin "add user") flows through other paths.25// creation (the seed, or a future admin "add user") flows through other paths.
26const SIGNUP_PATH = "/sign-up/email";26const SIGNUP_PATH = "/sign-up/email";
27 27
28let warnedInsecureTrustedOrigin = false;
29
28// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
29// Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor31// Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor
30// tables ("one connection, two schema regions").32// tables ("one connection, two schema regions").
@@ -63,6 +65,27 @@ const SIGNUP_PATH = "/sign-up/email"; · 32 unmodified lines
63 65
64const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => {66const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => {
65 const config = loadConfig();67 const config = loadConfig();
68 // A `Secure` session cookie is never sent back over plain HTTP, so an HTTP
69 // alias can sign in and then look signed out on every later request. Drop the
70 // attribute when ANY trusted origin is HTTP. This is not a new relaxation for
71 // the common cases: Better Auth already infers `useSecureCookies` from the
72 // baseURL scheme, so an all-HTTPS instance still gets `true` and the plain
73 // `http://localhost` default still gets `false`. It only changes the mixed
74 // case an operator opts into with EXECUTOR_TRUSTED_ORIGINS.
75 const hasInsecureTrustedOrigin = config.trustedOrigins.some((origin) =>
76 origin.startsWith("http://"),
77 );
78 // Warn only for that mixed case. An HTTP-only instance (local dev, a LAN
79 // deploy) never had Secure cookies to lose, and warning there would fire on
80 // every default boot.
81 const downgradesCanonicalCookies =
82 hasInsecureTrustedOrigin && config.webBaseUrl.startsWith("https://");
83 if (downgradesCanonicalCookies && !warnedInsecureTrustedOrigin) {
84 warnedInsecureTrustedOrigin = true;
85 console.warn(
86 "[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.",
87 );
88 }
66 // Always resolved (generated + persisted when no env is set); this guards only89 // Always resolved (generated + persisted when no env is set); this guards only
67 // an explicitly-set env secret that is too weak.90 // an explicitly-set env secret that is too weak.
68 const secret = config.authSecret;91 const secret = config.authSecret;
@@ -88,16 +111,17 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 19 unmodified lines
88 type: "sqlite" as const,111 type: "sqlite" as const,
89 },112 },
90 secret,113 secret,
91 // The browser Origin must match this exactly; CLI/MCP bearer requests carry114 // The canonical browser Origin is config.webBaseUrl; explicitly configured
92 // no Origin and are unaffected. `config.webBaseUrl` resolves from an explicit115 // aliases may also send cookie-authenticated requests. CLI/MCP bearer
93 // EXECUTOR_WEB_BASE_URL, else a platform-injected origin (Railway/Render/Fly/116 // requests carry no Origin and are unaffected. We deliberately do NOT derive
94 // …), else localhost — so a PaaS deploy is zero-config and any other host117 // either value from the request `Host`: matching the ecosystem (Windmill
95 // sets the one variable (a loud warning fires on the localhost fallback).118 // `BASE_URL`, n8n `WEBHOOK_URL`), a pinned origin keeps host-header injection
96 // See config.ts. We deliberately do NOT derive this from the request `Host`:119 // out of OAuth redirects and links. Additional trusted origins affect only
97 // matching the ecosystem (Windmill `BASE_URL`, n8n `WEBHOOK_URL`), a pinned120 // Better Auth's request validation; generated links and OAuth callbacks stay
98 // origin keeps host-header injection out of OAuth redirects and links.121 // pinned to config.webBaseUrl.
99 baseURL: config.webBaseUrl,122 baseURL: config.webBaseUrl,
100 trustedOrigins: [config.webBaseUrl],123 trustedOrigins: [...config.trustedOrigins],
124 advanced: { useSecureCookies: !hasInsecureTrustedOrigin },
101 emailAndPassword: { enabled: true },125 emailAndPassword: { enabled: true },
102 // `apiKey` issues long-lived personal keys (the API-keys page). With126 // `apiKey` issues long-lived personal keys (the API-keys page). With
103 // `enableSessionForAPIKeys`, presenting a key resolves to its owner's127 // `enableSessionForAPIKeys`, presenting a key resolves to its owner's
apps/host-selfhost/src/auth/invalid-origin-help.test.ts+2−0
@@ -16,6 +16,7 @@ test("originOf prefers Origin, then x-forwarded-host, then host", () => { · 15 unmodified lines
16test("the help message names the URL to set", () => {16test("the help message names the URL to set", () => {
17 const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788");17 const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788");
18 expect(msg).toContain("EXECUTOR_WEB_BASE_URL");18 expect(msg).toContain("EXECUTOR_WEB_BASE_URL");
19 expect(msg).toContain("EXECUTOR_TRUSTED_ORIGINS");
19 expect(msg).toContain("https://app.example.com");20 expect(msg).toContain("https://app.example.com");
20 expect(msg).toContain("http://localhost:4788");21 expect(msg).toContain("http://localhost:4788");
21});22});
@@ -38,6 +39,7 @@ test("rewriteInvalidOrigin replaces a 403 'Invalid origin' with the setup messag · 16 unmodified lines
38 const body = (await rewritten!.json()) as { code: string; message: string };39 const body = (await rewritten!.json()) as { code: string; message: string };
39 expect(body.code).toBe("INVALID_ORIGIN");40 expect(body.code).toBe("INVALID_ORIGIN");
40 expect(body.message).toContain("EXECUTOR_WEB_BASE_URL");41 expect(body.message).toContain("EXECUTOR_WEB_BASE_URL");
42 expect(body.message).toContain("EXECUTOR_TRUSTED_ORIGINS");
41 expect(body.message).toContain("https://app.example.com");43 expect(body.message).toContain("https://app.example.com");
42});44});
43 45
apps/host-selfhost/src/auth/invalid-origin-help.ts+4−3
@@ -21,10 +21,11 @@ export const originOf = (request: Request): string | null => { · 20 unmodified lines
21export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string =>21export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string =>
22 requestOrigin22 requestOrigin
23 ? `This Executor instance is configured for ${webBaseUrl}, but you're connecting from ${requestOrigin}. ` +23 ? `This Executor instance is configured for ${webBaseUrl}, but you're connecting from ${requestOrigin}. ` +
24 `Set the EXECUTOR_WEB_BASE_URL environment variable to ${requestOrigin} and restart the server, then try again. ` +24 `Set EXECUTOR_WEB_BASE_URL to ${requestOrigin} if it should be the canonical public URL, or add it to ` +
25 `(Railway, Render, Fly, Vercel, and similar hosts are detected automatically; set it explicitly for a custom domain or other host.)`25 `EXECUTOR_TRUSTED_ORIGINS if it is an intentional browser alias, then restart the server. ` +
26 `(Railway, Render, Fly, Vercel, and similar hosts detect the canonical public URL automatically.)`
26 : `This Executor instance is configured for ${webBaseUrl}. If you're reaching it at a different address, ` +27 : `This Executor instance is configured for ${webBaseUrl}. If you're reaching it at a different address, ` +
27 `set EXECUTOR_WEB_BASE_URL to that address and restart the server.`;28 `set EXECUTOR_WEB_BASE_URL to that canonical address or add the alias to EXECUTOR_TRUSTED_ORIGINS, then restart the server.`;
28 29
29/**30/**
30 * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy31 * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy
apps/host-selfhost/src/auth/origin-resolution.test.ts+53−1
@@ -20,6 +20,7 @@ process.env.EXECUTOR_DATA_DIR ??= mkdtempSync(join(tmpdir(), "origin-cfg-")); · 19 unmodified lines
20// try/finally — these are the only env vars these cases read).20// try/finally — these are the only env vars these cases read).
21const PLATFORM_VARS = [21const PLATFORM_VARS = [
22 "EXECUTOR_WEB_BASE_URL",22 "EXECUTOR_WEB_BASE_URL",
23 "EXECUTOR_TRUSTED_ORIGINS",
23 "RAILWAY_PUBLIC_DOMAIN",24 "RAILWAY_PUBLIC_DOMAIN",
24 "RENDER_EXTERNAL_URL",25 "RENDER_EXTERNAL_URL",
25 "RENDER_EXTERNAL_HOSTNAME",26 "RENDER_EXTERNAL_HOSTNAME",
@@ -33,7 +34,9 @@ const resetOriginEnv = (): void => { · 7 unmodified lines
33 34
34test("webBaseUrl falls back to localhost with no public origin", () => {35test("webBaseUrl falls back to localhost with no public origin", () => {
35 resetOriginEnv();36 resetOriginEnv();
36 expect(loadConfig().webBaseUrl).toBe("http://localhost:4788");37 const config = loadConfig();
38 expect(config.webBaseUrl).toBe("http://localhost:4788");
39 expect(config.trustedOrigins).toEqual(["http://localhost:4788"]);
37});40});
38 41
39test("webBaseUrl auto-resolves from a platform host var (Railway, host only → https)", () => {42test("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", () => · 20 unmodified lines
60 process.env.EXECUTOR_WEB_BASE_URL = "https://pinned.example.com";63 process.env.EXECUTOR_WEB_BASE_URL = "https://pinned.example.com";
61 expect(loadConfig().webBaseUrl).toBe("https://pinned.example.com");64 expect(loadConfig().webBaseUrl).toBe("https://pinned.example.com");
62});65});
66
67test("additional trusted origins are trimmed, normalized, and deduplicated", () => {
68 resetOriginEnv();
69 process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
70 process.env.EXECUTOR_TRUSTED_ORIGINS =
71 " http://executor.home.arpa:4788/, https://executor.example.com, http://192.0.2.10:4788 ";
72 expect(loadConfig().trustedOrigins).toEqual([
73 "https://executor.example.com",
74 "http://executor.home.arpa:4788",
75 "http://192.0.2.10:4788",
76 ]);
77});
78
79test("an empty or blank trusted-origins list leaves the canonical origin alone", () => {
80 resetOriginEnv();
81 process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com";
82 process.env.EXECUTOR_TRUSTED_ORIGINS = " , , ";
83 expect(loadConfig().trustedOrigins).toEqual(["https://executor.example.com"]);
84});
85
86// Every rejected shape is one an operator could plausibly type and then believe
87// was in force. A wildcard host is the dangerous one: accepting it as a literal
88// hostname would silently allow nothing while reading like it allows a whole
89// domain. A path/query/fragment reads like a scoped grant that origins cannot
90// express, and credentials in the URL are almost always a copy-paste mistake.
91test.each([
92 "https://executor.example.com/login",
93 "https://executor.example.com/?next=/",
94 "https://executor.example.com/#top",
95 "https://user:pass@executor.example.com",
96 "https://*.example.com",
97 "file:///tmp/executor",
98 "ftp://executor.example.com",
99])("a trusted origin that is not an exact http(s) origin (%s) refuses to boot", (raw) => {
100 resetOriginEnv();
101 process.env.EXECUTOR_TRUSTED_ORIGINS = raw;
102 expect(() => loadConfig()).toThrow(/exact http\(s\) origin/);
103});
104
105// A bare hostname is the most common typo, and it never parses as a URL at all,
106// so it gets the other message. Both name the variable.
107test.each(["executor.example.com", "//executor.example.com", "not a url"])(
108 "a trusted origin that is not a URL (%s) refuses to boot",
109 (raw) => {
110 resetOriginEnv();
111 process.env.EXECUTOR_TRUSTED_ORIGINS = raw;
112 expect(() => loadConfig()).toThrow(/EXECUTOR_TRUSTED_ORIGINS/);
113 },
114);
apps/host-selfhost/src/config.ts+56−1
@@ -26,6 +26,8 @@ export interface SelfHostConfig { · 25 unmodified lines
26 readonly dbPath: string;26 readonly dbPath: string;
27 /** Public base URL used by core tools that build absolute links. */27 /** Public base URL used by core tools that build absolute links. */
28 readonly webBaseUrl: string;28 readonly webBaseUrl: string;
29 /** Browser origins allowed to send cookie-authenticated requests. */
30 readonly trustedOrigins: readonly string[];
29 /**31 /**
30 * Whether sandboxed code may reach loopback/private network addresses.32 * Whether sandboxed code may reach loopback/private network addresses.
31 * Defaults to false — adversarial LLM code should not hit the host's33 * Defaults to false — adversarial LLM code should not hit the host's
@@ -155,11 +157,13 @@ const resolveWebBaseUrl = (port: number): string => { · 123 unmodified lines
155export const loadConfig = (): SelfHostConfig => {157export const loadConfig = (): SelfHostConfig => {
156 const port = Number.parseInt(process.env.PORT ?? "4788", 10);158 const port = Number.parseInt(process.env.PORT ?? "4788", 10);
157 const dataDir = resolveDataDir();159 const dataDir = resolveDataDir();
160 const webBaseUrl = resolveWebBaseUrl(port);
158 return {161 return {
159 host: process.env.EXECUTOR_HOST ?? "127.0.0.1",162 host: process.env.EXECUTOR_HOST ?? "127.0.0.1",
160 port,163 port,
161 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),164 dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"),
162 webBaseUrl: resolveWebBaseUrl(port),165 webBaseUrl,
166 trustedOrigins: resolveTrustedOrigins(webBaseUrl),
163 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",167 allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
164 authSecret: resolveAuthSecret(),168 authSecret: resolveAuthSecret(),
165 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,169 bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,
@@ -206,6 +210,57 @@ const resolveMcpSessionIdleTtlMs = (): number | undefined => { · 40 unmodified lines
206 return Math.floor(parsed);210 return Math.floor(parsed);
207};211};
208 212
213// EXECUTOR_TRUSTED_ORIGINS — extra browser origins allowed to send
214// cookie-authenticated requests when one instance is deliberately reachable
215// under more than one address (a LAN IP as well as a domain, say).
216//
217// This list widens ONLY Better Auth's origin/CSRF check. `webBaseUrl` stays the
218// single canonical origin for OAuth callbacks, MCP metadata, and every other
219// generated link, so an alias can never redirect a callback somewhere else.
220//
221// Entries must be exact origins. A path, query, fragment, credential, wildcard
222// host, or non-http(s) scheme is refused rather than trimmed off: an operator
223// who writes `https://*.example.com` means a pattern, and silently accepting it
224// as the literal host would leave them believing a wildcard is in force. Like
225// the other knobs here, a malformed value refuses to boot instead of quietly
226// leaving the browser locked out with an "Invalid origin" page.
227const normalizeTrustedOrigin = (value: string): string => {
228 if (!URL.canParse(value)) {
229 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob
230 throw new Error(
231 `EXECUTOR_TRUSTED_ORIGINS contains ${JSON.stringify(value)}, which is not a valid URL origin`,
232 );
233 }
234 const url = new URL(value);
235 if (
236 (url.protocol !== "http:" && url.protocol !== "https:") ||
237 url.username.length > 0 ||
238 url.password.length > 0 ||
239 url.hostname.includes("*") ||
240 (url.pathname !== "" && url.pathname !== "/") ||
241 url.search.length > 0 ||
242 url.hash.length > 0
243 ) {
244 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob
245 throw new Error(
246 `EXECUTOR_TRUSTED_ORIGINS entry ${JSON.stringify(value)} must be an exact http(s) origin (scheme, host, and optional port only)`,
247 );
248 }
249 return url.origin;
250};
251
252// The canonical origin always leads the list, so the unset case reproduces the
253// previous `[webBaseUrl]` exactly and an operator who repeats it in the env var
254// does not get a duplicate.
255const resolveTrustedOrigins = (webBaseUrl: string): readonly string[] => {
256 const additional = (process.env.EXECUTOR_TRUSTED_ORIGINS ?? "")
257 .split(",")
258 .map((value) => value.trim())
259 .filter((value) => value.length > 0)
260 .map(normalizeTrustedOrigin);
261 return [...new Set([webBaseUrl, ...additional])];
262};
263
209// The org slug doubles as a URL segment (`/<slug>/policies`), so an264// The org slug doubles as a URL segment (`/<slug>/policies`), so an
210// operator-set value must fit the shared grammar and avoid reserved root265// operator-set value must fit the shared grammar and avoid reserved root
211// segments (api, mcp, login, …) — a colliding slug would shadow real routes.266// segments (api, mcp, login, …) — a colliding slug would shadow real routes.
apps/host-selfhost/src/db/data-migrations.ts+10−1
@@ -5,7 +5,11 @@ · 4 unmodified lines
5// renamed.5// renamed.
6// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
7 7
8import { sqliteDataMigration, type SqliteDataMigration } from "@executor-js/sdk";8import {
9 bigintStorageClassSqliteMigration,
10 sqliteDataMigration,
11 type SqliteDataMigration,
12} from "@executor-js/sdk";
9import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";13import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
10import {14import {
11 openApiNdjsonOutputDataMigration,15 openApiNdjsonOutputDataMigration,
@@ -20,6 +24,11 @@ import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-en · 8 unmodified lines
20import { authConfigTransforms } from "./auth-config-migration";24import { authConfigTransforms } from "./auth-config-migration";
21 25
22export const selfHostDataMigrations: readonly SqliteDataMigration[] = [26export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
27 // FIRST, because it un-bricks reads every later migration and the whole app
28 // depend on: `bigint` columns an older build left in SQLite's INTEGER storage
29 // class cannot be read by the bigint row mapper, so a single legacy
30 // `connection.expires_at` failed every catalog read (issue #1771).
31 bigintStorageClassSqliteMigration,
23 // Rewrite pre-canonical integration auth configs into the shared32 // Rewrite pre-canonical integration auth configs into the shared
24 // placements model.33 // placements model.
25 sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>34 sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>
apps/local/src/db/data-migrations.ts+6−0
@@ -7,6 +7,7 @@ · 6 unmodified lines
7 7
8import {8import {
9 Effect,9 Effect,
10 bigintStorageClassSqliteMigration,
10 oauthClientGcSqliteMigration,11 oauthClientGcSqliteMigration,
11 sqliteDataMigration,12 sqliteDataMigration,
12 type SqliteDataMigration,13 type SqliteDataMigration,
@@ -30,6 +31,11 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 17 unmodified lines
30 // stamped atomically inside the staged v2 build; fresh/pre-v2-native DBs get31 // stamped atomically inside the staged v2 build; fresh/pre-v2-native DBs get
31 // the same stamp here so future boots skip legacy shape probing.32 // the same stamp here so future boots skip legacy shape probing.
32 { name: LOCAL_V1_V2_LEDGER_NAME, run: () => Effect.void },33 { name: LOCAL_V1_V2_LEDGER_NAME, run: () => Effect.void },
34 // FIRST, because it un-bricks reads every later migration and the whole app
35 // depend on: `bigint` columns an older build left in SQLite's INTEGER storage
36 // class cannot be read by the bigint row mapper, so a single legacy
37 // `connection.expires_at` failed every catalog read (issue #1771).
38 bigintStorageClassSqliteMigration,
33 // Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs)39 // Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs)
34 // into the shared placements model.40 // into the shared placements model.
35 sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>41 sqliteDataMigration("2026-06-05-auth-config-placements", (client) =>
apps/local/src/db/legacy-bigint-boot.test.ts+120−0
@@ -0,0 +1,120 @@
1// ---------------------------------------------------------------------------
2// Boot-level proof for issue #1771: a local database holding a legacy INTEGER
3// `connection.expires_at` is unreadable, and the local boot sequence heals it.
4//
5// The migration body is unit-tested in the SDK. What this pins is the WIRING —
6// that `localDataMigrations` actually carries the entry, early enough that the
7// catalog reads which follow the ledger run see repaired rows. That wiring is
8// the part that recovers a user's install; a correct migration nobody runs
9// would leave the gateway just as empty.
10// ---------------------------------------------------------------------------
11
12import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
13import { mkdtempSync, rmSync } from "node:fs";
14import { tmpdir } from "node:os";
15import { join } from "node:path";
16import { Effect } from "effect";
17import { withQueryContext } from "@executor-js/fumadb/query";
18
19import { collectTables } from "@executor-js/api/server";
20import { runSqliteDataMigrations } from "@executor-js/sdk";
21
22import { localDataMigrations } from "./data-migrations";
23import { createSqliteFumaDb } from "./sqlite-fumadb";
24
25const TENANT = "executor-workspace-1771";
26const SUBJECT = "local";
27// Epoch millis, the shape an OAuth token expiry takes.
28const LEGACY_EXPIRES_AT = 1787321623456;
29
30let workDir: string;
31
32beforeEach(() => {
33 workDir = mkdtempSync(join(tmpdir(), "executor-legacy-bigint-"));
34});
35
36afterEach(() => {
37 rmSync(workDir, { recursive: true, force: true });
38});
39
40const openDb = (dbPath: string) =>
41 createSqliteFumaDb({
42 tables: collectTables(),
43 namespace: "executor_local",
44 path: dbPath,
45 });
46
47/** Write the row a pre-`bigint` build left behind: `expires_at` as a bare
48 * integer literal, which SQLite keeps in the INTEGER storage class. */
49const seedLegacyConnection = async (dbPath: string): Promise<void> => {
50 const sqlite = await openDb(dbPath);
51 await sqlite.client.execute({
52 sql: `INSERT INTO connection
53 (row_id, tenant, owner, subject, integration, name, template, provider, item_ids,
54 expires_at, created_at, updated_at)
55 VALUES ('c1', ?, 'user', ?, 'acme', 'default', 'oauth2', 'file', ?,
56 ${LEGACY_EXPIRES_AT}, ?, ?)`,
57 args: [
58 TENANT,
59 SUBJECT,
60 JSON.stringify({ token: "item_1" }),
61 Math.floor(Date.now() / 1000),
62 Math.floor(Date.now() / 1000),
63 ],
64 });
65 await sqlite.close();
66};
67
68describe("local boot over a legacy bigint database", () => {
69 it("cannot read the connection table before the migrations run", async () => {
70 const dbPath = join(workDir, "data.db");
71 await seedLegacyConnection(dbPath);
72
73 const sqlite = await openDb(dbPath);
74 const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });
75 // The reported symptom: not a wrong value, a throw — so the gateway lost
76 // every saved integration at once.
77 await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/);
78 await sqlite.close();
79 });
80
81 it("heals it through the local data-migration registry", async () => {
82 const dbPath = join(workDir, "data.db");
83 await seedLegacyConnection(dbPath);
84
85 const sqlite = await openDb(dbPath);
86 const applied = await Effect.runPromise(
87 runSqliteDataMigrations(sqlite.client, localDataMigrations),
88 );
89 expect(applied).toContain("2026-08-28-bigint-storage-class");
90
91 const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });
92 const rows = await scoped.findMany("connection", {});
93 expect(rows.map((row) => [row.name, Number(row.expires_at)])).toEqual([
94 ["default", LEGACY_EXPIRES_AT],
95 ]);
96 await sqlite.close();
97 });
98
99 it("stays readable across a reboot", async () => {
100 const dbPath = join(workDir, "data.db");
101 await seedLegacyConnection(dbPath);
102
103 const first = await openDb(dbPath);
104 await Effect.runPromise(runSqliteDataMigrations(first.client, localDataMigrations));
105 await first.close();
106
107 // Second boot: the entry is stamped, so it is skipped — the data must
108 // already be in the shape the mapper reads.
109 const second = await openDb(dbPath);
110 const applied = await Effect.runPromise(
111 runSqliteDataMigrations(second.client, localDataMigrations),
112 );
113 expect(applied).not.toContain("2026-08-28-bigint-storage-class");
114
115 const scoped = withQueryContext(second.db, { tenant: TENANT, subject: SUBJECT });
116 const rows = await scoped.findMany("connection", {});
117 expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]);
118 await second.close();
119 });
120});
apps/local/vite.config.ts+35−1
@@ -1,5 +1,6 @@
1import { readFileSync } from "node:fs";1import { readFileSync } from "node:fs";
2import { resolve } from "node:path";2import { createRequire } from "node:module";
3import { dirname, resolve } from "node:path";
3import { Readable } from "node:stream";4import { Readable } from "node:stream";
4import { fileURLToPath } from "node:url";5import { fileURLToPath } from "node:url";
5import { defineConfig, type Plugin } from "vite";6import { defineConfig, type Plugin } from "vite";
@@ -42,6 +43,19 @@ const EXECUTOR_GITHUB_URL = ( · 36 unmodified lines
42const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));43const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
43const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));44const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));
44 45
46// js-yaml reaches the browser through @executor-js/plugin-openapi's UI, so it
47// belongs in the pre-bundle below. Vite resolves `optimizeDeps.include` from
48// `root`, which here is packages/app, and bun's isolated install keeps js-yaml
49// in the openapi plugin's own node_modules — out of reach of both a plain
50// "js-yaml" specifier and the "<pkg> > <dep>" form from that directory. Point
51// the bare specifier at the package the plugin already resolves, so `root` can
52// name it. It is the same copy either way, so nothing about resolution changes.
53const JS_YAML_DIR = dirname(
54 createRequire(fileURLToPath(new URL("../../packages/plugins/openapi/", import.meta.url))).resolve(
55 "js-yaml",
56 ),
57);
58
45const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response =>59const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response =>
46 new Response(60 new Response(
47 JSON.stringify(61 JSON.stringify(
@@ -215,6 +229,25 @@ export default defineConfig({ · 167 unmodified lines
215 outDir: resolve(import.meta.dirname, "dist"),229 outDir: resolve(import.meta.dirname, "dist"),
216 emptyOutDir: true,230 emptyOutDir: true,
217 },231 },
232 // Deps vite only discovers once a lazy-loaded React chunk actually renders
233 // (e.g. opening the integration Edit sheet, or the MCP/OpenAPI add-source
234 // flow). Discovering one mid-run forces a re-optimize and a full page
235 // reload, which throws away whatever the person was doing — an open sheet
236 // and its form state included. Pre-bundle them at boot so vite never
237 // discovers them mid-run. Keep in sync with apps/host-selfhost and
238 // apps/cloud.
239 optimizeDeps: {
240 include: [
241 "effect/Match",
242 "effect/Predicate",
243 "effect/Exit",
244 "effect/Option",
245 "effect/Cause",
246 "effect/Data",
247 "effect/Schema",
248 "js-yaml",
249 ],
250 },
218 define: {251 define: {
219 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),252 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),
220 // The local app IS the npm-installed CLI, so its update card shows the npm253 // The local app IS the npm-installed CLI, so its update card shows the npm
@@ -229,6 +262,7 @@ export default defineConfig({ · 8 unmodified lines
229 },262 },
230 resolve: {263 resolve: {
231 tsconfigPaths: true,264 tsconfigPaths: true,
265 alias: { "js-yaml": JS_YAML_DIR },
232 },266 },
233 server: {267 server: {
234 port: parseInt(process.env.PORT ?? "5173", 10),268 port: parseInt(process.env.PORT ?? "5173", 10),
bun.lock+1−0
@@ -999,6 +999,7 @@ · 998 unmodified lines
999 "@modelcontextprotocol/client": "2.0.0",999 "@modelcontextprotocol/client": "2.0.0",
1000 "@modelcontextprotocol/core": "2.0.0",1000 "@modelcontextprotocol/core": "2.0.0",
1001 "@modelcontextprotocol/sdk": "^1.29.0",1001 "@modelcontextprotocol/sdk": "^1.29.0",
1002 "lucide-react": "^1.7.0",
1002 "zod": "4.3.6",1003 "zod": "4.3.6",
1003 },1004 },
1004 "devDependencies": {1005 "devDependencies": {
e2e/local/stdio-mcp-edit-ui.test.ts+138−0
@@ -0,0 +1,138 @@
1// Local-only — the integration Edit sheet for a STDIO MCP server, driven in a
2// real browser. `local` is the only surface that enables stdio MCP
3// (`dangerouslyAllowStdioMCP: true`), so it is the only place this sheet can be
4// exercised end to end.
5//
6// The sheet used to say "Stdio MCP integrations cannot be edited. Remove and
7// recreate the integration with the updated command." (#812) — changing a
8// command meant editing `executor.jsonc` by hand, or losing the integration's
9// connections and policies to a delete-and-re-add.
10//
11// The assertion is deliberately not "the field accepted my text". The scenario
12// edits the DECLARED env map through the form and then reads the tool catalog:
13// the fixture advertises `saw_declared_env` only when that variable is present
14// in the spawned child's environment, so the tool appearing proves the edited
15// config travelled the whole way — form, config write, respawn, rediscovery.
16import { fileURLToPath } from "node:url";
17
18import { expect } from "@effect/vitest";
19import { Effect } from "effect";
20import { HttpApiClient } from "effect/unstable/httpapi";
21import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
22import { composePluginApi } from "@executor-js/api/server";
23import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
24
25import { scenario } from "../src/scenario";
26import { Browser, Cli, RunDir, Target } from "../src/services";
27import { withLocalServer } from "./local-server";
28
29const api = composePluginApi([mcpHttpPlugin()] as const);
30
31const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));
32
33const SECRET = "s3cr3t-typed-into-the-edit-sheet";
34
35scenario(
36 "Local · a stdio MCP server's command and environment are editable from the integration Edit sheet",
37 { timeout: 300_000 },
38 Effect.gen(function* () {
39 const cli = yield* Cli;
40 const browser = yield* Browser;
41 const target = yield* Target;
42 const runDir = yield* RunDir;
43 const identity = yield* target.newIdentity();
44
45 yield* withLocalServer(cli, runDir, (server) =>
46 Effect.gen(function* () {
47 const client = yield* HttpApiClient.make(api, {
48 baseUrl: new URL("/api", server.origin).toString(),
49 transformClient: HttpClient.mapRequest((request) =>
50 HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
51 ),
52 }).pipe(Effect.provide(FetchHttpClient.layer));
53
54 const slug = "e2e-stdio-editable";
55
56 // A plain stdio server: no declared env, so the env-gated tool is absent
57 // until the edit adds the variable.
58 yield* client.mcp.addServer({
59 payload: {
60 transport: "stdio",
61 name: "E2E Stdio Editable",
62 command: "node",
63 args: [FIXTURE],
64 slug,
65 },
66 });
67
68 const before = yield* client.tools.list({ query: { integration: slug } });
69 expect(
70 before.map((t) => t.name),
71 "the server starts with its base tool and no declared env",
72 ).toContain("echo_tool");
73 expect(
74 before.map((t) => t.name),
75 "the env-gated tool is absent before the edit",
76 ).not.toContain("saw_declared_env");
77
78 yield* browser.session(identity, async ({ page, step }) => {
79 await step("Open the stdio integration from the console", async () => {
80 await page.goto(server.url, { waitUntil: "domcontentloaded" });
81 await page.getByTestId(`integration-entry-${slug}`).first().click();
82 // Wait for the detail page's own data, not just its shell: clicking
83 // Edit while the accounts panel is still a skeleton races the layout
84 // shift that lands when it resolves.
85 await page.getByText("default").first().waitFor({ timeout: 30_000 });
86 await page.getByRole("button", { name: "Edit" }).waitFor({ timeout: 30_000 });
87 });
88
89 await step("Open the Edit sheet — the stdio command is editable", async () => {
90 await page.getByRole("button", { name: "Edit" }).click();
91 await page.getByText("Edit integration").waitFor({ timeout: 30_000 });
92 await page.getByText("Server command").waitFor({ timeout: 30_000 });
93 // The read-only dead end this replaces.
94 expect(
95 await page.getByText("Stdio MCP integrations cannot be edited").count(),
96 "the read-only message is gone",
97 ).toBe(0);
98 expect(
99 await page.getByRole("textbox", { name: "Command" }).inputValue(),
100 "the stored command is loaded into the form",
101 ).toBe("node");
102 });
103
104 await step("Declare an environment variable and save", async () => {
105 await page
106 .getByRole("textbox", { name: "Environment variables" })
107 .fill(`EXECUTOR_E2E_SECRET=${SECRET}`);
108 await page.getByRole("button", { name: "Save" }).click();
109 await page.getByText("Server command").waitFor({ state: "hidden", timeout: 30_000 });
110 });
111 });
112
113 // The edit persisted as the DECLARED static env map on the config.
114 const stored = yield* client.mcp.getServer({ params: { slug } });
115 expect(
116 stored?.config.transport === "stdio" ? stored.config.env : undefined,
117 "the sheet wrote the declared env map",
118 ).toEqual({ EXECUTOR_E2E_SECRET: SECRET });
119 expect(
120 stored?.config.transport === "stdio" ? stored.config.command : undefined,
121 "the command it did not touch is unchanged",
122 ).toBe("node");
123
124 // And it reached the spawned server: the fixture gates this tool's very
125 // existence on that variable being in its own environment.
126 const after = yield* client.tools.list({ query: { integration: slug } });
127 expect(
128 after.map((t) => t.name),
129 "the edited environment reached the respawned server and the catalog was rebuilt",
130 ).toContain("saw_declared_env");
131 expect(
132 after.map((t) => t.name),
133 "the rebuild kept the tools that still exist",
134 ).toContain("echo_tool");
135 }),
136 );
137 }),
138);
e2e/local/stdio-mcp.test.ts+42−0
@@ -222,6 +222,48 @@ scenario( · 221 unmodified lines
222 autoTools.map((t) => t.name),222 autoTools.map((t) => t.name),
223 "auto negotiation falls back to legacy and still discovers tools",223 "auto negotiation falls back to legacy and still discovers tools",
224 ).toContain("echo_tool");224 ).toContain("echo_tool");
225 expect(
226 autoTools.map((t) => t.name),
227 "this server declared no env, so the env-gated tool is absent to begin with",
228 ).not.toContain("saw_declared_env");
229
230 // --- Editing a stdio server's config (what the integration Edit sheet
231 // now does instead of telling you to remove and recreate). The tool
232 // catalog is persisted per connection, so a plain config replace is
233 // only enough because core stamps `config_revised_at` on a config
234 // write and every connection whose catalog predates that stamp is
235 // rebuilt on the next read. The edit path therefore needs NO explicit
236 // refresh of its own — this asserts that, so nobody adds one back.
237 //
238 // Adding a declared env var is the lever: the fixture advertises
239 // `saw_declared_env` only when that variable reached the child, so the
240 // tool appearing with no further action proves both halves — the new
241 // config reached the spawn, and the catalog was rebuilt from it. ---
242 const editedConfig = {
243 ...autoStored!.config,
244 env: { EXECUTOR_E2E_SECRET: SECRET },
245 } as typeof autoStored.config;
246
247 yield* client.mcp.configureServer({
248 params: { slug: autoSlug },
249 payload: { config: editedConfig },
250 });
251
252 const editedStored = yield* client.mcp.getServer({ params: { slug: autoSlug } });
253 expect(
254 JSON.stringify(editedStored?.config ?? {}),
255 "the edit persisted, and left the untouched negotiation mode alone",
256 ).toContain('"versionNegotiation":"auto"');
257
258 const editedTools = yield* client.tools.list({ query: { integration: autoSlug } });
259 expect(
260 editedTools.map((t) => t.name),
261 "the edited config reached the spawn and the catalog was rediscovered",
262 ).toContain("saw_declared_env");
263 expect(
264 editedTools.map((t) => t.name),
265 "rediscovery replaced the catalog rather than dropping what still exists",
266 ).toContain("echo_tool");
225 }),267 }),
226 { env: DAEMON_ENV },268 { env: DAEMON_ENV },
227 );269 );
e2e/scenarios/graphql-introspection-health.test.ts+62−0
@@ -8,6 +8,11 @@ import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; · 7 unmodified lines
8import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";8import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
9import { variable } from "@executor-js/sdk/http-auth";9import { variable } from "@executor-js/sdk/http-auth";
10 10
11import {
12 makeGreetingGraphqlSchema,
13 serveGraphqlTestServer,
14} from "@executor-js/plugin-graphql/testing";
15
11import { scenario } from "../src/scenario";16import { scenario } from "../src/scenario";
12import { Api, Browser, Target } from "../src/services";17import { Api, Browser, Target } from "../src/services";
13import { visit } from "../src/surfaces/browser";18import { visit } from "../src/surfaces/browser";
@@ -144,3 +149,60 @@ scenario( · 130 unmodified lines
144 }),149 }),
145 ),150 ),
146);151);
152
153scenario(
154 "GraphQL · a healthy account row keeps the connection's name",
155 {},
156 Effect.scoped(
157 Effect.gen(function* () {
158 const target = yield* Target;
159 const browser = yield* Browser;
160 const { client: makeApiClient } = yield* Api;
161 const identity = yield* target.newIdentity();
162 const client = yield* makeApiClient(api, identity);
163 const slug = unique("graphql_label");
164 const upstream = yield* serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() });
165
166 yield* client.graphql.addIntegration({
167 payload: {
168 endpoint: upstream.endpoint,
169 slug,
170 name: "Greeting API",
171 },
172 });
173
174 yield* Effect.gen(function* () {
175 yield* client.connections.create({
176 payload: {
177 owner: "org",
178 name: ConnectionName.make("workspace"),
179 integration: IntegrationSlug.make(slug),
180 template: AuthTemplateSlug.make("none"),
181 value: "unused",
182 },
183 });
184
185 yield* browser.session(identity, async ({ page, step }) => {
186 await step("The healthy account row is labeled with the connection name", async () => {
187 await visit(page, `/integrations/${slug}`);
188 await page.getByRole("tab", { name: "Accounts" }).click();
189 // Wait for the automatic health probe to land so a probe identity
190 // would have replaced the label if the plugin still reported one.
191 await page.getByLabel("Status: Healthy").waitFor();
192 await page.getByText("workspace", { exact: true }).waitFor();
193 expect(
194 await page.getByText("GraphQL schema", { exact: false }).count(),
195 "the schema root type never masquerades as the account label",
196 ).toBe(0);
197 });
198 });
199 }).pipe(
200 Effect.ensuring(
201 client.integrations
202 .remove({ params: { slug: IntegrationSlug.make(slug) } })
203 .pipe(Effect.ignore),
204 ),
205 );
206 }),
207 ),
208);
e2e/scenarios/integration-families.test.ts+69−0
@@ -0,0 +1,69 @@
1import { randomBytes } from "node:crypto";
2
3import { expect } from "@effect/vitest";
4import { Effect } from "effect";
5import type { HttpApiClient } from "effect/unstable/httpapi";
6import { composePluginApi } from "@executor-js/api/server";
7import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
8import { IntegrationSlug } from "@executor-js/sdk/shared";
9
10import { scenario } from "../src/scenario";
11import { Api, Browser, Target } from "../src/services";
12
13const api = composePluginApi([mcpHttpPlugin()] as const);
14type Client = HttpApiClient.ForApi<typeof api>;
15
16scenario(
17 "Integrations · related MCP services collapse into their declared family",
18 {},
19 Effect.gen(function* () {
20 const target = yield* Target;
21 const { client: makeClient } = yield* Api;
22 const browser = yield* Browser;
23 const identity = yield* target.newIdentity();
24 const client: Client = yield* makeClient(api, identity);
25 const suffix = randomBytes(4).toString("hex");
26 const family = `cloudflare-${suffix}`;
27 const apiSlug = IntegrationSlug.make(`family-api-${suffix}`);
28 const docsSlug = IntegrationSlug.make(`family-docs-${suffix}`);
29
30 yield* Effect.ensuring(
31 Effect.gen(function* () {
32 yield* client.mcp.addServer({
33 payload: {
34 name: "Cloudflare API",
35 family,
36 endpoint: "https://api.example.com/mcp",
37 slug: String(apiSlug),
38 },
39 });
40 yield* client.mcp.addServer({
41 payload: {
42 name: "Cloudflare Docs",
43 family,
44 endpoint: "https://docs.example.com/mcp",
45 slug: String(docsSlug),
46 },
47 });
48
49 yield* browser.session(identity, async ({ page, step }) => {
50 await step("Open the integrations catalog", async () => {
51 await page.goto("/", { waitUntil: "networkidle" });
52 await page.getByText("Integrations").first().waitFor();
53 });
54
55 await step("The related MCP services share one family card", async () => {
56 const group = page.getByTestId(`integration-group-${family}`);
57 await group.waitFor();
58 expect(await group.innerText()).toContain("Cloudflare API");
59 expect(await group.innerText()).toContain("Cloudflare Docs");
60 });
61 });
62 }),
63 Effect.gen(function* () {
64 yield* client.mcp.removeServer({ params: { slug: apiSlug } }).pipe(Effect.ignore);
65 yield* client.mcp.removeServer({ params: { slug: docsSlug } }).pipe(Effect.ignore);
66 }),
67 );
68 }),
69);
e2e/scenarios/mcp-add-url-typing-probe.test.ts+48−0
@@ -0,0 +1,48 @@
1// The add-MCP flow probes the Server URL as it is typed. Every keystroke is a
2// prefix of the next one, so a probe on a half-typed value dials something the
3// user never meant to submit and drops the field into a loading and then an
4// error state. This guards that an incomplete URL is left alone and that a
5// finished one is still probed without being submitted.
6import { Effect } from "effect";
7
8import { scenario } from "../src/scenario";
9import { Browser, Target } from "../src/services";
10
11const urlField = "https://mcp.example.com";
12const retry = "Try again";
13
14scenario(
15 "MCP add flow · a half-typed Server URL is not probed",
16 {},
17 Effect.gen(function* () {
18 const target = yield* Target;
19 const browser = yield* Browser;
20 const identity = yield* target.newIdentity();
21
22 yield* browser.session(identity, async ({ page, step }) => {
23 await step("Open the add-MCP flow", async () => {
24 await page.goto("/integrations/add/mcp", { waitUntil: "networkidle" });
25 await page.getByPlaceholder(urlField).waitFor();
26 });
27
28 await step("A URL still being typed is left alone", async () => {
29 // Well past the 400ms debounce. The field must stay editable — the
30 // probing state replaces it with a skeleton — and report no failure.
31 for (const partial of ["h", "http://", "http://a", "https://mcp"]) {
32 await page.getByPlaceholder(urlField).fill(partial);
33 await page.waitForTimeout(700);
34 await page.getByPlaceholder(urlField).waitFor({ state: "visible" });
35 await page.getByRole("button", { name: retry }).waitFor({ state: "hidden" });
36 }
37 });
38
39 await step("A finished URL is still probed", async () => {
40 // `.invalid` is reserved and never resolves, so the probe fails and the
41 // retry affordance appears. That it appears at all is the assertion:
42 // the shape gate lets a complete URL through.
43 await page.getByPlaceholder(urlField).fill("https://mcp.notareal.invalid/mcp");
44 await page.getByRole("button", { name: retry }).waitFor();
45 });
46 });
47 }),
48);
e2e/scenarios/org-slug-routing.test.ts+59−13
@@ -5,9 +5,10 @@ · 4 unmodified lines
5//5//
6// - /account/me advertises the org's URL slug (valid grammar)6// - /account/me advertises the org's URL slug (valid grammar)
7// - a bare deep link (/policies) canonicalizes to /<slug>/policies7// - a bare deep link (/policies) canonicalizes to /<slug>/policies
8// - an unknown slug (/zz-no-such-org/policies) is a wrong address — a8// - an unknown slug (/zz-no-such-org/policies) is a wrong address on a
9// not-found page, never a silent redirect into a workspace the URL9// multi-tenant host — a not-found page with the console shell never built
10// didn't name10// at any point, never a silent redirect into a workspace the URL didn't
11// name (a single-tenant host canonicalizes it instead; both are pinned)
11// - in-shell navigation keeps the slug prefix on every link12// - in-shell navigation keeps the slug prefix on every link
12//13//
13// Cloud's switch-into-another-org-by-URL behavior is covered separately by14// Cloud's switch-into-another-org-by-URL behavior is covered separately by
@@ -20,6 +21,14 @@ import { scenario } from "../src/scenario"; · 6 unmodified lines
20import { Api, Browser, Target } from "../src/services";21import { Api, Browser, Target } from "../src/services";
21import { visit } from "../src/surfaces/browser";22import { visit } from "../src/surfaces/browser";
22 23
24declare global {
25 interface Window {
26 /** Set by this scenario's init script the first time a console shell
27 * sidebar is attached to the document. Test-only. */
28 __executorShellEverMounted?: boolean;
29 }
30}
31
23scenario(32scenario(
24 "Org URLs · console paths carry the organization slug",33 "Org URLs · console paths carry the organization slug",
25 {},34 {},
@@ -46,17 +55,54 @@ scenario( · 20 unmodified lines
46 await page.getByText("Policies").first().waitFor();55 await page.getByText("Policies").first().waitFor();
47 });56 });
48 57
49 // The "unknown slug is a 404" contract is multi-tenant only. Selfhost is58 // What an unknown slug MEANS depends on tenancy, so both answers are
50 // single-tenant: /account/me always returns the instance org regardless of59 // pinned rather than one being skipped:
51 // the URL segment, so the slug is cosmetic and an unknown one canonicalizes60 //
52 // onto the shell rather than 404ing. Cloud enforces the not-found; selfhost61 // multi-tenant (cloud) — the slug is a real request scope. /account/me
53 // legitimately does not.62 // returns no organization for one this session cannot see, so the
54 if (!target.name.startsWith("selfhost")) {63 // address is simply wrong: a not-found page, the URL left exactly as
55 await step("An unknown org slug is a wrong address, not a redirect", async () => {64 // typed, and no console shell built at any point along the way.
56 await visit(page, "/zz-no-such-org/policies");65 // single-tenant (selfhost) — /account/me always returns the instance
66 // org regardless of the URL segment, so the slug is cosmetic and
67 // OrgSlugGate canonicalizes it onto the shell instead.
68 const singleTenant = target.name.startsWith("selfhost");
69
70 // Waiting for the not-found text on its own is not enough. The shell used
71 // to render first from the auth-hint cookie — which names the session's
72 // OWN org, never the URL's — and correct itself only once /account/me
73 // answered, so a run that flashed an entire workspace under a foreign
74 // address still ended on "Page not found" and passed. Record the shell
75 // from before the page's own scripts run, so a sidebar that exists for a
76 // single frame is still caught long after it is gone.
77 await page.addInitScript(() => {
78 window.__executorShellEverMounted = false;
79 const look = () => {
80 if (document.querySelector('aside, button[aria-label="Open navigation"]')) {
81 window.__executorShellEverMounted = true;
82 }
83 };
84 new MutationObserver(look).observe(document, { childList: true, subtree: true });
85 look();
86 });
87
88 await step("An unknown org slug resolves by tenancy, never by redirect", async () => {
89 await visit(page, "/zz-no-such-org/policies");
90 if (singleTenant) {
91 await page.waitForURL((url) => url.pathname === `/${slug}/policies`, { timeout: 30_000 });
92 } else {
57 await page.getByText("Page not found").waitFor({ timeout: 30_000 });93 await page.getByText("Page not found").waitFor({ timeout: 30_000 });
58 });94 }
59 }95
96 const landed = {
97 pathname: new URL(page.url()).pathname,
98 shellEverMounted: await page.evaluate(() => window.__executorShellEverMounted === true),
99 };
100 expect(landed, "an unknown slug never lands in a workspace the URL did not name").toEqual(
101 singleTenant
102 ? { pathname: `/${slug}/policies`, shellEverMounted: true }
103 : { pathname: "/zz-no-such-org/policies", shellEverMounted: false },
104 );
105 });
60 106
61 await step("In-shell navigation keeps the slug prefix", async () => {107 await step("In-shell navigation keeps the slug prefix", async () => {
62 await visit(page, `/${slug}`);108 await visit(page, `/${slug}`);
e2e/scenarios/policy-remove-confirm.test.ts+85−0
@@ -0,0 +1,85 @@
1// Cross-target (browser): removing a tool-access policy asks for confirmation
2// first. Remove fires from a row's dropdown menu and is irreversible — the
3// rule is gone and every tool it governed silently falls back to the default
4// policy — so the menu item must open a confirm dialog rather than firing the
5// mutation directly. Cancel keeps the rule; confirming removes it for real
6// (asserted through the API, not just the rendered list).
7import { randomBytes } from "node:crypto";
8
9import { expect } from "@effect/vitest";
10import { Effect } from "effect";
11import { composePluginApi } from "@executor-js/api/server";
12
13import { scenario } from "../src/scenario";
14import { Api, Browser, Target } from "../src/services";
15import { visit } from "../src/surfaces/browser";
16
17const coreApi = composePluginApi([] as const);
18
19scenario(
20 "Policies (UI) · Remove asks for confirmation; cancel keeps, confirm removes",
21 {},
22 Effect.gen(function* () {
23 const target = yield* Target;
24 const browser = yield* Browser;
25 const { client: makeClient } = yield* Api;
26 const identity = yield* target.newIdentity();
27 const client = yield* makeClient(coreApi, identity);
28
29 // Selfhost scenarios share one workspace, so the pattern is unique to this
30 // run and the row lookup below cannot match another scenario's rule.
31 const pattern = `rmconfirm-${randomBytes(4).toString("hex")}.*`;
32
33 const created = yield* client.policies.create({
34 payload: { owner: "org", pattern, action: "block" },
35 });
36
37 yield* Effect.ensuring(
38 Effect.gen(function* () {
39 yield* browser.session(identity, async ({ page, step }) => {
40 const row = page.locator("[data-slot='card-stack-entry']").filter({ hasText: pattern });
41 const menuTrigger = row.locator('button[aria-haspopup="menu"]');
42 const confirm = page.getByRole("alertdialog");
43
44 await step("Open the policies page", async () => {
45 await visit(page, "/policies");
46 await row.waitFor();
47 });
48
49 await step("Remove asks for confirmation instead of firing", async () => {
50 await menuTrigger.click();
51 await page.getByRole("menuitem", { name: "Remove" }).click();
52 await confirm.getByText("Remove policy?").waitFor();
53 // The dialog names the exact rule being destroyed.
54 await confirm.getByText(pattern, { exact: false }).waitFor();
55 });
56
57 await step("Cancel keeps the policy", async () => {
58 await confirm.getByRole("button", { name: "Cancel" }).click();
59 await confirm.waitFor({ state: "detached" });
60 await row.waitFor();
61 });
62
63 await step("Confirming actually removes it", async () => {
64 await menuTrigger.click();
65 await page.getByRole("menuitem", { name: "Remove" }).click();
66 await confirm.getByRole("button", { name: "Remove policy" }).click();
67 await confirm.waitFor({ state: "detached" });
68 await row.waitFor({ state: "detached" });
69 });
70 });
71
72 // Cancel left the removal un-fired and confirm fired it for real: the
73 // rule is gone from the API, not merely from the rendered list.
74 const remaining = yield* client.policies.list();
75 expect(
76 remaining.map((policy) => policy.pattern),
77 "the removed policy is gone from the API",
78 ).not.toContain(pattern);
79 }),
80 client.policies
81 .remove({ params: { policyId: created.id }, payload: { owner: "org" } })
82 .pipe(Effect.ignore),
83 );
84 }),
85);
e2e/scenarios/provider-plugins-ui.test.ts+5−3
@@ -14,10 +14,12 @@ scenario( · 13 unmodified lines
14 const identity = yield* target.newIdentity();14 const identity = yield* target.newIdentity();
15 15
16 yield* browser.session(identity, async ({ page, step }) => {16 yield* browser.session(identity, async ({ page, step }) => {
17 await step("Open the integrations picker", async () => {17 await step("Open the integrations picker from the sidebar", async () => {
18 await visit(page, "/integrations");18 await visit(page, "/policies");
19 await clickToReveal(19 await clickToReveal(
20 page.getByRole("button", { name: "Connect" }),20 page
21 .getByRole("navigation")
22 .getByRole("button", { name: "Browse integrations", exact: true }),
21 page.getByRole("dialog", { name: "Connect an integration" }),23 page.getByRole("dialog", { name: "Connect an integration" }),
22 );24 );
23 });25 });
e2e/selfhost/execute-emit-envelope.test.ts+94−0
@@ -0,0 +1,94 @@
1// Selfhost: emit() output actually reaches the MCP client's content blocks,
2// not merely the `emitted` count in the envelope. Regression coverage for
3// issue #1592 — a self-hosted user saw a non-zero `emitted` count but no
4// content blocks in the tool result, only `logs` and the return value. There
5// is cloud coverage for the envelope count
6// (`e2e/cloud/execute-emit-envelope.test.ts`), but nothing that asserts on
7// the wire-level `content` array on self-host, and nothing on either target
8// that covers all three emit shapes from the report: a plain string, a
9// ToolFile, and a structured/object value.
10import { expect } from "@effect/vitest";
11import { Effect } from "effect";
12
13import { scenario } from "../src/scenario";
14import { Mcp, Target } from "../src/services";
15
16interface ContentBlock {
17 readonly type: string;
18 readonly text?: string;
19}
20
21// Mirrors the issue's repro exactly: a plain string emit, a ToolFile emit, a
22// structured/object emit, then a return.
23const EXECUTE_CODE = `
24const attachment = {
25 _tag: "ToolFile",
26 name: "report.txt",
27 mimeType: "text/plain",
28 encoding: "base64",
29 data: "aGVsbG8gZmlsZQ==",
30 byteLength: 10,
31};
32emit("plain string emit");
33emit(attachment);
34emit({ hello: "object emit" });
35return { returnedValue: "this is the return" };
36`;
37
38scenario(
39 "Execute · emit() output reaches the MCP client's content blocks",
40 {},
41 Effect.gen(function* () {
42 const target = yield* Target;
43 const mcp = yield* Mcp;
44 const identity = yield* target.newIdentity();
45 const session = mcp.session(identity);
46
47 const result = yield* session.call("execute", { code: EXECUTE_CODE });
48 expect(result.ok, `execute completed (got: ${result.text.slice(0, 300)})`).toBe(true);
49
50 const structured = (result.raw as { structuredContent?: Record<string, unknown> })
51 .structuredContent;
52 expect(structured?.status, "the run completed").toBe("completed");
53 expect(structured?.emitted, "the envelope counts all three emitted items").toBe(3);
54 expect(structured?.result, "the return value still comes back to the caller").toEqual({
55 returnedValue: "this is the return",
56 });
57
58 // The real assertion: the JSON-RPC tool result's `content` array — what
59 // an MCP client actually renders — carries all three emitted items, not
60 // just the `emitted` count.
61 const content = (result.raw as { content?: ContentBlock[] }).content ?? [];
62 expect(Array.isArray(content) && content.length > 0, "the wire result carries content").toBe(
63 true,
64 );
65
66 expect(
67 content.some((block) => block.type === "text" && block.text === "plain string emit"),
68 `content carries the plain string emit; got: ${JSON.stringify(content)}`,
69 ).toBe(true);
70
71 // A text-mimetype ToolFile renders as a summary line plus its decoded
72 // text content (see outputFileContent/toolFileContent in tool-server.ts).
73 expect(
74 content.some(
75 (block) =>
76 block.type === "text" &&
77 typeof block.text === "string" &&
78 block.text.includes("report.txt"),
79 ),
80 `content carries the ToolFile emit's summary line; got: ${JSON.stringify(content)}`,
81 ).toBe(true);
82 expect(
83 content.some((block) => block.type === "text" && block.text === "hello file"),
84 `content carries the ToolFile emit's decoded text; got: ${JSON.stringify(content)}`,
85 ).toBe(true);
86
87 // A plain object emit is not a ToolFile or an MCP content block, so it
88 // falls back to a JSON text block.
89 expect(
90 content.some((block) => block.type === "text" && block.text === '{"hello":"object emit"}'),
91 `content carries the structured/object emit; got: ${JSON.stringify(content)}`,
92 ).toBe(true);
93 }),
94);
e2e/selfhost/mcp-request-headers-add.test.ts+133−0
@@ -0,0 +1,133 @@
1// Regression guard for adding a remote MCP server that sits behind an edge
2// authenticator. Cloudflare Access answers an unauthenticated request with a
3// `403` HTML sign-in page, so the MCP server itself is never reached: no
4// Bearer challenge, no RFC 9728 metadata, no JSON-RPC body. That used to read
5// as "Couldn't reach this URL", which is wrong and offered no way forward.
6//
7// Now the 403 classifies as auth-required, and the add flow carries a request
8// headers editor whose values ride along on the connection check. Both halves
9// are asserted here: the flow reaches the auth editor, and the service-token
10// headers actually reach the server.
11//
12// Selfhost-only because the probe must reach a loopback server: the selfhost
13// instance runs with EXECUTOR_ALLOW_LOCAL_NETWORK. Video is the artifact.
14import { randomBytes } from "node:crypto";
15
16import { expect } from "@effect/vitest";
17import { Effect, Ref } from "effect";
18import { HttpServerResponse } from "effect/unstable/http";
19import { composePluginApi } from "@executor-js/api/server";
20import { deriveMcpNamespace } from "@executor-js/plugin-mcp";
21import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
22import { IntegrationSlug } from "@executor-js/sdk/shared";
23import { serveTestHttpApp } from "@executor-js/sdk/testing";
24
25import { scenario } from "../src/scenario";
26import { Api, Browser, Target } from "../src/services";
27import { visit } from "../src/surfaces/browser";
28
29const api = composePluginApi([mcpHttpPlugin()] as const);
30
31const CLIENT_ID_HEADER = "CF-Access-Client-Id";
32const CLIENT_SECRET_HEADER = "CF-Access-Client-Secret";
33const CLIENT_ID = "e2e-service-token-id";
34const CLIENT_SECRET = "e2e-service-token-secret";
35
36scenario(
37 "MCP headers · a 403 edge gate is addable with service-token headers",
38 {},
39 Effect.scoped(
40 Effect.gen(function* () {
41 const target = yield* Target;
42 const browser = yield* Browser;
43 const { client: makeApiClient } = yield* Api;
44
45 // Cloudflare Access shape: every request is answered with a 403 HTML
46 // sign-in page. The headers each request carried are recorded so the
47 // scenario can prove the connection check sent the configured pair.
48 const seen = yield* Ref.make<readonly Readonly<Record<string, string>>[]>([]);
49 const server = yield* serveTestHttpApp((request) =>
50 Effect.gen(function* () {
51 yield* Ref.update(seen, (all) => [...all, request.headers]);
52 if ((request.url ?? "").includes("/.well-known/")) {
53 return HttpServerResponse.text("missing", { status: 404 });
54 }
55 return HttpServerResponse.text("<html><body>Sign in</body></html>", {
56 status: 403,
57 contentType: "text/html",
58 });
59 }),
60 );
61
62 const endpoint = server.url("/mcp");
63 // The gate reports no server name, so the probe cannot seed a unique
64 // identity. Selfhost identities share one tenant, so name the
65 // integration uniquely to keep the derived slug stable across runs.
66 const name = `edge-gated-403-${randomBytes(3).toString("hex")}`;
67 const slug = IntegrationSlug.make(deriveMcpNamespace({ name }));
68 const identity = yield* target.newIdentity();
69 const client = yield* makeApiClient(api, identity);
70
71 yield* Effect.gen(function* () {
72 yield* browser.session(identity, async ({ page, step }) => {
73 await step("Open the add-MCP flow pointed at the 403-gated server", async () => {
74 await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(endpoint)}`);
75 // Before the fix the 403 read as unreachable and the flow stopped
76 // on "Couldn't reach this URL". Now it continues.
77 await page.getByText("How does this server authenticate?").waitFor();
78 await page.getByText("Auth required").first().waitFor();
79 });
80
81 await step("Configure the Cloudflare Access service-token headers", async () => {
82 await page.getByRole("button", { name: "Add header" }).click();
83 await page.getByLabel("Header name").nth(0).fill(CLIENT_ID_HEADER);
84 await page.getByLabel("Header value").nth(0).fill(CLIENT_ID);
85 await page.getByRole("button", { name: "Add header" }).click();
86 await page.getByLabel("Header name").nth(1).fill(CLIENT_SECRET_HEADER);
87 await page.getByLabel("Header value").nth(1).fill(CLIENT_SECRET);
88 });
89
90 await step("Test connection sends the headers to the server", async () => {
91 await page.getByRole("button", { name: "Test connection" }).click();
92 // The button reports the in-flight probe with `data-loading`.
93 // Wait for it to clear so the re-probe has landed before we add.
94 await page.locator("button[data-loading]").waitFor({ state: "detached" });
95 await page.getByText("Auth required").first().waitFor();
96 });
97
98 await step("Add the integration", async () => {
99 await page.getByPlaceholder("e.g. Linear").fill(name);
100 await page.getByRole("button", { name: "Add integration" }).click();
101 await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
102 const landedSlug = new URL(page.url()).pathname.split("/").filter(Boolean).at(-1);
103 expect(landedSlug, "the add flow lands on the created integration").toBe(String(slug));
104 });
105 });
106
107 const requests = yield* Ref.get(seen);
108 const authenticated = requests.filter(
109 (headers) => headers[CLIENT_ID_HEADER.toLowerCase()] === CLIENT_ID,
110 );
111 expect(
112 authenticated.length,
113 "the connection check reaches the server with the configured headers",
114 ).toBeGreaterThan(0);
115 expect(
116 authenticated.some(
117 (headers) => headers[CLIENT_SECRET_HEADER.toLowerCase()] === CLIENT_SECRET,
118 ),
119 "both halves of the service token are sent together",
120 ).toBe(true);
121
122 const stored = yield* client.mcp.getServer({ params: { slug } });
123 expect(stored?.config, "the headers persist on the integration").toMatchObject({
124 transport: "remote",
125 headers: {
126 [CLIENT_ID_HEADER]: CLIENT_ID,
127 [CLIENT_SECRET_HEADER]: CLIENT_SECRET,
128 },
129 });
130 }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore)));
131 }),
132 ),
133);
packages/core/api/src/index.ts+3−0
@@ -3,7 +3,10 @@ export { · 2 unmodified lines
3 checkForUpdate,3 checkForUpdate,
4 resolveDistTags,4 resolveDistTags,
5 resolveUpdateChannel,5 resolveUpdateChannel,
6 resolveComparisonChannel,
6 compareVersions,7 compareVersions,
8 isUnstampedVersion,
9 isUpdateAvailable,
7 EXECUTOR_PACKAGE_NAME,10 EXECUTOR_PACKAGE_NAME,
8 type UpdateStatus,11 type UpdateStatus,
9 type UpdateChannel,12 type UpdateChannel,
packages/core/api/src/server/scoped-executor.ts+10−0
@@ -123,6 +123,15 @@ export interface HostConfigShape { · 122 unmodified lines
123 * operator knob.123 * operator knob.
124 */124 */
125 readonly toolsSyncTtlMs?: number | null;125 readonly toolsSyncTtlMs?: number | null;
126 /**
127 * Forwarded verbatim to `ExecutorConfig.waitUntil`: the host's keep-alive
128 * for background work that outlives a request (stale tool-catalog rebuilds
129 * that keep running after a read stops waiting). Cloud supplies the
130 * platform `waitUntil` from `cloudflare:workers`, which binds to the
131 * in-flight invocation ambiently; long-lived hosts (self-host, local,
132 * tests) omit it and detached fibers simply run to completion in-process.
133 */
134 readonly waitUntil?: (promise: Promise<unknown>) => void;
126}135}
127 136
128export class HostConfig extends Context.Service<HostConfig, HostConfigShape>()(137export class HostConfig extends Context.Service<HostConfig, HostConfigShape>()(
@@ -305,6 +314,7 @@ export const makeScopedExecutor = < · 176 unmodified lines
305 fetch: hostedFetch,314 fetch: hostedFetch,
306 onIntegrationChange: config.onIntegrationChange,315 onIntegrationChange: config.onIntegrationChange,
307 ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}),316 ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}),
317 ...(config.waitUntil !== undefined ? { waitUntil: config.waitUntil } : {}),
308 onElicitation: "accept-all",318 onElicitation: "accept-all",
309 redirectUri,319 redirectUri,
310 oauthCallbackStateOrgSlug: orgSlug,320 oauthCallbackStateOrgSlug: orgSlug,
packages/core/api/src/update-check.test.ts+103−2
@@ -10,6 +10,9 @@ import { · 9 unmodified lines
10 __resetDistTagsCache,10 __resetDistTagsCache,
11 checkForUpdate,11 checkForUpdate,
12 compareVersions,12 compareVersions,
13 isUnstampedVersion,
14 isUpdateAvailable,
15 resolveComparisonChannel,
13 resolveDistTags,16 resolveDistTags,
14 resolveUpdateChannel,17 resolveUpdateChannel,
15} from "./update-check";18} from "./update-check";
@@ -53,6 +56,73 @@ describe("resolveUpdateChannel", () => { · 37 unmodified lines
53 });56 });
54});57});
55 58
59describe("isUnstampedVersion", () => {
60 it("flags 0.0.0, with or without a prerelease suffix", () => {
61 expect(isUnstampedVersion("0.0.0")).toBe(true);
62 expect(isUnstampedVersion("0.0.0-dev")).toBe(true);
63 });
64
65 it("leaves any published version alone", () => {
66 expect(isUnstampedVersion("1.5.22")).toBe(false);
67 expect(isUnstampedVersion("0.0.1")).toBe(false);
68 });
69
70 it("is false for unparseable input", () => {
71 expect(isUnstampedVersion("not-a-version")).toBe(false);
72 });
73});
74
75describe("resolveComparisonChannel", () => {
76 it("suppresses unstamped build-time fallback versions", () => {
77 expect(resolveComparisonChannel("0.0.0")).toBeNull();
78 expect(resolveComparisonChannel("0.0.0-dev")).toBeNull();
79 });
80
81 it("suppresses unparseable input", () => {
82 expect(resolveComparisonChannel("not-a-version")).toBeNull();
83 });
84
85 it("routes a release version to the latest tag", () => {
86 expect(resolveComparisonChannel("1.6.0")).toBe("latest");
87 });
88
89 it("routes a beta prerelease to the beta tag", () => {
90 expect(resolveComparisonChannel("1.6.0-beta.1")).toBe("beta");
91 });
92
93 it("suppresses prereleases with no matching dist-tag", () => {
94 // rc, alpha, next, dev, ... — none of these publish a dist-tag, so
95 // comparing against `latest` would nag forever.
96 expect(resolveComparisonChannel("1.6.0-rc.1")).toBeNull();
97 expect(resolveComparisonChannel("1.6.0-alpha.1")).toBeNull();
98 expect(resolveComparisonChannel("1.6.0-next.1")).toBeNull();
99 });
100});
101
102describe("isUpdateAvailable", () => {
103 it("is false with no current version", () => {
104 expect(isUpdateAvailable(undefined, "1.6.0")).toBe(false);
105 });
106
107 it("is false with no comparison channel", () => {
108 expect(isUpdateAvailable("0.0.0-dev", "1.6.0")).toBe(false);
109 expect(isUpdateAvailable("1.6.0-rc.1", "1.6.0")).toBe(false);
110 });
111
112 it("is false with no published tag", () => {
113 expect(isUpdateAvailable("1.5.22", null)).toBe(false);
114 });
115
116 it("is false when already current", () => {
117 expect(isUpdateAvailable("1.6.0", "1.6.0")).toBe(false);
118 });
119
120 it("is true when a newer version is published on the matching channel", () => {
121 expect(isUpdateAvailable("1.6.0", "1.6.1")).toBe(true);
122 expect(isUpdateAvailable("1.6.0-beta.1", "1.6.0-beta.2")).toBe(true);
123 });
124});
125
56describe("resolveDistTags", () => {126describe("resolveDistTags", () => {
57 it("returns nothing when the check is disabled", async () => {127 it("returns nothing when the check is disabled", async () => {
58 const tags = await resolveDistTags({128 const tags = await resolveDistTags({
@@ -126,6 +196,14 @@ describe("checkForUpdate", () => { · 67 unmodified lines
126 expect(status.updateAvailable).toBe(false);196 expect(status.updateAvailable).toBe(false);
127 });197 });
128 198
199 it("flags a patch release on the latest channel", async () => {
200 const status = await checkForUpdate("1.6.0", {
201 env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.6.1" }) },
202 });
203 expect(status.updateAvailable).toBe(true);
204 expect(status.latestVersion).toBe("1.6.1");
205 });
206
129 it("compares a beta build against the beta tag", async () => {207 it("compares a beta build against the beta tag", async () => {
130 const status = await checkForUpdate("1.6.0-beta.1", {208 const status = await checkForUpdate("1.6.0-beta.1", {
131 env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.5.22", beta: "1.6.0-beta.2" }) },209 env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.5.22", beta: "1.6.0-beta.2" }) },
@@ -136,10 +214,33 @@ describe("checkForUpdate", () => { · 4 unmodified lines
136 expect(status.command).toBe("npm i -g executor@beta");214 expect(status.command).toBe("npm i -g executor@beta");
137 });215 });
138 216
139 it("treats the dev build as upgradeable to any release", async () => {217 it("stays quiet on an unstamped dev build", async () => {
218 // 0.0.0-dev is the build-time fallback, not a real release. It must never
219 // claim an update is available, and — because there is no dist-tag it
220 // could legitimately compare against — must not even hit the registry.
140 const status = await checkForUpdate("0.0.0-dev", {221 const status = await checkForUpdate("0.0.0-dev", {
141 env: { EXECUTOR_FORCE_LATEST_VERSION: "1.5.22" },222 env: { EXECUTOR_FORCE_LATEST_VERSION: "1.5.22" },
223 fetchImpl: fetchThatFails(),
142 });224 });
143 expect(status.updateAvailable).toBe(true);225 expect(status.updateAvailable).toBe(false);
226 expect(status.latestVersion).toBeNull();
227 });
228
229 it("stays quiet on the desktop's unstamped fallback (plain 0.0.0)", async () => {
230 const status = await checkForUpdate("0.0.0", {
231 env: { EXECUTOR_FORCE_LATEST_VERSION: "1.5.22" },
232 fetchImpl: fetchThatFails(),
233 });
234 expect(status.updateAvailable).toBe(false);
235 expect(status.latestVersion).toBeNull();
236 });
237
238 it("stays quiet on a prerelease channel with no matching dist-tag", async () => {
239 const status = await checkForUpdate("1.6.0-rc.1", {
240 env: { EXECUTOR_NPM_DIST_TAGS: JSON.stringify({ latest: "1.6.0" }) },
241 fetchImpl: fetchThatFails(),
242 });
243 expect(status.updateAvailable).toBe(false);
244 expect(status.latestVersion).toBeNull();
144 });245 });
145});246});
packages/core/api/src/update-check.ts+58−3
@@ -102,6 +102,53 @@ export const compareVersions = (left: string, right: string): number | null => { · 101 unmodified lines
102 return comparePrereleaseIdentifiers(lv.prerelease, rv.prerelease);102 return comparePrereleaseIdentifiers(lv.prerelease, rv.prerelease);
103};103};
104 104
105/**
106 * True when `version` parses as `0.0.0`, with or without a prerelease suffix
107 * (e.g. `0.0.0-dev`). `0.0.0` is never a published release — it is the
108 * build-time fallback baked in when the real version was unavailable at
109 * build time — so a build carrying it must never claim an update.
110 */
111export const isUnstampedVersion = (version: string): boolean => {
112 const parsed = parseVersion(version);
113 return parsed !== null && parsed.major === 0 && parsed.minor === 0 && parsed.patch === 0;
114};
115
116/**
117 * The dist-tag channel `version` can legitimately be compared against, or
118 * `null` when there is none (the check should be suppressed, not run):
119 * - an unstamped build (0.0.0*) never shipped, so there is nothing to
120 * compare it against
121 * - unparseable input has no meaningful channel
122 * - no prerelease -> the `latest` tag
123 * - a prerelease whose first identifier is `beta` -> the `beta` tag
124 * - any other prerelease (rc, alpha, next, dev, ...) has no matching
125 * dist-tag, so suppress rather than nag forever against a channel it can
126 * never catch up to
127 */
128export const resolveComparisonChannel = (version: string): UpdateChannel | null => {
129 if (isUnstampedVersion(version)) return null;
130 const parsed = parseVersion(version);
131 if (!parsed) return null;
132 if (parsed.prerelease === null) return "latest";
133 return parsed.prerelease[0] === "beta" ? "beta" : null;
134};
135
136/**
137 * The single "should we nag?" verdict both the CLI and the web UpdateCard
138 * read. False whenever there is nothing to compare: no current version, no
139 * dist-tag channel this version could match (see `resolveComparisonChannel`),
140 * or no published tag for that channel.
141 */
142export const isUpdateAvailable = (
143 currentVersion: string | undefined,
144 latestVersion: string | null,
145): boolean => {
146 if (currentVersion === undefined) return false;
147 if (resolveComparisonChannel(currentVersion) === null) return false;
148 if (latestVersion === null) return false;
149 return compareVersions(currentVersion, latestVersion) === -1;
150};
151
105// ── dist-tags resolution ──────────────────────────────────────────────────152// ── dist-tags resolution ──────────────────────────────────────────────────
106 153
107export type DistTags = Partial<Record<UpdateChannel, string>>;154export type DistTags = Partial<Record<UpdateChannel, string>>;
@@ -223,9 +270,17 @@ export const checkForUpdate = async ( · 115 unmodified lines
223): Promise<UpdateStatus> => {270): Promise<UpdateStatus> => {
224 const channel = resolveUpdateChannel(currentVersion);271 const channel = resolveUpdateChannel(currentVersion);
225 const command = `npm i -g ${EXECUTOR_PACKAGE_NAME}@${channel}`;272 const command = `npm i -g ${EXECUTOR_PACKAGE_NAME}@${channel}`;
273
274 // No dist-tag can legitimately be compared against this version (unstamped
275 // dev build, or a prerelease channel that never gets published) — do not
276 // even hit the registry, since the answer is already known.
277 const comparisonChannel = resolveComparisonChannel(currentVersion);
278 if (comparisonChannel === null) {
279 return { updateAvailable: false, currentVersion, latestVersion: null, channel, command };
280 }
281
226 const tags = await resolveDistTags(options);282 const tags = await resolveDistTags(options);
227 const latestVersion = tags[channel] ?? null;283 const latestVersion = tags[comparisonChannel] ?? null;
228 const updateAvailable =284 const updateAvailable = isUpdateAvailable(currentVersion, latestVersion);
229 latestVersion !== null && compareVersions(currentVersion, latestVersion) === -1;
230 return { updateAvailable, currentVersion, latestVersion, channel, command };285 return { updateAvailable, currentVersion, latestVersion, channel, command };
231};286};
packages/core/sdk/src/executor.ts+66−1
@@ -2,6 +2,7 @@ import { · 1 unmodified line
2 Deferred,2 Deferred,
3 Duration,3 Duration,
4 Effect,4 Effect,
5 Fiber,
5 Inspectable,6 Inspectable,
6 Layer,7 Layer,
7 Option,8 Option,
@@ -696,6 +697,23 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 688 unmodified lines
696 * config-revision re-sync still apply).697 * config-revision re-sync still apply).
697 */698 */
698 readonly toolsSyncTtlMs?: number | null;699 readonly toolsSyncTtlMs?: number | null;
700 /**
701 * How long a tools read WAITS for stale-catalog rebuilds before answering
702 * from the persisted rows. Rebuilds keep running past the deadline (see
703 * `waitUntil`) and land on a later read; the read itself never pays more
704 * than this for upstream listings it did not ask for. Defaults to 2
705 * seconds; pass `null` to block until every rebuild finishes (the strict
706 * mode: a read then always reflects a fully converged catalog).
707 */
708 readonly toolsSyncGraceMs?: number | null;
709 /**
710 * Host keep-alive for background work that outlives a request — the
711 * platform `waitUntil` on Cloudflare Workers, where I/O started inside a
712 * request is cancelled once the response settles unless a host holds the
713 * context open. Long-lived processes (self-host, CLI, tests) omit it;
714 * their detached fibers simply run to completion.
715 */
716 readonly waitUntil?: (promise: Promise<unknown>) => void;
699 /**717 /**
700 * Notified after a durable integration-catalog change commits (a row718 * Notified after a durable integration-catalog change commits (a row
701 * created or removed). Best-effort observation only: the notification runs719 * created or removed). Best-effort observation only: the notification runs
@@ -729,6 +747,13 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 27 unmodified lines
729 * `ExecutorConfig.toolsSyncTtlMs`). */747 * `ExecutorConfig.toolsSyncTtlMs`). */
730export const DEFAULT_TOOLS_SYNC_TTL_MS = 15 * 60 * 1000;748export const DEFAULT_TOOLS_SYNC_TTL_MS = 15 * 60 * 1000;
731 749
750/** Default wait budget a tools read spends on stale-catalog rebuilds before
751 * answering from the persisted rows (see `ExecutorConfig.toolsSyncGraceMs`).
752 * Sized to cover a healthy upstream re-list (one handshake + one
753 * `tools/list`) while keeping a read gated on a slow or dead server bounded
754 * well under any per-connection network timeout. */
755export const DEFAULT_TOOLS_SYNC_GRACE_MS = 2000;
756
732/** How many stale connection catalogs are DISCOVERED at once on a tools read.757/** How many stale connection catalogs are DISCOVERED at once on a tools read.
733 * Bounded so a host with a large stale set cannot open an unbounded number of758 * Bounded so a host with a large stale set cannot open an unbounded number of
734 * upstream listings from a single read. Only the discovery phase runs at this759 * upstream listings from a single read. Only the discovery phase runs at this
@@ -4219,9 +4244,49 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3484 unmodified lines
4219 yield* Effect.all(rebuilds, { concurrency: STALE_TOOLS_SYNC_CONCURRENCY });4244 yield* Effect.all(rebuilds, { concurrency: STALE_TOOLS_SYNC_CONCURRENCY });
4220 });4245 });
4221 4246
4247 // How long a tools read waits for the stale sync before answering from
4248 // the persisted rows (`ExecutorConfig.toolsSyncGraceMs`; `null` blocks
4249 // until convergence).
4250 const toolsSyncGraceMs =
4251 config.toolsSyncGraceMs === undefined ? DEFAULT_TOOLS_SYNC_GRACE_MS : config.toolsSyncGraceMs;
4252
4253 // Run the stale sync with a bounded wait: a read pays at most the grace
4254 // budget for upstream listings it did not ask for, then serves the
4255 // persisted catalog while the rebuilds finish on their detached fibers
4256 // (deduplicated per connection by `produceConnectionTools`, so overlapping
4257 // reads share one rebuild instead of stacking new ones). One slow or dead
4258 // MCP server must never gate every catalog read behind its network
4259 // timeout. Past the deadline the sync is best-effort by construction —
4260 // failures log and the stale-but-working catalog stays — so the fork
4261 // swallows its scan errors the same way each rebuild already swallows its
4262 // own.
4263 const awaitStaleSyncWithinGrace = (graceMs: number) =>
4264 Effect.gen(function* () {
4265 const fiber = yield* Effect.forkDetach(
4266 syncStaleConnectionTools.pipe(
4267 Effect.catch((error) =>
4268 Effect.logWarning("executor stale tool sync scan failed", {
4269 error: describeSyncFailure(error),
4270 }),
4271 ),
4272 ),
4273 );
4274 // On hosts that cancel request-scoped I/O once the response settles
4275 // (Cloudflare Workers), hand the host the rebuilds' completion so the
4276 // catalog still converges after the read stops waiting.
4277 config.waitUntil?.(
4278 new Promise<void>((resolve) => fiber.addObserver(() => resolve(undefined))),
4279 );
4280 yield* Fiber.await(fiber).pipe(Effect.timeoutOption(graceMs), Effect.asVoid);
4281 });
4282
4222 const toolsList = (filter?: ToolListFilter): Effect.Effect<readonly Tool[], StorageFailure> =>4283 const toolsList = (filter?: ToolListFilter): Effect.Effect<readonly Tool[], StorageFailure> =>
4223 Effect.gen(function* () {4284 Effect.gen(function* () {
4224 yield* syncStaleConnectionTools;4285 if (toolsSyncGraceMs === null) {
4286 yield* syncStaleConnectionTools;
4287 } else {
4288 yield* awaitStaleSyncWithinGrace(toolsSyncGraceMs);
4289 }
4225 // Projected: the list surface is metadata (address, description,4290 // Projected: the list surface is metadata (address, description,
4226 // annotations) — loading every tool's input/output schema JSON made4291 // annotations) — loading every tool's input/output schema JSON made
4227 // an unbounded list scale with schema bytes, not tool count.4292 // an unbounded list scale with schema bytes, not tool count.
packages/core/sdk/src/index.ts+9−0
@@ -425,6 +425,7 @@ export { · 424 unmodified lines
425 type ExecutorDbFactory,425 type ExecutorDbFactory,
426 type ExecutorDbInput,426 type ExecutorDbInput,
427 type ParsedToolAddress,427 type ParsedToolAddress,
428 DEFAULT_TOOLS_SYNC_GRACE_MS,
428 STALE_TOOLS_SYNC_CONCURRENCY,429 STALE_TOOLS_SYNC_CONCURRENCY,
429 createExecutor,430 createExecutor,
430 collectTables,431 collectTables,
@@ -488,6 +489,14 @@ export { · 57 unmodified lines
488 oauthClientGcSqliteMigration,489 oauthClientGcSqliteMigration,
489 runSqliteOAuthClientGcMigration,490 runSqliteOAuthClientGcMigration,
490} from "./sqlite-oauth-client-gc-migration";491} from "./sqlite-oauth-client-gc-migration";
492// Rewrite `bigint` columns an earlier build left in SQLite's INTEGER storage
493// class, which the bigint row mapper cannot read (issue #1771).
494export {
495 bigintStorageClassSqliteMigration,
496 runSqliteBigintStorageClassMigration,
497 LEGACY_BIGINT_STORAGE_CLASS_COLUMNS,
498 type BigintStorageClassColumn,
499} from "./sqlite-bigint-storage-class-migration";
491export {500export {
492 authToolFailure,501 authToolFailure,
493 isUnauthorizedToolFailure,502 isUnauthorizedToolFailure,
packages/core/sdk/src/sqlite-bigint-storage-class-migration.test.ts+202−0
@@ -0,0 +1,202 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { withQueryContext } from "@executor-js/fumadb/query";
4
5import { collectTables } from "./executor";
6import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
7import {
8 LEGACY_BIGINT_STORAGE_CLASS_COLUMNS,
9 bigintStorageClassSqliteMigration,
10 runSqliteBigintStorageClassMigration,
11} from "./sqlite-bigint-storage-class-migration";
12
13// A `bigint` column is stored on SQLite as a blob holding the decimal digits
14// (drizzle's `blob({ mode: "bigint" })`). Before the columns below carried the
15// `bigint` flag they were plain numbers, so SQLite kept them in the INTEGER
16// storage class — and an install written by that build still holds integers
17// today. Reading one back through the bigint mapper reaches
18// `Buffer.from(<number>)`, which throws `ERR_INVALID_ARG_TYPE`, and because the
19// throw is on the ROW mapper it takes down the whole `findMany`, not one field.
20//
21// That is issue #1771: `connection.expires_at` held an epoch-millis integer, so
22// every catalog read threw and the MCP gateway served an empty tool list even
23// though the integrations were still saved.
24
25const TENANT = "t1";
26const SUBJECT = "user_a";
27const LEGACY_EXPIRES_AT = 1787321623456;
28const HEALTHY_EXPIRES_AT = 1787321699999;
29
30const withDb = <A>(body: (db: SqliteTestFumaDb) => Promise<A>): Promise<A> =>
31 Effect.runPromise(
32 Effect.acquireUseRelease(
33 Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })),
34 (db) => Effect.promise(() => body(db)),
35 (db) => Effect.promise(() => db.close()),
36 ),
37 );
38
39const seconds = (ms: number) => Math.floor(ms / 1000);
40
41/** Insert a connection row whose `expires_at` is set by a raw SQL expression,
42 * so the test controls its SQLite storage class exactly: an integer literal
43 * lands as INTEGER — what a build that declared the column `integer` wrote —
44 * while `CAST(... AS BLOB)` lands as BLOB, what the ORM writes today. A bound
45 * JS number would land as REAL under the column's current BLOB affinity, which
46 * is a different (also broken) shape. */
47const insertConnection = (
48 db: SqliteTestFumaDb,
49 row: { readonly rowId: string; readonly name: string; readonly expiresAtSql: string },
50): Promise<unknown> =>
51 db.client.execute({
52 sql: `INSERT INTO connection
53 (row_id, tenant, owner, subject, integration, name, template, provider, item_ids,
54 expires_at, created_at, updated_at)
55 VALUES (?, ?, 'user', ?, 'acme', ?, 'oauth2', 'file', ?, ${row.expiresAtSql}, ?, ?)`,
56 args: [
57 row.rowId,
58 TENANT,
59 SUBJECT,
60 row.name,
61 JSON.stringify({ token: "item_1" }),
62 seconds(Date.now()),
63 seconds(Date.now()),
64 ],
65 });
66
67/** The legacy shape: a bare integer literal, stored in the INTEGER class. */
68const legacyInteger = String(LEGACY_EXPIRES_AT);
69/** The current shape: the decimal digits as bytes. */
70const currentBlob = `CAST('${HEALTHY_EXPIRES_AT}' AS BLOB)`;
71
72const storageClassOf = async (db: SqliteTestFumaDb, rowId: string): Promise<string> => {
73 const result = await db.client.execute({
74 sql: "SELECT typeof(expires_at) AS kind FROM connection WHERE row_id = ?",
75 args: [rowId],
76 });
77 return String(result.rows[0]?.["kind"]);
78};
79
80describe("legacy bigint storage class migration", () => {
81 it.effect("reproduces the catalog read failure on a legacy integer row", () =>
82 Effect.promise(() =>
83 withDb(async (db) => {
84 await insertConnection(db, {
85 rowId: "c_legacy",
86 name: "legacy",
87 expiresAtSql: legacyInteger,
88 });
89 expect(await storageClassOf(db, "c_legacy")).toBe("integer");
90
91 const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
92 // Not "returns a bad value" — the read THROWS, which is why the gateway
93 // lost every saved integration rather than one field of one row.
94 await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/);
95 }),
96 ),
97 );
98
99 it.effect("converts legacy integers so the catalog reads again", () =>
100 Effect.promise(() =>
101 withDb(async (db) => {
102 await insertConnection(db, {
103 rowId: "c_legacy",
104 name: "legacy",
105 expiresAtSql: legacyInteger,
106 });
107 await insertConnection(db, {
108 rowId: "c_healthy",
109 name: "healthy",
110 expiresAtSql: currentBlob,
111 });
112 await insertConnection(db, { rowId: "c_null", name: "null", expiresAtSql: "NULL" });
113
114 const converted = await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client));
115 expect(converted).toBe(1);
116
117 expect(await storageClassOf(db, "c_legacy")).toBe("blob");
118 expect(await storageClassOf(db, "c_healthy")).toBe("blob");
119 expect(await storageClassOf(db, "c_null")).toBe("null");
120
121 const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
122 const rows = await scoped.findMany("connection", {});
123 expect(
124 rows.map((row) => [row.name, row.expires_at == null ? null : Number(row.expires_at)]),
125 ).toEqual([
126 ["healthy", HEALTHY_EXPIRES_AT],
127 ["legacy", LEGACY_EXPIRES_AT],
128 ["null", null],
129 ]);
130 }),
131 ),
132 );
133
134 it.effect("is idempotent", () =>
135 Effect.promise(() =>
136 withDb(async (db) => {
137 await insertConnection(db, {
138 rowId: "c_legacy",
139 name: "legacy",
140 expiresAtSql: legacyInteger,
141 });
142
143 expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(1);
144 expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(0);
145
146 const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
147 const rows = await scoped.findMany("connection", {});
148 expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]);
149 }),
150 ),
151 );
152
153 it.effect("converts every bigint column that predates the flag", () =>
154 Effect.promise(() =>
155 withDb(async (db) => {
156 // `oauth_session.expires_at` was re-typed in the same change, and
157 // `connection.tools_synced_at` / `integration.config_revised_at` /
158 // `subject.last_seen_at` share the representation.
159 await db.client.execute({
160 sql: `INSERT INTO integration
161 (row_id, tenant, slug, plugin_id, description, config_revised_at, created_at, updated_at)
162 VALUES ('i1', ?, 'acme', 'openapi', '', ${legacyInteger}, ?, ?)`,
163 args: [TENANT, seconds(Date.now()), seconds(Date.now())],
164 });
165 await insertConnection(db, { rowId: "c1", name: "c1", expiresAtSql: "NULL" });
166 await db.client.execute(
167 `UPDATE connection SET tools_synced_at = ${legacyInteger} WHERE row_id = 'c1'`,
168 );
169
170 expect(await Effect.runPromise(runSqliteBigintStorageClassMigration(db.client))).toBe(2);
171
172 const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT });
173 const integrations = await scoped.findMany("integration", {});
174 expect(integrations.map((row) => Number(row.config_revised_at))).toEqual([
175 LEGACY_EXPIRES_AT,
176 ]);
177 const connections = await scoped.findMany("connection", {});
178 expect(connections.map((row) => Number(row.tools_synced_at))).toEqual([LEGACY_EXPIRES_AT]);
179 }),
180 ),
181 );
182
183 it("covers every bigint column the core schema declares", () => {
184 const tables = collectTables() as Record<string, { readonly columns: Record<string, unknown> }>;
185 const declared: string[] = [];
186 for (const [tableName, table] of Object.entries(tables)) {
187 for (const [columnName, column] of Object.entries(table.columns)) {
188 if ((column as { readonly type?: string }).type === "bigint") {
189 declared.push(`${tableName}.${columnName}`);
190 }
191 }
192 }
193 const covered = LEGACY_BIGINT_STORAGE_CLASS_COLUMNS.map(
194 (entry) => `${entry.table}.${entry.column}`,
195 );
196 expect(covered.slice().sort()).toEqual(declared.sort());
197 });
198
199 it("is registered under a stable, date-prefixed name", () => {
200 expect(bigintStorageClassSqliteMigration.name).toBe("2026-08-28-bigint-storage-class");
201 });
202});
packages/core/sdk/src/sqlite-bigint-storage-class-migration.ts+166−0
@@ -0,0 +1,166 @@
1// ---------------------------------------------------------------------------
2// libSQL boot migration: rewrite `bigint` columns that an earlier build left in
3// SQLite's INTEGER storage class (issue #1771).
4//
5// A `bigint` column is stored on SQLite as a BLOB holding the value's decimal
6// digits — that is what drizzle's `blob({ mode: "bigint" })` writes, and its
7// row mapper reads the blob back with `Buffer.from(...)`. Before these columns
8// carried the `bigint` flag they were plain numbers, which SQLite kept in the
9// INTEGER storage class. SQLite does not rewrite existing rows when a column's
10// declared type changes, so an install written by that build still holds
11// integers today.
12//
13// Reading one back now reaches `Buffer.from(<number>)`, which throws
14// `ERR_INVALID_ARG_TYPE`. The throw is in the ROW mapper, so it fails the whole
15// `findMany` rather than one field: on a database whose `connection` rows
16// carried an OAuth `expires_at` from that era, EVERY catalog read threw and the
17// MCP gateway served an empty tool list even though the integrations were all
18// still saved.
19//
20// The rewrite is deliberately narrow. It touches only the columns the schema
21// declares `bigint`, and within them only values whose storage class is
22// `integer` or `real` — the shapes the mapper cannot read. Values already
23// stored as BLOB or TEXT (TEXT is what the v1→v2 migration writes, and the
24// mapper reads it fine) are left exactly as they are. `CAST(x AS BLOB)`
25// converts through TEXT, so the stored bytes end up identical to what the ORM
26// writes today. Idempotent: after a run no column is in a numeric storage
27// class, so a second run updates nothing.
28// ---------------------------------------------------------------------------
29
30import { Effect } from "effect";
31
32import { coreSchema } from "./core-schema";
33import {
34 DataMigrationError,
35 type SqliteDataMigration,
36 type SqliteDataMigrationClient,
37} from "./sqlite-data-migrations";
38
39const MIGRATION_NAME = "2026-08-28-bigint-storage-class";
40
41export interface BigintStorageClassColumn {
42 /** SQL table name. */
43 readonly table: string;
44 /** SQL column name. */
45 readonly column: string;
46}
47
48/**
49 * Every `bigint` column in the core schema, by SQL name.
50 *
51 * Derived from the schema rather than hand-listed so a column added later can
52 * never be silently missed. Scanning a column that never held an integer is a
53 * no-op, so over-coverage costs one indexed-free table scan at first boot.
54 */
55export const LEGACY_BIGINT_STORAGE_CLASS_COLUMNS: readonly BigintStorageClassColumn[] =
56 Object.values(coreSchema).flatMap((table) =>
57 Object.values(table.columns)
58 .filter((column) => column.type === "bigint")
59 .map((column) => ({ table: table.names.sql, column: column.names.sql })),
60 );
61
62const execute = (
63 client: SqliteDataMigrationClient,
64 stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
65) =>
66 Effect.tryPromise({
67 try: () => client.execute(stmt),
68 catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }),
69 });
70
71/** SQLite identifiers are quoted, not parameterized. Every name here comes from
72 * the compiled-in schema, so this always matches; anything else is refused
73 * rather than interpolated. */
74const quoteIdentifier = (name: string): Effect.Effect<string, DataMigrationError> =>
75 /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
76 ? Effect.succeed(`"${name}"`)
77 : Effect.fail(
78 new DataMigrationError({
79 migration: MIGRATION_NAME,
80 cause: `Refusing to interpolate SQL identifier: ${name}`,
81 }),
82 );
83
84const hasColumn = (
85 client: SqliteDataMigrationClient,
86 table: string,
87 quotedTable: string,
88 column: string,
89): Effect.Effect<boolean, DataMigrationError> =>
90 execute(client, {
91 sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
92 args: [table],
93 }).pipe(
94 Effect.flatMap((tables) =>
95 tables.rows.length === 0
96 ? Effect.succeed(false)
97 : execute(client, `PRAGMA table_info(${quotedTable})`).pipe(
98 Effect.map((info) => info.rows.some((row) => row["name"] === column)),
99 ),
100 ),
101 );
102
103/**
104 * Convert legacy INTEGER/REAL values in the schema's `bigint` columns to the
105 * blob representation the ORM reads.
106 *
107 * Returns the number of rows rewritten. Wrapped in BEGIN…COMMIT so a mid-run
108 * failure leaves the database untouched and the (unstamped) migration re-runs
109 * cleanly on the next boot.
110 */
111export const runSqliteBigintStorageClassMigration = (
112 client: SqliteDataMigrationClient,
113): Effect.Effect<number, DataMigrationError> =>
114 Effect.gen(function* () {
115 const pending: { readonly sql: string; readonly count: number }[] = [];
116
117 for (const target of LEGACY_BIGINT_STORAGE_CLASS_COLUMNS) {
118 const table = yield* quoteIdentifier(target.table);
119 const column = yield* quoteIdentifier(target.column);
120 if (!(yield* hasColumn(client, target.table, table, target.column))) continue;
121
122 // `typeof()` reports the STORAGE class, which is what the mapper trips
123 // over — the declared column type is irrelevant to SQLite here.
124 const predicate = `typeof(${column}) IN ('integer', 'real')`;
125
126 const counted = yield* execute(
127 client,
128 `SELECT COUNT(*) AS n FROM ${table} WHERE ${predicate}`,
129 );
130 const count = Number(counted.rows[0]?.["n"] ?? 0);
131 if (count === 0) continue;
132
133 pending.push({
134 sql: `UPDATE ${table} SET ${column} = CAST(CAST(${column} AS INTEGER) AS BLOB) WHERE ${predicate}`,
135 count,
136 });
137 }
138
139 if (pending.length === 0) return 0;
140
141 const applyAll = Effect.gen(function* () {
142 let converted = 0;
143 for (const statement of pending) {
144 yield* execute(client, statement.sql);
145 converted += statement.count;
146 }
147 yield* execute(client, "COMMIT");
148 return converted;
149 });
150
151 yield* execute(client, "BEGIN");
152 return yield* applyAll.pipe(
153 Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)),
154 // `tapError` only fires on a typed failure, not on fiber interruption
155 // (e.g. the boot sequence timing out mid-migration) — so without this an
156 // interrupted run can leave the transaction open. Never fires on success:
157 // COMMIT has already run by then.
158 Effect.onInterrupt(() => execute(client, "ROLLBACK").pipe(Effect.ignore)),
159 );
160 });
161
162/** Registry entry for the SQLite hosts' boot-time data-migration ledger. */
163export const bigintStorageClassSqliteMigration: SqliteDataMigration = {
164 name: MIGRATION_NAME,
165 run: (client) => runSqliteBigintStorageClassMigration(client).pipe(Effect.asVoid),
166};
packages/plugins/graphql/src/sdk/plugin.test.ts+5−1
@@ -729,8 +729,12 @@ describe("graphqlPlugin real protocol server", () => { · 728 unmodified lines
729 expect(result).toMatchObject({729 expect(result).toMatchObject({
730 status: "healthy",730 status: "healthy",
731 httpStatus: 200,731 httpStatus: 200,
732 identity: "GraphQL schema: Query",732 responseSample: [{ path: "__schema.queryType.name", value: "Query" }],
733 });733 });
734 // The schema's root type name is not an account identity; reporting it
735 // as one made the accounts UI label every GraphQL connection
736 // "GraphQL schema: Query".
737 expect(result).not.toHaveProperty("identity");
734 }),738 }),
735 );739 );
736 740
packages/plugins/graphql/src/sdk/plugin.ts+4−4
@@ -737,16 +737,16 @@ const checkGraphqlHealth = (input: { · 736 unmodified lines
737 : { ...verdict, detail: scrubCredentialValues(verdict.detail, input.credential.values) };737 : { ...verdict, detail: scrubCredentialValues(verdict.detail, input.credential.values) };
738 }738 }
739 739
740 // No `identity`: the schema's root type name ("Query" almost everywhere)
741 // identifies nothing, and the accounts UI would show it as the account
742 // label. Introspection proves reachability, not who the credential is.
740 const queryType = result.introspection.__schema.queryType?.name;743 const queryType = result.introspection.__schema.queryType?.name;
741 return {744 return {
742 status: "healthy",745 status: "healthy",
743 httpStatus: 200,746 httpStatus: 200,
744 checkedAt,747 checkedAt,
745 ...(queryType != null748 ...(queryType != null
746 ? {749 ? { responseSample: [{ path: "__schema.queryType.name", value: queryType }] }
747 identity: `GraphQL schema: ${queryType}`,
748 responseSample: [{ path: "__schema.queryType.name", value: queryType }],
749 }
750 : {}),750 : {}),
751 } satisfies HealthCheckResult;751 } satisfies HealthCheckResult;
752 });752 });
packages/plugins/mcp/package.json+1−0
@@ -69,6 +69,7 @@ · 68 unmodified lines
69 "@modelcontextprotocol/client": "2.0.0",69 "@modelcontextprotocol/client": "2.0.0",
70 "@modelcontextprotocol/core": "2.0.0",70 "@modelcontextprotocol/core": "2.0.0",
71 "@modelcontextprotocol/sdk": "^1.29.0",71 "@modelcontextprotocol/sdk": "^1.29.0",
72 "lucide-react": "^1.7.0",
72 "zod": "4.3.6"73 "zod": "4.3.6"
73 },74 },
74 "devDependencies": {75 "devDependencies": {
packages/plugins/mcp/src/api/group.ts+2−0
@@ -31,6 +31,7 @@ const StringMap = Schema.Record(Schema.String, Schema.String); · 30 unmodified lines
31const AddRemoteServerPayload = Schema.Struct({31const AddRemoteServerPayload = Schema.Struct({
32 transport: Schema.optional(Schema.Literal("remote")),32 transport: Schema.optional(Schema.Literal("remote")),
33 name: Schema.String,33 name: Schema.String,
34 family: Schema.optional(Schema.String),
34 /** Agent-visible catalog description. Defaults to the display name. */35 /** Agent-visible catalog description. Defaults to the display name. */
35 description: Schema.optional(Schema.String),36 description: Schema.optional(Schema.String),
36 endpoint: Schema.String,37 endpoint: Schema.String,
@@ -48,6 +49,7 @@ const AddRemoteServerPayload = Schema.Struct({ · 11 unmodified lines
48const AddStdioServerPayload = Schema.Struct({49const AddStdioServerPayload = Schema.Struct({
49 transport: Schema.Literal("stdio"),50 transport: Schema.Literal("stdio"),
50 name: Schema.String,51 name: Schema.String,
52 family: Schema.optional(Schema.String),
51 description: Schema.optional(Schema.String),53 description: Schema.optional(Schema.String),
52 command: Schema.String,54 command: Schema.String,
53 args: Schema.optional(Schema.Array(Schema.String)),55 args: Schema.optional(Schema.Array(Schema.String)),
packages/plugins/mcp/src/api/handlers.ts+4−0
@@ -33,6 +33,7 @@ const toServerInput = ( · 32 unmodified lines
33 const p = payload as {33 const p = payload as {
34 transport: "stdio";34 transport: "stdio";
35 name: string;35 name: string;
36 family?: string;
36 description?: string;37 description?: string;
37 command: string;38 command: string;
38 args?: readonly string[];39 args?: readonly string[];
@@ -45,6 +46,7 @@ const toServerInput = ( · 6 unmodified lines
45 return {46 return {
46 transport: "stdio",47 transport: "stdio",
47 name: p.name,48 name: p.name,
49 family: p.family,
48 description: p.description,50 description: p.description,
49 command: p.command,51 command: p.command,
50 args: p.args ? [...p.args] : undefined,52 args: p.args ? [...p.args] : undefined,
@@ -59,6 +61,7 @@ const toServerInput = ( · 8 unmodified lines
59 const p = payload as {61 const p = payload as {
60 transport?: "remote";62 transport?: "remote";
61 name: string;63 name: string;
64 family?: string;
62 description?: string;65 description?: string;
63 endpoint: string;66 endpoint: string;
64 remoteTransport?: "streamable-http" | "sse" | "auto";67 remoteTransport?: "streamable-http" | "sse" | "auto";
@@ -74,6 +77,7 @@ const toServerInput = ( · 9 unmodified lines
74 return {77 return {
75 transport: "remote",78 transport: "remote",
76 name: p.name,79 name: p.name,
80 family: p.family,
77 description: p.description,81 description: p.description,
78 endpoint: p.endpoint,82 endpoint: p.endpoint,
79 remoteTransport: p.remoteTransport,83 remoteTransport: p.remoteTransport,
packages/plugins/mcp/src/react/AddMcpIntegration.tsx+41−19
@@ -38,7 +38,11 @@ import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; · 37 unmodified lines
38import type { McpAuthMethodInput } from "../sdk/types";38import type { McpAuthMethodInput } from "../sdk/types";
39import { probeMcpEndpoint, addMcpServer } from "./atoms";39import { probeMcpEndpoint, addMcpServer } from "./atoms";
40import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields";40import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields";
41import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
42import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";
41import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";43import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
44import { parseStdioArgs } from "./stdio-fields";
45import { isProbableMcpEndpoint } from "./probe-url";
42import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";46import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";
43import { mcpPresets, type McpPreset } from "../sdk/presets";47import { mcpPresets, type McpPreset } from "../sdk/presets";
44 48
@@ -59,19 +63,6 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 14 unmodified lines
59 return mcpPresets.find((p) => p.id === id);63 return mcpPresets.find((p) => p.id === id);
60}64}
61 65
62// Splits the raw args field into tokens, honoring double-quoted groups so an
63// argument with spaces stays intact.
64function parseStdioArgs(raw: string): string[] {
65 if (!raw.trim()) return [];
66 const args: string[] = [];
67 const regex = /[^\s"]+|"([^"]*)"/g;
68 let match;
69 while ((match = regex.exec(raw)) !== null) {
70 args.push(match[1] ?? match[0]);
71 }
72 return args;
73}
74
75// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
76// State machine (remote flow)67// State machine (remote flow)
77// ---------------------------------------------------------------------------68// ---------------------------------------------------------------------------
@@ -208,6 +199,12 @@ export default function AddMcpIntegration(props: { · 130 unmodified lines
208 remoteUrl ? { step: "url" as const, url: remoteUrl } : init,199 remoteUrl ? { step: "url" as const, url: remoteUrl } : init,
209 );200 );
210 201
202 // Static request headers for the endpoint (e.g. a Cloudflare Access service
203 // token). They gate the probe as much as the live traffic, so the same
204 // values feed both.
205 const [headerRows, setHeaderRows] = useState<readonly McpHeaderRow[]>([]);
206 const headers = useMemo(() => mcpHeadersFromRows(headerRows), [headerRows]);
207
211 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });208 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
212 const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });209 const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });
213 210
@@ -270,11 +267,23 @@ export default function AddMcpIntegration(props: { · 56 unmodified lines
270 267
271 // ---- Remote actions ----268 // ---- Remote actions ----
272 269
270 // Each probe run takes a token. Editing the URL invalidates it, so a reply
271 // that lands after the user has moved on is dropped instead of reporting on
272 // a URL that is no longer in the field. The probe atom exposes no abort
273 // signal, so the request itself still finishes; only its answer is ignored.
274 const probeRunRef = useRef(0);
275
276 useEffect(() => {
277 probeRunRef.current += 1;
278 }, [state.url]);
279
273 const handleProbe = useCallback(async () => {280 const handleProbe = useCallback(async () => {
281 const run = (probeRunRef.current += 1);
274 dispatch({ type: "probe-start" });282 dispatch({ type: "probe-start" });
275 const exit = await doProbe({283 const exit = await doProbe({
276 payload: { endpoint: state.url.trim() },284 payload: { endpoint: state.url.trim(), ...(headers ? { headers } : {}) },
277 });285 });
286 if (run !== probeRunRef.current) return;
278 if (Exit.isFailure(exit)) {287 if (Exit.isFailure(exit)) {
279 dispatch({288 dispatch({
280 type: "probe-fail",289 type: "probe-fail",
@@ -283,7 +292,7 @@ export default function AddMcpIntegration(props: { · 2 unmodified lines
283 return;292 return;
284 }293 }
285 dispatch({ type: "probe-ok", probe: exit.value });294 dispatch({ type: "probe-ok", probe: exit.value });
286 }, [state.url, doProbe]);295 }, [state.url, headers, doProbe]);
287 296
288 // Keep the latest handleProbe in a ref so the debounced effect can call it297 // Keep the latest handleProbe in a ref so the debounced effect can call it
289 // without depending on its identity (which changes every render).298 // without depending on its identity (which changes every render).
@@ -291,12 +300,14 @@ export default function AddMcpIntegration(props: { · 1 unmodified line
291 handleProbeRef.current = handleProbe;300 handleProbeRef.current = handleProbe;
292 301
293 // Auto-probe whenever the URL changes (debounced) while we're on the302 // Auto-probe whenever the URL changes (debounced) while we're on the
294 // remote transport and not already probing/probed.303 // remote transport and not already probing/probed. The shape gate keeps a
304 // half-typed URL from being dialled: without it every keystroke that is a
305 // non-empty string gets probed, and the field flashes through a loading and
306 // then an error state for values the user never meant to submit.
295 useEffect(() => {307 useEffect(() => {
296 if (transport !== "remote") return;308 if (transport !== "remote") return;
297 if (state.step !== "url") return;309 if (state.step !== "url") return;
298 const trimmed = state.url.trim();310 if (!isProbableMcpEndpoint(state.url)) return;
299 if (!trimmed) return;
300 const handle = setTimeout(() => {311 const handle = setTimeout(() => {
301 handleProbeRef.current();312 handleProbeRef.current();
302 }, 400);313 }, 400);
@@ -318,6 +329,7 @@ export default function AddMcpIntegration(props: { · 15 unmodified lines
318 : {}),329 : {}),
319 endpoint: state.url.trim(),330 endpoint: state.url.trim(),
320 ...(slug ? { slug } : {}),331 ...(slug ? { slug } : {}),
332 ...(headers ? { headers } : {}),
321 authenticationTemplate,333 authenticationTemplate,
322 },334 },
323 reactivityKeys: integrationWriteKeys,335 reactivityKeys: integrationWriteKeys,
@@ -331,7 +343,7 @@ export default function AddMcpIntegration(props: { · 7 unmodified lines
331 }343 }
332 return exit.value.slug;344 return exit.value.slug;
333 },345 },
334 [doAddServer, probe, remoteIdentity, resolvedDescription, state.url],346 [doAddServer, headers, probe, remoteIdentity, resolvedDescription, state.url],
335 );347 );
336 348
337 const handleAddRemote = useCallback(async () => {349 const handleAddRemote = useCallback(async () => {
@@ -446,6 +458,16 @@ export default function AddMcpIntegration(props: { · 108 unmodified lines
446 </Info>458 </Info>
447 )}459 )}
448 460
461 {/* Static request headers. Shown in every remote state, because an
462 endpoint behind an edge authenticator (Cloudflare Access) fails
463 the very first probe until its service-token headers are set. */}
464 <McpRequestHeadersEditor
465 rows={headerRows}
466 onChange={setHeaderRows}
467 onTest={handleProbe}
468 testing={isProbing}
469 />
470
449 {/* Authentication — declares the auth methods to register through the471 {/* Authentication — declares the auth methods to register through the
450 shared list editor. The credentials themselves (API key value /472 shared list editor. The credentials themselves (API key value /
451 OAuth sign-in) are added from the integration's detail hub after473 OAuth sign-in) are added from the integration's detail hub after
packages/plugins/mcp/src/react/EditMcpIntegration.tsx+158−22
@@ -13,10 +13,16 @@ import { · 12 unmodified lines
13 type AuthMethodRow,13 type AuthMethodRow,
14 type AuthMethodSeed,14 type AuthMethodSeed,
15} from "@executor-js/react/components/auth-method-list-editor";15} from "@executor-js/react/components/auth-method-list-editor";
16import { Badge } from "@executor-js/react/components/badge";16import {
17import { FormErrorAlert } from "@executor-js/react/lib/integration-add";17 CardStack,
18 CardStackContent,
19 CardStackEntryField,
20} from "@executor-js/react/components/card-stack";
21import { Input } from "@executor-js/react/components/input";
22import { Textarea } from "@executor-js/react/components/textarea";
23import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add";
18 24
19import { configureMcpAuth, mcpServerAtom } from "./atoms";25import { configureMcpAuth, configureMcpServer, mcpServerAtom } from "./atoms";
20import type {26import type {
21 McpAuthMethod,27 McpAuthMethod,
22 McpCanonicalAuthMethodInput,28 McpCanonicalAuthMethodInput,
@@ -27,6 +33,7 @@ import { · 4 unmodified lines
27 mcpAuthMethodInputFromEditorValue,33 mcpAuthMethodInputFromEditorValue,
28 mcpWireAuthInput,34 mcpWireAuthInput,
29} from "./auth-method-config";35} from "./auth-method-config";
36import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields";
30 37
31type McpServer = {38type McpServer = {
32 readonly slug: IntegrationSlug;39 readonly slug: IntegrationSlug;
@@ -174,30 +181,160 @@ function RemoteEdit(props: { · 141 unmodified lines
174}181}
175 182
176// ---------------------------------------------------------------------------183// ---------------------------------------------------------------------------
177// Stdio read-only view184// Stdio edit — the command, its arguments, the working directory, and the
185// DECLARED static environment. Secret env vars are not edited here: a stdio
186// server declares them as a `stdio_env` auth method and their values live on
187// the connection, managed from the integration page's accounts hub. Changes
188// are staged and applied by the sheet's Save, like the remote editor above.
178// ---------------------------------------------------------------------------189// ---------------------------------------------------------------------------
179 190
180function StdioReadOnly(props: {191type McpStdioConfig = Extract<McpIntegrationConfig, { transport: "stdio" }>;
181 server: McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> };192
193/** Order-independent identity for a declared env map, so re-ordering the lines
194 * in the field is not reported as a change. */
195const envIdentity = (env: Readonly<Record<string, string>> | undefined): string =>
196 Object.entries(env ?? {})
197 .sort(([a], [b]) => a.localeCompare(b))
198 .map(([key, value]) => `${key}=${value}`)
199 .join("\n");
200
201function StdioEdit(props: {
202 server: McpServer & { config: McpStdioConfig };
203 onPendingChange?: EditSheetSectionProps["onPendingChange"];
182}) {204}) {
183 const { command, args } = props.server.config;205 const { server } = props;
206 const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" });
207
208 const [command, setCommand] = useState(server.config.command);
209 const [args, setArgs] = useState(() => formatStdioArgs(server.config.args));
210 const [cwd, setCwd] = useState(server.config.cwd ?? "");
211 const [env, setEnv] = useState(() => formatStdioEnv(server.config.env));
212 const [error, setError] = useState<string | null>(null);
213
214 // The edited config. `configureServer` replaces the whole blob, so anything
215 // this form does not surface (`versionNegotiation`, `authenticationTemplate`)
216 // is carried through untouched. Empty optional fields are omitted rather than
217 // written as `undefined`.
218 const edited = useMemo<McpStdioConfig>(() => {
219 const { args: _args, cwd: _cwd, env: _env, ...rest } = server.config;
220 const nextArgs = parseStdioArgs(args);
221 const nextEnv = parseStdioEnv(env);
222 const nextCwd = cwd.trim();
223 return {
224 ...rest,
225 command: command.trim(),
226 ...(nextArgs.length > 0 ? { args: nextArgs } : {}),
227 ...(Object.keys(nextEnv).length > 0 ? { env: nextEnv } : {}),
228 ...(nextCwd !== "" ? { cwd: nextCwd } : {}),
229 };
230 }, [args, command, cwd, env, server.config]);
231
232 const changed =
233 edited.command !== server.config.command ||
234 formatStdioArgs(edited.args) !== formatStdioArgs(server.config.args) ||
235 (edited.cwd ?? "") !== (server.config.cwd ?? "") ||
236 envIdentity(edited.env) !== envIdentity(server.config.env);
237
238 // Staged apply, run by the sheet's Save. Persisting the config re-runs
239 // discovery on every connection, so the tool catalog matches the new command.
240 const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {
241 setError(null);
242 if (edited.command === "") {
243 setError("A command is required.");
244 return { ok: false };
245 }
246 const exit = await doConfigure({
247 params: { slug: server.slug },
248 payload: { config: edited },
249 reactivityKeys: integrationWriteKeys,
250 });
251 if (Exit.isFailure(exit)) {
252 setError(errorMessageFromExit(exit, "Failed to update the server command"));
253 return { ok: false };
254 }
255 return { ok: true, summary: "Server command updated." };
256 }, [doConfigure, edited, server.slug]);
257
258 const onPendingChangeRef = useRef(props.onPendingChange);
259 onPendingChangeRef.current = props.onPendingChange;
260 useEffect(() => {
261 onPendingChangeRef.current?.(changed ? applyStaged : null);
262 return () => onPendingChangeRef.current?.(null);
263 }, [changed, applyStaged]);
264
184 return (265 return (
185 <div className="space-y-3 border-t border-border/60 pt-5">266 <div className="space-y-4 border-t border-border/60 pt-5">
186 <div className="space-y-1">267 <div className="space-y-1">
187 <p className="text-sm font-medium text-foreground">Server command</p>268 <p className="text-sm font-medium text-foreground">Server command</p>
188 <p className="text-xs text-muted-foreground">269 <p className="text-xs text-muted-foreground">
189 Stdio MCP integrations cannot be edited. Remove and recreate the integration with the270 Changes apply when you save. The server's tools are then rediscovered with the new
190 updated command.271 command.
191 </p>272 </p>
192 </div>273 </div>
193 <div className="flex items-center gap-3 rounded-md border border-border/60 bg-muted/40 px-3 py-2">274
194 <p className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">275 <CardStack>
195 {command} {(args ?? []).join(" ")}276 <CardStackContent className="border-t-0">
196 </p>277 <CardStackEntryField
197 <Badge variant="secondary" className="text-xs">278 label="Command"
198 stdio279 description="- The executable to run (e.g. npx, uvx, node)."
199 </Badge>280 >
200 </div>281 <Input
282 value={command}
283 aria-label="Command"
284 onChange={(e) => setCommand((e.target as HTMLInputElement).value)}
285 placeholder="npx"
286 className="font-mono text-sm"
287 />
288 </CardStackEntryField>
289
290 <CardStackEntryField
291 label="Arguments"
292 description="- Space-separated arguments passed to the command."
293 >
294 <Input
295 value={args}
296 aria-label="Arguments"
297 onChange={(e) => setArgs((e.target as HTMLInputElement).value)}
298 placeholder="-y chrome-devtools-mcp@latest"
299 className="font-mono text-sm"
300 />
301 </CardStackEntryField>
302
303 <CardStackEntryField
304 label="Working directory"
305 description="- Optional. Where the command runs."
306 >
307 <Input
308 value={cwd}
309 aria-label="Working directory"
310 onChange={(e) => setCwd((e.target as HTMLInputElement).value)}
311 placeholder="/path/to/server"
312 className="font-mono text-sm"
313 />
314 </CardStackEntryField>
315
316 <CardStackEntryField
317 label="Environment variables"
318 description="- One KEY=value per line. The server receives these plus a small base set; it does not inherit executor's environment."
319 >
320 <Textarea
321 value={env}
322 aria-label="Environment variables"
323 onChange={(e) => setEnv((e.target as HTMLTextAreaElement).value)}
324 placeholder={"LOG_LEVEL=debug\nREGION=eu-west-1"}
325 className="font-mono text-sm"
326 />
327 </CardStackEntryField>
328 </CardStackContent>
329 </CardStack>
330
331 <p className="text-xs text-muted-foreground">
332 Secret values do not belong here. A server that needs an API key declares its variable name
333 as an authentication method, and the value is entered per connection from the accounts
334 panel.
335 </p>
336
337 {error && <FormErrorAlert message={error} />}
201 </div>338 </div>
202 );339 );
203}340}
@@ -219,10 +356,9 @@ export default function EditMcpIntegration({ · 15 unmodified lines
219 356
220 if (server.config.transport === "stdio") {357 if (server.config.transport === "stdio") {
221 return (358 return (
222 <StdioReadOnly359 <StdioEdit
223 server={360 server={server as McpServer & { config: McpStdioConfig }}
224 server as McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> }361 {...(onPendingChange ? { onPendingChange } : {})}
225 }
226 />362 />
227 );363 );
228 }364 }
packages/plugins/mcp/src/react/McpRequestHeadersEditor.tsx+118−0
@@ -0,0 +1,118 @@
1import { PlusIcon, XIcon } from "lucide-react";
2
3import { Button } from "@executor-js/react/components/button";
4import {
5 CardStack,
6 CardStackContent,
7 CardStackEntryField,
8} from "@executor-js/react/components/card-stack";
9import { FieldError } from "@executor-js/react/components/field";
10import { Input } from "@executor-js/react/components/input";
11
12import { emptyHeaderRow, isValidHeaderName, type McpHeaderRow } from "./request-headers";
13
14// ---------------------------------------------------------------------------
15// Request headers editor — the name/value pairs sent on every request to a
16// remote MCP server, including the connection check.
17//
18// Deliberately plain: two mono fields and a remove control per row, matching
19// the metadata voice the rest of the add flow uses. Rows are keyed by index
20// because the values are fully controlled, exactly as the shared placement
21// editor does it.
22// ---------------------------------------------------------------------------
23
24export function McpRequestHeadersEditor(props: {
25 readonly rows: readonly McpHeaderRow[];
26 readonly onChange: (rows: McpHeaderRow[]) => void;
27 /** Re-run the connection check with the headers as typed. */
28 readonly onTest?: () => void;
29 readonly testing?: boolean;
30}) {
31 const { rows, onChange } = props;
32
33 const set = (index: number, patch: Partial<McpHeaderRow>): void =>
34 onChange(rows.map((row, j) => (j === index ? { ...row, ...patch } : row)));
35
36 const remove = (index: number): void => onChange(rows.filter((_row, j) => j !== index));
37
38 const hasInvalidName = rows.some((row) => !isValidHeaderName(row.name));
39
40 return (
41 <CardStack>
42 <CardStackContent className="border-t-0">
43 <CardStackEntryField
44 label="Request headers"
45 description="- Optional. Sent on every request, including the connection check."
46 >
47 {rows.length > 0 && (
48 <div className="flex flex-col gap-2">
49 {rows.map((row, index) => (
50 <div key={index} className="flex items-center gap-2">
51 <Input
52 aria-label="Header name"
53 value={row.name}
54 onChange={(e) => set(index, { name: (e.target as HTMLInputElement).value })}
55 placeholder="CF-Access-Client-Id"
56 className="h-8 min-w-0 flex-1 font-mono text-xs"
57 aria-invalid={isValidHeaderName(row.name) ? undefined : true}
58 />
59 <Input
60 aria-label="Header value"
61 value={row.value}
62 onChange={(e) => set(index, { value: (e.target as HTMLInputElement).value })}
63 placeholder="Value"
64 className="h-8 min-w-0 flex-1 font-mono text-xs"
65 />
66 <Button
67 type="button"
68 variant="ghost"
69 size="icon-sm"
70 aria-label="Remove header"
71 className="shrink-0 text-muted-foreground hover:text-foreground"
72 onClick={() => remove(index)}
73 >
74 <XIcon />
75 </Button>
76 </div>
77 ))}
78 </div>
79 )}
80
81 {hasInvalidName && (
82 <FieldError>A header name cannot contain spaces or a colon.</FieldError>
83 )}
84
85 <div className="flex flex-wrap items-center gap-2">
86 <Button
87 type="button"
88 variant="outline"
89 size="sm"
90 className="w-fit border-dashed"
91 onClick={() => onChange([...rows, emptyHeaderRow()])}
92 >
93 <PlusIcon />
94 Add header
95 </Button>
96 {props.onTest && rows.length > 0 && (
97 <Button
98 type="button"
99 variant="ghost"
100 size="sm"
101 onClick={props.onTest}
102 loading={props.testing}
103 >
104 Test connection
105 </Button>
106 )}
107 </div>
108
109 <p className="text-[11px] text-muted-foreground">
110 Stored with the integration and sent verbatim. Use these for endpoint-level access
111 tokens, such as a Cloudflare Access service token. A per-account credential belongs in
112 an auth method instead.
113 </p>
114 </CardStackEntryField>
115 </CardStackContent>
116 </CardStack>
117 );
118}
packages/plugins/mcp/src/react/probe-url.test.ts+63−0
@@ -0,0 +1,63 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { isProbableMcpEndpoint } from "./probe-url";
4
5describe("isProbableMcpEndpoint", () => {
6 it("rejects the prefixes a URL passes through while it is typed", () => {
7 for (const typed of [
8 "",
9 " ",
10 "h",
11 "ht",
12 "htt",
13 "http",
14 "http:",
15 "http:/",
16 "http://",
17 "http://a",
18 "http://example",
19 "https://mcp",
20 "https://mcp.",
21 "https://.com",
22 ]) {
23 expect(isProbableMcpEndpoint(typed), typed).toBe(false);
24 }
25 });
26
27 it("accepts a finished http(s) endpoint", () => {
28 for (const finished of [
29 "https://mcp.example.com",
30 "https://mcp.example.com/mcp",
31 "http://example.com:4789/mcp?codemode=false",
32 "https://example.co.uk/sse",
33 " https://mcp.example.com/mcp ",
34 "HTTPS://MCP.EXAMPLE.COM/mcp",
35 ]) {
36 expect(isProbableMcpEndpoint(finished), finished).toBe(true);
37 }
38 });
39
40 it("accepts local development hosts, which have no dot to wait for", () => {
41 for (const local of [
42 "http://localhost",
43 "http://localhost:4789/mcp",
44 "http://app.localhost:4789/mcp",
45 "http://127.0.0.1:4789/mcp",
46 "http://[::1]:4789/mcp",
47 ]) {
48 expect(isProbableMcpEndpoint(local), local).toBe(true);
49 }
50 });
51
52 it("rejects schemes the MCP remote transport cannot dial", () => {
53 for (const wrongScheme of [
54 "ftp://mcp.example.com",
55 "ws://mcp.example.com",
56 "file:///tmp/mcp",
57 "mailto:someone@example.com",
58 "npx @example/mcp-server",
59 ]) {
60 expect(isProbableMcpEndpoint(wrongScheme), wrongScheme).toBe(false);
61 }
62 });
63});
packages/plugins/mcp/src/react/probe-url.ts+25−0
@@ -0,0 +1,25 @@
1// The remote add form probes the endpoint as the user types, so the gate below
2// decides which intermediate strings are worth dialling. Every keystroke is a
3// prefix of the next one: "h", "ht", "http://a" all parse or fail in ways that
4// tell us nothing, and probing them puts the field into a loading state and
5// then an error state for a URL the user never meant to submit.
6
7/** Whether `value` looks like a finished MCP endpoint, and so is worth dialling
8 * while the user is still typing. Format-only: it says nothing about whether a
9 * server answers there. */
10export const isProbableMcpEndpoint = (value: string): boolean => {
11 const trimmed = value.trim();
12 if (!URL.canParse(trimmed)) return false;
13
14 const url = new URL(trimmed);
15 if (url.protocol !== "http:" && url.protocol !== "https:") return false;
16
17 const hostname = url.hostname.toLowerCase();
18 // Local development servers have no dot to wait for.
19 if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
20 // An IPv6 literal arrives bracketed and is complete once it parses.
21 if (hostname.startsWith("[")) return true;
22 // Otherwise wait for a dot with a label on each side. A bare "example" is
23 // still being typed; "example.com" is something we can dial.
24 return hostname.includes(".") && !hostname.startsWith(".") && !hostname.endsWith(".");
25};
packages/plugins/mcp/src/react/request-headers.test.ts+83−0
@@ -0,0 +1,83 @@
1// ---------------------------------------------------------------------------
2// The row -> wire conversion behind the request-headers editor. What matters
3// is that a half-typed row never reaches the transport and that a pasted
4// token still works, so both are pinned here.
5// ---------------------------------------------------------------------------
6
7import { describe, expect, it } from "@effect/vitest";
8
9import { emptyHeaderRow, isValidHeaderName, mcpHeadersFromRows } from "./request-headers";
10
11describe("mcpHeadersFromRows", () => {
12 it("returns undefined when nothing is configured", () => {
13 expect(mcpHeadersFromRows([])).toBeUndefined();
14 expect(mcpHeadersFromRows([emptyHeaderRow()])).toBeUndefined();
15 });
16
17 it("builds the wire map from completed rows", () => {
18 expect(
19 mcpHeadersFromRows([
20 { name: "CF-Access-Client-Id", value: "client-id" },
21 { name: "CF-Access-Client-Secret", value: "client-secret" },
22 ]),
23 ).toEqual({
24 "CF-Access-Client-Id": "client-id",
25 "CF-Access-Client-Secret": "client-secret",
26 });
27 });
28
29 it("drops a row the user has not finished naming", () => {
30 expect(
31 mcpHeadersFromRows([
32 { name: "X-Api-Key", value: "k" },
33 { name: " ", value: "orphaned" },
34 ]),
35 ).toEqual({ "X-Api-Key": "k" });
36 });
37
38 it("trims surrounding whitespace off a pasted token", () => {
39 expect(mcpHeadersFromRows([{ name: " X-Token ", value: " abc123\n" }])).toEqual({
40 "X-Token": "abc123",
41 });
42 });
43
44 it("drops a name that cannot be put on the wire", () => {
45 expect(
46 mcpHeadersFromRows([
47 { name: "Bad Header", value: "v" },
48 { name: "Also:Bad", value: "v" },
49 { name: "Good-Header", value: "v" },
50 ]),
51 ).toEqual({ "Good-Header": "v" });
52 });
53
54 it("keeps a header whose value is deliberately empty", () => {
55 expect(mcpHeadersFromRows([{ name: "X-Empty", value: "" }])).toEqual({ "X-Empty": "" });
56 });
57
58 it("lets a later row win a duplicated name", () => {
59 expect(
60 mcpHeadersFromRows([
61 { name: "X-Token", value: "old" },
62 { name: "X-Token", value: "new" },
63 ]),
64 ).toEqual({ "X-Token": "new" });
65 });
66});
67
68describe("isValidHeaderName", () => {
69 it("accepts an RFC 7230 token", () => {
70 expect(isValidHeaderName("CF-Access-Client-Id")).toBe(true);
71 expect(isValidHeaderName("X_Api_Key")).toBe(true);
72 });
73
74 it("treats a blank name as unfinished rather than invalid", () => {
75 expect(isValidHeaderName("")).toBe(true);
76 expect(isValidHeaderName(" ")).toBe(true);
77 });
78
79 it("rejects a name with a space or a colon", () => {
80 expect(isValidHeaderName("Bad Header")).toBe(false);
81 expect(isValidHeaderName("Also:Bad")).toBe(false);
82 });
83});
packages/plugins/mcp/src/react/request-headers.ts+58−0
@@ -0,0 +1,58 @@
1// ---------------------------------------------------------------------------
2// Static request headers for a remote MCP server.
3//
4// Some MCP endpoints sit behind an edge authenticator rather than the MCP
5// authorization spec — Cloudflare Access, for example, wants a
6// `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair on every request and
7// answers 403 without them. Those are properties of the ENDPOINT, not of an
8// account: every caller sends the same pair, and the server behind them still
9// does its own MCP-level auth. They therefore belong in the integration's
10// static `headers` config, which the probe and the live transport already
11// read, and NOT in an auth method (an auth method carries one per-account
12// credential value, so it cannot express a two-part token at all).
13//
14// The editor keeps rows rather than a map so a half-typed row survives a
15// re-render. This module owns the row -> wire conversion.
16// ---------------------------------------------------------------------------
17
18export type McpHeaderRow = {
19 readonly name: string;
20 readonly value: string;
21};
22
23export const emptyHeaderRow = (): McpHeaderRow => ({ name: "", value: "" });
24
25/** RFC 7230 `token`: the only characters a header field name may contain.
26 * A name outside this set makes the whole request unsendable, so it is
27 * rejected in the editor instead of being discovered at connect time. */
28const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
29
30/** Whether a row's name can be put on the wire. A blank name is not
31 * "invalid" — it is an unfinished row, which the editor leaves alone. */
32export const isValidHeaderName = (name: string): boolean => {
33 const trimmed = name.trim();
34 return trimmed.length === 0 || HEADER_NAME_PATTERN.test(trimmed);
35};
36
37/**
38 * Collapse editor rows into the wire `headers` map.
39 *
40 * Blank and malformed names are dropped: the first is a row the user has not
41 * finished, the second cannot be sent at all. Names and values are trimmed —
42 * RFC 7230 treats surrounding whitespace as no part of a field value, so a
43 * token pasted with a trailing newline still works.
44 *
45 * Returns `undefined` when nothing is configured, so callers omit the field
46 * rather than sending an empty object.
47 */
48export const mcpHeadersFromRows = (
49 rows: readonly McpHeaderRow[],
50): Record<string, string> | undefined => {
51 const headers: Record<string, string> = {};
52 for (const row of rows) {
53 const name = row.name.trim();
54 if (!name || !HEADER_NAME_PATTERN.test(name)) continue;
55 headers[name] = row.value.trim();
56 }
57 return Object.keys(headers).length > 0 ? headers : undefined;
58};
packages/plugins/mcp/src/react/stdio-fields.test.ts+70−0
@@ -0,0 +1,70 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields";
4
5describe("parseStdioArgs", () => {
6 it("splits on whitespace", () => {
7 expect(parseStdioArgs("-y chrome-devtools-mcp@latest")).toEqual([
8 "-y",
9 "chrome-devtools-mcp@latest",
10 ]);
11 });
12
13 it("keeps a double-quoted group intact", () => {
14 expect(parseStdioArgs('--dir "/Users/a b/mcp" --port 3000')).toEqual([
15 "--dir",
16 "/Users/a b/mcp",
17 "--port",
18 "3000",
19 ]);
20 });
21
22 it("returns nothing for a blank field", () => {
23 expect(parseStdioArgs(" ")).toEqual([]);
24 });
25});
26
27describe("formatStdioArgs", () => {
28 it("re-quotes tokens containing whitespace so the round trip is lossless", () => {
29 const args = ["--dir", "/Users/a b/mcp", "--port", "3000"];
30 expect(parseStdioArgs(formatStdioArgs(args))).toEqual(args);
31 });
32
33 it("renders an absent list as an empty field", () => {
34 expect(formatStdioArgs(undefined)).toBe("");
35 });
36});
37
38describe("parseStdioEnv", () => {
39 it("reads one KEY=value per line", () => {
40 expect(parseStdioEnv("LOG_LEVEL=debug\nREGION=eu-west-1")).toEqual({
41 LOG_LEVEL: "debug",
42 REGION: "eu-west-1",
43 });
44 });
45
46 it("splits on the first = only, so a value may contain =", () => {
47 expect(parseStdioEnv("DSN=postgres://u:p@host/db?a=b")).toEqual({
48 DSN: "postgres://u:p@host/db?a=b",
49 });
50 });
51
52 it("keeps a declared-but-empty value", () => {
53 expect(parseStdioEnv("NO_COLOR=")).toEqual({ NO_COLOR: "" });
54 });
55
56 it("drops blank lines, comments, and lines with no key", () => {
57 expect(parseStdioEnv("\n# a comment\n=orphan\nA=1\n")).toEqual({ A: "1" });
58 });
59});
60
61describe("formatStdioEnv", () => {
62 it("round-trips a declared map", () => {
63 const env = { LOG_LEVEL: "debug", DSN: "postgres://u:p@host/db?a=b" };
64 expect(parseStdioEnv(formatStdioEnv(env))).toEqual(env);
65 });
66
67 it("renders an absent map as an empty field", () => {
68 expect(formatStdioEnv(undefined)).toBe("");
69 });
70});
packages/plugins/mcp/src/react/stdio-fields.ts+51−0
@@ -0,0 +1,51 @@
1// ---------------------------------------------------------------------------
2// Text ↔ config codecs for the stdio fields, shared by the add flow
3// (`AddMcpIntegration`) and the edit sheet (`EditMcpIntegration`). Both render
4// the same single-line arguments field and the same KEY=value environment
5// block, so the parsing lives in one place and round-trips: a config that is
6// formatted and re-parsed is unchanged.
7// ---------------------------------------------------------------------------
8
9/** Split the raw arguments field into tokens, honoring double-quoted groups so
10 * an argument with spaces stays intact. */
11export function parseStdioArgs(raw: string): string[] {
12 if (!raw.trim()) return [];
13 const args: string[] = [];
14 const regex = /[^\s"]+|"([^"]*)"/g;
15 let match;
16 while ((match = regex.exec(raw)) !== null) {
17 args.push(match[1] ?? match[0]);
18 }
19 return args;
20}
21
22/** Render stored arguments back into the single-line field, re-quoting any
23 * token that contains whitespace so `parseStdioArgs` recovers it. */
24export function formatStdioArgs(args: readonly string[] | undefined): string {
25 return (args ?? []).map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" ");
26}
27
28/** Parse the KEY=value block into the declared static env map. Splits on the
29 * FIRST `=` only, so a value may itself contain `=`. Blank lines, `#`
30 * comments, and lines without a key are dropped; an empty value is kept
31 * (declaring a variable as empty is meaningful to a subprocess). */
32export function parseStdioEnv(raw: string): Record<string, string> {
33 const env: Record<string, string> = {};
34 for (const line of raw.split("\n")) {
35 const trimmed = line.trim();
36 if (trimmed === "" || trimmed.startsWith("#")) continue;
37 const separator = trimmed.indexOf("=");
38 if (separator <= 0) continue;
39 const key = trimmed.slice(0, separator).trim();
40 if (key === "") continue;
41 env[key] = trimmed.slice(separator + 1).trim();
42 }
43 return env;
44}
45
46/** Render the stored env map back into the KEY=value block. */
47export function formatStdioEnv(env: Readonly<Record<string, string>> | undefined): string {
48 return Object.entries(env ?? {})
49 .map(([key, value]) => `${key}=${value}`)
50 .join("\n");
51}
packages/plugins/mcp/src/sdk/catalog-sync.test.ts+104−1
@@ -42,11 +42,17 @@ const TEMPLATE = AuthTemplateSlug.make("none"); · 41 unmodified lines
42 42
43const makeCatalogTestExecutor = (43const makeCatalogTestExecutor = (
44 serverUrl: string,44 serverUrl: string,
45 options?: { readonly toolsSyncTtlMs?: number | null },45 options?: {
46 readonly toolsSyncTtlMs?: number | null;
47 readonly toolsSyncGraceMs?: number | null;
48 },
46) =>49) =>
47 createExecutor({50 createExecutor({
48 ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),51 ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
49 ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }),52 ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }),
53 ...(options?.toolsSyncGraceMs === undefined
54 ? {}
55 : { toolsSyncGraceMs: options.toolsSyncGraceMs }),
50 }).pipe(56 }).pipe(
51 Effect.tap((executor) =>57 Effect.tap((executor) =>
52 Effect.gen(function* () {58 Effect.gen(function* () {
@@ -355,6 +361,11 @@ describe("MCP stale-catalog refresh", () => { · 302 unmodified lines
355 // Everything is expired on every read, so a single tools read has the361 // Everything is expired on every read, so a single tools read has the
356 // whole set to rebuild.362 // whole set to rebuild.
357 toolsSyncTtlMs: 0,363 toolsSyncTtlMs: 0,
364 // Strict mode: the assertions below synchronize on the read fiber
365 // completing only after every rebuild has finished. With a grace
366 // budget the read would return early and `Fiber.join` would no longer
367 // order the final listing before the count assertion.
368 toolsSyncGraceMs: null,
358 });369 });
359 370
360 for (let index = 0; index < STALE_CONNECTIONS; index++) {371 for (let index = 0; index < STALE_CONNECTIONS; index++) {
@@ -401,3 +412,95 @@ describe("MCP stale-catalog refresh", () => { · 40 unmodified lines
401 }),412 }),
402 );413 );
403});414});
415
416// ---------------------------------------------------------------------------
417// Stale-refresh grace budget.
418//
419// A tools read waits at most `toolsSyncGraceMs` for stale rebuilds, then
420// answers from the persisted catalog while the rebuilds finish detached. One
421// slow upstream server must bound neither the read nor convergence: the read
422// serves the stale-but-working rows now, and a later read reflects the
423// re-listed catalog once the server finally answers.
424// ---------------------------------------------------------------------------
425
426const serveLatchedMutableServer = () =>
427 Effect.gen(function* () {
428 const catalog = yield* Ref.make("alpha");
429 const armed = yield* Ref.make(false);
430 const release = yield* Deferred.make<void>();
431
432 const server = yield* serveTestHttpApp((request) =>
433 Effect.gen(function* () {
434 if (request.method === "GET") {
435 return HttpServerResponse.text("SSE disabled", { status: 405 });
436 }
437 const body = yield* request.text.pipe(Effect.orDie);
438 const rpc = Option.getOrUndefined(decodeJsonRpcRequest(body));
439 if (!rpc) {
440 return HttpServerResponse.text("Invalid JSON-RPC fixture request", { status: 400 });
441 }
442 if (rpc.method === "initialize") {
443 return jsonRpcResult(rpc, {
444 protocolVersion: "2025-06-18",
445 capabilities: { tools: { listChanged: true } },
446 serverInfo: { name: "latched-mutable-fixture", version: "1.0.0" },
447 });
448 }
449 if (rpc.method === "notifications/initialized") {
450 return HttpServerResponse.text("", { status: 202 });
451 }
452 if (rpc.method !== "tools/list") {
453 return HttpServerResponse.text("Unexpected JSON-RPC method", { status: 400 });
454 }
455 // Once armed, park every listing until released — the "slow server".
456 if (yield* Ref.get(armed)) {
457 yield* Deferred.await(release);
458 }
459 return jsonRpcResult(rpc, { tools: [pageTool(yield* Ref.get(catalog))] });
460 }),
461 );
462
463 return {
464 url: server.url("/mcp"),
465 rename: Ref.set(catalog, "beta"),
466 arm: Ref.set(armed, true),
467 release: Deferred.succeed(release, undefined),
468 } as const;
469 });
470
471describe("MCP stale-refresh grace budget", () => {
472 // `it.live` (real clock): the grace timeout must actually fire while a real
473 // HTTP listing stays parked.
474 it.live("a read outlasting the grace serves the stored catalog, then converges", () =>
475 Effect.gen(function* () {
476 const fixture = yield* serveLatchedMutableServer();
477 const executor = yield* makeCatalogTestExecutor(fixture.url, {
478 // Every read finds the catalog expired, and waits at most 100ms.
479 toolsSyncTtlMs: 0,
480 toolsSyncGraceMs: 100,
481 });
482
483 expect(toolNames(yield* executor.tools.list())).toContain("alpha");
484
485 // The server's catalog changes AND the server stops answering listings.
486 yield* fixture.rename;
487 yield* fixture.arm;
488
489 // The re-list is parked, so only the grace path can produce an answer —
490 // and it is the persisted (stale) catalog, not a failure or a hang.
491 expect(toolNames(yield* executor.tools.list())).toContain("alpha");
492
493 // Once the server answers, the detached rebuild lands and a later read
494 // reflects the re-listed catalog.
495 yield* fixture.release;
496 const converged = yield* Effect.gen(function* () {
497 while (true) {
498 const names = toolNames(yield* executor.tools.list());
499 if (names.includes("beta")) return names;
500 yield* Effect.sleep("100 millis");
501 }
502 }).pipe(Effect.timeoutOption("10 seconds"));
503 expect(Option.isSome(converged)).toBe(true);
504 }),
505 );
506});
packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts+18−0
@@ -260,6 +260,24 @@ describe("describeMcpAuthMethods", () => { · 259 unmodified lines
260 ).toEqual({ url: "https://mcp.posthog.com/mcp" });260 ).toEqual({ url: "https://mcp.posthog.com/mcp" });
261 });261 });
262 262
263 it("projects catalog family for remote and stdio integrations", () => {
264 expect(
265 describeMcpIntegrationDisplay(
266 recordWith({
267 transport: "remote",
268 family: "cloudflare",
269 endpoint: "https://mcp.cloudflare.com/mcp",
270 authenticationTemplate: [{ slug: "none", kind: "none" }],
271 }),
272 ),
273 ).toEqual({ url: "https://mcp.cloudflare.com/mcp", family: "cloudflare" });
274 expect(
275 describeMcpIntegrationDisplay(
276 recordWith({ transport: "stdio", family: "design", command: "design-mcp" }),
277 ),
278 ).toEqual({ family: "design" });
279 });
280
263 it("does not expose display metadata for stdio or malformed configs", () => {281 it("does not expose display metadata for stdio or malformed configs", () => {
264 expect(282 expect(
265 describeMcpIntegrationDisplay(recordWith({ transport: "stdio", command: "run" })),283 describeMcpIntegrationDisplay(recordWith({ transport: "stdio", command: "run" })),
packages/plugins/mcp/src/sdk/index.ts+1−0
@@ -22,6 +22,7 @@ export { · 21 unmodified lines
22 McpTransport,22 McpTransport,
23 McpToolAnnotations,23 McpToolAnnotations,
24 McpToolBinding,24 McpToolBinding,
25 McpToolMeta,
25 parseMcpIntegrationConfig,26 parseMcpIntegrationConfig,
26} from "./types";27} from "./types";
27 28
packages/plugins/mcp/src/sdk/manifest.ts+16−1
@@ -1,6 +1,6 @@
1import { Option, Schema } from "effect";1import { Option, Schema } from "effect";
2 2
3import { McpToolAnnotations } from "./types";3import { McpToolAnnotations, McpToolMeta } from "./types";
4 4
5// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
6// Output types6// Output types
@@ -13,6 +13,8 @@ export interface McpToolManifestEntry { · 6 unmodified lines
13 readonly inputSchema?: unknown;13 readonly inputSchema?: unknown;
14 readonly outputSchema?: unknown;14 readonly outputSchema?: unknown;
15 readonly annotations?: McpToolAnnotations;15 readonly annotations?: McpToolAnnotations;
16 /** The tool's reserved MCP `_meta` map, carried through verbatim. */
17 readonly _meta?: McpToolMeta;
16}18}
17 19
18export interface McpServerMetadata {20export interface McpServerMetadata {
@@ -38,6 +40,11 @@ const ListedTool = Schema.Struct({ · 19 unmodified lines
38 parameters: Schema.optional(Schema.Unknown),40 parameters: Schema.optional(Schema.Unknown),
39 outputSchema: Schema.optional(Schema.Unknown),41 outputSchema: Schema.optional(Schema.Unknown),
40 annotations: Schema.optional(McpToolAnnotations),42 annotations: Schema.optional(McpToolAnnotations),
43 // `_meta` is opaque and entirely server-controlled, so it stays `Unknown`
44 // here and is narrowed to the spec's map shape per entry. Declaring the map
45 // inline would make one server's malformed `_meta` fail the whole-list
46 // decode and drop every tool it advertises.
47 _meta: Schema.optional(Schema.Unknown),
41});48});
42 49
43const ListToolsResult = Schema.Struct({50const ListToolsResult = Schema.Struct({
@@ -62,6 +69,13 @@ const ServerInfo = Schema.Struct({ · 18 unmodified lines
62 version: Schema.optional(Schema.String),69 version: Schema.optional(Schema.String),
63});70});
64 71
72const decodeMcpToolMeta = Schema.decodeUnknownOption(McpToolMeta);
73
74/** Narrow a listed tool's `_meta` to the spec's map shape, dropping anything
75 * else. The contents stay unknown and uninterpreted. */
76const readToolMeta = (value: unknown): McpToolMeta | undefined =>
77 value === undefined ? undefined : Option.getOrUndefined(decodeMcpToolMeta(value));
78
65const decodeListToolsResult = Schema.decodeUnknownOption(ListToolsResult);79const decodeListToolsResult = Schema.decodeUnknownOption(ListToolsResult);
66const decodeListToolsPageOption = Schema.decodeUnknownOption(ListToolsPage);80const decodeListToolsPageOption = Schema.decodeUnknownOption(ListToolsPage);
67const decodeServerInfo = Schema.decodeUnknownOption(ServerInfo);81const decodeServerInfo = Schema.decodeUnknownOption(ServerInfo);
@@ -130,6 +144,7 @@ export const extractManifestFromListToolsResult = ( · 62 unmodified lines
130 inputSchema: tool.inputSchema ?? tool.parameters,144 inputSchema: tool.inputSchema ?? tool.parameters,
131 outputSchema: tool.outputSchema,145 outputSchema: tool.outputSchema,
132 annotations: tool.annotations,146 annotations: tool.annotations,
147 _meta: readToolMeta(tool._meta),
133 },148 },
134 ];149 ];
135 });150 });
packages/plugins/mcp/src/sdk/plugin.test.ts+81−0
@@ -239,6 +239,45 @@ describe("extractManifestFromListToolsResult", () => { · 238 unmodified lines
239 expect(result.tools[2]!.annotations).toBeUndefined();239 expect(result.tools[2]!.annotations).toBeUndefined();
240 }),240 }),
241 );241 );
242
243 it.effect("carries the reserved `_meta` map through verbatim", () =>
244 Effect.sync(() => {
245 const meta = {
246 serverName: "time",
247 shortDescription: "Current time",
248 defer_loading: false,
249 nested: { any: ["shape"] },
250 };
251
252 const result = extractManifestFromListToolsResult({
253 tools: [
254 {
255 name: "time_get_current_time",
256 description: "Get the current time",
257 inputSchema: { type: "object" },
258 _meta: meta,
259 },
260 { name: "no_meta", description: "Has no _meta" },
261 ],
262 });
263
264 expect(result.tools[0]!._meta).toEqual(meta);
265 expect(result.tools[1]!._meta).toBeUndefined();
266 }),
267 );
268
269 // `_meta` is opaque and server-controlled, so a value that does not match the
270 // spec's map shape must not take the rest of the list down with it.
271 it.effect("ignores a malformed `_meta` without dropping the tool", () =>
272 Effect.sync(() => {
273 const result = extractManifestFromListToolsResult({
274 tools: [{ name: "odd_meta", _meta: "not-a-map" }, { name: "plain" }],
275 });
276
277 expect(result.tools.map((tool) => tool.toolName)).toEqual(["odd_meta", "plain"]);
278 expect(result.tools[0]!._meta).toBeUndefined();
279 }),
280 );
242});281});
243 282
244// ---------------------------------------------------------------------------283// ---------------------------------------------------------------------------
@@ -346,6 +385,23 @@ describe("mcpPlugin", () => { · 101 unmodified lines
346 }),385 }),
347 );386 );
348 387
388 it.effect("projects an MCP server family into the integration catalog", () =>
389 Effect.gen(function* () {
390 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
391 yield* executor.mcp.addServer({
392 name: "Cloudflare Docs",
393 family: "cloudflare",
394 endpoint: "https://example.com/mcp",
395 slug: "cloudflare_docs",
396 });
397
398 const integrations = yield* executor.integrations.list();
399 expect(integrations.find((item) => item.slug === "cloudflare_docs")?.family).toBe(
400 "cloudflare",
401 );
402 }),
403 );
404
349 it.effect("connection tools list is empty until a connection is created", () =>405 it.effect("connection tools list is empty until a connection is created", () =>
350 Effect.gen(function* () {406 Effect.gen(function* () {
351 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));407 const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));
@@ -1006,6 +1062,31 @@ describe("MCP destructiveHint → requiresApproval", () => { · 654 unmodified lines
1006 expect(deleteTitled?.annotations?.approvalDescription).toBe("Delete dataset");1062 expect(deleteTitled?.annotations?.approvalDescription).toBe("Delete dataset");
1007 }),1063 }),
1008 );1064 );
1065
1066 // Executor's `Tool` has no `_meta` field, so the reserved MCP map rides in
1067 // the `mcp` stamp the plugin persists into the tool row's annotations. A host
1068 // embedding the plugin reads it back from there.
1069 it.effect("persists the tool's reserved `_meta` into the catalog stamp", () =>
1070 Effect.gen(function* () {
1071 const server = yield* serveAnnotationsTestServer;
1072 const executor = yield* seedAnnotationsExecutor(server.url);
1073
1074 const tools = yield* executor.tools.list();
1075
1076 const stamped = tools.find((t) => String(t.name) === "meta_stamped");
1077 expect(stamped?.annotations).toMatchObject({
1078 mcp: {
1079 toolName: "meta_stamped",
1080 _meta: { serverName: "time", shortDescription: "Current time", defer_loading: false },
1081 },
1082 });
1083
1084 const ping = tools.find((t) => String(t.name) === "ping");
1085 expect(
1086 (ping?.annotations as { readonly mcp?: { readonly _meta?: unknown } })?.mcp?._meta,
1087 ).toBeUndefined();
1088 }),
1089 );
1009});1090});
1010 1091
1011describe("userFacingProbeMessage", () => {1092describe("userFacingProbeMessage", () => {
packages/plugins/mcp/src/sdk/plugin.ts+26−8
@@ -60,6 +60,7 @@ import { · 59 unmodified lines
60 McpRemoteTransport,60 McpRemoteTransport,
61 type McpAuthMethod,61 type McpAuthMethod,
62 type McpToolAnnotations,62 type McpToolAnnotations,
63 McpToolMeta,
63 expandMcpAuthMethodInputs,64 expandMcpAuthMethodInputs,
64 mcpAuthMethodFromShorthand,65 mcpAuthMethodFromShorthand,
65 normalizeMcpAuthMethods,66 normalizeMcpAuthMethods,
@@ -139,14 +140,17 @@ const legacyMcpClientMatches = ( · 73 unmodified lines
139// ---------------------------------------------------------------------------140// ---------------------------------------------------------------------------
140// Tool annotations carry an `mcp` envelope alongside the executor's policy141// Tool annotations carry an `mcp` envelope alongside the executor's policy
141// hints. The executor persists `ToolDef.annotations` verbatim into the tool142// hints. The executor persists `ToolDef.annotations` verbatim into the tool
142// row's JSON column, so the real MCP tool name + upstream annotations survive143// row's JSON column, so the real MCP tool name, the upstream annotations, and
143// to `invokeTool` / `resolveAnnotations` with no plugin-side store (resolveTools144// the tool's reserved `_meta` map survive to `invokeTool` /
144// has no ctx to write one anyway). The envelope is opaque to core.145// `resolveAnnotations` with no plugin-side store (resolveTools has no ctx to
146// write one anyway). The envelope is opaque to core.
145// ---------------------------------------------------------------------------147// ---------------------------------------------------------------------------
146 148
147interface McpToolStamp {149interface McpToolStamp {
148 readonly toolName: string;150 readonly toolName: string;
149 readonly upstream?: McpToolAnnotations;151 readonly upstream?: McpToolAnnotations;
152 /** The tool's reserved MCP `_meta` map, opaque to core and to the model. */
153 readonly _meta?: McpToolMeta;
150}154}
151 155
152type StampedAnnotations = ToolAnnotations & { readonly mcp: McpToolStamp };156type StampedAnnotations = ToolAnnotations & { readonly mcp: McpToolStamp };
@@ -162,6 +166,7 @@ const McpStampSchema = Schema.Struct({ · 9 unmodified lines
162 openWorldHint: Schema.optional(Schema.Boolean),166 openWorldHint: Schema.optional(Schema.Boolean),
163 }),167 }),
164 ),168 ),
169 _meta: Schema.optional(McpToolMeta),
165});170});
166const AnnotationsWithStamp = Schema.Struct({ mcp: McpStampSchema });171const AnnotationsWithStamp = Schema.Struct({ mcp: McpStampSchema });
167const decodeStamp = Schema.decodeUnknownOption(AnnotationsWithStamp);172const decodeStamp = Schema.decodeUnknownOption(AnnotationsWithStamp);
@@ -181,6 +186,8 @@ const readStamp = (annotations: unknown): McpToolStamp | null => · 13 unmodified lines
181const McpRemoteServerInputSchema = Schema.Struct({186const McpRemoteServerInputSchema = Schema.Struct({
182 transport: Schema.optional(Schema.Literal("remote")),187 transport: Schema.optional(Schema.Literal("remote")),
183 name: Schema.String,188 name: Schema.String,
189 /** Optional catalog family used to group related integrations. */
190 family: Schema.optional(Schema.String),
184 /** Agent-visible catalog description. Defaults to the display name. */191 /** Agent-visible catalog description. Defaults to the display name. */
185 description: Schema.optional(Schema.String),192 description: Schema.optional(Schema.String),
186 endpoint: Schema.String,193 endpoint: Schema.String,
@@ -198,6 +205,8 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 11 unmodified lines
198const McpStdioServerInputSchema = Schema.Struct({205const McpStdioServerInputSchema = Schema.Struct({
199 transport: Schema.Literal("stdio"),206 transport: Schema.Literal("stdio"),
200 name: Schema.String,207 name: Schema.String,
208 /** Optional catalog family used to group related integrations. */
209 family: Schema.optional(Schema.String),
201 description: Schema.optional(Schema.String),210 description: Schema.optional(Schema.String),
202 command: Schema.String,211 command: Schema.String,
203 args: Schema.optional(Schema.Array(Schema.String)),212 args: Schema.optional(Schema.Array(Schema.String)),
@@ -375,6 +384,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 171 unmodified lines
375 const vars = stdioEnvVarNames(input);384 const vars = stdioEnvVarNames(input);
376 return {385 return {
377 transport: "stdio",386 transport: "stdio",
387 family: input.family?.trim() || undefined,
378 command: input.command,388 command: input.command,
379 args: input.args ? [...input.args] : undefined,389 args: input.args ? [...input.args] : undefined,
380 cwd: input.cwd,390 cwd: input.cwd,
@@ -387,6 +397,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 6 unmodified lines
387 }397 }
388 return {398 return {
389 transport: "remote",399 transport: "remote",
400 family: input.family?.trim() || undefined,
390 endpoint: input.endpoint,401 endpoint: input.endpoint,
391 remoteTransport: input.remoteTransport ?? "auto",402 remoteTransport: input.remoteTransport ?? "auto",
392 queryParams: input.queryParams,403 queryParams: input.queryParams,
@@ -427,13 +438,16 @@ const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonS · 34 unmodified lines
427};438};
428 439
429/** Build the executor-facing ToolDef for one discovered MCP tool, stamping the440/** Build the executor-facing ToolDef for one discovered MCP tool, stamping the
430 * real MCP tool name + upstream annotations into the persisted annotations so441 * real MCP tool name, the upstream annotations, and the tool's `_meta` map
431 * they survive to invokeTool with no plugin-side store. */442 * into the persisted annotations so they survive to invokeTool with no
443 * plugin-side store. Executor's `ToolDef` has no `_meta` field of its own, so
444 * the stamp is where a host reads it back. */
432const toToolDef = (entry: McpToolManifestEntry): ToolDef => {445const toToolDef = (entry: McpToolManifestEntry): ToolDef => {
433 const destructive = entry.annotations?.destructiveHint === true;446 const destructive = entry.annotations?.destructiveHint === true;
434 const stamp: McpToolStamp = {447 const stamp: McpToolStamp = {
435 toolName: entry.toolName,448 toolName: entry.toolName,
436 ...(entry.annotations ? { upstream: entry.annotations } : {}),449 ...(entry.annotations ? { upstream: entry.annotations } : {}),
450 ...(entry._meta ? { _meta: entry._meta } : {}),
437 };451 };
438 const annotations: StampedAnnotations = {452 const annotations: StampedAnnotations = {
439 requiresApproval: destructive,453 requiresApproval: destructive,
@@ -753,10 +767,14 @@ export const describeMcpAuthMethods = ( · 313 unmodified lines
753 767
754export const describeMcpIntegrationDisplay = (768export const describeMcpIntegrationDisplay = (
755 record: IntegrationRecord,769 record: IntegrationRecord,
756): { readonly url?: string } => {770): { readonly url?: string; readonly family?: string } => {
757 const config = parseMcpIntegrationConfig(record.config);771 const config = parseMcpIntegrationConfig(record.config);
758 if (!config || config.transport === "stdio") return {};772 if (!config) return {};
759 return { url: config.endpoint };773 const family = config.family?.trim();
774 return {
775 ...(config.transport === "remote" ? { url: config.endpoint } : {}),
776 ...(family ? { family } : {}),
777 };
760};778};
761 779
762// ---------------------------------------------------------------------------780// ---------------------------------------------------------------------------
packages/plugins/mcp/src/sdk/probe-shape.test.ts+90−0
@@ -267,6 +267,96 @@ describe("probeMcpEndpointShape", () => { · 266 unmodified lines
267 ),267 ),
268 );268 );
269 269
270 // Cloudflare Access shape: an edge authenticator in front of the MCP
271 // server answers an unauthenticated request with `403` and an HTML
272 // login page. The MCP server is never reached, so there is no Bearer
273 // challenge and no JSON-RPC body. This must read as "supply
274 // credentials", not as "this URL is not MCP" — the latter told users
275 // the endpoint was unreachable when it was merely protected.
276 it.effect("classifies a 403 HTML edge challenge as auth-required", () =>
277 withServer(
278 () =>
279 HttpServerResponse.text("<html><body>Sign in</body></html>", {
280 status: 403,
281 contentType: "text/html",
282 }),
283 (endpoint) =>
284 Effect.gen(function* () {
285 const result = yield* probeMcpEndpointShape(endpoint);
286 expect(result).toMatchObject({ kind: "not-mcp", category: "auth-required" });
287 }),
288 ),
289 );
290
291 it.effect("classifies 403 with Bearer + JSON-RPC error envelope as MCP+auth", () =>
292 withServer(
293 () =>
294 HttpServerResponse.jsonUnsafe(
295 {
296 jsonrpc: "2.0",
297 id: null,
298 error: { code: -32000, message: "Forbidden" },
299 },
300 { status: 403, headers: { "www-authenticate": "Bearer" } },
301 ),
302 (endpoint) =>
303 Effect.gen(function* () {
304 const result = yield* probeMcpEndpointShape(endpoint);
305 expect(result).toEqual({ kind: "mcp", requiresAuth: true });
306 }),
307 ),
308 );
309
310 it.effect("rejects a 403 whose Bearer challenge carries a GraphQL body", () =>
311 withServer(
312 () =>
313 HttpServerResponse.jsonUnsafe(
314 { errors: [{ message: "Forbidden" }] },
315 { status: 403, headers: { "www-authenticate": "Bearer" } },
316 ),
317 (endpoint) =>
318 Effect.gen(function* () {
319 const result = yield* probeMcpEndpointShape(endpoint);
320 expect(result).toMatchObject({ kind: "not-mcp", category: "auth-required" });
321 }),
322 ),
323 );
324
325 // The other half of the Cloudflare Access story: once the service-token
326 // headers are configured, the same endpoint answers normally. Proves the
327 // probe actually puts `options.headers` on the wire.
328 it.effect("sends configured request headers and clears an edge challenge", () =>
329 Effect.scoped(
330 Effect.gen(function* () {
331 const server = yield* serveProbeEndpoint((request) => {
332 if (request.headers["cf-access-client-id"] !== "client-id") {
333 return HttpServerResponse.text("<html>Sign in</html>", {
334 status: 403,
335 contentType: "text/html",
336 });
337 }
338 return HttpServerResponse.jsonUnsafe({
339 jsonrpc: "2.0",
340 id: 1,
341 result: {
342 protocolVersion: "2025-06-18",
343 capabilities: {},
344 serverInfo: { name: "t", version: "0" },
345 },
346 });
347 });
348
349 const blocked = yield* probeMcpEndpointShape(server.endpoint);
350 expect(blocked).toMatchObject({ kind: "not-mcp", category: "auth-required" });
351
352 const allowed = yield* probeMcpEndpointShape(server.endpoint, {
353 headers: { "CF-Access-Client-Id": "client-id" },
354 });
355 expect(allowed).toEqual({ kind: "mcp", requiresAuth: false });
356 }),
357 ),
358 );
359
270 it.effect("falls back to GET for OAuth-protected SSE endpoints", () =>360 it.effect("falls back to GET for OAuth-protected SSE endpoints", () =>
271 withServer(361 withServer(
272 (request) => {362 (request) => {
packages/plugins/mcp/src/sdk/probe-shape.ts+28−17
@@ -20,10 +20,12 @@ · 19 unmodified lines
20// transport, body is an SSE stream we don't consume.20// transport, body is an SSE stream we don't consume.
21// - 2xx with `Content-Type: application/json` whose body parses as a21// - 2xx with `Content-Type: application/json` whose body parses as a
22// JSON-RPC 2.0 envelope (`{jsonrpc:"2.0", result|error|method,...}`).22// JSON-RPC 2.0 envelope (`{jsonrpc:"2.0", result|error|method,...}`).
23// - 401 with `WWW-Authenticate: Bearer` AND a JSON-RPC error envelope23// - 401 (or 403) with `WWW-Authenticate: Bearer` AND a JSON-RPC error
24// in the body. The body shape is what separates a real MCP server24// envelope in the body. The body shape is what separates a real MCP
25// from an unrelated OAuth-protected API: GraphQL/REST/HTML 401s25// server from an unrelated OAuth-protected API: GraphQL/REST/HTML
26// don't shape themselves as JSON-RPC.26// 401s don't shape themselves as JSON-RPC. 403 travels the same path
27// because an edge authenticator (Cloudflare Access) answers with 403
28// and the endpoint is not therefore unreachable.
27//29//
28// When POST returns 404/405/406/415 we retry with GET + `Accept:30// When POST returns 404/405/406/415 we retry with GET + `Accept:
29// text/event-stream` to support legacy SSE-only servers; that path31// text/event-stream` to support legacy SSE-only servers; that path
@@ -82,6 +84,16 @@ const readHeader = (headers: Readonly<Record<string, string>>, name: string): st · 52 unmodified lines
82 return null;84 return null;
83};85};
84 86
87/** Statuses that mean "you are not authenticated for this resource".
88 *
89 * 401 is what the MCP authorization spec mandates. 403 is what an edge
90 * authenticator in front of the server returns instead — Cloudflare
91 * Access, for example, answers an unauthenticated request with 403 and
92 * never reaches the MCP server at all. Both say the same thing to the
93 * user: supply credentials and retry. Treating 403 as a wrong shape
94 * told them the URL was wrong, which it was not. */
95const isAuthStatus = (status: number): boolean => status === 401 || status === 403;
96
85class ProbeTransportError extends Data.TaggedError("ProbeTransportError")<{97class ProbeTransportError extends Data.TaggedError("ProbeTransportError")<{
86 readonly reason: string;98 readonly reason: string;
87 readonly cause: unknown;99 readonly cause: unknown;
@@ -213,9 +225,9 @@ const reasonFromBoundaryCause = (cause: unknown): string => { · 125 unmodified lines
213 225
214/** Why the probe rejected an endpoint as not-MCP.226/** Why the probe rejected an endpoint as not-MCP.
215 *227 *
216 * - `auth-required` — server returned 401. We don't know for sure it's228 * - `auth-required` — server returned 401 or 403. We don't know for
217 * an MCP server (no spec-compliant Bearer challenge or the body229 * sure it's an MCP server (no spec-compliant Bearer challenge or the
218 * isn't JSON-RPC), but the right next step for the user is the same230 * body isn't JSON-RPC), but the right next step for the user is the same
219 * either way: provide credentials and retry. This is what231 * either way: provide credentials and retry. This is what
220 * misclassifies real MCP servers like cubic.dev (no232 * misclassifies real MCP servers like cubic.dev (no
221 * resource_metadata) or ref.tools (no WWW-Authenticate at all)233 * resource_metadata) or ref.tools (no WWW-Authenticate at all)
@@ -227,7 +239,7 @@ export type McpProbeRejectCategory = "auth-required" | "wrong-shape"; · 5 unmodified lines
227 239
228export type McpShapeProbeResult =240export type McpShapeProbeResult =
229 /** Server answered initialize successfully — either a 2xx with a241 /** Server answered initialize successfully — either a 2xx with a
230 * JSON-RPC payload, or a 401 + WWW-Authenticate: Bearer (RFC 6750242 * JSON-RPC payload, or a 401/403 + WWW-Authenticate: Bearer (RFC 6750
231 * challenge) that the MCP auth spec requires. */243 * challenge) that the MCP auth spec requires. */
232 | { readonly kind: "mcp"; readonly requiresAuth: boolean }244 | { readonly kind: "mcp"; readonly requiresAuth: boolean }
233 /** Endpoint is reachable but the response does not look like MCP. */245 /** Endpoint is reachable but the response does not look like MCP. */
@@ -253,7 +265,7 @@ export interface ProbeOptions { · 19 unmodified lines
253 *265 *
254 * Returns `{kind: "mcp"}` only when the endpoint either:266 * Returns `{kind: "mcp"}` only when the endpoint either:
255 * - answers with 2xx (unauth-OK MCP server), or267 * - answers with 2xx (unauth-OK MCP server), or
256 * - responds 401 with a `Bearer` WWW-Authenticate challenge.268 * - responds 401 or 403 with a `Bearer` WWW-Authenticate challenge.
257 *269 *
258 * Anything else (400, 404, 200-with-HTML, 200-with-GraphQL-errors, ...)270 * Anything else (400, 404, 200-with-HTML, 200-with-GraphQL-errors, ...)
259 * is classified `not-mcp`. Transport errors surface as `unreachable`.271 * is classified `not-mcp`. Transport errors surface as `unreachable`.
@@ -287,20 +299,20 @@ export const probeMcpEndpointShape = ( · 27 unmodified lines
287 const contentType = readHeader(response.headers, "content-type") ?? "";299 const contentType = readHeader(response.headers, "content-type") ?? "";
288 const isSse = /^\s*text\/event-stream\b/i.test(contentType);300 const isSse = /^\s*text\/event-stream\b/i.test(contentType);
289 301
290 if (response.status === 401) {302 if (isAuthStatus(response.status)) {
291 const wwwAuth = readHeader(response.headers, "www-authenticate");303 const wwwAuth = readHeader(response.headers, "www-authenticate");
292 if (!wwwAuth || !/^\s*bearer\b/i.test(wwwAuth)) {304 if (!wwwAuth || !/^\s*bearer\b/i.test(wwwAuth)) {
293 // Spec-non-compliant 401 (no `Bearer` challenge). Before305 // Spec-non-compliant challenge (no `Bearer`). Before giving
294 // giving up, check whether the server still publishes306 // up, check whether the server still publishes RFC 9728
295 // RFC 9728 protected-resource metadata for this path —307 // protected-resource metadata for this path — some real MCP
296 // some real MCP servers (Datadog) do exactly this.308 // servers (Datadog) do exactly this.
297 if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) {309 if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) {
298 return { kind: "mcp", requiresAuth: true } as const;310 return { kind: "mcp", requiresAuth: true } as const;
299 }311 }
300 return {312 return {
301 kind: "not-mcp",313 kind: "not-mcp",
302 category: "auth-required",314 category: "auth-required",
303 reason: "401 without Bearer WWW-Authenticate — not an MCP auth challenge",315 reason: `${response.status} without Bearer WWW-Authenticate — not an MCP auth challenge`,
304 } as const;316 } as const;
305 }317 }
306 // Spec-compliant MCP signal: the auth spec mandates a318 // Spec-compliant MCP signal: the auth spec mandates a
@@ -347,8 +359,7 @@ export const probeMcpEndpointShape = ( · 40 unmodified lines
347 return {359 return {
348 kind: "not-mcp",360 kind: "not-mcp",
349 category: "auth-required",361 category: "auth-required",
350 reason:362 reason: `${response.status} + Bearer without resource_metadata, JSON-RPC body, or OAuth error body`,
351 "401 + Bearer without resource_metadata, JSON-RPC body, or OAuth error body",
352 } as const;363 } as const;
353 }364 }
354 return { kind: "mcp", requiresAuth: true } as const;365 return { kind: "mcp", requiresAuth: true } as const;
packages/plugins/mcp/src/sdk/types.ts+14−0
@@ -215,6 +215,8 @@ const StringMap = Schema.Record(Schema.String, Schema.String); · 214 unmodified lines
215 215
216export const McpRemoteIntegrationConfig = Schema.Struct({216export const McpRemoteIntegrationConfig = Schema.Struct({
217 transport: Schema.Literal("remote"),217 transport: Schema.Literal("remote"),
218 /** Optional catalog family used to group related integrations. */
219 family: Schema.optional(Schema.String),
218 /** The MCP server endpoint URL */220 /** The MCP server endpoint URL */
219 endpoint: Schema.String,221 endpoint: Schema.String,
220 /** Transport preference for this remote server */222 /** Transport preference for this remote server */
@@ -234,6 +236,8 @@ export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type; · 13 unmodified lines
234 236
235export const McpStdioIntegrationConfig = Schema.Struct({237export const McpStdioIntegrationConfig = Schema.Struct({
236 transport: Schema.Literal("stdio"),238 transport: Schema.Literal("stdio"),
239 /** Optional catalog family used to group related integrations. */
240 family: Schema.optional(Schema.String),
237 /** The command to run */241 /** The command to run */
238 command: Schema.String,242 command: Schema.String,
239 /** Arguments to the command */243 /** Arguments to the command */
@@ -287,6 +291,16 @@ export const McpToolAnnotations = Schema.Struct({ · 47 unmodified lines
287});291});
288export type McpToolAnnotations = typeof McpToolAnnotations.Type;292export type McpToolAnnotations = typeof McpToolAnnotations.Type;
289 293
294// ---------------------------------------------------------------------------
295// Tool `_meta` — the reserved, implementation-defined map the MCP spec puts on
296// `Tool`. It is opaque to the executor and to the model: servers use it for
297// host-only routing and policy hints that do not belong in the closed
298// `annotations` set. It is carried through verbatim, never interpreted.
299// ---------------------------------------------------------------------------
300
301export const McpToolMeta = Schema.Record(Schema.String, Schema.Unknown);
302export type McpToolMeta = typeof McpToolMeta.Type;
303
290// ---------------------------------------------------------------------------304// ---------------------------------------------------------------------------
291// Tool binding — maps a persisted (sanitized) tool name back to its real MCP305// Tool binding — maps a persisted (sanitized) tool name back to its real MCP
292// tool name and upstream annotations, persisted per-connection so invokeTool306// tool name and upstream annotations, persisted per-connection so invokeTool
packages/plugins/mcp/src/testing/server.ts+12−0
@@ -634,5 +634,17 @@ export const makeAnnotationsMcpServer = () => { · 633 unmodified lines
634 async () => ({ content: [] }),634 async () => ({ content: [] }),
635 );635 );
636 636
637 // Host-only routing/policy hints the MCP spec reserves on `Tool._meta`. They
638 // are not part of the closed `annotations` set and are never shown to a model.
639 server.registerTool(
640 "meta_stamped",
641 {
642 description: "A tool carrying reserved `_meta`",
643 inputSchema: {},
644 _meta: { serverName: "time", shortDescription: "Current time", defer_loading: false },
645 },
646 async () => ({ content: [] }),
647 );
648
637 return server;649 return server;
638};650};
packages/react/src/components/add-account-modal.tsx+3−6
@@ -2382,15 +2382,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 2381 unmodified lines
2382 // Non-modal for the same reason as the health-check editor sheet: a modal2382 // Non-modal for the same reason as the health-check editor sheet: a modal
2383 // dialog's react-remove-scroll locks the wheel to the dialog subtree, so2383 // dialog's react-remove-scroll locks the wheel to the dialog subtree, so
2384 // the operation combobox's PORTALED popup (and the modal body while it is2384 // the operation combobox's PORTALED popup (and the modal body while it is
2385 // open) cannot scroll. The overlay still dims and outside-click still2385 // open) cannot scroll. The overlay still dims, so the dialog keeps its
2386 // closes; the portaled-popup guard in DialogContent keeps option clicks2386 // modal look. This form holds credentials and a half-built auth method, so
2387 // from dismissing.2387 // it takes DialogContent's default: an outside click does not dismiss it.
2388 <Dialog open={open} onOpenChange={onOpenChange} modal={false}>2388 <Dialog open={open} onOpenChange={onOpenChange} modal={false}>
2389 <DialogContent2389 <DialogContent
2390 forceOverlay2390 forceOverlay
2391 onInteractOutside={
2392 oauthClientHandoff?.action === "reconnect" ? (event) => event.preventDefault() : undefined
2393 }
2394 className={cn(2391 className={cn(
2395 "max-h-[85vh] overflow-x-hidden overflow-y-auto",2392 "max-h-[85vh] overflow-x-hidden overflow-y-auto",
2396 (addingMethod && createCustomMethod) || oauthRegistering || oauthEditing2393 (addingMethod && createCustomMethod) || oauthRegistering || oauthEditing
packages/react/src/components/command.tsx+6−0
@@ -26,12 +26,17 @@ function CommandDialog({ · 25 unmodified lines
26 children,26 children,
27 className,27 className,
28 showCloseButton = true,28 showCloseButton = true,
29 dismissOnOutsideClick = true,
29 ...props30 ...props
30}: React.ComponentProps<typeof Dialog> & {31}: React.ComponentProps<typeof Dialog> & {
31 title?: string;32 title?: string;
32 description?: string;33 description?: string;
33 className?: string;34 className?: string;
34 showCloseButton?: boolean;35 showCloseButton?: boolean;
36 /** A command palette holds nothing but a search string, and clicking away is
37 * the expected way to leave it, so this stays on — unlike `DialogContent`,
38 * whose default protects form state. */
39 dismissOnOutsideClick?: boolean;
35}) {40}) {
36 return (41 return (
37 <Dialog {...props}>42 <Dialog {...props}>
@@ -42,6 +47,7 @@ function CommandDialog({ · 4 unmodified lines
42 <DialogContent47 <DialogContent
43 className={cn("overflow-hidden p-0", className)}48 className={cn("overflow-hidden p-0", className)}
44 showCloseButton={showCloseButton}49 showCloseButton={showCloseButton}
50 dismissOnOutsideClick={dismissOnOutsideClick}
45 >51 >
46 <Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">52 <Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
47 {children}53 {children}
packages/react/src/components/dialog.tsx+15−12
@@ -2,6 +2,7 @@ import * as React from "react"; · 1 unmodified line
2import { XIcon } from "lucide-react";2import { XIcon } from "lucide-react";
3import { Dialog as DialogPrimitive } from "radix-ui";3import { Dialog as DialogPrimitive } from "radix-ui";
4 4
5import { applyOutsideDismissPolicy } from "../lib/outside-dismiss";
5import { cn } from "../lib/utils";6import { cn } from "../lib/utils";
6import { Button } from "./button";7import { Button } from "./button";
7 8
@@ -37,25 +38,26 @@ function DialogOverlay({ · 29 unmodified lines
37 );38 );
38}39}
39 40
40// base-ui popups (combobox/select) portal their list OUTSIDE the dialog content,
41// so clicking an option reads as an interaction outside the dialog and would
42// dismiss it before the selection lands. Keep the dialog open for interactions
43// that originate inside such a popup.
44const PORTALED_POPUP_SELECTOR = "[data-slot='combobox-content'],[data-slot='select-content']";
45
46function DialogContent({41function DialogContent({
47 className,42 className,
48 children,43 children,
49 showCloseButton = true,44 showCloseButton = true,
45 dismissOnOutsideClick = false,
50 onInteractOutside,46 onInteractOutside,
47 onPointerDownOutside,
51 forceOverlay = false,48 forceOverlay = false,
52 ...props49 ...props
53}: React.ComponentProps<typeof DialogPrimitive.Content> & {50}: React.ComponentProps<typeof DialogPrimitive.Content> & {
54 showCloseButton?: boolean;51 showCloseButton?: boolean;
52 /** Let a click outside the dialog close it. Off by default: a stray click on
53 * the page behind a form must not discard what the user typed. Turn it on
54 * for a dialog with nothing to lose — a confirmation, a picker, a read-only
55 * panel. Escape and the close button close either way. */
56 dismissOnOutsideClick?: boolean;
55 /** Pair with `<Dialog modal={false}>`: Radix renders no overlay in non-modal57 /** Pair with `<Dialog modal={false}>`: Radix renders no overlay in non-modal
56 * mode, so this renders a plain dim layer instead. It still eats outside58 * mode, so this renders a plain dim layer instead. It still eats outside
57 * clicks (which dismiss via onInteractOutside), so the dialog keeps its59 * clicks, so the dialog keeps its modal look while the wheel stays free for
58 * modal look while the wheel stays free for portaled popups. */60 * portaled popups. */
59 forceOverlay?: boolean;61 forceOverlay?: boolean;
60}) {62}) {
61 return (63 return (
@@ -66,12 +68,13 @@ function DialogContent({ · 4 unmodified lines
66 ) : null}68 ) : null}
67 <DialogPrimitive.Content69 <DialogPrimitive.Content
68 data-slot="dialog-content"70 data-slot="dialog-content"
71 onPointerDownOutside={(event) => {
72 onPointerDownOutside?.(event);
73 applyOutsideDismissPolicy(event, dismissOnOutsideClick);
74 }}
69 onInteractOutside={(event) => {75 onInteractOutside={(event) => {
70 const target = event.detail.originalEvent.target;
71 if (target instanceof Element && target.closest(PORTALED_POPUP_SELECTOR)) {
72 event.preventDefault();
73 }
74 onInteractOutside?.(event);76 onInteractOutside?.(event);
77 applyOutsideDismissPolicy(event, dismissOnOutsideClick);
75 }}78 }}
76 className={cn(79 className={cn(
77 "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",80 "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
packages/react/src/components/remove-oauth-app-dialog.tsx+2−1
@@ -29,7 +29,8 @@ export function RemoveOAuthAppDialog(props: { · 28 unmodified lines
29 const inUse = connections.length > 0;29 const inUse = connections.length > 0;
30 return (30 return (
31 <Dialog open onOpenChange={(open: boolean) => (open ? undefined : props.onClose())}>31 <Dialog open onOpenChange={(open: boolean) => (open ? undefined : props.onClose())}>
32 <DialogContent>32 {/* A confirmation with nothing to lose: clicking away cancels it. */}
33 <DialogContent dismissOnOutsideClick>
33 <DialogHeader>34 <DialogHeader>
34 <DialogTitle>Remove {String(client.slug)}?</DialogTitle>35 <DialogTitle>Remove {String(client.slug)}?</DialogTitle>
35 <DialogDescription>36 <DialogDescription>
packages/react/src/components/sheet.tsx+13−10
@@ -4,6 +4,7 @@ import * as React from "react"; · 3 unmodified lines
4import { XIcon } from "lucide-react";4import { XIcon } from "lucide-react";
5import { Dialog as SheetPrimitive } from "radix-ui";5import { Dialog as SheetPrimitive } from "radix-ui";
6 6
7import { applyOutsideDismissPolicy } from "../lib/outside-dismiss";
7import { cn } from "../lib/utils";8import { cn } from "../lib/utils";
8 9
9function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {10function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
@@ -38,34 +39,36 @@ function SheetOverlay({ · 28 unmodified lines
38 );39 );
39}40}
40 41
41// base-ui popups (combobox/select) portal their list OUTSIDE the dialog content,
42// so clicking an option reads as an interaction outside the sheet and would
43// dismiss it before the selection lands. Keep the sheet open for interactions
44// that originate inside such a popup.
45const PORTALED_POPUP_SELECTOR = "[data-slot='combobox-content'],[data-slot='select-content']";
46
47function SheetContent({42function SheetContent({
48 className,43 className,
49 children,44 children,
50 side = "right",45 side = "right",
51 showCloseButton = true,46 showCloseButton = true,
47 dismissOnOutsideClick = false,
52 onInteractOutside,48 onInteractOutside,
49 onPointerDownOutside,
53 ...props50 ...props
54}: React.ComponentProps<typeof SheetPrimitive.Content> & {51}: React.ComponentProps<typeof SheetPrimitive.Content> & {
55 side?: "top" | "right" | "bottom" | "left";52 side?: "top" | "right" | "bottom" | "left";
56 showCloseButton?: boolean;53 showCloseButton?: boolean;
54 /** Let a click outside the sheet close it. Off by default: a stray click on
55 * the page behind a form must not discard what the user typed. Turn it on
56 * for a sheet with nothing to lose — navigation, a picker, a read-only
57 * panel. Escape and the close button close either way. */
58 dismissOnOutsideClick?: boolean;
57}) {59}) {
58 return (60 return (
59 <SheetPortal>61 <SheetPortal>
60 <SheetOverlay />62 <SheetOverlay />
61 <SheetPrimitive.Content63 <SheetPrimitive.Content
62 data-slot="sheet-content"64 data-slot="sheet-content"
65 onPointerDownOutside={(event) => {
66 onPointerDownOutside?.(event);
67 applyOutsideDismissPolicy(event, dismissOnOutsideClick);
68 }}
63 onInteractOutside={(event) => {69 onInteractOutside={(event) => {
64 const target = event.detail.originalEvent.target;
65 if (target instanceof Element && target.closest(PORTALED_POPUP_SELECTOR)) {
66 event.preventDefault();
67 }
68 onInteractOutside?.(event);70 onInteractOutside?.(event);
71 applyOutsideDismissPolicy(event, dismissOnOutsideClick);
69 }}72 }}
70 className={cn(73 className={cn(
71 "fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",74 "fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
packages/react/src/components/sidebar.tsx+3−0
@@ -168,6 +168,9 @@ function Sidebar({ · 167 unmodified lines
168 return (168 return (
169 <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>169 <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
170 <SheetContent170 <SheetContent
171 // Navigation only. Tapping the page behind it is the normal way to
172 // put the mobile nav away, and nothing is lost.
173 dismissOnOutsideClick
171 data-sidebar="sidebar"174 data-sidebar="sidebar"
172 data-slot="sidebar"175 data-slot="sidebar"
173 data-mobile="true"176 data-mobile="true"
packages/react/src/components/update-card.test.ts+58−0
@@ -0,0 +1,58 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { isUpdateAvailable } from "@executor-js/api";
4
5import { updateFetchChannel } from "./update-card";
6
7/**
8 * `updateFetchChannel` is what lets the hook skip the network call for a
9 * build that can never legitimately claim an update — the dev build's
10 * unstamped fallback, or a prerelease channel with no published dist-tag.
11 * Pinning it here is what keeps that decision testable without rendering.
12 */
13describe("updateFetchChannel", () => {
14 it("fetches nothing for the unstamped dev build", () => {
15 expect(updateFetchChannel("0.0.0-dev")).toBeNull();
16 });
17
18 it("fetches nothing for the desktop's plain unstamped fallback", () => {
19 expect(updateFetchChannel("0.0.0")).toBeNull();
20 });
21
22 it("fetches nothing when there is no version yet", () => {
23 expect(updateFetchChannel(undefined)).toBeNull();
24 });
25
26 it("fetches the latest tag for a release build", () => {
27 expect(updateFetchChannel("1.6.0")).toBe("latest");
28 });
29
30 it("fetches the beta tag for a beta prerelease", () => {
31 expect(updateFetchChannel("1.6.0-beta.2")).toBe("beta");
32 });
33
34 it("fetches nothing for a prerelease channel with no matching dist-tag", () => {
35 expect(updateFetchChannel("1.6.0-rc.1")).toBeNull();
36 });
37});
38
39/**
40 * The card's verdict is the shared `isUpdateAvailable`, so the fetch decision
41 * above and the "should we show the card?" decision can never disagree.
42 */
43describe("isUpdateAvailable through the card's fetch channel", () => {
44 it("stays quiet on the unstamped dev build even if a tag comes back", () => {
45 expect(updateFetchChannel("0.0.0-dev")).toBeNull();
46 expect(isUpdateAvailable("0.0.0-dev", "1.6.0")).toBe(false);
47 });
48
49 it("flags a real update on the latest channel", () => {
50 expect(updateFetchChannel("1.6.0")).toBe("latest");
51 expect(isUpdateAvailable("1.6.0", "1.6.1")).toBe(true);
52 });
53
54 it("flags a real update on the beta channel", () => {
55 expect(updateFetchChannel("1.6.0-beta.1")).toBe("beta");
56 expect(isUpdateAvailable("1.6.0-beta.1", "1.6.0-beta.2")).toBe(true);
57 });
58});
packages/react/src/components/update-card.tsx+33−11
@@ -13,11 +13,17 @@ · 12 unmodified lines
13// - managed cloud (`"managed"`): nothing, it deploys itself.13// - managed cloud (`"managed"`): nothing, it deploys itself.
14//14//
15// The "is a newer version published?" verdict comes from the same resolver as15// The "is a newer version published?" verdict comes from the same resolver as
16// the CLI notice (@executor-js/api) so the two can never disagree.16// the CLI notice (@executor-js/api) — `isUpdateAvailable` — so the two can
17// never disagree.
17import { useCallback, useEffect, useState } from "react";18import { useCallback, useEffect, useState } from "react";
18 19
19import { Effect, Exit } from "effect";20import { Effect, Exit } from "effect";
20import { compareVersions, resolveUpdateChannel, type UpdateChannel } from "@executor-js/api";21import {
22 isUpdateAvailable,
23 resolveComparisonChannel,
24 resolveUpdateChannel,
25 type UpdateChannel,
26} from "@executor-js/api";
21 27
22import { Button } from "./button";28import { Button } from "./button";
23import { toast } from "./sonner";29import { toast } from "./sonner";
@@ -47,12 +53,31 @@ const UPGRADE_DOCS_URL: Partial<Record<UpgradeHint, string>> = { · 23 unmodified lines
47 53
48// ── useLatestVersion ────────────────────────────────────────────────────54// ── useLatestVersion ────────────────────────────────────────────────────
49 55
56/**
57 * The dist-tag channel to fetch for `currentVersion`, or `null` when there is
58 * nothing to check — no version baked in yet, or a build the check should
59 * stay quiet on (an unstamped `0.0.0*` fallback, or a prerelease channel with
60 * no published dist-tag; see `resolveComparisonChannel`). Returning `null`
61 * here is what lets the hook skip the network call entirely, so a dev build
62 * never even hits `/v1/app/npm/dist-tags`.
63 */
64export function updateFetchChannel(currentVersion: string | undefined): UpdateChannel | null {
65 if (currentVersion === undefined) return null;
66 return resolveComparisonChannel(currentVersion);
67}
68
50function useLatestVersion(currentVersion: string | undefined) {69function useLatestVersion(currentVersion: string | undefined) {
51 const channel: UpdateChannel = currentVersion ? resolveUpdateChannel(currentVersion) : "latest";70 // The channel shown in the upgrade command differs from the channel
71 // fetched for comparison: the command always names a real channel, while
72 // the fetch is skipped entirely for a version with nothing to compare.
73 const displayChannel: UpdateChannel = currentVersion
74 ? resolveUpdateChannel(currentVersion)
75 : "latest";
76 const fetchChannel = updateFetchChannel(currentVersion);
52 const [latestVersion, setLatestVersion] = useState<string | null>(null);77 const [latestVersion, setLatestVersion] = useState<string | null>(null);
53 78
54 useEffect(() => {79 useEffect(() => {
55 if (!currentVersion) return;80 if (fetchChannel === null) return;
56 let cancelled = false;81 let cancelled = false;
57 void Effect.runPromiseExit(82 void Effect.runPromiseExit(
58 Effect.tryPromise({83 Effect.tryPromise({
@@ -65,20 +90,17 @@ function useLatestVersion(currentVersion: string | undefined) { · 6 unmodified lines
65 }),90 }),
66 ).then((exit) => {91 ).then((exit) => {
67 if (!cancelled && Exit.isSuccess(exit)) {92 if (!cancelled && Exit.isSuccess(exit)) {
68 setLatestVersion(exit.value?.[channel] ?? null);93 setLatestVersion(exit.value?.[fetchChannel] ?? null);
69 }94 }
70 });95 });
71 return () => {96 return () => {
72 cancelled = true;97 cancelled = true;
73 };98 };
74 }, [channel, currentVersion]);99 }, [fetchChannel]);
75 100
76 const updateAvailable =101 const updateAvailable = isUpdateAvailable(currentVersion, latestVersion);
77 currentVersion !== undefined &&
78 latestVersion !== null &&
79 compareVersions(currentVersion, latestVersion) === -1;
80 102
81 return { latestVersion, updateAvailable, channel };103 return { latestVersion, updateAvailable, channel: displayChannel };
82}104}
83 105
84// ── Card chrome ──────────────────────────────────────────────────────────106// ── Card chrome ──────────────────────────────────────────────────────────
packages/react/src/lib/integration-grouping.test.ts+14−1
@@ -38,6 +38,19 @@ describe("groupIntegrations", () => { · 37 unmodified lines
38 ]);38 ]);
39 });39 });
40 40
41 it("groups arbitrary provider families", () => {
42 const items = groupIntegrations([
43 integration("cloudflare_api", "mcp", "Cloudflare API", "cloudflare"),
44 integration("cloudflare_docs", "mcp", "Cloudflare Docs", "cloudflare"),
45 ]);
46
47 expect(items).toHaveLength(1);
48 const group = items[0] as IntegrationFamilyGroup;
49 expect(group.type).toBe("group");
50 expect(group.family).toBe("cloudflare");
51 expect(group.label).toBe("Cloudflare");
52 });
53
41 it("leaves integrations without config family ungrouped", () => {54 it("leaves integrations without config family ungrouped", () => {
42 const items = groupIntegrations([55 const items = groupIntegrations([
43 integration("stripe", "openapi", "Stripe"),56 integration("stripe", "openapi", "Stripe"),
@@ -72,7 +85,7 @@ describe("groupIntegrations", () => { · 28 unmodified lines
72 ).toEqual(["single", "group:google", "group:microsoft", "single"]);85 ).toEqual(["single", "group:google", "group:microsoft", "single"]);
73 });86 });
74 87
75 it("does not group from integration kind alone", () => {88 it("does not infer a family from integration kind alone", () => {
76 expect(89 expect(
77 integrationFamily(integration("google_calendar", "google", "Google Calendar")),90 integrationFamily(integration("google_calendar", "google", "Google Calendar")),
78 ).toBeNull();91 ).toBeNull();
packages/react/src/lib/integration-grouping.ts+1−3
@@ -1,7 +1,5 @@
1import type { Integration } from "@executor-js/sdk/shared";1import type { Integration } from "@executor-js/sdk/shared";
2 2
3export const MULTI_SERVICE_FAMILIES: ReadonlySet<string> = new Set(["google", "microsoft"]);
4
5const FAMILY_LABELS: Record<string, string> = {3const FAMILY_LABELS: Record<string, string> = {
6 google: "Google",4 google: "Google",
7 microsoft: "Microsoft",5 microsoft: "Microsoft",
@@ -12,7 +10,7 @@ export const familyLabel = (family: string): string => · 4 unmodified lines
12 10
13export const integrationFamily = (integration: Integration): string | null => {11export const integrationFamily = (integration: Integration): string | null => {
14 const family = integration.family?.trim();12 const family = integration.family?.trim();
15 return family && MULTI_SERVICE_FAMILIES.has(family) ? family : null;13 return family || null;
16};14};
17 15
18export interface IntegrationFamilyGroup {16export interface IntegrationFamilyGroup {
packages/react/src/lib/outside-dismiss.test.ts+77−0
@@ -0,0 +1,77 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 applyOutsideDismissPolicy,
5 dismissesOnOutsideInteraction,
6 PORTALED_POPUP_SELECTOR,
7} from "./outside-dismiss";
8
9/**
10 * The property under test: a dialog or sheet keeps what the user typed.
11 *
12 * Radix closes an overlay surface whenever an outside interaction reaches it
13 * un-prevented, so "does not dismiss" is the thing that has to be asserted, not
14 * assumed. Escape is Radix's own `onEscapeKeyDown` path and never passes
15 * through here, which is why no case below can close a surface with a key.
16 */
17describe("dismissesOnOutsideInteraction", () => {
18 it("keeps the surface open by default", () => {
19 expect(
20 dismissesOnOutsideInteraction({
21 dismissOnOutsideClick: false,
22 insidePortaledPopup: false,
23 }),
24 ).toBe(false);
25 });
26
27 it("closes the surface when it opts in", () => {
28 expect(
29 dismissesOnOutsideInteraction({ dismissOnOutsideClick: true, insidePortaledPopup: false }),
30 ).toBe(true);
31 });
32
33 it("never closes on a click inside a portaled popup, even when it opts in", () => {
34 // A combobox or select renders its list outside the surface, so choosing an
35 // option arrives as an outside interaction. Dismissing there would drop the
36 // selection before it lands.
37 expect(
38 dismissesOnOutsideInteraction({ dismissOnOutsideClick: true, insidePortaledPopup: true }),
39 ).toBe(false);
40 });
41});
42
43/** A stand-in for the event Radix dispatches, recording whether it was blocked. */
44const outsideEvent = () => {
45 let prevented = false;
46 return {
47 event: {
48 detail: { originalEvent: { target: { nodeName: "DIV" } } },
49 preventDefault: () => {
50 prevented = true;
51 },
52 },
53 prevented: () => prevented,
54 };
55};
56
57describe("applyOutsideDismissPolicy", () => {
58 it("blocks a plain outside click by default", () => {
59 const outside = outsideEvent();
60 applyOutsideDismissPolicy(outside.event, false);
61 expect(outside.prevented()).toBe(true);
62 });
63
64 it("lets a plain outside click through when the surface opts in", () => {
65 const outside = outsideEvent();
66 applyOutsideDismissPolicy(outside.event, true);
67 expect(outside.prevented()).toBe(false);
68 });
69});
70
71describe("PORTALED_POPUP_SELECTOR", () => {
72 it("covers both popup slots that portal out of a surface", () => {
73 expect(PORTALED_POPUP_SELECTOR).toBe(
74 "[data-slot='combobox-content'],[data-slot='select-content']",
75 );
76 });
77});
packages/react/src/lib/outside-dismiss.ts+53−0
@@ -0,0 +1,53 @@
1/**
2 * Outside-interaction policy shared by `DialogContent` and `SheetContent`.
3 *
4 * Radix dismisses an overlay surface when an outside interaction is not
5 * default-prevented. That loses whatever the user typed, and a stray click on
6 * the page behind a form is easy to make. So the default here is the opposite
7 * of Radix's: an outside interaction keeps the surface open. Escape and the
8 * close button are unaffected — they still close.
9 *
10 * A surface with nothing to lose (a confirmation, a picker, a read-only panel)
11 * opts back in with `dismissOnOutsideClick`.
12 */
13
14/** base-ui popups (combobox/select) portal their list OUTSIDE the surface, so a
15 * click on an option reads as an interaction outside it. Such a click must
16 * never dismiss, even when the surface opts in. */
17export const PORTALED_POPUP_SELECTOR =
18 "[data-slot='combobox-content'],[data-slot='select-content']";
19
20/** True when the interaction started inside a popup this surface portals out. */
21export const isInsidePortaledPopup = (target: unknown): boolean =>
22 typeof Element !== "undefined" &&
23 target instanceof Element &&
24 target.closest(PORTALED_POPUP_SELECTOR) !== null;
25
26/**
27 * Whether an outside interaction should close the surface.
28 *
29 * Pure so the decision is testable without a DOM: the caller does the element
30 * lookup and passes the answer in.
31 */
32export const dismissesOnOutsideInteraction = (input: {
33 readonly dismissOnOutsideClick: boolean;
34 readonly insidePortaledPopup: boolean;
35}): boolean => input.dismissOnOutsideClick && !input.insidePortaledPopup;
36
37/** The shape Radix hands to `onInteractOutside` and `onPointerDownOutside`. */
38type OutsideInteractionEvent = {
39 readonly detail: { readonly originalEvent: { readonly target: unknown } };
40 readonly preventDefault: () => void;
41};
42
43/** Apply the policy to a Radix outside-interaction event. */
44export const applyOutsideDismissPolicy = (
45 event: OutsideInteractionEvent,
46 dismissOnOutsideClick: boolean,
47): void => {
48 const dismisses = dismissesOnOutsideInteraction({
49 dismissOnOutsideClick,
50 insidePortaledPopup: isInsidePortaledPopup(event.detail.originalEvent.target),
51 });
52 if (!dismisses) event.preventDefault();
53};
packages/react/src/multiplayer/org-slug-gate.tsx+5−1
@@ -16,7 +16,11 @@ import { useNavigate, useParams, useRouterState } from "@tanstack/react-router"; · 15 unmodified lines
16// membership and resolves data for that org — same as the MCP URL-pinned org.16// membership and resolves data for that org — same as the MCP URL-pinned org.
17// So a foreign slug never reaches this gate as "active" on a multi-org host:17// So a foreign slug never reaches this gate as "active" on a multi-org host:
18// the server returns no organization for an org the caller can't see, and the18// the server returns no organization for an org the caller can't see, and the
19// shell 404s upstream. That makes two browser tabs on different orgs fully19// shell 404s upstream. The host is what enforces that — it must not build this
20// subtree until the authenticated answer NAMES the slug in the URL, or an
21// optimistic answer about the session's own org (cloud's auth-hint cookie)
22// would mount the shell under a foreign address before the server ever
23// replies. That makes two browser tabs on different orgs fully
20// independent — no shared "active org" to steal. On a single-org host (e.g.24// independent — no shared "active org" to steal. On a single-org host (e.g.
21// self-host) every slug resolves to the same org server-side, so a bogus slug25// self-host) every slug resolves to the same org server-side, so a bogus slug
22// (e.g. `/totally-bogus`) would otherwise fuzzy-match a route and render26// (e.g. `/totally-bogus`) would otherwise fuzzy-match a route and render
packages/react/src/multiplayer/shell.tsx+26−2
@@ -2,7 +2,7 @@ import { Link, Outlet, useLocation, useParams } from "@tanstack/react-router"; · 1 unmodified line
2import { useEffect, useRef, useState, type ReactNode } from "react";2import { useEffect, useRef, useState, type ReactNode } from "react";
3import { useAtomValue } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { BookOpen, Command, ExternalLink } from "lucide-react";5import { BookOpen, Command, ExternalLink, PlusIcon } from "lucide-react";
6import type { Integration } from "@executor-js/sdk/shared";6import type { Integration } from "@executor-js/sdk/shared";
7import { integrationsOptimisticAtom } from "../api/atoms";7import { integrationsOptimisticAtom } from "../api/atoms";
8import { trackEvent } from "../api/analytics";8import { trackEvent } from "../api/analytics";
@@ -23,6 +23,7 @@ import { · 14 unmodified lines
23} from "../components/integration-favicon";23} from "../components/integration-favicon";
24import { CommandPalette } from "../components/command-palette";24import { CommandPalette } from "../components/command-palette";
25import { Wordmark } from "../components/wordmark";25import { Wordmark } from "../components/wordmark";
26import { ConnectDialog } from "../pages/integrations";
26import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";27import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
27import { useAuth } from "./auth-context";28import { useAuth } from "./auth-context";
28 29
@@ -347,6 +348,7 @@ function SidebarContent( · 318 unmodified lines
347 onNavigate?: () => void;348 onNavigate?: () => void;
348 showBrand?: boolean;349 showBrand?: boolean;
349 onOpenCommands: () => void;350 onOpenCommands: () => void;
351 onOpenIntegrationConnect: () => void;
350 },352 },
351) {353) {
352 const plugins = useClientPlugins();354 const plugins = useClientPlugins();
@@ -382,8 +384,19 @@ function SidebarContent( · 29 unmodified lines
382 />384 />
383 ))}385 ))}
384 386
385 <div className="mt-5 mb-1 px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">387 <div className="mt-5 mb-1 flex items-center justify-between px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
386 <span>Integrations</span>388 <span>Integrations</span>
389 <Button
390 type="button"
391 variant="ghost"
392 size="icon-xs"
393 aria-label="Browse integrations"
394 title="Browse integrations"
395 onClick={props.onOpenIntegrationConnect}
396 className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground"
397 >
398 <PlusIcon className="size-3.5" />
399 </Button>
387 </div>400 </div>
388 401
389 <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />402 <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />
@@ -415,6 +428,7 @@ export function Shell(props: ShellProps) { · 25 unmodified lines
415 const lastPathname = useRef(pathname);428 const lastPathname = useRef(pathname);
416 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);429 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
417 const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);430 const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
431 const [connectIntegrationOpen, setConnectIntegrationOpen] = useState(false);
418 if (lastPathname.current !== pathname) {432 if (lastPathname.current !== pathname) {
419 lastPathname.current = pathname;433 lastPathname.current = pathname;
420 if (mobileSidebarOpen) setMobileSidebarOpen(false);434 if (mobileSidebarOpen) setMobileSidebarOpen(false);
@@ -432,12 +446,17 @@ export function Shell(props: ShellProps) { · 11 unmodified lines
432 return (446 return (
433 <div className="flex h-screen overflow-hidden">447 <div className="flex h-screen overflow-hidden">
434 <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />448 <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
449 <ConnectDialog open={connectIntegrationOpen} onOpenChange={setConnectIntegrationOpen} />
435 {/* Desktop sidebar */}450 {/* Desktop sidebar */}
436 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">451 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
437 <SidebarContent452 <SidebarContent
438 {...props}453 {...props}
439 pathname={pathname}454 pathname={pathname}
440 onOpenCommands={() => setCommandPaletteOpen(true)}455 onOpenCommands={() => setCommandPaletteOpen(true)}
456 onOpenIntegrationConnect={() => {
457 setConnectIntegrationOpen(true);
458 trackEvent("integration_connect_dialog_opened");
459 }}
441 />460 />
442 </aside>461 </aside>
443 462
@@ -481,6 +500,11 @@ export function Shell(props: ShellProps) { · 37 unmodified lines
481 setMobileSidebarOpen(false);500 setMobileSidebarOpen(false);
482 setCommandPaletteOpen(true);501 setCommandPaletteOpen(true);
483 }}502 }}
503 onOpenIntegrationConnect={() => {
504 setMobileSidebarOpen(false);
505 setConnectIntegrationOpen(true);
506 trackEvent("integration_connect_dialog_opened");
507 }}
484 />508 />
485 </div>509 </div>
486 </div>510 </div>
packages/react/src/pages/admin-users.tsx+2−1
@@ -670,7 +670,8 @@ export function AdminUsersPage() { · 669 unmodified lines
670 })}670 })}
671 671
672 <Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>672 <Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
673 <SheetContent className="w-full gap-0 p-0 sm:max-w-xl">673 {/* A read-only detail panel: clicking away closes it. */}
674 <SheetContent dismissOnOutsideClick className="w-full gap-0 p-0 sm:max-w-xl">
674 {selected && (675 {selected && (
675 <>676 <>
676 <SheetHeader className="border-b border-border">677 <SheetHeader className="border-b border-border">
packages/react/src/pages/api-keys.tsx+45−4
@@ -233,6 +233,7 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode }) { · 232 unmodified lines
233 // stays mounted for Radix's exit animation (see CreateKeyDialogBody).233 // stays mounted for Radix's exit animation (see CreateKeyDialogBody).
234 const [openCount, setOpenCount] = useState(0);234 const [openCount, setOpenCount] = useState(0);
235 const [revokingId, setRevokingId] = useState<string | null>(null);235 const [revokingId, setRevokingId] = useState<string | null>(null);
236 const [confirmRevoke, setConfirmRevoke] = useState<ApiKeySummary | null>(null);
236 237
237 const handleCreate = async (name: string): Promise<CreatedKey | null> => {238 const handleCreate = async (name: string): Promise<CreatedKey | null> => {
238 const exit = await doCreate({ payload: { name }, reactivityKeys: apiKeyWriteKeys });239 const exit = await doCreate({ payload: { name }, reactivityKeys: apiKeyWriteKeys });
@@ -312,7 +313,11 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode }) { · 73 unmodified lines
312 </p>313 </p>
313 </div>314 </div>
314 ) : (315 ) : (
315 <KeyTable keys={value.apiKeys} revokingId={revokingId} onRevoke={handleRevoke} />316 <KeyTable
317 keys={value.apiKeys}
318 revokingId={revokingId}
319 onRevoke={(key) => setConfirmRevoke(key)}
320 />
316 ),321 ),
317 })322 })
318 )}323 )}
@@ -333,6 +338,42 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode }) { · 14 unmodified lines
333 />338 />
334 </DialogContent>339 </DialogContent>
335 </Dialog>340 </Dialog>
341
342 {/* A personal key revoke breaks any script or tool using it, so it asks
343 first — same as the org-key revoke. */}
344 <Dialog
345 open={confirmRevoke !== null}
346 onOpenChange={(open) => {
347 if (!open) setConfirmRevoke(null);
348 }}
349 >
350 <DialogContent className="sm:max-w-[480px]">
351 <DialogHeader>
352 <DialogTitle className="font-display text-xl">Revoke API key</DialogTitle>
353 <DialogDescription className="text-sm leading-relaxed">
354 {confirmRevoke
355 ? `Revoke ${confirmRevoke.name}? Any script or tool authenticating with it loses access immediately. This cannot be undone.`
356 : ""}
357 </DialogDescription>
358 </DialogHeader>
359 <DialogFooter>
360 <DialogClose asChild>
361 <Button variant="ghost">Cancel</Button>
362 </DialogClose>
363 <Button
364 variant="destructive"
365 onClick={() => {
366 if (confirmRevoke) {
367 void handleRevoke(confirmRevoke);
368 setConfirmRevoke(null);
369 }
370 }}
371 >
372 Revoke key
373 </Button>
374 </DialogFooter>
375 </DialogContent>
376 </Dialog>
336 </PageContainer>377 </PageContainer>
337 );378 );
338}379}
@@ -492,15 +533,15 @@ function OrgApiKeysSectionBody() { · 153 unmodified lines
492 </DialogContent>533 </DialogContent>
493 </Dialog>534 </Dialog>
494 535
495 {/* Revoking an org key breaks every backend using it, so it is the one536 {/* Revoking an org key breaks every backend using it, so it asks first. */}
496 revoke on this page that asks first. */}
497 <Dialog537 <Dialog
498 open={confirmRevoke !== null}538 open={confirmRevoke !== null}
499 onOpenChange={(open) => {539 onOpenChange={(open) => {
500 if (!open) setConfirmRevoke(null);540 if (!open) setConfirmRevoke(null);
501 }}541 }}
502 >542 >
503 <DialogContent className="sm:max-w-[480px]">543 {/* A confirmation with nothing to lose: clicking away cancels it. */}
544 <DialogContent dismissOnOutsideClick className="sm:max-w-[480px]">
504 <DialogHeader>545 <DialogHeader>
505 <DialogTitle className="font-display text-xl">Revoke organization key</DialogTitle>546 <DialogTitle className="font-display text-xl">Revoke organization key</DialogTitle>
506 <DialogDescription className="text-sm leading-relaxed">547 <DialogDescription className="text-sm leading-relaxed">
packages/react/src/pages/integrations.tsx+1−1
@@ -163,7 +163,7 @@ const looksLikeUrl = (raw: string): boolean => { · 162 unmodified lines
163 return false;163 return false;
164};164};
165 165
166function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) {166export function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) {
167 const integrationPlugins = useIntegrationPlugins();167 const integrationPlugins = useIntegrationPlugins();
168 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });168 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });
169 const navigate = useNavigate();169 const navigate = useNavigate();
packages/react/src/pages/org.tsx+52−2
@@ -14,6 +14,16 @@ import { · 13 unmodified lines
14 DialogFooter,14 DialogFooter,
15 DialogClose,15 DialogClose,
16} from "../components/dialog";16} from "../components/dialog";
17import {
18 AlertDialog,
19 AlertDialogAction,
20 AlertDialogCancel,
21 AlertDialogContent,
22 AlertDialogDescription,
23 AlertDialogFooter,
24 AlertDialogHeader,
25 AlertDialogTitle,
26} from "../components/alert-dialog";
17import { Button } from "../components/button";27import { Button } from "../components/button";
18import { PageContainer, PageHeader } from "../components/page";28import { PageContainer, PageHeader } from "../components/page";
19import { Badge } from "../components/badge";29import { Badge } from "../components/badge";
@@ -153,6 +163,7 @@ export function OrgPage(props: { · 133 unmodified lines
153 const refreshMembers = useAtomRefresh(orgMembersAtom);163 const refreshMembers = useAtomRefresh(orgMembersAtom);
154 const rolesResult = useAtomValue(orgRolesAtom);164 const rolesResult = useAtomValue(orgRolesAtom);
155 const doRemove = useAtomSet(removeMember, { mode: "promiseExit" });165 const doRemove = useAtomSet(removeMember, { mode: "promiseExit" });
166 const [removingMember, setRemovingMember] = useState<{ id: string; name: string } | null>(null);
156 const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" });167 const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" });
157 const doUpdateOrgName = useAtomSet(updateOrgName, { mode: "promiseExit" });168 const doUpdateOrgName = useAtomSet(updateOrgName, { mode: "promiseExit" });
158 const [inviteOpen, setInviteOpen] = useState(false);169 const [inviteOpen, setInviteOpen] = useState(false);
@@ -179,6 +190,7 @@ export function OrgPage(props: { · 20 unmodified lines
179 const showUpgradeOnInvite = atSeatLimit && !!props.upgradeAction;190 const showUpgradeOnInvite = atSeatLimit && !!props.upgradeAction;
180 191
181 const handleRemove = async (membershipId: string, name: string) => {192 const handleRemove = async (membershipId: string, name: string) => {
193 setRemovingMember(null);
182 const exit = await doRemove({194 const exit = await doRemove({
183 params: { membershipId },195 params: { membershipId },
184 reactivityKeys: orgMemberWriteKeys,196 reactivityKeys: orgMemberWriteKeys,
@@ -406,7 +418,12 @@ export function OrgPage(props: { · 221 unmodified lines
406 )}418 )}
407 <DropdownMenuItem419 <DropdownMenuItem
408 className="text-destructive focus:text-destructive text-sm"420 className="text-destructive focus:text-destructive text-sm"
409 onClick={() => handleRemove(member.id, member.name ?? member.email)}421 onClick={() =>
422 setRemovingMember({
423 id: member.id,
424 name: member.name ?? member.email,
425 })
426 }
410 >427 >
411 Remove member428 Remove member
412 </DropdownMenuItem>429 </DropdownMenuItem>
@@ -426,6 +443,38 @@ export function OrgPage(props: { · 13 unmodified lines
426 443
427 {props.dangerZoneSection}444 {props.dangerZoneSection}
428 445
446 <AlertDialog
447 open={removingMember !== null}
448 onOpenChange={(open: boolean) => {
449 if (!open) setRemovingMember(null);
450 }}
451 >
452 <AlertDialogContent size="sm">
453 <AlertDialogHeader>
454 <AlertDialogTitle>
455 {removingMember ? `Remove ${removingMember.name}?` : "Remove member?"}
456 </AlertDialogTitle>
457 <AlertDialogDescription>
458 They lose access to this organization immediately. This cannot be undone; you would
459 need to invite them again.
460 </AlertDialogDescription>
461 </AlertDialogHeader>
462 <AlertDialogFooter>
463 <AlertDialogCancel>Cancel</AlertDialogCancel>
464 <AlertDialogAction
465 variant="destructive"
466 onClick={() => {
467 if (removingMember !== null) {
468 void handleRemove(removingMember.id, removingMember.name);
469 }
470 }}
471 >
472 Remove member
473 </AlertDialogAction>
474 </AlertDialogFooter>
475 </AlertDialogContent>
476 </AlertDialog>
477
429 <InviteDialog open={inviteOpen} onOpenChange={setInviteOpen} roles={roles} />478 <InviteDialog open={inviteOpen} onOpenChange={setInviteOpen} roles={roles} />
430 <UpgradeDialog479 <UpgradeDialog
431 open={upgradeOpen}480 open={upgradeOpen}
@@ -447,7 +496,8 @@ function UpgradeDialog(props: { · 15 unmodified lines
447}) {496}) {
448 return (497 return (
449 <Dialog open={props.open} onOpenChange={props.onOpenChange}>498 <Dialog open={props.open} onOpenChange={props.onOpenChange}>
450 <DialogContent className="sm:max-w-[400px]">499 {/* Informational only: nothing to lose, so clicking away dismisses it. */}
500 <DialogContent dismissOnOutsideClick className="sm:max-w-[400px]">
451 <DialogHeader>501 <DialogHeader>
452 <DialogTitle className="font-display text-xl">You are at your member limit</DialogTitle>502 <DialogTitle className="font-display text-xl">You are at your member limit</DialogTitle>
453 <DialogDescription className="text-sm leading-relaxed">503 <DialogDescription className="text-sm leading-relaxed">
packages/react/src/pages/policies.tsx+50−1
@@ -30,6 +30,16 @@ import { · 29 unmodified lines
30} from "../lib/policy-display";30} from "../lib/policy-display";
31import { Button } from "../components/button";31import { Button } from "../components/button";
32import { PageContainer, PageHeader } from "../components/page";32import { PageContainer, PageHeader } from "../components/page";
33import {
34 AlertDialog,
35 AlertDialogAction,
36 AlertDialogCancel,
37 AlertDialogContent,
38 AlertDialogDescription,
39 AlertDialogFooter,
40 AlertDialogHeader,
41 AlertDialogTitle,
42} from "../components/alert-dialog";
33import {43import {
34 CardStack,44 CardStack,
35 CardStackContent,45 CardStackContent,
@@ -291,6 +301,11 @@ export function PoliciesPage() { · 255 unmodified lines
291 const doUpdate = useAtomSet(updatePolicyOptimistic, { mode: "promiseExit" });301 const doUpdate = useAtomSet(updatePolicyOptimistic, { mode: "promiseExit" });
292 const doRemove = useAtomSet(removePolicyOptimistic, { mode: "promiseExit" });302 const doRemove = useAtomSet(removePolicyOptimistic, { mode: "promiseExit" });
293 const [busy, setBusy] = useState(false);303 const [busy, setBusy] = useState(false);
304 const [removingPolicy, setRemovingPolicy] = useState<{
305 id: string;
306 owner: Owner;
307 pattern: string;
308 } | null>(null);
294 const ownerDisplay = useOwnerDisplay();309 const ownerDisplay = useOwnerDisplay();
295 // Policies default to org/workspace. On local this is the hidden Local owner310 // Policies default to org/workspace. On local this is the hidden Local owner
296 // that v1 local data migrates into.311 // that v1 local data migrates into.
@@ -336,6 +351,7 @@ export function PoliciesPage() { · 39 unmodified lines
336 };351 };
337 352
338 const handleRemove = async (policy: { id: string; owner: Owner }) => {353 const handleRemove = async (policy: { id: string; owner: Owner }) => {
354 setRemovingPolicy(null);
339 const exit = await doRemove({355 const exit = await doRemove({
340 params: { policyId: PolicyId.make(policy.id) },356 params: { policyId: PolicyId.make(policy.id) },
341 payload: { owner: policy.owner },357 payload: { owner: policy.owner },
@@ -462,7 +478,9 @@ export function PoliciesPage() { · 120 unmodified lines
462 isFirst={!reorderable || j === 0}478 isFirst={!reorderable || j === 0}
463 isLast={!reorderable || j === committed.length - 1}479 isLast={!reorderable || j === committed.length - 1}
464 showOwnerLabel={ownerDisplay.showOwnerLabels}480 showOwnerLabel={ownerDisplay.showOwnerLabels}
465 onRemove={() => handleRemove({ id: p.id, owner: p.owner })}481 onRemove={() =>
482 setRemovingPolicy({ id: p.id, owner: p.owner, pattern: p.pattern })
483 }
466 onChangeAction={(action) =>484 onChangeAction={(action) =>
467 handleUpdate({ id: p.id, owner: p.owner }, action)485 handleUpdate({ id: p.id, owner: p.owner }, action)
468 }486 }
@@ -490,6 +508,37 @@ export function PoliciesPage() { · 21 unmodified lines
490 },508 },
491 })509 })
492 )}510 )}
511
512 <AlertDialog
513 open={removingPolicy !== null}
514 onOpenChange={(open: boolean) => {
515 if (!open) setRemovingPolicy(null);
516 }}
517 >
518 <AlertDialogContent size="sm">
519 <AlertDialogHeader>
520 <AlertDialogTitle>Remove policy?</AlertDialogTitle>
521 <AlertDialogDescription>
522 {removingPolicy
523 ? `The rule for "${removingPolicy.pattern}" will be deleted. Tools it governs fall back to the default policy. This cannot be undone.`
524 : ""}
525 </AlertDialogDescription>
526 </AlertDialogHeader>
527 <AlertDialogFooter>
528 <AlertDialogCancel>Cancel</AlertDialogCancel>
529 <AlertDialogAction
530 variant="destructive"
531 onClick={() => {
532 if (removingPolicy !== null) {
533 void handleRemove({ id: removingPolicy.id, owner: removingPolicy.owner });
534 }
535 }}
536 >
537 Remove policy
538 </AlertDialogAction>
539 </AlertDialogFooter>
540 </AlertDialogContent>
541 </AlertDialog>
493 </PageContainer>542 </PageContainer>
494 );543 );
495}544}