Unified · Split
| @@ -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 | + | |
| 7 | +Radix 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 | + | |
| 9 | +The 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. | |
| @@ -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 | + | |
| 7 | +Opening `/<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 | + | |
| 9 | +The shell's not-found only fired once `/account/me` had answered for the URL's slug. Until then the console read its identity from the auth-hint cookie, which always names the organization the session last landed in, never the one in the address bar. So the first paint answered a question about a different organization and built a whole workspace out of it, and whether you ever saw that depended on how fast the server replied. | |
| 10 | + | |
| 11 | +The shell is now built only from an answer that names the organization the URL names. A slug the current answer does not cover renders nothing at all until `/account/me` resolves for that slug, and then either the workspace or — for an organization this session cannot see — the not-found page. The URL is never rewritten: a wrong address stays a wrong address. | |
| 12 | + | |
| 13 | +The ordinary cold load is untouched. The hint names the slug already in the URL, so it matches on the very first paint and the shell renders with no round trip. Only a slug the hint does not name waits: a foreign one, and the single frame after switching organizations, which now paints the organization the URL asked for instead of briefly showing the previous one. | |
| @@ -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 | + | |
| 7 | +Some installs lost every saved integration from the MCP gateway at once. The credentials were never deleted — the gateway simply could not read the table they live in, so it served an empty tool list and restarting did not help. | |
| 8 | + | |
| 9 | +The `connection.expires_at` column records when an OAuth access token expires. It used to be a plain number; it now holds the value's digits, because a millisecond timestamp is larger than a 32-bit integer. SQLite does not rewrite rows when a column's type changes, so a connection saved by an older build still held the old form. Reading one back failed, and because the failure happened while mapping the row, it failed the whole query rather than that one field — one stale row was enough to hide every integration. | |
| 10 | + | |
| 11 | +A boot-time migration now converts those values to the current form. It runs before anything reads the table, so the integrations are back on the first restart after upgrading. It only touches values still in the old numeric form: rows already written by a current build are left exactly as they are, and it runs once. | |
| @@ -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 | + | |
| 7 | +The 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 | + | |
| 9 | +The 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 | + | |
| 11 | +A 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. | |
| @@ -0,0 +1,5 @@ | ||
| 1 | +--- | |
| 2 | +"@executor-js/plugin-mcp": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Allow MCP integrations to declare a catalog family and group any family with multiple services in the integrations UI. | |
| @@ -0,0 +1,15 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +**Fix: add remote MCP servers that sit behind an authenticating proxy** | |
| 6 | + | |
| 7 | +The add-MCP form now carries an optional request headers editor. The name/value | |
| 8 | +pairs are sent on the connection check and on every later request, so an | |
| 9 | +endpoint gated by an edge authenticator — a Cloudflare Access service token, | |
| 10 | +for example — can be discovered and added. | |
| 11 | + | |
| 12 | +A `403` from such a gate is also no longer read as an unreachable server. It is | |
| 13 | +classified the same way a `401` is: the endpoint needs credentials, so the add | |
| 14 | +flow continues to the auth step instead of stopping on "Couldn't reach this | |
| 15 | +URL". | |
| @@ -0,0 +1,26 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +**Stdio MCP integrations can be edited from the UI** | |
| 6 | + | |
| 7 | +The integration Edit sheet showed stdio servers as read-only text and told you | |
| 8 | +to remove and recreate the integration to change its command. Fixing a typo in | |
| 9 | +an argument, moving a server to a new path, or adding a static environment | |
| 10 | +variable meant editing `executor.jsonc` by hand, or losing the integration's | |
| 11 | +connections and tool policies to a delete-and-re-add. | |
| 12 | + | |
| 13 | +The sheet now edits the command, its arguments, the working directory, and the | |
| 14 | +declared environment map, staged and applied by the sheet's own Save like the | |
| 15 | +remote editor beside it. Arguments use the same quote-aware parsing as the add | |
| 16 | +flow, so an argument containing spaces survives a round trip. | |
| 17 | + | |
| 18 | +The environment field edits the DECLARED static variables only. A stdio server | |
| 19 | +receives those plus a small fixed base set — it does not inherit executor's | |
| 20 | +environment — and secret values still belong to the connection, entered per | |
| 21 | +account against the server's declared `stdio_env` method. | |
| 22 | + | |
| 23 | +Saving revises the integration config, which is already enough to rebuild the | |
| 24 | +tool catalog: connections whose catalog predates the revision re-list on their | |
| 25 | +next read, so an edited command's tools are correct without an explicit | |
| 26 | +refresh. | |
| @@ -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 | + | |
| 7 | +The 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 | + | |
| 9 | +The 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 | + | |
| 11 | +The 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. | |
| @@ -0,0 +1,10 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Self-hosted instances can now allow additional browser origins without changing | |
| 6 | +their canonical public URL. Set `EXECUTOR_TRUSTED_ORIGINS` to a comma-separated | |
| 7 | +list of exact HTTP or HTTPS origins when one instance is intentionally reachable | |
| 8 | +through multiple hostnames or addresses. OAuth callbacks, MCP metadata, approval | |
| 9 | +links, and other generated URLs remain pinned to `EXECUTOR_WEB_BASE_URL`, and | |
| 10 | +origins are never inferred from request headers. | |
| @@ -0,0 +1,15 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +**Connect an integration from the sidebar** | |
| 6 | + | |
| 7 | +The sidebar lists your integrations on every console route, but connecting | |
| 8 | +another one meant navigating back to the integrations page to reach its Connect | |
| 9 | +action — the picker state was owned by that page, so the shared shell could | |
| 10 | +render the list without being able to open the flow behind it. | |
| 11 | + | |
| 12 | +The connect dialog now belongs to the shell. A labelled plus button sits beside | |
| 13 | +the sidebar's Integrations heading and opens the same picker, records the same | |
| 14 | +event, and leaves the current route in place behind it. On mobile the navigation | |
| 15 | +drawer closes first so the dialog gets the full viewport. | |
| @@ -0,0 +1,11 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +**Tools reads stop waiting on slow upstream servers** | |
| 6 | + | |
| 7 | +A tools read rebuilds every connection whose catalog has gone stale before answering. The rebuilds already ran concurrently, but the read still waited for all of them, so one slow or unreachable MCP server gated every catalog read behind its network timeout — a tools listing could take tens of seconds while healthy connections sat ready. | |
| 8 | + | |
| 9 | +A read now waits at most a short grace budget (2 seconds by default) for the rebuilds, then answers from the persisted catalog. The rebuilds keep running after the read returns and land on a later read, so the catalog still converges — it just no longer holds the reader hostage while it does. Overlapping reads share one in-flight rebuild per connection instead of stacking new ones. | |
| 10 | + | |
| 11 | +The budget is `toolsSyncGraceMs` on the SDK config. Pass `null` to restore the strict behavior, where a read blocks until every rebuild finishes and always reflects a fully converged catalog. | |
| @@ -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 | + | |
| 7 | +A build stamped with the placeholder 0.0.0 version always compared as older | |
| 8 | +than the latest release, and a prerelease on a channel with no matching | |
| 9 | +dist-tag (rc, alpha, and similar) always lost the comparison too. Both cases | |
| 10 | +now short-circuit to "no update available" before the check reaches the | |
| 11 | +registry. | |
| 12 | + | |
| 13 | +This applies wherever the update check runs, so the CLI check and the sidebar | |
| 14 | +update card both stop showing an update prompt that a user could never act on. | |
| @@ -0,0 +1,100 @@ | ||
| 1 | +name: Bug report | |
| 2 | +description: Something in Executor does not work as expected. | |
| 3 | +title: "[bug] " | |
| 4 | +labels: ["bug"] | |
| 5 | +body: | |
| 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 | |
| @@ -0,0 +1,51 @@ | ||
| 1 | +name: Feature request | |
| 2 | +description: Suggest a capability or an improvement for Executor. | |
| 3 | +title: "[feature] " | |
| 4 | +labels: ["enhancement"] | |
| 5 | +body: | |
| 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 | |
| @@ -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 | + | |
| 9 | +Fixes # | |
| 10 | + | |
| 11 | +## Verification | |
| 12 | + | |
| 13 | +<!-- | |
| 14 | +Say what you ran and what it showed. File paths alone are not evidence. | |
| 15 | +User-visible work needs a specific e2e run or a browsable dev instance, plus | |
| 16 | +the recording or trace that shows what to inspect. Add an e2e scenario when | |
| 17 | +none 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. | |
| @@ -32,7 +32,7 @@ · 31 unmodified lines | ||
| 32 | 32 | // seams module; the decorator is composed on top. |
| 33 | 33 | // --------------------------------------------------------------------------- |
| 34 | 34 | |
| 35 | −import { env } from "cloudflare:workers"; | |
| 35 | +import { env, waitUntil } from "cloudflare:workers"; | |
| 36 | 36 | import { Layer } from "effect"; |
| 37 | 37 | |
| 38 | 38 | import { |
| @@ -199,6 +199,10 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, ( · 160 unmodified lines | ||
| 199 | 199 | // user-selectable provider surface. |
| 200 | 200 | exposeCredentialProviders: false, |
| 201 | 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 | 206 | // Enterprise-managed authorization ships behind a PostHog flag. Cloud is the |
| 203 | 207 | // one host with a flag service, so cloud is the one host that installs a |
| 204 | 208 | // gate; everywhere else the seam stays empty and the profile is attempted as |
| @@ -262,15 +262,33 @@ function AuthGate() { · 261 unmodified lines | ||
| 262 | 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 | 285 | const activeSlug = auth.organization.slug; |
| 266 | 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` | |
| 268 | − // comes from the SSR auth-hint (the COOKIE's org), so a multi-org user viewing | |
| 269 | − // /<orgB> while their cookie still points at orgA would briefly render orgA's | |
| 270 | − // slug in the copyable URL before /account/me (URL-scoped) corrects it. The | |
| 271 | − // URL slug is the actual request scope and is correct on the very first paint, | |
| 272 | − // so sourcing it from there removes that flash. VALIDATED (pathnameOrgSlug, | |
| 273 | − // not the raw route param): the `{-$orgSlug}` param also captures reserved | |
| 287 | + // Source it from the URL, which is the actual request scope and is correct on | |
| 288 | + // the very first paint. The gate above has already made the two agree | |
| 289 | + // whenever the URL names a slug at all, so this is the same value stated in | |
| 290 | + // the terms the rest of the tree is keyed on. VALIDATED (pathnameOrgSlug, not | |
| 291 | + // the raw route param): the `{-$orgSlug}` param also captures reserved | |
| 274 | 292 | // console roots ("/integrations" → orgSlug "integrations"), which are not |
| 275 | 293 | // org scopes. Falls back to the auth org on a bare/reserved URL (which |
| 276 | 294 | // OrgSlugGate canonicalizes onto it below). |
| @@ -401,7 +401,8 @@ function EnterpriseContactDialog() { · 400 unmodified lines | ||
| 401 | 401 | Contact us |
| 402 | 402 | </Button> |
| 403 | 403 | </DialogTrigger> |
| 404 | − <DialogContent> | |
| 404 | + {/* Static contact details only: clicking away dismisses it. */} | |
| 405 | + <DialogContent dismissOnOutsideClick> | |
| 405 | 406 | <DialogHeader> |
| 406 | 407 | <DialogTitle>Talk to us about Enterprise</DialogTitle> |
| 407 | 408 | <DialogDescription> |
| @@ -449,7 +450,8 @@ function SlackContactCta() { · 41 unmodified lines | ||
| 449 | 450 | <span aria-hidden>→</span> |
| 450 | 451 | </Button> |
| 451 | 452 | </DialogTrigger> |
| 452 | − <DialogContent> | |
| 453 | + {/* Static contact details only: clicking away dismisses it. */} | |
| 454 | + <DialogContent dismissOnOutsideClick> | |
| 453 | 455 | <DialogHeader> |
| 454 | 456 | <DialogTitle>Get in touch on Slack</DialogTitle> |
| 455 | 457 | <DialogDescription> |
| @@ -44,7 +44,8 @@ export function SupportSlot() { · 43 unmodified lines | ||
| 44 | 44 | <HelpIcon className="size-3.5 text-muted-foreground" /> |
| 45 | 45 | Get support |
| 46 | 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 | 49 | <DialogHeader> |
| 49 | 50 | <DialogTitle className="font-display text-xl">Get support</DialogTitle> |
| 50 | 51 | <DialogDescription className="text-sm leading-relaxed"> |
| @@ -60,6 +60,7 @@ the container defaults. · 59 unmodified lines | ||
| 60 | 60 | | `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. | |
| 61 | 61 | | `EXECUTOR_DB_PATH` | `<data dir>/data.db` | SQLite database file. | |
| 62 | 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 | 64 | | `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. | |
| 64 | 65 | | `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. | |
| 65 | 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 | ||
| 91 | 92 | and `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` to create the admin without the browser |
| 92 | 93 | setup screen. |
| 93 | 94 | |
| 95 | +### Additional browser aliases | |
| 96 | + | |
| 97 | +Keep `EXECUTOR_WEB_BASE_URL` pinned to the canonical public HTTPS origin used for | |
| 98 | +OAuth callbacks and generated links. If the same instance is intentionally | |
| 99 | +available through additional browser addresses, allow those exact origins | |
| 100 | +separately: | |
| 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 | + | |
| 107 | +Only cookie-authenticated browser requests use this allowlist. OAuth callbacks, | |
| 108 | +MCP metadata, approval links, and other absolute URLs remain pinned to | |
| 109 | +`EXECUTOR_WEB_BASE_URL`. Origins are never inferred from request headers. | |
| 110 | + | |
| 94 | 111 | ## Connect an agent |
| 95 | 112 | |
| 96 | 113 | The server exposes a streamable-HTTP MCP endpoint at `/mcp`. Point your client at |
| @@ -8,6 +8,11 @@ · 7 unmodified lines | ||
| 8 | 8 | # rejected. Behind a reverse proxy / TLS, set this to your public https URL. |
| 9 | 9 | # 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 | 16 | # --- Session secret ----------------------------------------------------------- |
| 12 | 17 | # Generated and persisted under the data volume on first boot if unset. Set this |
| 13 | 18 | # to manage it yourself (must be at least 32 characters). Rotating it signs every |
| @@ -7,10 +7,16 @@ import { afterAll, expect, test } from "@effect/vitest"; · 6 unmodified lines | ||
| 7 | 7 | import { mintInviteCode } from "../testing/mint-invite"; |
| 8 | 8 | |
| 9 | 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. | |
| 12 | +process.env.NODE_ENV = "production"; | |
| 13 | +process.env.TEST = "false"; | |
| 10 | 14 | process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-auth-")); |
| 11 | 15 | process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv"; |
| 12 | 16 | process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; |
| 13 | 17 | process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123"; |
| 18 | +process.env.EXECUTOR_WEB_BASE_URL = "https://executor.example.com"; | |
| 19 | +process.env.EXECUTOR_TRUSTED_ORIGINS = "http://executor.home.arpa:4788"; | |
| 14 | 20 | |
| 15 | 21 | const { makeSelfHostApiHandler } = await import("../app"); |
| 16 | 22 | |
| @@ -19,6 +25,79 @@ afterAll(() => dispose()); · 2 unmodified lines | ||
| 19 | 25 | |
| 20 | 26 | const BASE = "http://localhost:4788"; |
| 21 | 27 | |
| 28 | +test("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 | + | |
| 53 | +test("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 | + | |
| 77 | +test("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 | + | |
| 22 | 101 | test("migrations create both the Better Auth and FumaDB executor schema regions", async () => { |
| 23 | 102 | // Open a SEPARATE libSQL connection to the same file Better Auth (via its own |
| 24 | 103 | // LibsqlDialect connection) and the FumaDB drizzle client wrote to. That this |
| @@ -25,6 +25,8 @@ interface SignupGate { · 24 unmodified lines | ||
| 25 | 25 | // creation (the seed, or a future admin "add user") flows through other paths. |
| 26 | 26 | const SIGNUP_PATH = "/sign-up/email"; |
| 27 | 27 | |
| 28 | +let warnedInsecureTrustedOrigin = false; | |
| 29 | + | |
| 28 | 30 | // --------------------------------------------------------------------------- |
| 29 | 31 | // Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor |
| 30 | 32 | // tables ("one connection, two schema regions"). |
| @@ -63,6 +65,27 @@ const SIGNUP_PATH = "/sign-up/email"; · 32 unmodified lines | ||
| 63 | 65 | |
| 64 | 66 | const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: SignupGate) => { |
| 65 | 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 | 89 | // Always resolved (generated + persisted when no env is set); this guards only |
| 67 | 90 | // an explicitly-set env secret that is too weak. |
| 68 | 91 | const secret = config.authSecret; |
| @@ -88,16 +111,17 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 19 unmodified lines | ||
| 88 | 111 | type: "sqlite" as const, |
| 89 | 112 | }, |
| 90 | 113 | secret, |
| 91 | − // The browser Origin must match this exactly; CLI/MCP bearer requests carry | |
| 92 | − // no Origin and are unaffected. `config.webBaseUrl` resolves from an explicit | |
| 93 | − // EXECUTOR_WEB_BASE_URL, else a platform-injected origin (Railway/Render/Fly/ | |
| 94 | − // …), else localhost — so a PaaS deploy is zero-config and any other host | |
| 95 | − // sets the one variable (a loud warning fires on the localhost fallback). | |
| 96 | − // See config.ts. We deliberately do NOT derive this from the request `Host`: | |
| 97 | − // matching the ecosystem (Windmill `BASE_URL`, n8n `WEBHOOK_URL`), a pinned | |
| 98 | − // origin keeps host-header injection out of OAuth redirects and links. | |
| 114 | + // The canonical browser Origin is config.webBaseUrl; explicitly configured | |
| 115 | + // aliases may also send cookie-authenticated requests. CLI/MCP bearer | |
| 116 | + // requests carry no Origin and are unaffected. We deliberately do NOT derive | |
| 117 | + // either value from the request `Host`: matching the ecosystem (Windmill | |
| 118 | + // `BASE_URL`, n8n `WEBHOOK_URL`), a pinned origin keeps host-header injection | |
| 119 | + // out of OAuth redirects and links. Additional trusted origins affect only | |
| 120 | + // Better Auth's request validation; generated links and OAuth callbacks stay | |
| 121 | + // pinned to config.webBaseUrl. | |
| 99 | 122 | baseURL: config.webBaseUrl, |
| 100 | − trustedOrigins: [config.webBaseUrl], | |
| 123 | + trustedOrigins: [...config.trustedOrigins], | |
| 124 | + advanced: { useSecureCookies: !hasInsecureTrustedOrigin }, | |
| 101 | 125 | emailAndPassword: { enabled: true }, |
| 102 | 126 | // `apiKey` issues long-lived personal keys (the API-keys page). With |
| 103 | 127 | // `enableSessionForAPIKeys`, presenting a key resolves to its owner's |
| @@ -16,6 +16,7 @@ test("originOf prefers Origin, then x-forwarded-host, then host", () => { · 15 unmodified lines | ||
| 16 | 16 | test("the help message names the URL to set", () => { |
| 17 | 17 | const msg = invalidOriginHelp("https://app.example.com", "http://localhost:4788"); |
| 18 | 18 | expect(msg).toContain("EXECUTOR_WEB_BASE_URL"); |
| 19 | + expect(msg).toContain("EXECUTOR_TRUSTED_ORIGINS"); | |
| 19 | 20 | expect(msg).toContain("https://app.example.com"); |
| 20 | 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 | 39 | const body = (await rewritten!.json()) as { code: string; message: string }; |
| 39 | 40 | expect(body.code).toBe("INVALID_ORIGIN"); |
| 40 | 41 | expect(body.message).toContain("EXECUTOR_WEB_BASE_URL"); |
| 42 | + expect(body.message).toContain("EXECUTOR_TRUSTED_ORIGINS"); | |
| 41 | 43 | expect(body.message).toContain("https://app.example.com"); |
| 42 | 44 | }); |
| 43 | 45 | |
| @@ -21,10 +21,11 @@ export const originOf = (request: Request): string | null => { · 20 unmodified lines | ||
| 21 | 21 | export const invalidOriginHelp = (requestOrigin: string | null, webBaseUrl: string): string => |
| 22 | 22 | requestOrigin |
| 23 | 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. ` + | |
| 25 | − `(Railway, Render, Fly, Vercel, and similar hosts are detected automatically; set it explicitly for a custom domain or other host.)` | |
| 24 | + `Set EXECUTOR_WEB_BASE_URL to ${requestOrigin} if it should be the canonical public URL, or add it to ` + | |
| 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 | 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 | 31 | * If `response` is Better Auth's 403 "Invalid origin", return a friendlier copy |
| @@ -20,6 +20,7 @@ process.env.EXECUTOR_DATA_DIR ??= mkdtempSync(join(tmpdir(), "origin-cfg-")); · 19 unmodified lines | ||
| 20 | 20 | // try/finally — these are the only env vars these cases read). |
| 21 | 21 | const PLATFORM_VARS = [ |
| 22 | 22 | "EXECUTOR_WEB_BASE_URL", |
| 23 | + "EXECUTOR_TRUSTED_ORIGINS", | |
| 23 | 24 | "RAILWAY_PUBLIC_DOMAIN", |
| 24 | 25 | "RENDER_EXTERNAL_URL", |
| 25 | 26 | "RENDER_EXTERNAL_HOSTNAME", |
| @@ -33,7 +34,9 @@ const resetOriginEnv = (): void => { · 7 unmodified lines | ||
| 33 | 34 | |
| 34 | 35 | test("webBaseUrl falls back to localhost with no public origin", () => { |
| 35 | 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 | |
| 39 | 42 | test("webBaseUrl auto-resolves from a platform host var (Railway, host only → https)", () => { |
| @@ -60,3 +63,52 @@ test("an explicit EXECUTOR_WEB_BASE_URL always wins over a platform var", () => · 20 unmodified lines | ||
| 60 | 63 | process.env.EXECUTOR_WEB_BASE_URL = "https://pinned.example.com"; |
| 61 | 64 | expect(loadConfig().webBaseUrl).toBe("https://pinned.example.com"); |
| 62 | 65 | }); |
| 66 | + | |
| 67 | +test("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 | + | |
| 79 | +test("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. | |
| 91 | +test.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. | |
| 107 | +test.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 | +); | |
| @@ -26,6 +26,8 @@ export interface SelfHostConfig { · 25 unmodified lines | ||
| 26 | 26 | readonly dbPath: string; |
| 27 | 27 | /** Public base URL used by core tools that build absolute links. */ |
| 28 | 28 | readonly webBaseUrl: string; |
| 29 | + /** Browser origins allowed to send cookie-authenticated requests. */ | |
| 30 | + readonly trustedOrigins: readonly string[]; | |
| 29 | 31 | /** |
| 30 | 32 | * Whether sandboxed code may reach loopback/private network addresses. |
| 31 | 33 | * Defaults to false — adversarial LLM code should not hit the host's |
| @@ -155,11 +157,13 @@ const resolveWebBaseUrl = (port: number): string => { · 123 unmodified lines | ||
| 155 | 157 | export const loadConfig = (): SelfHostConfig => { |
| 156 | 158 | const port = Number.parseInt(process.env.PORT ?? "4788", 10); |
| 157 | 159 | const dataDir = resolveDataDir(); |
| 160 | + const webBaseUrl = resolveWebBaseUrl(port); | |
| 158 | 161 | return { |
| 159 | 162 | host: process.env.EXECUTOR_HOST ?? "127.0.0.1", |
| 160 | 163 | port, |
| 161 | 164 | dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"), |
| 162 | − webBaseUrl: resolveWebBaseUrl(port), | |
| 165 | + webBaseUrl, | |
| 166 | + trustedOrigins: resolveTrustedOrigins(webBaseUrl), | |
| 163 | 167 | allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", |
| 164 | 168 | authSecret: resolveAuthSecret(), |
| 165 | 169 | bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, |
| @@ -206,6 +210,57 @@ const resolveMcpSessionIdleTtlMs = (): number | undefined => { · 40 unmodified lines | ||
| 206 | 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. | |
| 227 | +const 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. | |
| 255 | +const 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 | 264 | // The org slug doubles as a URL segment (`/<slug>/policies`), so an |
| 210 | 265 | // operator-set value must fit the shared grammar and avoid reserved root |
| 211 | 266 | // segments (api, mcp, login, …) — a colliding slug would shadow real routes. |
| @@ -5,7 +5,11 @@ · 4 unmodified lines | ||
| 5 | 5 | // renamed. |
| 6 | 6 | // --------------------------------------------------------------------------- |
| 7 | 7 | |
| 8 | −import { sqliteDataMigration, type SqliteDataMigration } from "@executor-js/sdk"; | |
| 8 | +import { | |
| 9 | + bigintStorageClassSqliteMigration, | |
| 10 | + sqliteDataMigration, | |
| 11 | + type SqliteDataMigration, | |
| 12 | +} from "@executor-js/sdk"; | |
| 9 | 13 | import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth"; |
| 10 | 14 | import { |
| 11 | 15 | openApiNdjsonOutputDataMigration, |
| @@ -20,6 +24,11 @@ import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-en · 8 unmodified lines | ||
| 20 | 24 | import { authConfigTransforms } from "./auth-config-migration"; |
| 21 | 25 | |
| 22 | 26 | export 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 | 32 | // Rewrite pre-canonical integration auth configs into the shared |
| 24 | 33 | // placements model. |
| 25 | 34 | sqliteDataMigration("2026-06-05-auth-config-placements", (client) => |
| @@ -7,6 +7,7 @@ · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | import { |
| 9 | 9 | Effect, |
| 10 | + bigintStorageClassSqliteMigration, | |
| 10 | 11 | oauthClientGcSqliteMigration, |
| 11 | 12 | sqliteDataMigration, |
| 12 | 13 | type SqliteDataMigration, |
| @@ -30,6 +31,11 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 17 unmodified lines | ||
| 30 | 31 | // stamped atomically inside the staged v2 build; fresh/pre-v2-native DBs get |
| 31 | 32 | // the same stamp here so future boots skip legacy shape probing. |
| 32 | 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 | 39 | // Rewrite pre-canonical integration auth configs (incl. v1→v2 outputs) |
| 34 | 40 | // into the shared placements model. |
| 35 | 41 | sqliteDataMigration("2026-06-05-auth-config-placements", (client) => |
| @@ -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 | + | |
| 12 | +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; | |
| 13 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 14 | +import { tmpdir } from "node:os"; | |
| 15 | +import { join } from "node:path"; | |
| 16 | +import { Effect } from "effect"; | |
| 17 | +import { withQueryContext } from "@executor-js/fumadb/query"; | |
| 18 | + | |
| 19 | +import { collectTables } from "@executor-js/api/server"; | |
| 20 | +import { runSqliteDataMigrations } from "@executor-js/sdk"; | |
| 21 | + | |
| 22 | +import { localDataMigrations } from "./data-migrations"; | |
| 23 | +import { createSqliteFumaDb } from "./sqlite-fumadb"; | |
| 24 | + | |
| 25 | +const TENANT = "executor-workspace-1771"; | |
| 26 | +const SUBJECT = "local"; | |
| 27 | +// Epoch millis, the shape an OAuth token expiry takes. | |
| 28 | +const LEGACY_EXPIRES_AT = 1787321623456; | |
| 29 | + | |
| 30 | +let workDir: string; | |
| 31 | + | |
| 32 | +beforeEach(() => { | |
| 33 | + workDir = mkdtempSync(join(tmpdir(), "executor-legacy-bigint-")); | |
| 34 | +}); | |
| 35 | + | |
| 36 | +afterEach(() => { | |
| 37 | + rmSync(workDir, { recursive: true, force: true }); | |
| 38 | +}); | |
| 39 | + | |
| 40 | +const 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. */ | |
| 49 | +const 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 | + | |
| 68 | +describe("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 | +}); | |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { readFileSync } from "node:fs"; |
| 2 | −import { resolve } from "node:path"; | |
| 2 | +import { createRequire } from "node:module"; | |
| 3 | +import { dirname, resolve } from "node:path"; | |
| 3 | 4 | import { Readable } from "node:stream"; |
| 4 | 5 | import { fileURLToPath } from "node:url"; |
| 5 | 6 | import { defineConfig, type Plugin } from "vite"; |
| @@ -42,6 +43,19 @@ const EXECUTOR_GITHUB_URL = ( · 36 unmodified lines | ||
| 42 | 43 | const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); |
| 43 | 44 | const 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. | |
| 53 | +const JS_YAML_DIR = dirname( | |
| 54 | + createRequire(fileURLToPath(new URL("../../packages/plugins/openapi/", import.meta.url))).resolve( | |
| 55 | + "js-yaml", | |
| 56 | + ), | |
| 57 | +); | |
| 58 | + | |
| 45 | 59 | const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response => |
| 46 | 60 | new Response( |
| 47 | 61 | JSON.stringify( |
| @@ -215,6 +229,25 @@ export default defineConfig({ · 167 unmodified lines | ||
| 215 | 229 | outDir: resolve(import.meta.dirname, "dist"), |
| 216 | 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 | 251 | define: { |
| 219 | 252 | "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), |
| 220 | 253 | // 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 | 263 | resolve: { |
| 231 | 264 | tsconfigPaths: true, |
| 265 | + alias: { "js-yaml": JS_YAML_DIR }, | |
| 232 | 266 | }, |
| 233 | 267 | server: { |
| 234 | 268 | port: parseInt(process.env.PORT ?? "5173", 10), |
| @@ -999,6 +999,7 @@ · 998 unmodified lines | ||
| 999 | 999 | "@modelcontextprotocol/client": "2.0.0", |
| 1000 | 1000 | "@modelcontextprotocol/core": "2.0.0", |
| 1001 | 1001 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 1002 | + "lucide-react": "^1.7.0", | |
| 1002 | 1003 | "zod": "4.3.6", |
| 1003 | 1004 | }, |
| 1004 | 1005 | "devDependencies": { |
| @@ -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. | |
| 16 | +import { fileURLToPath } from "node:url"; | |
| 17 | + | |
| 18 | +import { expect } from "@effect/vitest"; | |
| 19 | +import { Effect } from "effect"; | |
| 20 | +import { HttpApiClient } from "effect/unstable/httpapi"; | |
| 21 | +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 22 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 23 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 24 | + | |
| 25 | +import { scenario } from "../src/scenario"; | |
| 26 | +import { Browser, Cli, RunDir, Target } from "../src/services"; | |
| 27 | +import { withLocalServer } from "./local-server"; | |
| 28 | + | |
| 29 | +const api = composePluginApi([mcpHttpPlugin()] as const); | |
| 30 | + | |
| 31 | +const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url)); | |
| 32 | + | |
| 33 | +const SECRET = "s3cr3t-typed-into-the-edit-sheet"; | |
| 34 | + | |
| 35 | +scenario( | |
| 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 | +); | |
| @@ -222,6 +222,48 @@ scenario( · 221 unmodified lines | ||
| 222 | 222 | autoTools.map((t) => t.name), |
| 223 | 223 | "auto negotiation falls back to legacy and still discovers tools", |
| 224 | 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 | 268 | { env: DAEMON_ENV }, |
| 227 | 269 | ); |
| @@ -8,6 +8,11 @@ import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; · 7 unmodified lines | ||
| 8 | 8 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; |
| 9 | 9 | import { variable } from "@executor-js/sdk/http-auth"; |
| 10 | 10 | |
| 11 | +import { | |
| 12 | + makeGreetingGraphqlSchema, | |
| 13 | + serveGraphqlTestServer, | |
| 14 | +} from "@executor-js/plugin-graphql/testing"; | |
| 15 | + | |
| 11 | 16 | import { scenario } from "../src/scenario"; |
| 12 | 17 | import { Api, Browser, Target } from "../src/services"; |
| 13 | 18 | import { visit } from "../src/surfaces/browser"; |
| @@ -144,3 +149,60 @@ scenario( · 130 unmodified lines | ||
| 144 | 149 | }), |
| 145 | 150 | ), |
| 146 | 151 | ); |
| 152 | + | |
| 153 | +scenario( | |
| 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 | +); | |
| @@ -0,0 +1,69 @@ | ||
| 1 | +import { randomBytes } from "node:crypto"; | |
| 2 | + | |
| 3 | +import { expect } from "@effect/vitest"; | |
| 4 | +import { Effect } from "effect"; | |
| 5 | +import type { HttpApiClient } from "effect/unstable/httpapi"; | |
| 6 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 7 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 8 | +import { IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 9 | + | |
| 10 | +import { scenario } from "../src/scenario"; | |
| 11 | +import { Api, Browser, Target } from "../src/services"; | |
| 12 | + | |
| 13 | +const api = composePluginApi([mcpHttpPlugin()] as const); | |
| 14 | +type Client = HttpApiClient.ForApi<typeof api>; | |
| 15 | + | |
| 16 | +scenario( | |
| 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 | +); | |
| @@ -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. | |
| 6 | +import { Effect } from "effect"; | |
| 7 | + | |
| 8 | +import { scenario } from "../src/scenario"; | |
| 9 | +import { Browser, Target } from "../src/services"; | |
| 10 | + | |
| 11 | +const urlField = "https://mcp.example.com"; | |
| 12 | +const retry = "Try again"; | |
| 13 | + | |
| 14 | +scenario( | |
| 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 | +); | |
| @@ -5,9 +5,10 @@ · 4 unmodified lines | ||
| 5 | 5 | // |
| 6 | 6 | // - /account/me advertises the org's URL slug (valid grammar) |
| 7 | 7 | // - a bare deep link (/policies) canonicalizes to /<slug>/policies |
| 8 | −// - an unknown slug (/zz-no-such-org/policies) is a wrong address — a | |
| 9 | −// not-found page, never a silent redirect into a workspace the URL | |
| 10 | −// didn't name | |
| 8 | +// - an unknown slug (/zz-no-such-org/policies) is a wrong address on a | |
| 9 | +// multi-tenant host — a not-found page with the console shell never built | |
| 10 | +// 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 | 12 | // - in-shell navigation keeps the slug prefix on every link |
| 12 | 13 | // |
| 13 | 14 | // 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 | ||
| 20 | 21 | import { Api, Browser, Target } from "../src/services"; |
| 21 | 22 | import { visit } from "../src/surfaces/browser"; |
| 22 | 23 | |
| 24 | +declare 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 | + | |
| 23 | 32 | scenario( |
| 24 | 33 | "Org URLs · console paths carry the organization slug", |
| 25 | 34 | {}, |
| @@ -46,17 +55,54 @@ scenario( · 20 unmodified lines | ||
| 46 | 55 | await page.getByText("Policies").first().waitFor(); |
| 47 | 56 | }); |
| 48 | 57 | |
| 49 | − // The "unknown slug is a 404" contract is multi-tenant only. Selfhost is | |
| 50 | − // single-tenant: /account/me always returns the instance org regardless of | |
| 51 | − // the URL segment, so the slug is cosmetic and an unknown one canonicalizes | |
| 52 | − // onto the shell rather than 404ing. Cloud enforces the not-found; selfhost | |
| 53 | − // legitimately does not. | |
| 54 | − if (!target.name.startsWith("selfhost")) { | |
| 55 | − await step("An unknown org slug is a wrong address, not a redirect", async () => { | |
| 56 | − await visit(page, "/zz-no-such-org/policies"); | |
| 58 | + // What an unknown slug MEANS depends on tenancy, so both answers are | |
| 59 | + // pinned rather than one being skipped: | |
| 60 | + // | |
| 61 | + // multi-tenant (cloud) — the slug is a real request scope. /account/me | |
| 62 | + // returns no organization for one this session cannot see, so the | |
| 63 | + // address is simply wrong: a not-found page, the URL left exactly as | |
| 64 | + // typed, and no console shell built at any point along the way. | |
| 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 | 93 | await page.getByText("Page not found").waitFor({ timeout: 30_000 }); |
| 58 | − }); | |
| 59 | − } | |
| 94 | + } | |
| 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 | 107 | await step("In-shell navigation keeps the slug prefix", async () => { |
| 62 | 108 | await visit(page, `/${slug}`); |
| @@ -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). | |
| 7 | +import { randomBytes } from "node:crypto"; | |
| 8 | + | |
| 9 | +import { expect } from "@effect/vitest"; | |
| 10 | +import { Effect } from "effect"; | |
| 11 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 12 | + | |
| 13 | +import { scenario } from "../src/scenario"; | |
| 14 | +import { Api, Browser, Target } from "../src/services"; | |
| 15 | +import { visit } from "../src/surfaces/browser"; | |
| 16 | + | |
| 17 | +const coreApi = composePluginApi([] as const); | |
| 18 | + | |
| 19 | +scenario( | |
| 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 | +); | |
| @@ -14,10 +14,12 @@ scenario( · 13 unmodified lines | ||
| 14 | 14 | const identity = yield* target.newIdentity(); |
| 15 | 15 | |
| 16 | 16 | yield* browser.session(identity, async ({ page, step }) => { |
| 17 | − await step("Open the integrations picker", async () => { | |
| 18 | − await visit(page, "/integrations"); | |
| 17 | + await step("Open the integrations picker from the sidebar", async () => { | |
| 18 | + await visit(page, "/policies"); | |
| 19 | 19 | await clickToReveal( |
| 20 | − page.getByRole("button", { name: "Connect" }), | |
| 20 | + page | |
| 21 | + .getByRole("navigation") | |
| 22 | + .getByRole("button", { name: "Browse integrations", exact: true }), | |
| 21 | 23 | page.getByRole("dialog", { name: "Connect an integration" }), |
| 22 | 24 | ); |
| 23 | 25 | }); |
| @@ -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. | |
| 10 | +import { expect } from "@effect/vitest"; | |
| 11 | +import { Effect } from "effect"; | |
| 12 | + | |
| 13 | +import { scenario } from "../src/scenario"; | |
| 14 | +import { Mcp, Target } from "../src/services"; | |
| 15 | + | |
| 16 | +interface 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. | |
| 23 | +const EXECUTE_CODE = ` | |
| 24 | +const attachment = { | |
| 25 | + _tag: "ToolFile", | |
| 26 | + name: "report.txt", | |
| 27 | + mimeType: "text/plain", | |
| 28 | + encoding: "base64", | |
| 29 | + data: "aGVsbG8gZmlsZQ==", | |
| 30 | + byteLength: 10, | |
| 31 | +}; | |
| 32 | +emit("plain string emit"); | |
| 33 | +emit(attachment); | |
| 34 | +emit({ hello: "object emit" }); | |
| 35 | +return { returnedValue: "this is the return" }; | |
| 36 | +`; | |
| 37 | + | |
| 38 | +scenario( | |
| 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 | +); | |
| @@ -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. | |
| 14 | +import { randomBytes } from "node:crypto"; | |
| 15 | + | |
| 16 | +import { expect } from "@effect/vitest"; | |
| 17 | +import { Effect, Ref } from "effect"; | |
| 18 | +import { HttpServerResponse } from "effect/unstable/http"; | |
| 19 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 20 | +import { deriveMcpNamespace } from "@executor-js/plugin-mcp"; | |
| 21 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 22 | +import { IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 23 | +import { serveTestHttpApp } from "@executor-js/sdk/testing"; | |
| 24 | + | |
| 25 | +import { scenario } from "../src/scenario"; | |
| 26 | +import { Api, Browser, Target } from "../src/services"; | |
| 27 | +import { visit } from "../src/surfaces/browser"; | |
| 28 | + | |
| 29 | +const api = composePluginApi([mcpHttpPlugin()] as const); | |
| 30 | + | |
| 31 | +const CLIENT_ID_HEADER = "CF-Access-Client-Id"; | |
| 32 | +const CLIENT_SECRET_HEADER = "CF-Access-Client-Secret"; | |
| 33 | +const CLIENT_ID = "e2e-service-token-id"; | |
| 34 | +const CLIENT_SECRET = "e2e-service-token-secret"; | |
| 35 | + | |
| 36 | +scenario( | |
| 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 | +); | |
| @@ -3,7 +3,10 @@ export { · 2 unmodified lines | ||
| 3 | 3 | checkForUpdate, |
| 4 | 4 | resolveDistTags, |
| 5 | 5 | resolveUpdateChannel, |
| 6 | + resolveComparisonChannel, | |
| 6 | 7 | compareVersions, |
| 8 | + isUnstampedVersion, | |
| 9 | + isUpdateAvailable, | |
| 7 | 10 | EXECUTOR_PACKAGE_NAME, |
| 8 | 11 | type UpdateStatus, |
| 9 | 12 | type UpdateChannel, |
| @@ -123,6 +123,15 @@ export interface HostConfigShape { · 122 unmodified lines | ||
| 123 | 123 | * operator knob. |
| 124 | 124 | */ |
| 125 | 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 | |
| 128 | 137 | export class HostConfig extends Context.Service<HostConfig, HostConfigShape>()( |
| @@ -305,6 +314,7 @@ export const makeScopedExecutor = < · 176 unmodified lines | ||
| 305 | 314 | fetch: hostedFetch, |
| 306 | 315 | onIntegrationChange: config.onIntegrationChange, |
| 307 | 316 | ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}), |
| 317 | + ...(config.waitUntil !== undefined ? { waitUntil: config.waitUntil } : {}), | |
| 308 | 318 | onElicitation: "accept-all", |
| 309 | 319 | redirectUri, |
| 310 | 320 | oauthCallbackStateOrgSlug: orgSlug, |
| @@ -10,6 +10,9 @@ import { · 9 unmodified lines | ||
| 10 | 10 | __resetDistTagsCache, |
| 11 | 11 | checkForUpdate, |
| 12 | 12 | compareVersions, |
| 13 | + isUnstampedVersion, | |
| 14 | + isUpdateAvailable, | |
| 15 | + resolveComparisonChannel, | |
| 13 | 16 | resolveDistTags, |
| 14 | 17 | resolveUpdateChannel, |
| 15 | 18 | } from "./update-check"; |
| @@ -53,6 +56,73 @@ describe("resolveUpdateChannel", () => { · 37 unmodified lines | ||
| 53 | 56 | }); |
| 54 | 57 | }); |
| 55 | 58 | |
| 59 | +describe("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 | + | |
| 75 | +describe("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 | + | |
| 102 | +describe("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 | + | |
| 56 | 126 | describe("resolveDistTags", () => { |
| 57 | 127 | it("returns nothing when the check is disabled", async () => { |
| 58 | 128 | const tags = await resolveDistTags({ |
| @@ -126,6 +196,14 @@ describe("checkForUpdate", () => { · 67 unmodified lines | ||
| 126 | 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 | 207 | it("compares a beta build against the beta tag", async () => { |
| 130 | 208 | const status = await checkForUpdate("1.6.0-beta.1", { |
| 131 | 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 | 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 | 221 | const status = await checkForUpdate("0.0.0-dev", { |
| 141 | 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 | }); |
| @@ -102,6 +102,53 @@ export const compareVersions = (left: string, right: string): number | null => { · 101 unmodified lines | ||
| 102 | 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 | + */ | |
| 111 | +export 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 | + */ | |
| 128 | +export 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 | + */ | |
| 142 | +export 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 | 152 | // ── dist-tags resolution ────────────────────────────────────────────────── |
| 106 | 153 | |
| 107 | 154 | export type DistTags = Partial<Record<UpdateChannel, string>>; |
| @@ -223,9 +270,17 @@ export const checkForUpdate = async ( · 115 unmodified lines | ||
| 223 | 270 | ): Promise<UpdateStatus> => { |
| 224 | 271 | const channel = resolveUpdateChannel(currentVersion); |
| 225 | 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 | 282 | const tags = await resolveDistTags(options); |
| 227 | − const latestVersion = tags[channel] ?? null; | |
| 228 | − const updateAvailable = | |
| 229 | − latestVersion !== null && compareVersions(currentVersion, latestVersion) === -1; | |
| 283 | + const latestVersion = tags[comparisonChannel] ?? null; | |
| 284 | + const updateAvailable = isUpdateAvailable(currentVersion, latestVersion); | |
| 230 | 285 | return { updateAvailable, currentVersion, latestVersion, channel, command }; |
| 231 | 286 | }; |
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | ||
| 2 | 2 | Deferred, |
| 3 | 3 | Duration, |
| 4 | 4 | Effect, |
| 5 | + Fiber, | |
| 5 | 6 | Inspectable, |
| 6 | 7 | Layer, |
| 7 | 8 | Option, |
| @@ -696,6 +697,23 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 688 unmodified lines | ||
| 696 | 697 | * config-revision re-sync still apply). |
| 697 | 698 | */ |
| 698 | 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 | 718 | * Notified after a durable integration-catalog change commits (a row |
| 701 | 719 | * 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 | 747 | * `ExecutorConfig.toolsSyncTtlMs`). */ |
| 730 | 748 | export 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. */ | |
| 755 | +export const DEFAULT_TOOLS_SYNC_GRACE_MS = 2000; | |
| 756 | + | |
| 732 | 757 | /** How many stale connection catalogs are DISCOVERED at once on a tools read. |
| 733 | 758 | * Bounded so a host with a large stale set cannot open an unbounded number of |
| 734 | 759 | * 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 | 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 | 4283 | const toolsList = (filter?: ToolListFilter): Effect.Effect<readonly Tool[], StorageFailure> => |
| 4223 | 4284 | Effect.gen(function* () { |
| 4224 | − yield* syncStaleConnectionTools; | |
| 4285 | + if (toolsSyncGraceMs === null) { | |
| 4286 | + yield* syncStaleConnectionTools; | |
| 4287 | + } else { | |
| 4288 | + yield* awaitStaleSyncWithinGrace(toolsSyncGraceMs); | |
| 4289 | + } | |
| 4225 | 4290 | // Projected: the list surface is metadata (address, description, |
| 4226 | 4291 | // annotations) — loading every tool's input/output schema JSON made |
| 4227 | 4292 | // an unbounded list scale with schema bytes, not tool count. |
| @@ -425,6 +425,7 @@ export { · 424 unmodified lines | ||
| 425 | 425 | type ExecutorDbFactory, |
| 426 | 426 | type ExecutorDbInput, |
| 427 | 427 | type ParsedToolAddress, |
| 428 | + DEFAULT_TOOLS_SYNC_GRACE_MS, | |
| 428 | 429 | STALE_TOOLS_SYNC_CONCURRENCY, |
| 429 | 430 | createExecutor, |
| 430 | 431 | collectTables, |
| @@ -488,6 +489,14 @@ export { · 57 unmodified lines | ||
| 488 | 489 | oauthClientGcSqliteMigration, |
| 489 | 490 | runSqliteOAuthClientGcMigration, |
| 490 | 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). | |
| 494 | +export { | |
| 495 | + bigintStorageClassSqliteMigration, | |
| 496 | + runSqliteBigintStorageClassMigration, | |
| 497 | + LEGACY_BIGINT_STORAGE_CLASS_COLUMNS, | |
| 498 | + type BigintStorageClassColumn, | |
| 499 | +} from "./sqlite-bigint-storage-class-migration"; | |
| 491 | 500 | export { |
| 492 | 501 | authToolFailure, |
| 493 | 502 | isUnauthorizedToolFailure, |
| @@ -0,0 +1,202 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect } from "effect"; | |
| 3 | +import { withQueryContext } from "@executor-js/fumadb/query"; | |
| 4 | + | |
| 5 | +import { collectTables } from "./executor"; | |
| 6 | +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; | |
| 7 | +import { | |
| 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 | + | |
| 25 | +const TENANT = "t1"; | |
| 26 | +const SUBJECT = "user_a"; | |
| 27 | +const LEGACY_EXPIRES_AT = 1787321623456; | |
| 28 | +const HEALTHY_EXPIRES_AT = 1787321699999; | |
| 29 | + | |
| 30 | +const 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 | + | |
| 39 | +const 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. */ | |
| 47 | +const 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. */ | |
| 68 | +const legacyInteger = String(LEGACY_EXPIRES_AT); | |
| 69 | +/** The current shape: the decimal digits as bytes. */ | |
| 70 | +const currentBlob = `CAST('${HEALTHY_EXPIRES_AT}' AS BLOB)`; | |
| 71 | + | |
| 72 | +const 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 | + | |
| 80 | +describe("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 | +}); | |
| @@ -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 | + | |
| 30 | +import { Effect } from "effect"; | |
| 31 | + | |
| 32 | +import { coreSchema } from "./core-schema"; | |
| 33 | +import { | |
| 34 | + DataMigrationError, | |
| 35 | + type SqliteDataMigration, | |
| 36 | + type SqliteDataMigrationClient, | |
| 37 | +} from "./sqlite-data-migrations"; | |
| 38 | + | |
| 39 | +const MIGRATION_NAME = "2026-08-28-bigint-storage-class"; | |
| 40 | + | |
| 41 | +export 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 | + */ | |
| 55 | +export 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 | + | |
| 62 | +const 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. */ | |
| 74 | +const 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 | + | |
| 84 | +const 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 | + */ | |
| 111 | +export 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. */ | |
| 163 | +export const bigintStorageClassSqliteMigration: SqliteDataMigration = { | |
| 164 | + name: MIGRATION_NAME, | |
| 165 | + run: (client) => runSqliteBigintStorageClassMigration(client).pipe(Effect.asVoid), | |
| 166 | +}; | |
| @@ -729,8 +729,12 @@ describe("graphqlPlugin real protocol server", () => { · 728 unmodified lines | ||
| 729 | 729 | expect(result).toMatchObject({ |
| 730 | 730 | status: "healthy", |
| 731 | 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 | |
| @@ -737,16 +737,16 @@ const checkGraphqlHealth = (input: { · 736 unmodified lines | ||
| 737 | 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 | 743 | const queryType = result.introspection.__schema.queryType?.name; |
| 741 | 744 | return { |
| 742 | 745 | status: "healthy", |
| 743 | 746 | httpStatus: 200, |
| 744 | 747 | checkedAt, |
| 745 | 748 | ...(queryType != null |
| 746 | − ? { | |
| 747 | − identity: `GraphQL schema: ${queryType}`, | |
| 748 | − responseSample: [{ path: "__schema.queryType.name", value: queryType }], | |
| 749 | − } | |
| 749 | + ? { responseSample: [{ path: "__schema.queryType.name", value: queryType }] } | |
| 750 | 750 | : {}), |
| 751 | 751 | } satisfies HealthCheckResult; |
| 752 | 752 | }); |
| @@ -69,6 +69,7 @@ · 68 unmodified lines | ||
| 69 | 69 | "@modelcontextprotocol/client": "2.0.0", |
| 70 | 70 | "@modelcontextprotocol/core": "2.0.0", |
| 71 | 71 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 72 | + "lucide-react": "^1.7.0", | |
| 72 | 73 | "zod": "4.3.6" |
| 73 | 74 | }, |
| 74 | 75 | "devDependencies": { |
| @@ -31,6 +31,7 @@ const StringMap = Schema.Record(Schema.String, Schema.String); · 30 unmodified lines | ||
| 31 | 31 | const AddRemoteServerPayload = Schema.Struct({ |
| 32 | 32 | transport: Schema.optional(Schema.Literal("remote")), |
| 33 | 33 | name: Schema.String, |
| 34 | + family: Schema.optional(Schema.String), | |
| 34 | 35 | /** Agent-visible catalog description. Defaults to the display name. */ |
| 35 | 36 | description: Schema.optional(Schema.String), |
| 36 | 37 | endpoint: Schema.String, |
| @@ -48,6 +49,7 @@ const AddRemoteServerPayload = Schema.Struct({ · 11 unmodified lines | ||
| 48 | 49 | const AddStdioServerPayload = Schema.Struct({ |
| 49 | 50 | transport: Schema.Literal("stdio"), |
| 50 | 51 | name: Schema.String, |
| 52 | + family: Schema.optional(Schema.String), | |
| 51 | 53 | description: Schema.optional(Schema.String), |
| 52 | 54 | command: Schema.String, |
| 53 | 55 | args: Schema.optional(Schema.Array(Schema.String)), |
| @@ -33,6 +33,7 @@ const toServerInput = ( · 32 unmodified lines | ||
| 33 | 33 | const p = payload as { |
| 34 | 34 | transport: "stdio"; |
| 35 | 35 | name: string; |
| 36 | + family?: string; | |
| 36 | 37 | description?: string; |
| 37 | 38 | command: string; |
| 38 | 39 | args?: readonly string[]; |
| @@ -45,6 +46,7 @@ const toServerInput = ( · 6 unmodified lines | ||
| 45 | 46 | return { |
| 46 | 47 | transport: "stdio", |
| 47 | 48 | name: p.name, |
| 49 | + family: p.family, | |
| 48 | 50 | description: p.description, |
| 49 | 51 | command: p.command, |
| 50 | 52 | args: p.args ? [...p.args] : undefined, |
| @@ -59,6 +61,7 @@ const toServerInput = ( · 8 unmodified lines | ||
| 59 | 61 | const p = payload as { |
| 60 | 62 | transport?: "remote"; |
| 61 | 63 | name: string; |
| 64 | + family?: string; | |
| 62 | 65 | description?: string; |
| 63 | 66 | endpoint: string; |
| 64 | 67 | remoteTransport?: "streamable-http" | "sse" | "auto"; |
| @@ -74,6 +77,7 @@ const toServerInput = ( · 9 unmodified lines | ||
| 74 | 77 | return { |
| 75 | 78 | transport: "remote", |
| 76 | 79 | name: p.name, |
| 80 | + family: p.family, | |
| 77 | 81 | description: p.description, |
| 78 | 82 | endpoint: p.endpoint, |
| 79 | 83 | remoteTransport: p.remoteTransport, |
| @@ -38,7 +38,11 @@ import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; · 37 unmodified lines | ||
| 38 | 38 | import type { McpAuthMethodInput } from "../sdk/types"; |
| 39 | 39 | import { probeMcpEndpoint, addMcpServer } from "./atoms"; |
| 40 | 40 | import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; |
| 41 | +import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; | |
| 42 | +import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; | |
| 41 | 43 | import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; |
| 44 | +import { parseStdioArgs } from "./stdio-fields"; | |
| 45 | +import { isProbableMcpEndpoint } from "./probe-url"; | |
| 42 | 46 | import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; |
| 43 | 47 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 44 | 48 | |
| @@ -59,19 +63,6 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 14 unmodified lines | ||
| 59 | 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. | |
| 64 | −function 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 | 67 | // State machine (remote flow) |
| 77 | 68 | // --------------------------------------------------------------------------- |
| @@ -208,6 +199,12 @@ export default function AddMcpIntegration(props: { · 130 unmodified lines | ||
| 208 | 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 | 208 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); |
| 212 | 209 | const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" }); |
| 213 | 210 | |
| @@ -270,11 +267,23 @@ export default function AddMcpIntegration(props: { · 56 unmodified lines | ||
| 270 | 267 | |
| 271 | 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 | 280 | const handleProbe = useCallback(async () => { |
| 281 | + const run = (probeRunRef.current += 1); | |
| 274 | 282 | dispatch({ type: "probe-start" }); |
| 275 | 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 | 287 | if (Exit.isFailure(exit)) { |
| 279 | 288 | dispatch({ |
| 280 | 289 | type: "probe-fail", |
| @@ -283,7 +292,7 @@ export default function AddMcpIntegration(props: { · 2 unmodified lines | ||
| 283 | 292 | return; |
| 284 | 293 | } |
| 285 | 294 | dispatch({ type: "probe-ok", probe: exit.value }); |
| 286 | − }, [state.url, doProbe]); | |
| 295 | + }, [state.url, headers, doProbe]); | |
| 287 | 296 | |
| 288 | 297 | // Keep the latest handleProbe in a ref so the debounced effect can call it |
| 289 | 298 | // without depending on its identity (which changes every render). |
| @@ -291,12 +300,14 @@ export default function AddMcpIntegration(props: { · 1 unmodified line | ||
| 291 | 300 | handleProbeRef.current = handleProbe; |
| 292 | 301 | |
| 293 | 302 | // 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 | 307 | useEffect(() => { |
| 296 | 308 | if (transport !== "remote") return; |
| 297 | 309 | if (state.step !== "url") return; |
| 298 | − const trimmed = state.url.trim(); | |
| 299 | − if (!trimmed) return; | |
| 310 | + if (!isProbableMcpEndpoint(state.url)) return; | |
| 300 | 311 | const handle = setTimeout(() => { |
| 301 | 312 | handleProbeRef.current(); |
| 302 | 313 | }, 400); |
| @@ -318,6 +329,7 @@ export default function AddMcpIntegration(props: { · 15 unmodified lines | ||
| 318 | 329 | : {}), |
| 319 | 330 | endpoint: state.url.trim(), |
| 320 | 331 | ...(slug ? { slug } : {}), |
| 332 | + ...(headers ? { headers } : {}), | |
| 321 | 333 | authenticationTemplate, |
| 322 | 334 | }, |
| 323 | 335 | reactivityKeys: integrationWriteKeys, |
| @@ -331,7 +343,7 @@ export default function AddMcpIntegration(props: { · 7 unmodified lines | ||
| 331 | 343 | } |
| 332 | 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 | 349 | const handleAddRemote = useCallback(async () => { |
| @@ -446,6 +458,16 @@ export default function AddMcpIntegration(props: { · 108 unmodified lines | ||
| 446 | 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 | 471 | {/* Authentication — declares the auth methods to register through the |
| 450 | 472 | shared list editor. The credentials themselves (API key value / |
| 451 | 473 | OAuth sign-in) are added from the integration's detail hub after |
| @@ -13,10 +13,16 @@ import { · 12 unmodified lines | ||
| 13 | 13 | type AuthMethodRow, |
| 14 | 14 | type AuthMethodSeed, |
| 15 | 15 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 16 | −import { Badge } from "@executor-js/react/components/badge"; | |
| 17 | −import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; | |
| 16 | +import { | |
| 17 | + CardStack, | |
| 18 | + CardStackContent, | |
| 19 | + CardStackEntryField, | |
| 20 | +} from "@executor-js/react/components/card-stack"; | |
| 21 | +import { Input } from "@executor-js/react/components/input"; | |
| 22 | +import { Textarea } from "@executor-js/react/components/textarea"; | |
| 23 | +import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add"; | |
| 18 | 24 | |
| 19 | −import { configureMcpAuth, mcpServerAtom } from "./atoms"; | |
| 25 | +import { configureMcpAuth, configureMcpServer, mcpServerAtom } from "./atoms"; | |
| 20 | 26 | import type { |
| 21 | 27 | McpAuthMethod, |
| 22 | 28 | McpCanonicalAuthMethodInput, |
| @@ -27,6 +33,7 @@ import { · 4 unmodified lines | ||
| 27 | 33 | mcpAuthMethodInputFromEditorValue, |
| 28 | 34 | mcpWireAuthInput, |
| 29 | 35 | } from "./auth-method-config"; |
| 36 | +import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields"; | |
| 30 | 37 | |
| 31 | 38 | type McpServer = { |
| 32 | 39 | readonly slug: IntegrationSlug; |
| @@ -174,30 +181,160 @@ function RemoteEdit(props: { · 141 unmodified lines | ||
| 174 | 181 | } |
| 175 | 182 | |
| 176 | 183 | // --------------------------------------------------------------------------- |
| 177 | −// Stdio read-only view | |
| 184 | +// 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 | |
| 180 | −function StdioReadOnly(props: { | |
| 181 | − server: McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> }; | |
| 191 | +type McpStdioConfig = 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. */ | |
| 195 | +const 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 | + | |
| 201 | +function 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 | 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 | 267 | <div className="space-y-1"> |
| 187 | 268 | <p className="text-sm font-medium text-foreground">Server command</p> |
| 188 | 269 | <p className="text-xs text-muted-foreground"> |
| 189 | − Stdio MCP integrations cannot be edited. Remove and recreate the integration with the | |
| 190 | − updated command. | |
| 270 | + Changes apply when you save. The server's tools are then rediscovered with the new | |
| 271 | + command. | |
| 191 | 272 | </p> |
| 192 | 273 | </div> |
| 193 | − <div className="flex items-center gap-3 rounded-md border border-border/60 bg-muted/40 px-3 py-2"> | |
| 194 | − <p className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> | |
| 195 | − {command} {(args ?? []).join(" ")} | |
| 196 | − </p> | |
| 197 | − <Badge variant="secondary" className="text-xs"> | |
| 198 | − stdio | |
| 199 | − </Badge> | |
| 200 | − </div> | |
| 274 | + | |
| 275 | + <CardStack> | |
| 276 | + <CardStackContent className="border-t-0"> | |
| 277 | + <CardStackEntryField | |
| 278 | + label="Command" | |
| 279 | + description="- The executable to run (e.g. npx, uvx, node)." | |
| 280 | + > | |
| 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 | 338 | </div> |
| 202 | 339 | ); |
| 203 | 340 | } |
| @@ -219,10 +356,9 @@ export default function EditMcpIntegration({ · 15 unmodified lines | ||
| 219 | 356 | |
| 220 | 357 | if (server.config.transport === "stdio") { |
| 221 | 358 | return ( |
| 222 | − <StdioReadOnly | |
| 223 | − server={ | |
| 224 | − server as McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> } | |
| 225 | − } | |
| 359 | + <StdioEdit | |
| 360 | + server={server as McpServer & { config: McpStdioConfig }} | |
| 361 | + {...(onPendingChange ? { onPendingChange } : {})} | |
| 226 | 362 | /> |
| 227 | 363 | ); |
| 228 | 364 | } |
| @@ -0,0 +1,118 @@ | ||
| 1 | +import { PlusIcon, XIcon } from "lucide-react"; | |
| 2 | + | |
| 3 | +import { Button } from "@executor-js/react/components/button"; | |
| 4 | +import { | |
| 5 | + CardStack, | |
| 6 | + CardStackContent, | |
| 7 | + CardStackEntryField, | |
| 8 | +} from "@executor-js/react/components/card-stack"; | |
| 9 | +import { FieldError } from "@executor-js/react/components/field"; | |
| 10 | +import { Input } from "@executor-js/react/components/input"; | |
| 11 | + | |
| 12 | +import { 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 | + | |
| 24 | +export 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 | +} | |
| @@ -0,0 +1,63 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { isProbableMcpEndpoint } from "./probe-url"; | |
| 4 | + | |
| 5 | +describe("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 | +}); | |
| @@ -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. */ | |
| 10 | +export 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 | +}; | |
| @@ -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 | + | |
| 7 | +import { describe, expect, it } from "@effect/vitest"; | |
| 8 | + | |
| 9 | +import { emptyHeaderRow, isValidHeaderName, mcpHeadersFromRows } from "./request-headers"; | |
| 10 | + | |
| 11 | +describe("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 | + | |
| 68 | +describe("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 | +}); | |
| @@ -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 | + | |
| 18 | +export type McpHeaderRow = { | |
| 19 | + readonly name: string; | |
| 20 | + readonly value: string; | |
| 21 | +}; | |
| 22 | + | |
| 23 | +export 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. */ | |
| 28 | +const 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. */ | |
| 32 | +export 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 | + */ | |
| 48 | +export 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 | +}; | |
| @@ -0,0 +1,70 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields"; | |
| 4 | + | |
| 5 | +describe("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 | + | |
| 27 | +describe("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 | + | |
| 38 | +describe("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 | + | |
| 61 | +describe("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 | +}); | |
| @@ -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. */ | |
| 11 | +export 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. */ | |
| 24 | +export 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). */ | |
| 32 | +export 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. */ | |
| 47 | +export function formatStdioEnv(env: Readonly<Record<string, string>> | undefined): string { | |
| 48 | + return Object.entries(env ?? {}) | |
| 49 | + .map(([key, value]) => `${key}=${value}`) | |
| 50 | + .join("\n"); | |
| 51 | +} | |
| @@ -42,11 +42,17 @@ const TEMPLATE = AuthTemplateSlug.make("none"); · 41 unmodified lines | ||
| 42 | 42 | |
| 43 | 43 | const makeCatalogTestExecutor = ( |
| 44 | 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 | 50 | createExecutor({ |
| 48 | 51 | ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }), |
| 49 | 52 | ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }), |
| 53 | + ...(options?.toolsSyncGraceMs === undefined | |
| 54 | + ? {} | |
| 55 | + : { toolsSyncGraceMs: options.toolsSyncGraceMs }), | |
| 50 | 56 | }).pipe( |
| 51 | 57 | Effect.tap((executor) => |
| 52 | 58 | Effect.gen(function* () { |
| @@ -355,6 +361,11 @@ describe("MCP stale-catalog refresh", () => { · 302 unmodified lines | ||
| 355 | 361 | // Everything is expired on every read, so a single tools read has the |
| 356 | 362 | // whole set to rebuild. |
| 357 | 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 | 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 | + | |
| 426 | +const 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 | + | |
| 471 | +describe("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 | +}); | |
| @@ -260,6 +260,24 @@ describe("describeMcpAuthMethods", () => { · 259 unmodified lines | ||
| 260 | 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 | 281 | it("does not expose display metadata for stdio or malformed configs", () => { |
| 264 | 282 | expect( |
| 265 | 283 | describeMcpIntegrationDisplay(recordWith({ transport: "stdio", command: "run" })), |
| @@ -22,6 +22,7 @@ export { · 21 unmodified lines | ||
| 22 | 22 | McpTransport, |
| 23 | 23 | McpToolAnnotations, |
| 24 | 24 | McpToolBinding, |
| 25 | + McpToolMeta, | |
| 25 | 26 | parseMcpIntegrationConfig, |
| 26 | 27 | } from "./types"; |
| 27 | 28 | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { Option, Schema } from "effect"; |
| 2 | 2 | |
| 3 | −import { McpToolAnnotations } from "./types"; | |
| 3 | +import { McpToolAnnotations, McpToolMeta } from "./types"; | |
| 4 | 4 | |
| 5 | 5 | // --------------------------------------------------------------------------- |
| 6 | 6 | // Output types |
| @@ -13,6 +13,8 @@ export interface McpToolManifestEntry { · 6 unmodified lines | ||
| 13 | 13 | readonly inputSchema?: unknown; |
| 14 | 14 | readonly outputSchema?: unknown; |
| 15 | 15 | readonly annotations?: McpToolAnnotations; |
| 16 | + /** The tool's reserved MCP `_meta` map, carried through verbatim. */ | |
| 17 | + readonly _meta?: McpToolMeta; | |
| 16 | 18 | } |
| 17 | 19 | |
| 18 | 20 | export interface McpServerMetadata { |
| @@ -38,6 +40,11 @@ const ListedTool = Schema.Struct({ · 19 unmodified lines | ||
| 38 | 40 | parameters: Schema.optional(Schema.Unknown), |
| 39 | 41 | outputSchema: Schema.optional(Schema.Unknown), |
| 40 | 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 | |
| 43 | 50 | const ListToolsResult = Schema.Struct({ |
| @@ -62,6 +69,13 @@ const ServerInfo = Schema.Struct({ · 18 unmodified lines | ||
| 62 | 69 | version: Schema.optional(Schema.String), |
| 63 | 70 | }); |
| 64 | 71 | |
| 72 | +const 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. */ | |
| 76 | +const readToolMeta = (value: unknown): McpToolMeta | undefined => | |
| 77 | + value === undefined ? undefined : Option.getOrUndefined(decodeMcpToolMeta(value)); | |
| 78 | + | |
| 65 | 79 | const decodeListToolsResult = Schema.decodeUnknownOption(ListToolsResult); |
| 66 | 80 | const decodeListToolsPageOption = Schema.decodeUnknownOption(ListToolsPage); |
| 67 | 81 | const decodeServerInfo = Schema.decodeUnknownOption(ServerInfo); |
| @@ -130,6 +144,7 @@ export const extractManifestFromListToolsResult = ( · 62 unmodified lines | ||
| 130 | 144 | inputSchema: tool.inputSchema ?? tool.parameters, |
| 131 | 145 | outputSchema: tool.outputSchema, |
| 132 | 146 | annotations: tool.annotations, |
| 147 | + _meta: readToolMeta(tool._meta), | |
| 133 | 148 | }, |
| 134 | 149 | ]; |
| 135 | 150 | }); |
| @@ -239,6 +239,45 @@ describe("extractManifestFromListToolsResult", () => { · 238 unmodified lines | ||
| 239 | 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 | 405 | it.effect("connection tools list is empty until a connection is created", () => |
| 350 | 406 | Effect.gen(function* () { |
| 351 | 407 | const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); |
| @@ -1006,6 +1062,31 @@ describe("MCP destructiveHint → requiresApproval", () => { · 654 unmodified lines | ||
| 1006 | 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 | |
| 1011 | 1092 | describe("userFacingProbeMessage", () => { |
| @@ -60,6 +60,7 @@ import { · 59 unmodified lines | ||
| 60 | 60 | McpRemoteTransport, |
| 61 | 61 | type McpAuthMethod, |
| 62 | 62 | type McpToolAnnotations, |
| 63 | + McpToolMeta, | |
| 63 | 64 | expandMcpAuthMethodInputs, |
| 64 | 65 | mcpAuthMethodFromShorthand, |
| 65 | 66 | normalizeMcpAuthMethods, |
| @@ -139,14 +140,17 @@ const legacyMcpClientMatches = ( · 73 unmodified lines | ||
| 139 | 140 | // --------------------------------------------------------------------------- |
| 140 | 141 | // Tool annotations carry an `mcp` envelope alongside the executor's policy |
| 141 | 142 | // hints. The executor persists `ToolDef.annotations` verbatim into the tool |
| 142 | −// row's JSON column, so the real MCP tool name + upstream annotations survive | |
| 143 | −// to `invokeTool` / `resolveAnnotations` with no plugin-side store (resolveTools | |
| 144 | −// has no ctx to write one anyway). The envelope is opaque to core. | |
| 143 | +// row's JSON column, so the real MCP tool name, the upstream annotations, and | |
| 144 | +// the tool's reserved `_meta` map survive to `invokeTool` / | |
| 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 | |
| 147 | 149 | interface McpToolStamp { |
| 148 | 150 | readonly toolName: string; |
| 149 | 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 | |
| 152 | 156 | type StampedAnnotations = ToolAnnotations & { readonly mcp: McpToolStamp }; |
| @@ -162,6 +166,7 @@ const McpStampSchema = Schema.Struct({ · 9 unmodified lines | ||
| 162 | 166 | openWorldHint: Schema.optional(Schema.Boolean), |
| 163 | 167 | }), |
| 164 | 168 | ), |
| 169 | + _meta: Schema.optional(McpToolMeta), | |
| 165 | 170 | }); |
| 166 | 171 | const AnnotationsWithStamp = Schema.Struct({ mcp: McpStampSchema }); |
| 167 | 172 | const decodeStamp = Schema.decodeUnknownOption(AnnotationsWithStamp); |
| @@ -181,6 +186,8 @@ const readStamp = (annotations: unknown): McpToolStamp | null => · 13 unmodified lines | ||
| 181 | 186 | const McpRemoteServerInputSchema = Schema.Struct({ |
| 182 | 187 | transport: Schema.optional(Schema.Literal("remote")), |
| 183 | 188 | name: Schema.String, |
| 189 | + /** Optional catalog family used to group related integrations. */ | |
| 190 | + family: Schema.optional(Schema.String), | |
| 184 | 191 | /** Agent-visible catalog description. Defaults to the display name. */ |
| 185 | 192 | description: Schema.optional(Schema.String), |
| 186 | 193 | endpoint: Schema.String, |
| @@ -198,6 +205,8 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 11 unmodified lines | ||
| 198 | 205 | const McpStdioServerInputSchema = Schema.Struct({ |
| 199 | 206 | transport: Schema.Literal("stdio"), |
| 200 | 207 | name: Schema.String, |
| 208 | + /** Optional catalog family used to group related integrations. */ | |
| 209 | + family: Schema.optional(Schema.String), | |
| 201 | 210 | description: Schema.optional(Schema.String), |
| 202 | 211 | command: Schema.String, |
| 203 | 212 | args: Schema.optional(Schema.Array(Schema.String)), |
| @@ -375,6 +384,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 171 unmodified lines | ||
| 375 | 384 | const vars = stdioEnvVarNames(input); |
| 376 | 385 | return { |
| 377 | 386 | transport: "stdio", |
| 387 | + family: input.family?.trim() || undefined, | |
| 378 | 388 | command: input.command, |
| 379 | 389 | args: input.args ? [...input.args] : undefined, |
| 380 | 390 | cwd: input.cwd, |
| @@ -387,6 +397,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 6 unmodified lines | ||
| 387 | 397 | } |
| 388 | 398 | return { |
| 389 | 399 | transport: "remote", |
| 400 | + family: input.family?.trim() || undefined, | |
| 390 | 401 | endpoint: input.endpoint, |
| 391 | 402 | remoteTransport: input.remoteTransport ?? "auto", |
| 392 | 403 | queryParams: input.queryParams, |
| @@ -427,13 +438,16 @@ const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonS · 34 unmodified lines | ||
| 427 | 438 | }; |
| 428 | 439 | |
| 429 | 440 | /** Build the executor-facing ToolDef for one discovered MCP tool, stamping the |
| 430 | − * real MCP tool name + upstream annotations into the persisted annotations so | |
| 431 | − * they survive to invokeTool with no plugin-side store. */ | |
| 441 | + * real MCP tool name, the upstream annotations, and the tool's `_meta` map | |
| 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. */ | |
| 432 | 445 | const toToolDef = (entry: McpToolManifestEntry): ToolDef => { |
| 433 | 446 | const destructive = entry.annotations?.destructiveHint === true; |
| 434 | 447 | const stamp: McpToolStamp = { |
| 435 | 448 | toolName: entry.toolName, |
| 436 | 449 | ...(entry.annotations ? { upstream: entry.annotations } : {}), |
| 450 | + ...(entry._meta ? { _meta: entry._meta } : {}), | |
| 437 | 451 | }; |
| 438 | 452 | const annotations: StampedAnnotations = { |
| 439 | 453 | requiresApproval: destructive, |
| @@ -753,10 +767,14 @@ export const describeMcpAuthMethods = ( · 313 unmodified lines | ||
| 753 | 767 | |
| 754 | 768 | export const describeMcpIntegrationDisplay = ( |
| 755 | 769 | record: IntegrationRecord, |
| 756 | −): { readonly url?: string } => { | |
| 770 | +): { readonly url?: string; readonly family?: string } => { | |
| 757 | 771 | const config = parseMcpIntegrationConfig(record.config); |
| 758 | − if (!config || config.transport === "stdio") return {}; | |
| 759 | − return { url: config.endpoint }; | |
| 772 | + if (!config) return {}; | |
| 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 | // --------------------------------------------------------------------------- |
| @@ -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 | 360 | it.effect("falls back to GET for OAuth-protected SSE endpoints", () => |
| 271 | 361 | withServer( |
| 272 | 362 | (request) => { |
| @@ -20,10 +20,12 @@ · 19 unmodified lines | ||
| 20 | 20 | // transport, body is an SSE stream we don't consume. |
| 21 | 21 | // - 2xx with `Content-Type: application/json` whose body parses as a |
| 22 | 22 | // JSON-RPC 2.0 envelope (`{jsonrpc:"2.0", result|error|method,...}`). |
| 23 | −// - 401 with `WWW-Authenticate: Bearer` AND a JSON-RPC error envelope | |
| 24 | −// in the body. The body shape is what separates a real MCP server | |
| 25 | −// from an unrelated OAuth-protected API: GraphQL/REST/HTML 401s | |
| 26 | −// don't shape themselves as JSON-RPC. | |
| 23 | +// - 401 (or 403) with `WWW-Authenticate: Bearer` AND a JSON-RPC error | |
| 24 | +// envelope in the body. The body shape is what separates a real MCP | |
| 25 | +// server from an unrelated OAuth-protected API: GraphQL/REST/HTML | |
| 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 | 30 | // When POST returns 404/405/406/415 we retry with GET + `Accept: |
| 29 | 31 | // 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 | 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. */ | |
| 95 | +const isAuthStatus = (status: number): boolean => status === 401 || status === 403; | |
| 96 | + | |
| 85 | 97 | class ProbeTransportError extends Data.TaggedError("ProbeTransportError")<{ |
| 86 | 98 | readonly reason: string; |
| 87 | 99 | readonly cause: unknown; |
| @@ -213,9 +225,9 @@ const reasonFromBoundaryCause = (cause: unknown): string => { · 125 unmodified lines | ||
| 213 | 225 | |
| 214 | 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's | |
| 217 | − * an MCP server (no spec-compliant Bearer challenge or the body | |
| 218 | − * isn't JSON-RPC), but the right next step for the user is the same | |
| 228 | + * - `auth-required` — server returned 401 or 403. We don't know for | |
| 229 | + * sure it's an MCP server (no spec-compliant Bearer challenge or the | |
| 230 | + * body isn't JSON-RPC), but the right next step for the user is the same | |
| 219 | 231 | * either way: provide credentials and retry. This is what |
| 220 | 232 | * misclassifies real MCP servers like cubic.dev (no |
| 221 | 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 | |
| 228 | 240 | export type McpShapeProbeResult = |
| 229 | 241 | /** Server answered initialize successfully — either a 2xx with a |
| 230 | − * JSON-RPC payload, or a 401 + WWW-Authenticate: Bearer (RFC 6750 | |
| 242 | + * JSON-RPC payload, or a 401/403 + WWW-Authenticate: Bearer (RFC 6750 | |
| 231 | 243 | * challenge) that the MCP auth spec requires. */ |
| 232 | 244 | | { readonly kind: "mcp"; readonly requiresAuth: boolean } |
| 233 | 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 | 266 | * Returns `{kind: "mcp"}` only when the endpoint either: |
| 255 | 267 | * - 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 | 270 | * Anything else (400, 404, 200-with-HTML, 200-with-GraphQL-errors, ...) |
| 259 | 271 | * is classified `not-mcp`. Transport errors surface as `unreachable`. |
| @@ -287,20 +299,20 @@ export const probeMcpEndpointShape = ( · 27 unmodified lines | ||
| 287 | 299 | const contentType = readHeader(response.headers, "content-type") ?? ""; |
| 288 | 300 | const isSse = /^\s*text\/event-stream\b/i.test(contentType); |
| 289 | 301 | |
| 290 | − if (response.status === 401) { | |
| 302 | + if (isAuthStatus(response.status)) { | |
| 291 | 303 | const wwwAuth = readHeader(response.headers, "www-authenticate"); |
| 292 | 304 | if (!wwwAuth || !/^\s*bearer\b/i.test(wwwAuth)) { |
| 293 | − // Spec-non-compliant 401 (no `Bearer` challenge). Before | |
| 294 | − // giving up, check whether the server still publishes | |
| 295 | − // RFC 9728 protected-resource metadata for this path — | |
| 296 | − // some real MCP servers (Datadog) do exactly this. | |
| 305 | + // Spec-non-compliant challenge (no `Bearer`). Before giving | |
| 306 | + // up, check whether the server still publishes RFC 9728 | |
| 307 | + // protected-resource metadata for this path — some real MCP | |
| 308 | + // servers (Datadog) do exactly this. | |
| 297 | 309 | if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) { |
| 298 | 310 | return { kind: "mcp", requiresAuth: true } as const; |
| 299 | 311 | } |
| 300 | 312 | return { |
| 301 | 313 | kind: "not-mcp", |
| 302 | 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 | 316 | } as const; |
| 305 | 317 | } |
| 306 | 318 | // Spec-compliant MCP signal: the auth spec mandates a |
| @@ -347,8 +359,7 @@ export const probeMcpEndpointShape = ( · 40 unmodified lines | ||
| 347 | 359 | return { |
| 348 | 360 | kind: "not-mcp", |
| 349 | 361 | category: "auth-required", |
| 350 | − reason: | |
| 351 | − "401 + Bearer without resource_metadata, JSON-RPC body, or OAuth error body", | |
| 362 | + reason: `${response.status} + Bearer without resource_metadata, JSON-RPC body, or OAuth error body`, | |
| 352 | 363 | } as const; |
| 353 | 364 | } |
| 354 | 365 | return { kind: "mcp", requiresAuth: true } as const; |
| @@ -215,6 +215,8 @@ const StringMap = Schema.Record(Schema.String, Schema.String); · 214 unmodified lines | ||
| 215 | 215 | |
| 216 | 216 | export const McpRemoteIntegrationConfig = Schema.Struct({ |
| 217 | 217 | transport: Schema.Literal("remote"), |
| 218 | + /** Optional catalog family used to group related integrations. */ | |
| 219 | + family: Schema.optional(Schema.String), | |
| 218 | 220 | /** The MCP server endpoint URL */ |
| 219 | 221 | endpoint: Schema.String, |
| 220 | 222 | /** Transport preference for this remote server */ |
| @@ -234,6 +236,8 @@ export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type; · 13 unmodified lines | ||
| 234 | 236 | |
| 235 | 237 | export const McpStdioIntegrationConfig = Schema.Struct({ |
| 236 | 238 | transport: Schema.Literal("stdio"), |
| 239 | + /** Optional catalog family used to group related integrations. */ | |
| 240 | + family: Schema.optional(Schema.String), | |
| 237 | 241 | /** The command to run */ |
| 238 | 242 | command: Schema.String, |
| 239 | 243 | /** Arguments to the command */ |
| @@ -287,6 +291,16 @@ export const McpToolAnnotations = Schema.Struct({ · 47 unmodified lines | ||
| 287 | 291 | }); |
| 288 | 292 | export 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 | + | |
| 301 | +export const McpToolMeta = Schema.Record(Schema.String, Schema.Unknown); | |
| 302 | +export type McpToolMeta = typeof McpToolMeta.Type; | |
| 303 | + | |
| 290 | 304 | // --------------------------------------------------------------------------- |
| 291 | 305 | // Tool binding — maps a persisted (sanitized) tool name back to its real MCP |
| 292 | 306 | // tool name and upstream annotations, persisted per-connection so invokeTool |
| @@ -634,5 +634,17 @@ export const makeAnnotationsMcpServer = () => { · 633 unmodified lines | ||
| 634 | 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 | 649 | return server; |
| 638 | 650 | }; |
| @@ -2382,15 +2382,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 2381 unmodified lines | ||
| 2382 | 2382 | // Non-modal for the same reason as the health-check editor sheet: a modal |
| 2383 | 2383 | // dialog's react-remove-scroll locks the wheel to the dialog subtree, so |
| 2384 | 2384 | // the operation combobox's PORTALED popup (and the modal body while it is |
| 2385 | − // open) cannot scroll. The overlay still dims and outside-click still | |
| 2386 | − // closes; the portaled-popup guard in DialogContent keeps option clicks | |
| 2387 | − // from dismissing. | |
| 2385 | + // open) cannot scroll. The overlay still dims, so the dialog keeps its | |
| 2386 | + // modal look. This form holds credentials and a half-built auth method, so | |
| 2387 | + // it takes DialogContent's default: an outside click does not dismiss it. | |
| 2388 | 2388 | <Dialog open={open} onOpenChange={onOpenChange} modal={false}> |
| 2389 | 2389 | <DialogContent |
| 2390 | 2390 | forceOverlay |
| 2391 | − onInteractOutside={ | |
| 2392 | − oauthClientHandoff?.action === "reconnect" ? (event) => event.preventDefault() : undefined | |
| 2393 | − } | |
| 2394 | 2391 | className={cn( |
| 2395 | 2392 | "max-h-[85vh] overflow-x-hidden overflow-y-auto", |
| 2396 | 2393 | (addingMethod && createCustomMethod) || oauthRegistering || oauthEditing |
| @@ -26,12 +26,17 @@ function CommandDialog({ · 25 unmodified lines | ||
| 26 | 26 | children, |
| 27 | 27 | className, |
| 28 | 28 | showCloseButton = true, |
| 29 | + dismissOnOutsideClick = true, | |
| 29 | 30 | ...props |
| 30 | 31 | }: React.ComponentProps<typeof Dialog> & { |
| 31 | 32 | title?: string; |
| 32 | 33 | description?: string; |
| 33 | 34 | className?: string; |
| 34 | 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 | 41 | return ( |
| 37 | 42 | <Dialog {...props}> |
| @@ -42,6 +47,7 @@ function CommandDialog({ · 4 unmodified lines | ||
| 42 | 47 | <DialogContent |
| 43 | 48 | className={cn("overflow-hidden p-0", className)} |
| 44 | 49 | showCloseButton={showCloseButton} |
| 50 | + dismissOnOutsideClick={dismissOnOutsideClick} | |
| 45 | 51 | > |
| 46 | 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 | 53 | {children} |
| @@ -2,6 +2,7 @@ import * as React from "react"; · 1 unmodified line | ||
| 2 | 2 | import { XIcon } from "lucide-react"; |
| 3 | 3 | import { Dialog as DialogPrimitive } from "radix-ui"; |
| 4 | 4 | |
| 5 | +import { applyOutsideDismissPolicy } from "../lib/outside-dismiss"; | |
| 5 | 6 | import { cn } from "../lib/utils"; |
| 6 | 7 | import { 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. | |
| 44 | −const PORTALED_POPUP_SELECTOR = "[data-slot='combobox-content'],[data-slot='select-content']"; | |
| 45 | − | |
| 46 | 41 | function DialogContent({ |
| 47 | 42 | className, |
| 48 | 43 | children, |
| 49 | 44 | showCloseButton = true, |
| 45 | + dismissOnOutsideClick = false, | |
| 50 | 46 | onInteractOutside, |
| 47 | + onPointerDownOutside, | |
| 51 | 48 | forceOverlay = false, |
| 52 | 49 | ...props |
| 53 | 50 | }: React.ComponentProps<typeof DialogPrimitive.Content> & { |
| 54 | 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 | 57 | /** Pair with `<Dialog modal={false}>`: Radix renders no overlay in non-modal |
| 56 | 58 | * mode, so this renders a plain dim layer instead. It still eats outside |
| 57 | − * clicks (which dismiss via onInteractOutside), so the dialog keeps its | |
| 58 | − * modal look while the wheel stays free for portaled popups. */ | |
| 59 | + * clicks, so the dialog keeps its modal look while the wheel stays free for | |
| 60 | + * portaled popups. */ | |
| 59 | 61 | forceOverlay?: boolean; |
| 60 | 62 | }) { |
| 61 | 63 | return ( |
| @@ -66,12 +68,13 @@ function DialogContent({ · 4 unmodified lines | ||
| 66 | 68 | ) : null} |
| 67 | 69 | <DialogPrimitive.Content |
| 68 | 70 | data-slot="dialog-content" |
| 71 | + onPointerDownOutside={(event) => { | |
| 72 | + onPointerDownOutside?.(event); | |
| 73 | + applyOutsideDismissPolicy(event, dismissOnOutsideClick); | |
| 74 | + }} | |
| 69 | 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 | 76 | onInteractOutside?.(event); |
| 77 | + applyOutsideDismissPolicy(event, dismissOnOutsideClick); | |
| 75 | 78 | }} |
| 76 | 79 | className={cn( |
| 77 | 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", |
| @@ -29,7 +29,8 @@ export function RemoveOAuthAppDialog(props: { · 28 unmodified lines | ||
| 29 | 29 | const inUse = connections.length > 0; |
| 30 | 30 | return ( |
| 31 | 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 | 34 | <DialogHeader> |
| 34 | 35 | <DialogTitle>Remove {String(client.slug)}?</DialogTitle> |
| 35 | 36 | <DialogDescription> |
| @@ -4,6 +4,7 @@ import * as React from "react"; · 3 unmodified lines | ||
| 4 | 4 | import { XIcon } from "lucide-react"; |
| 5 | 5 | import { Dialog as SheetPrimitive } from "radix-ui"; |
| 6 | 6 | |
| 7 | +import { applyOutsideDismissPolicy } from "../lib/outside-dismiss"; | |
| 7 | 8 | import { cn } from "../lib/utils"; |
| 8 | 9 | |
| 9 | 10 | function 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. | |
| 45 | −const PORTALED_POPUP_SELECTOR = "[data-slot='combobox-content'],[data-slot='select-content']"; | |
| 46 | − | |
| 47 | 42 | function SheetContent({ |
| 48 | 43 | className, |
| 49 | 44 | children, |
| 50 | 45 | side = "right", |
| 51 | 46 | showCloseButton = true, |
| 47 | + dismissOnOutsideClick = false, | |
| 52 | 48 | onInteractOutside, |
| 49 | + onPointerDownOutside, | |
| 53 | 50 | ...props |
| 54 | 51 | }: React.ComponentProps<typeof SheetPrimitive.Content> & { |
| 55 | 52 | side?: "top" | "right" | "bottom" | "left"; |
| 56 | 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 | 60 | return ( |
| 59 | 61 | <SheetPortal> |
| 60 | 62 | <SheetOverlay /> |
| 61 | 63 | <SheetPrimitive.Content |
| 62 | 64 | data-slot="sheet-content" |
| 65 | + onPointerDownOutside={(event) => { | |
| 66 | + onPointerDownOutside?.(event); | |
| 67 | + applyOutsideDismissPolicy(event, dismissOnOutsideClick); | |
| 68 | + }} | |
| 63 | 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 | 70 | onInteractOutside?.(event); |
| 71 | + applyOutsideDismissPolicy(event, dismissOnOutsideClick); | |
| 69 | 72 | }} |
| 70 | 73 | className={cn( |
| 71 | 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", |
| @@ -168,6 +168,9 @@ function Sidebar({ · 167 unmodified lines | ||
| 168 | 168 | return ( |
| 169 | 169 | <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> |
| 170 | 170 | <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 | 174 | data-sidebar="sidebar" |
| 172 | 175 | data-slot="sidebar" |
| 173 | 176 | data-mobile="true" |
| @@ -0,0 +1,58 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { isUpdateAvailable } from "@executor-js/api"; | |
| 4 | + | |
| 5 | +import { 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 | + */ | |
| 13 | +describe("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 | + */ | |
| 43 | +describe("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 | +}); | |
| @@ -13,11 +13,17 @@ · 12 unmodified lines | ||
| 13 | 13 | // - managed cloud (`"managed"`): nothing, it deploys itself. |
| 14 | 14 | // |
| 15 | 15 | // 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. | |
| 17 | 18 | import { useCallback, useEffect, useState } from "react"; |
| 18 | 19 | |
| 19 | 20 | import { Effect, Exit } from "effect"; |
| 20 | −import { compareVersions, resolveUpdateChannel, type UpdateChannel } from "@executor-js/api"; | |
| 21 | +import { | |
| 22 | + isUpdateAvailable, | |
| 23 | + resolveComparisonChannel, | |
| 24 | + resolveUpdateChannel, | |
| 25 | + type UpdateChannel, | |
| 26 | +} from "@executor-js/api"; | |
| 21 | 27 | |
| 22 | 28 | import { Button } from "./button"; |
| 23 | 29 | import { toast } from "./sonner"; |
| @@ -47,12 +53,31 @@ const UPGRADE_DOCS_URL: Partial<Record<UpgradeHint, string>> = { · 23 unmodified lines | ||
| 47 | 53 | |
| 48 | 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 | + */ | |
| 64 | +export function updateFetchChannel(currentVersion: string | undefined): UpdateChannel | null { | |
| 65 | + if (currentVersion === undefined) return null; | |
| 66 | + return resolveComparisonChannel(currentVersion); | |
| 67 | +} | |
| 68 | + | |
| 50 | 69 | function 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 | 77 | const [latestVersion, setLatestVersion] = useState<string | null>(null); |
| 53 | 78 | |
| 54 | 79 | useEffect(() => { |
| 55 | − if (!currentVersion) return; | |
| 80 | + if (fetchChannel === null) return; | |
| 56 | 81 | let cancelled = false; |
| 57 | 82 | void Effect.runPromiseExit( |
| 58 | 83 | Effect.tryPromise({ |
| @@ -65,20 +90,17 @@ function useLatestVersion(currentVersion: string | undefined) { · 6 unmodified lines | ||
| 65 | 90 | }), |
| 66 | 91 | ).then((exit) => { |
| 67 | 92 | if (!cancelled && Exit.isSuccess(exit)) { |
| 68 | − setLatestVersion(exit.value?.[channel] ?? null); | |
| 93 | + setLatestVersion(exit.value?.[fetchChannel] ?? null); | |
| 69 | 94 | } |
| 70 | 95 | }); |
| 71 | 96 | return () => { |
| 72 | 97 | cancelled = true; |
| 73 | 98 | }; |
| 74 | − }, [channel, currentVersion]); | |
| 99 | + }, [fetchChannel]); | |
| 75 | 100 | |
| 76 | − const updateAvailable = | |
| 77 | − currentVersion !== undefined && | |
| 78 | − latestVersion !== null && | |
| 79 | − compareVersions(currentVersion, latestVersion) === -1; | |
| 101 | + const updateAvailable = isUpdateAvailable(currentVersion, latestVersion); | |
| 80 | 102 | |
| 81 | − return { latestVersion, updateAvailable, channel }; | |
| 103 | + return { latestVersion, updateAvailable, channel: displayChannel }; | |
| 82 | 104 | } |
| 83 | 105 | |
| 84 | 106 | // ── Card chrome ────────────────────────────────────────────────────────── |
| @@ -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 | 54 | it("leaves integrations without config family ungrouped", () => { |
| 42 | 55 | const items = groupIntegrations([ |
| 43 | 56 | integration("stripe", "openapi", "Stripe"), |
| @@ -72,7 +85,7 @@ describe("groupIntegrations", () => { · 28 unmodified lines | ||
| 72 | 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 | 89 | expect( |
| 77 | 90 | integrationFamily(integration("google_calendar", "google", "Google Calendar")), |
| 78 | 91 | ).toBeNull(); |
| @@ -1,7 +1,5 @@ | ||
| 1 | 1 | import type { Integration } from "@executor-js/sdk/shared"; |
| 2 | 2 | |
| 3 | −export const MULTI_SERVICE_FAMILIES: ReadonlySet<string> = new Set(["google", "microsoft"]); | |
| 4 | − | |
| 5 | 3 | const FAMILY_LABELS: Record<string, string> = { |
| 6 | 4 | google: "Google", |
| 7 | 5 | microsoft: "Microsoft", |
| @@ -12,7 +10,7 @@ export const familyLabel = (family: string): string => · 4 unmodified lines | ||
| 12 | 10 | |
| 13 | 11 | export const integrationFamily = (integration: Integration): string | null => { |
| 14 | 12 | const family = integration.family?.trim(); |
| 15 | − return family && MULTI_SERVICE_FAMILIES.has(family) ? family : null; | |
| 13 | + return family || null; | |
| 16 | 14 | }; |
| 17 | 15 | |
| 18 | 16 | export interface IntegrationFamilyGroup { |
| @@ -0,0 +1,77 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { | |
| 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 | + */ | |
| 17 | +describe("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. */ | |
| 44 | +const 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 | + | |
| 57 | +describe("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 | + | |
| 71 | +describe("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 | +}); | |
| @@ -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. */ | |
| 17 | +export 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. */ | |
| 21 | +export 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 | + */ | |
| 32 | +export 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`. */ | |
| 38 | +type 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. */ | |
| 44 | +export 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 | +}; | |
| @@ -16,7 +16,11 @@ import { useNavigate, useParams, useRouterState } from "@tanstack/react-router"; · 15 unmodified lines | ||
| 16 | 16 | // membership and resolves data for that org — same as the MCP URL-pinned org. |
| 17 | 17 | // So a foreign slug never reaches this gate as "active" on a multi-org host: |
| 18 | 18 | // 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 fully | |
| 19 | +// 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 | 24 | // independent — no shared "active org" to steal. On a single-org host (e.g. |
| 21 | 25 | // self-host) every slug resolves to the same org server-side, so a bogus slug |
| 22 | 26 | // (e.g. `/totally-bogus`) would otherwise fuzzy-match a route and render |
| @@ -2,7 +2,7 @@ import { Link, Outlet, useLocation, useParams } from "@tanstack/react-router"; · 1 unmodified line | ||
| 2 | 2 | import { useEffect, useRef, useState, type ReactNode } from "react"; |
| 3 | 3 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | −import { BookOpen, Command, ExternalLink } from "lucide-react"; | |
| 5 | +import { BookOpen, Command, ExternalLink, PlusIcon } from "lucide-react"; | |
| 6 | 6 | import type { Integration } from "@executor-js/sdk/shared"; |
| 7 | 7 | import { integrationsOptimisticAtom } from "../api/atoms"; |
| 8 | 8 | import { trackEvent } from "../api/analytics"; |
| @@ -23,6 +23,7 @@ import { · 14 unmodified lines | ||
| 23 | 23 | } from "../components/integration-favicon"; |
| 24 | 24 | import { CommandPalette } from "../components/command-palette"; |
| 25 | 25 | import { Wordmark } from "../components/wordmark"; |
| 26 | +import { ConnectDialog } from "../pages/integrations"; | |
| 26 | 27 | import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; |
| 27 | 28 | import { useAuth } from "./auth-context"; |
| 28 | 29 | |
| @@ -347,6 +348,7 @@ function SidebarContent( · 318 unmodified lines | ||
| 347 | 348 | onNavigate?: () => void; |
| 348 | 349 | showBrand?: boolean; |
| 349 | 350 | onOpenCommands: () => void; |
| 351 | + onOpenIntegrationConnect: () => void; | |
| 350 | 352 | }, |
| 351 | 353 | ) { |
| 352 | 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 | 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 | 400 | </div> |
| 388 | 401 | |
| 389 | 402 | <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} /> |
| @@ -415,6 +428,7 @@ export function Shell(props: ShellProps) { · 25 unmodified lines | ||
| 415 | 428 | const lastPathname = useRef(pathname); |
| 416 | 429 | const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); |
| 417 | 430 | const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); |
| 431 | + const [connectIntegrationOpen, setConnectIntegrationOpen] = useState(false); | |
| 418 | 432 | if (lastPathname.current !== pathname) { |
| 419 | 433 | lastPathname.current = pathname; |
| 420 | 434 | if (mobileSidebarOpen) setMobileSidebarOpen(false); |
| @@ -432,12 +446,17 @@ export function Shell(props: ShellProps) { · 11 unmodified lines | ||
| 432 | 446 | return ( |
| 433 | 447 | <div className="flex h-screen overflow-hidden"> |
| 434 | 448 | <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} /> |
| 449 | + <ConnectDialog open={connectIntegrationOpen} onOpenChange={setConnectIntegrationOpen} /> | |
| 435 | 450 | {/* Desktop sidebar */} |
| 436 | 451 | <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56"> |
| 437 | 452 | <SidebarContent |
| 438 | 453 | {...props} |
| 439 | 454 | pathname={pathname} |
| 440 | 455 | onOpenCommands={() => setCommandPaletteOpen(true)} |
| 456 | + onOpenIntegrationConnect={() => { | |
| 457 | + setConnectIntegrationOpen(true); | |
| 458 | + trackEvent("integration_connect_dialog_opened"); | |
| 459 | + }} | |
| 441 | 460 | /> |
| 442 | 461 | </aside> |
| 443 | 462 | |
| @@ -481,6 +500,11 @@ export function Shell(props: ShellProps) { · 37 unmodified lines | ||
| 481 | 500 | setMobileSidebarOpen(false); |
| 482 | 501 | setCommandPaletteOpen(true); |
| 483 | 502 | }} |
| 503 | + onOpenIntegrationConnect={() => { | |
| 504 | + setMobileSidebarOpen(false); | |
| 505 | + setConnectIntegrationOpen(true); | |
| 506 | + trackEvent("integration_connect_dialog_opened"); | |
| 507 | + }} | |
| 484 | 508 | /> |
| 485 | 509 | </div> |
| 486 | 510 | </div> |
| @@ -670,7 +670,8 @@ export function AdminUsersPage() { · 669 unmodified lines | ||
| 670 | 670 | })} |
| 671 | 671 | |
| 672 | 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 | 675 | {selected && ( |
| 675 | 676 | <> |
| 676 | 677 | <SheetHeader className="border-b border-border"> |
| @@ -233,6 +233,7 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode }) { · 232 unmodified lines | ||
| 233 | 233 | // stays mounted for Radix's exit animation (see CreateKeyDialogBody). |
| 234 | 234 | const [openCount, setOpenCount] = useState(0); |
| 235 | 235 | const [revokingId, setRevokingId] = useState<string | null>(null); |
| 236 | + const [confirmRevoke, setConfirmRevoke] = useState<ApiKeySummary | null>(null); | |
| 236 | 237 | |
| 237 | 238 | const handleCreate = async (name: string): Promise<CreatedKey | null> => { |
| 238 | 239 | const exit = await doCreate({ payload: { name }, reactivityKeys: apiKeyWriteKeys }); |
| @@ -312,7 +313,11 @@ export function ApiKeysPage(props: { readonly orgKeysSection?: ReactNode }) { · 73 unmodified lines | ||
| 312 | 313 | </p> |
| 313 | 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 | 339 | </DialogContent> |
| 335 | 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 | 377 | </PageContainer> |
| 337 | 378 | ); |
| 338 | 379 | } |
| @@ -492,15 +533,15 @@ function OrgApiKeysSectionBody() { · 153 unmodified lines | ||
| 492 | 533 | </DialogContent> |
| 493 | 534 | </Dialog> |
| 494 | 535 | |
| 495 | − {/* Revoking an org key breaks every backend using it, so it is the one | |
| 496 | − revoke on this page that asks first. */} | |
| 536 | + {/* Revoking an org key breaks every backend using it, so it asks first. */} | |
| 497 | 537 | <Dialog |
| 498 | 538 | open={confirmRevoke !== null} |
| 499 | 539 | onOpenChange={(open) => { |
| 500 | 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 | 545 | <DialogHeader> |
| 505 | 546 | <DialogTitle className="font-display text-xl">Revoke organization key</DialogTitle> |
| 506 | 547 | <DialogDescription className="text-sm leading-relaxed"> |
| @@ -163,7 +163,7 @@ const looksLikeUrl = (raw: string): boolean => { · 162 unmodified lines | ||
| 163 | 163 | return false; |
| 164 | 164 | }; |
| 165 | 165 | |
| 166 | −function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) { | |
| 166 | +export function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) { | |
| 167 | 167 | const integrationPlugins = useIntegrationPlugins(); |
| 168 | 168 | const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); |
| 169 | 169 | const navigate = useNavigate(); |
| @@ -14,6 +14,16 @@ import { · 13 unmodified lines | ||
| 14 | 14 | DialogFooter, |
| 15 | 15 | DialogClose, |
| 16 | 16 | } from "../components/dialog"; |
| 17 | +import { | |
| 18 | + AlertDialog, | |
| 19 | + AlertDialogAction, | |
| 20 | + AlertDialogCancel, | |
| 21 | + AlertDialogContent, | |
| 22 | + AlertDialogDescription, | |
| 23 | + AlertDialogFooter, | |
| 24 | + AlertDialogHeader, | |
| 25 | + AlertDialogTitle, | |
| 26 | +} from "../components/alert-dialog"; | |
| 17 | 27 | import { Button } from "../components/button"; |
| 18 | 28 | import { PageContainer, PageHeader } from "../components/page"; |
| 19 | 29 | import { Badge } from "../components/badge"; |
| @@ -153,6 +163,7 @@ export function OrgPage(props: { · 133 unmodified lines | ||
| 153 | 163 | const refreshMembers = useAtomRefresh(orgMembersAtom); |
| 154 | 164 | const rolesResult = useAtomValue(orgRolesAtom); |
| 155 | 165 | const doRemove = useAtomSet(removeMember, { mode: "promiseExit" }); |
| 166 | + const [removingMember, setRemovingMember] = useState<{ id: string; name: string } | null>(null); | |
| 156 | 167 | const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" }); |
| 157 | 168 | const doUpdateOrgName = useAtomSet(updateOrgName, { mode: "promiseExit" }); |
| 158 | 169 | const [inviteOpen, setInviteOpen] = useState(false); |
| @@ -179,6 +190,7 @@ export function OrgPage(props: { · 20 unmodified lines | ||
| 179 | 190 | const showUpgradeOnInvite = atSeatLimit && !!props.upgradeAction; |
| 180 | 191 | |
| 181 | 192 | const handleRemove = async (membershipId: string, name: string) => { |
| 193 | + setRemovingMember(null); | |
| 182 | 194 | const exit = await doRemove({ |
| 183 | 195 | params: { membershipId }, |
| 184 | 196 | reactivityKeys: orgMemberWriteKeys, |
| @@ -406,7 +418,12 @@ export function OrgPage(props: { · 221 unmodified lines | ||
| 406 | 418 | )} |
| 407 | 419 | <DropdownMenuItem |
| 408 | 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 | 428 | Remove member |
| 412 | 429 | </DropdownMenuItem> |
| @@ -426,6 +443,38 @@ export function OrgPage(props: { · 13 unmodified lines | ||
| 426 | 443 | |
| 427 | 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 | 478 | <InviteDialog open={inviteOpen} onOpenChange={setInviteOpen} roles={roles} /> |
| 430 | 479 | <UpgradeDialog |
| 431 | 480 | open={upgradeOpen} |
| @@ -447,7 +496,8 @@ function UpgradeDialog(props: { · 15 unmodified lines | ||
| 447 | 496 | }) { |
| 448 | 497 | return ( |
| 449 | 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 | 501 | <DialogHeader> |
| 452 | 502 | <DialogTitle className="font-display text-xl">You are at your member limit</DialogTitle> |
| 453 | 503 | <DialogDescription className="text-sm leading-relaxed"> |
| @@ -30,6 +30,16 @@ import { · 29 unmodified lines | ||
| 30 | 30 | } from "../lib/policy-display"; |
| 31 | 31 | import { Button } from "../components/button"; |
| 32 | 32 | import { PageContainer, PageHeader } from "../components/page"; |
| 33 | +import { | |
| 34 | + AlertDialog, | |
| 35 | + AlertDialogAction, | |
| 36 | + AlertDialogCancel, | |
| 37 | + AlertDialogContent, | |
| 38 | + AlertDialogDescription, | |
| 39 | + AlertDialogFooter, | |
| 40 | + AlertDialogHeader, | |
| 41 | + AlertDialogTitle, | |
| 42 | +} from "../components/alert-dialog"; | |
| 33 | 43 | import { |
| 34 | 44 | CardStack, |
| 35 | 45 | CardStackContent, |
| @@ -291,6 +301,11 @@ export function PoliciesPage() { · 255 unmodified lines | ||
| 291 | 301 | const doUpdate = useAtomSet(updatePolicyOptimistic, { mode: "promiseExit" }); |
| 292 | 302 | const doRemove = useAtomSet(removePolicyOptimistic, { mode: "promiseExit" }); |
| 293 | 303 | const [busy, setBusy] = useState(false); |
| 304 | + const [removingPolicy, setRemovingPolicy] = useState<{ | |
| 305 | + id: string; | |
| 306 | + owner: Owner; | |
| 307 | + pattern: string; | |
| 308 | + } | null>(null); | |
| 294 | 309 | const ownerDisplay = useOwnerDisplay(); |
| 295 | 310 | // Policies default to org/workspace. On local this is the hidden Local owner |
| 296 | 311 | // that v1 local data migrates into. |
| @@ -336,6 +351,7 @@ export function PoliciesPage() { · 39 unmodified lines | ||
| 336 | 351 | }; |
| 337 | 352 | |
| 338 | 353 | const handleRemove = async (policy: { id: string; owner: Owner }) => { |
| 354 | + setRemovingPolicy(null); | |
| 339 | 355 | const exit = await doRemove({ |
| 340 | 356 | params: { policyId: PolicyId.make(policy.id) }, |
| 341 | 357 | payload: { owner: policy.owner }, |
| @@ -462,7 +478,9 @@ export function PoliciesPage() { · 120 unmodified lines | ||
| 462 | 478 | isFirst={!reorderable || j === 0} |
| 463 | 479 | isLast={!reorderable || j === committed.length - 1} |
| 464 | 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 | 484 | onChangeAction={(action) => |
| 467 | 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 | 542 | </PageContainer> |
| 494 | 543 | ); |
| 495 | 544 | } |